mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-06-24 20:15:21 -04:00
Compare commits
137 Commits
05c7a77c8b
..
hover
| Author | SHA1 | Date | |
|---|---|---|---|
| fc72b6d779 | |||
| 3701b3d7a3 | |||
| bae98daa5c | |||
| b34a04f723 | |||
| 1c0245f2db | |||
| 7777e87dc8 | |||
| 820fa07846 | |||
| 66794582c9 | |||
| 73eb471ae3 | |||
| 0f2f4b96c4 | |||
| d53809cf2b | |||
| 08fd6e26d8 | |||
| 29e8470f2e | |||
| 573785d4ce | |||
| 5483303714 | |||
| 5a5cc4f4e9 | |||
| cd672c341f | |||
| 12438d63c2 | |||
| 35255e4053 | |||
| 8856d45887 | |||
| 38af56c6fd | |||
| 9111e4809d | |||
| d08c7c5e55 | |||
| 69f3dee25a | |||
| 8155970ba2 | |||
| d356957dad | |||
| e7ccb702a3 | |||
| bf3ce6deb2 | |||
| f5295fb35d | |||
| 6c5836722a | |||
| 5716249bd9 | |||
| 4d0aab773b | |||
| e50ac208e3 | |||
| bcb5617194 | |||
| d3c23ba737 | |||
| e0ab0a6b90 | |||
| 713ce5f430 | |||
| 8eb23bcc29 | |||
| 4181343ef3 | |||
| d16566aa8d | |||
| 45eb101f40 | |||
| 59431869dc | |||
| 6e7aca8b15 | |||
| 6f387b0481 | |||
| 82d4364032 | |||
| e3de54c941 | |||
| 6991b45fbe | |||
| e5fff91ae6 | |||
| 2f2d4c9d9b | |||
| bfca1b46a6 | |||
| b117c80e47 | |||
| d20aa3b80a | |||
| a34fda984d | |||
| 510269dda9 | |||
| d51b34797c | |||
| d2905072c0 | |||
| 1ee42506b6 | |||
| 84fe2d751f | |||
| 5d0fc48706 | |||
| 335c5b4ac5 | |||
| 8c20f448ed | |||
| 0a668df138 | |||
| 3e4d2b4d46 | |||
| 12e43d120e | |||
| a9845bf3cd | |||
| e51ceed175 | |||
| 304baf6f60 | |||
| 6b141a9b06 | |||
| 0c3659a612 | |||
| a44bef5796 | |||
| b1ac6b0ef9 | |||
| 98844a3b85 | |||
| a32b8911c7 | |||
| 3118e7b9c3 | |||
| 2ca2bc5fb8 | |||
| 4bfb08f6ef | |||
| 0689339780 | |||
| a265625851 | |||
| 389fffaf64 | |||
| b7daf3f64a | |||
| 461da22b08 | |||
| 2b661e241d | |||
| d7df3800c2 | |||
| f2961f9b6a | |||
| f2d5ee4692 | |||
| 7c2d5ce15e | |||
| 5ceb908b8b | |||
| d819865853 | |||
| 38176ab543 | |||
| 53936d7034 | |||
| aafc2ea4d7 | |||
| 8a4be4936a | |||
| af097d0f33 | |||
| 44867e7b43 | |||
| a366bf3ca0 | |||
| 89f86be00a | |||
| 12a744e985 | |||
| 54f272ba1e | |||
| 60b64f22c6 | |||
| 97666dc73d | |||
| 6c6756936b | |||
| 91f8ca4efe | |||
| 045ac59a44 | |||
| 078180fe42 | |||
| d9525908f1 | |||
| 6093c37b41 | |||
| bb05cbb6c5 | |||
| 4d4af8f549 | |||
| 0b55fbcb15 | |||
| 7476a220b5 | |||
| aaff1ab61e | |||
| 39622eb62a | |||
| eea039f575 | |||
| ef5de19f6b | |||
| f0c31bd7b3 | |||
| 7ddd0ca90d | |||
| b84e5abc4a | |||
| fb9ec8e721 | |||
| 078c9b4890 | |||
| 37c98220a9 | |||
| fc07611b3b | |||
| a923308c09 | |||
| 0990b43a43 | |||
| 548c2305fb | |||
| 4634763840 | |||
| cdc1102092 | |||
| 4845299cc2 | |||
| 81a1bb1cd7 | |||
| 4528552610 | |||
| 0b55bf5dac | |||
| 8dd891f93a | |||
| 9bd68d44a1 | |||
| 90ea136379 | |||
| 2f4a39f9eb | |||
| 5e558660c3 | |||
| c923c43322 | |||
| 9f2ae6241e |
@@ -235,7 +235,7 @@ Conditionally show/hide the bar pill:
|
|||||||
```qml
|
```qml
|
||||||
PluginComponent {
|
PluginComponent {
|
||||||
visibilityCommand: "pgrep -x myapp"
|
visibilityCommand: "pgrep -x myapp"
|
||||||
visibilityInterval: 5000 // check every 5 seconds
|
visibilityInterval: 5 // seconds between checks; polling pauses while the bar is hidden
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- What does this PR do and why? -->
|
||||||
|
|
||||||
|
## Type of change
|
||||||
|
|
||||||
|
<!-- Check all that apply. -->
|
||||||
|
|
||||||
|
- [ ] Bug fix (non-breaking change that fixes an issue)
|
||||||
|
- [ ] New feature (non-breaking change that adds functionality)
|
||||||
|
- [ ] Breaking change (fix or feature that changes existing behavior)
|
||||||
|
- [ ] Refactor / internal cleanup
|
||||||
|
- [ ] Documentation
|
||||||
|
- [ ] Other
|
||||||
|
|
||||||
|
## Related issues
|
||||||
|
|
||||||
|
<!-- e.g. "Fixes #123", "Closes #123". Leave blank if none. -->
|
||||||
|
|
||||||
|
## Screenshots / video
|
||||||
|
|
||||||
|
<!-- Include screenshots or a video for any user-facing or visual change. -->
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] My code follows the conventions in CONTRIBUTING.md
|
||||||
|
- [ ] I have tested my changes locally
|
||||||
|
- [ ] New user-facing strings are wrapped in `I18n.tr()` with translator context, reusing existing terms where possible
|
||||||
|
- [ ] Go changes: ran `make fmt`, added/updated tests, `make test` passes, and `go mod tidy` is clean
|
||||||
|
- [ ] QML changes: ran `make lint-qml` with no new warnings
|
||||||
|
- [ ] I have opened a corresponding pull request in dlx-docs to document any new behaviors: https://github.com/AvengeMedia/DankLinux-Docs
|
||||||
@@ -26,4 +26,4 @@ jobs:
|
|||||||
go-version-file: core/go.mod
|
go-version-file: core/go.mod
|
||||||
|
|
||||||
- name: run pre-commit hooks
|
- name: run pre-commit hooks
|
||||||
uses: j178/prek-action@v1
|
uses: j178/prek-action@v2
|
||||||
|
|||||||
@@ -115,3 +115,5 @@ core.*
|
|||||||
.direnv/
|
.direnv/
|
||||||
quickshell/dms-plugins
|
quickshell/dms-plugins
|
||||||
__pycache__
|
__pycache__
|
||||||
|
|
||||||
|
.vscode/
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ configure passwordless sudo for your user.`,
|
|||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
rootCmd.Flags().StringVarP(&compositor, "compositor", "c", "", "Compositor/WM to install: niri or hyprland (enables headless mode)")
|
rootCmd.Flags().StringVarP(&compositor, "compositor", "c", "", "Compositor/WM to install: niri, hyprland, or mango (enables headless mode)")
|
||||||
rootCmd.Flags().StringVarP(&term, "term", "t", "", "Terminal emulator to install: ghostty, kitty, or alacritty (enables headless mode)")
|
rootCmd.Flags().StringVarP(&term, "term", "t", "", "Terminal emulator to install: ghostty, kitty, or alacritty (enables headless mode)")
|
||||||
rootCmd.Flags().StringSliceVar(&includeDeps, "include-deps", []string{}, "Optional deps to enable (e.g. dms-greeter)")
|
rootCmd.Flags().StringSliceVar(&includeDeps, "include-deps", []string{}, "Optional deps to enable (e.g. dms-greeter)")
|
||||||
rootCmd.Flags().StringSliceVar(&excludeDeps, "exclude-deps", []string{}, "Deps to skip during installation")
|
rootCmd.Flags().StringSliceVar(&excludeDeps, "exclude-deps", []string{}, "Deps to skip during installation")
|
||||||
@@ -95,7 +95,7 @@ func runDankinstall(cmd *cobra.Command, args []string) error {
|
|||||||
func runHeadless() error {
|
func runHeadless() error {
|
||||||
// Validate required flags
|
// Validate required flags
|
||||||
if compositor == "" {
|
if compositor == "" {
|
||||||
return fmt.Errorf("--compositor is required for headless mode (niri or hyprland)")
|
return fmt.Errorf("--compositor is required for headless mode (niri, hyprland, or mango)")
|
||||||
}
|
}
|
||||||
if term == "" {
|
if term == "" {
|
||||||
return fmt.Errorf("--term is required for headless mode (ghostty, kitty, or alacritty)")
|
return fmt.Errorf("--term is required for headless mode (ghostty, kitty, or alacritty)")
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/plugins"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/plugins"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server"
|
||||||
@@ -37,6 +38,7 @@ var runCmd = &cobra.Command{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
log.ApplyEnvOverrides()
|
log.ApplyEnvOverrides()
|
||||||
|
config.CleanupStrayHyprlandConfFile(log.Infof)
|
||||||
if daemon {
|
if daemon {
|
||||||
runShellDaemon(session)
|
runShellDaemon(session)
|
||||||
} else {
|
} else {
|
||||||
@@ -539,5 +541,6 @@ func getCommonCommands() []*cobra.Command {
|
|||||||
blurCmd,
|
blurCmd,
|
||||||
trashCmd,
|
trashCmd,
|
||||||
systemCmd,
|
systemCmd,
|
||||||
|
switchUserCmd,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/luaconfig"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
@@ -27,7 +28,21 @@ var resolveIncludeCmd = &cobra.Command{
|
|||||||
case 0:
|
case 0:
|
||||||
return []string{"hyprland", "niri", "mangowc"}, cobra.ShellCompDirectiveNoFileComp
|
return []string{"hyprland", "niri", "mangowc"}, cobra.ShellCompDirectiveNoFileComp
|
||||||
case 1:
|
case 1:
|
||||||
return []string{"cursor.kdl", "cursor.conf", "outputs.kdl", "outputs.conf", "binds.kdl", "binds.conf"}, cobra.ShellCompDirectiveNoFileComp
|
return []string{
|
||||||
|
"binds.lua",
|
||||||
|
"binds-user.lua",
|
||||||
|
"colors.lua",
|
||||||
|
"layout.lua",
|
||||||
|
"outputs.lua",
|
||||||
|
"cursor.lua",
|
||||||
|
"windowrules.lua",
|
||||||
|
"cursor.kdl",
|
||||||
|
"outputs.kdl",
|
||||||
|
"binds.kdl",
|
||||||
|
"cursor.conf",
|
||||||
|
"outputs.conf",
|
||||||
|
"binds.conf",
|
||||||
|
}, cobra.ShellCompDirectiveNoFileComp
|
||||||
}
|
}
|
||||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
},
|
},
|
||||||
@@ -39,8 +54,10 @@ func init() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type IncludeResult struct {
|
type IncludeResult struct {
|
||||||
Exists bool `json:"exists"`
|
Exists bool `json:"exists"`
|
||||||
Included bool `json:"included"`
|
Included bool `json:"included"`
|
||||||
|
ConfigFormat string `json:"configFormat,omitempty"`
|
||||||
|
ReadOnly bool `json:"readOnly,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func runResolveInclude(cmd *cobra.Command, args []string) {
|
func runResolveInclude(cmd *cobra.Command, args []string) {
|
||||||
@@ -55,7 +72,7 @@ func runResolveInclude(cmd *cobra.Command, args []string) {
|
|||||||
result, err = checkHyprlandInclude(filename)
|
result, err = checkHyprlandInclude(filename)
|
||||||
case "niri":
|
case "niri":
|
||||||
result, err = checkNiriInclude(filename)
|
result, err = checkNiriInclude(filename)
|
||||||
case "mangowc", "dwl", "mango":
|
case "mangowc", "mango":
|
||||||
result, err = checkMangoWCInclude(filename)
|
result, err = checkMangoWCInclude(filename)
|
||||||
default:
|
default:
|
||||||
log.Fatalf("Unknown compositor: %s", compositor)
|
log.Fatalf("Unknown compositor: %s", compositor)
|
||||||
@@ -70,10 +87,7 @@ func runResolveInclude(cmd *cobra.Command, args []string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func checkHyprlandInclude(filename string) (IncludeResult, error) {
|
func checkHyprlandInclude(filename string) (IncludeResult, error) {
|
||||||
configDir, err := utils.ExpandPath("$HOME/.config/hypr")
|
configDir := filepath.Join(utils.XDGConfigHome(), "hypr")
|
||||||
if err != nil {
|
|
||||||
return IncludeResult{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
targetPath := filepath.Join(configDir, "dms", filename)
|
targetPath := filepath.Join(configDir, "dms", filename)
|
||||||
result := IncludeResult{}
|
result := IncludeResult{}
|
||||||
@@ -82,17 +96,41 @@ func checkHyprlandInclude(filename string) (IncludeResult, error) {
|
|||||||
result.Exists = true
|
result.Exists = true
|
||||||
}
|
}
|
||||||
|
|
||||||
mainConfig := filepath.Join(configDir, "hyprland.conf")
|
targetAbs, err := filepath.Abs(targetPath)
|
||||||
if _, err := os.Stat(mainConfig); os.IsNotExist(err) {
|
if err != nil {
|
||||||
return result, nil
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
targetRel := filepath.ToSlash(filepath.Join("dms", filename))
|
||||||
|
|
||||||
|
mainLua := filepath.Join(configDir, "hyprland.lua")
|
||||||
|
if _, err := os.Stat(mainLua); err == nil {
|
||||||
|
result.ConfigFormat = "lua"
|
||||||
|
result.ReadOnly = false
|
||||||
|
processedLua := make(map[string]bool)
|
||||||
|
if luaconfig.RequiresTarget(mainLua, targetAbs, processedLua) {
|
||||||
|
result.Included = true
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mainConf := filepath.Join(configDir, "hyprland.conf")
|
||||||
|
if _, err := os.Stat(mainConf); err == nil {
|
||||||
|
if result.ConfigFormat == "" {
|
||||||
|
result.ConfigFormat = "hyprlang"
|
||||||
|
result.ReadOnly = true
|
||||||
|
}
|
||||||
|
processed := make(map[string]bool)
|
||||||
|
if hyprlandFindIncludeHyprlang(mainConf, targetRel, processed) {
|
||||||
|
result.Included = true
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
processed := make(map[string]bool)
|
|
||||||
result.Included = hyprlandFindInclude(mainConfig, "dms/"+filename, processed)
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func hyprlandFindInclude(filePath, target string, processed map[string]bool) bool {
|
func hyprlandFindIncludeHyprlang(filePath, target string, processed map[string]bool) bool {
|
||||||
absPath, err := filepath.Abs(filePath)
|
absPath, err := filepath.Abs(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
@@ -141,7 +179,7 @@ func hyprlandFindInclude(filePath, target string, processed map[string]bool) boo
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if hyprlandFindInclude(expanded, target, processed) {
|
if hyprlandFindIncludeHyprlang(expanded, target, processed) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -150,10 +188,7 @@ func hyprlandFindInclude(filePath, target string, processed map[string]bool) boo
|
|||||||
}
|
}
|
||||||
|
|
||||||
func checkNiriInclude(filename string) (IncludeResult, error) {
|
func checkNiriInclude(filename string) (IncludeResult, error) {
|
||||||
configDir, err := utils.ExpandPath("$HOME/.config/niri")
|
configDir := filepath.Join(utils.XDGConfigHome(), "niri")
|
||||||
if err != nil {
|
|
||||||
return IncludeResult{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
targetPath := filepath.Join(configDir, "dms", filename)
|
targetPath := filepath.Join(configDir, "dms", filename)
|
||||||
result := IncludeResult{}
|
result := IncludeResult{}
|
||||||
@@ -229,10 +264,7 @@ func niriFindInclude(filePath, target string, processed map[string]bool) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func checkMangoWCInclude(filename string) (IncludeResult, error) {
|
func checkMangoWCInclude(filename string) (IncludeResult, error) {
|
||||||
configDir, err := utils.ExpandPath("$HOME/.config/mango")
|
configDir := filepath.Join(utils.XDGConfigHome(), "mango")
|
||||||
if err != nil {
|
|
||||||
return IncludeResult{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
targetPath := filepath.Join(configDir, "dms", filename)
|
targetPath := filepath.Join(configDir, "dms", filename)
|
||||||
result := IncludeResult{}
|
result := IncludeResult{}
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ const (
|
|||||||
catConfigFiles
|
catConfigFiles
|
||||||
catServices
|
catServices
|
||||||
catEnvironment
|
catEnvironment
|
||||||
|
catFonts
|
||||||
)
|
)
|
||||||
|
|
||||||
func (c category) String() string {
|
func (c category) String() string {
|
||||||
@@ -147,6 +148,8 @@ func (c category) String() string {
|
|||||||
return "Services"
|
return "Services"
|
||||||
case catEnvironment:
|
case catEnvironment:
|
||||||
return "Environment"
|
return "Environment"
|
||||||
|
case catFonts:
|
||||||
|
return "Fonts"
|
||||||
default:
|
default:
|
||||||
return "Unknown"
|
return "Unknown"
|
||||||
}
|
}
|
||||||
@@ -213,6 +216,7 @@ func runDoctor(cmd *cobra.Command, args []string) {
|
|||||||
checkConfigurationFiles(),
|
checkConfigurationFiles(),
|
||||||
checkSystemdServices(),
|
checkSystemdServices(),
|
||||||
checkEnvironmentVars(),
|
checkEnvironmentVars(),
|
||||||
|
checkFonts(),
|
||||||
)
|
)
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
@@ -947,9 +951,12 @@ func checkSystemdServices() []checkResult {
|
|||||||
message = fmt.Sprintf("%s, %s", dmsState.enabled, dmsState.active)
|
message = fmt.Sprintf("%s, %s", dmsState.enabled, dmsState.active)
|
||||||
}
|
}
|
||||||
switch {
|
switch {
|
||||||
|
case dmsState.active == "failed":
|
||||||
|
status = statusError
|
||||||
|
case dmsState.active == "active":
|
||||||
case dmsState.enabled == "disabled":
|
case dmsState.enabled == "disabled":
|
||||||
status, message = statusWarn, "Disabled"
|
status, message = statusWarn, "Disabled"
|
||||||
case dmsState.active == "failed" || dmsState.active == "inactive":
|
case dmsState.active == "inactive":
|
||||||
status = statusError
|
status = statusError
|
||||||
}
|
}
|
||||||
results = append(results, checkResult{catServices, "dms.service", status, message, "", doctorDocsURL + "#services"})
|
results = append(results, checkResult{catServices, "dms.service", status, message, "", doctorDocsURL + "#services"})
|
||||||
@@ -1132,3 +1139,100 @@ func formatResultsPlain(results []checkResult) string {
|
|||||||
|
|
||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func checkFonts() []checkResult {
|
||||||
|
var results []checkResult
|
||||||
|
url := doctorDocsURL + "#fonts"
|
||||||
|
|
||||||
|
configDir, err := os.UserConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
settingsPath := filepath.Join(configDir, "DankMaterialShell", "settings.json")
|
||||||
|
|
||||||
|
fontFamily := "Inter Variable"
|
||||||
|
monoFontFamily := "Fira Code"
|
||||||
|
|
||||||
|
if data, err := os.ReadFile(settingsPath); err == nil {
|
||||||
|
var settings struct {
|
||||||
|
FontFamily string `json:"fontFamily"`
|
||||||
|
MonoFontFamily string `json:"monoFontFamily"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &settings); err == nil {
|
||||||
|
if settings.FontFamily != "" {
|
||||||
|
fontFamily = settings.FontFamily
|
||||||
|
}
|
||||||
|
if settings.MonoFontFamily != "" {
|
||||||
|
monoFontFamily = settings.MonoFontFamily
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !utils.CommandExists("fc-list") {
|
||||||
|
results = append(results, checkResult{catFonts, "Fontconfig Tools", statusWarn, "fc-list not installed", "Cannot verify if fonts are cached.", url})
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrieve font list
|
||||||
|
output, err := exec.Command("fc-list", ":", "family").Output()
|
||||||
|
if err != nil {
|
||||||
|
results = append(results, checkResult{catFonts, "Fontconfig Cache", statusError, "Failed to query font list", "Fontconfig cache query failed. Try running 'fc-cache -fv'.", url})
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
outStr := string(output)
|
||||||
|
if len(strings.TrimSpace(outStr)) == 0 {
|
||||||
|
results = append(results, checkResult{catFonts, "Fontconfig Cache", statusError, "Cache is empty", "No fonts found in fontconfig cache. Try running 'fc-cache -fv'.", url})
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
lowerFonts := strings.ToLower(outStr)
|
||||||
|
|
||||||
|
// Helper to check if a font exists
|
||||||
|
hasFont := func(name string) bool {
|
||||||
|
target := strings.ToLower(strings.TrimSpace(name))
|
||||||
|
if target == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(lowerFonts, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Each line can have comma-separated families
|
||||||
|
families := strings.Split(line, ",")
|
||||||
|
for _, fam := range families {
|
||||||
|
if strings.TrimSpace(fam) == target {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal Font Check
|
||||||
|
if hasFont(fontFamily) {
|
||||||
|
results = append(results, checkResult{catFonts, "Normal Font", statusOK, fontFamily, "Available", url})
|
||||||
|
} else {
|
||||||
|
results = append(results, checkResult{
|
||||||
|
catFonts, "Normal Font", statusWarn,
|
||||||
|
fmt.Sprintf("'%s' not found", fontFamily),
|
||||||
|
"Font is not registered. Try running 'fc-cache -fv' or install the font.",
|
||||||
|
url,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Monospace Font Check
|
||||||
|
if hasFont(monoFontFamily) {
|
||||||
|
results = append(results, checkResult{catFonts, "Monospace Font", statusOK, monoFontFamily, "Available", url})
|
||||||
|
} else {
|
||||||
|
results = append(results, checkResult{
|
||||||
|
catFonts, "Monospace Font", statusWarn,
|
||||||
|
fmt.Sprintf("'%s' not found", monoFontFamily),
|
||||||
|
"Font is not registered. Try running 'fc-cache -fv' or install the font.",
|
||||||
|
url,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
@@ -59,22 +60,36 @@ var greeterInstallCmd = &cobra.Command{
|
|||||||
}
|
}
|
||||||
|
|
||||||
var greeterSyncCmd = &cobra.Command{
|
var greeterSyncCmd = &cobra.Command{
|
||||||
Use: "sync",
|
Use: "sync",
|
||||||
Short: "Sync DMS theme and settings with greeter",
|
Short: "Sync DMS theme and settings with greeter",
|
||||||
Long: "Synchronize your current user's DMS theme, settings, and wallpaper configuration with the login greeter screen",
|
Long: "Synchronize your current user's DMS theme, settings, and wallpaper configuration with the login greeter screen. Also updates a per-user cache slot at users/<username>/ for multi-account greeter theme preview.\n\nUse --profile on secondary accounts to sync only your own users/<username>/ slot without sudo or greetd changes.",
|
||||||
PreRunE: preRunPrivileged,
|
PreRunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
profile, _ := cmd.Flags().GetBool("profile")
|
||||||
|
if profile {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return preRunPrivileged(cmd, args)
|
||||||
|
},
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
yes, _ := cmd.Flags().GetBool("yes")
|
yes, _ := cmd.Flags().GetBool("yes")
|
||||||
auth, _ := cmd.Flags().GetBool("auth")
|
auth, _ := cmd.Flags().GetBool("auth")
|
||||||
local, _ := cmd.Flags().GetBool("local")
|
local, _ := cmd.Flags().GetBool("local")
|
||||||
|
profile, _ := cmd.Flags().GetBool("profile")
|
||||||
|
autologinOnly, _ := cmd.Flags().GetBool("autologin")
|
||||||
term, _ := cmd.Flags().GetBool("terminal")
|
term, _ := cmd.Flags().GetBool("terminal")
|
||||||
if term {
|
if term {
|
||||||
if err := syncInTerminal(yes, auth, local); err != nil {
|
if err := syncInTerminal(yes, auth, local, profile, autologinOnly); err != nil {
|
||||||
log.Fatalf("Error launching sync in terminal: %v", err)
|
log.Fatalf("Error launching sync in terminal: %v", err)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := syncGreeter(yes, auth, local); err != nil {
|
if autologinOnly {
|
||||||
|
if err := syncGreeterAutoLoginOnly(yes); err != nil {
|
||||||
|
log.Fatalf("Error syncing greeter auto-login: %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := syncGreeter(yes, auth, local, profile); err != nil {
|
||||||
log.Fatalf("Error syncing greeter: %v", err)
|
log.Fatalf("Error syncing greeter: %v", err)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -85,6 +100,8 @@ func init() {
|
|||||||
greeterSyncCmd.Flags().BoolP("terminal", "t", false, "Run sync in a new terminal (for entering sudo password); terminal auto-closes when done")
|
greeterSyncCmd.Flags().BoolP("terminal", "t", false, "Run sync in a new terminal (for entering sudo password); terminal auto-closes when done")
|
||||||
greeterSyncCmd.Flags().BoolP("auth", "a", false, "Configure PAM for fingerprint and U2F (adds both if modules exist); overrides UI toggles")
|
greeterSyncCmd.Flags().BoolP("auth", "a", false, "Configure PAM for fingerprint and U2F (adds both if modules exist); overrides UI toggles")
|
||||||
greeterSyncCmd.Flags().BoolP("local", "l", false, "Developer mode: force greetd config to use a local DMS checkout path")
|
greeterSyncCmd.Flags().BoolP("local", "l", false, "Developer mode: force greetd config to use a local DMS checkout path")
|
||||||
|
greeterSyncCmd.Flags().BoolP("profile", "p", false, "Sync only your per-user greeter slot (no sudo; for secondary accounts)")
|
||||||
|
greeterSyncCmd.Flags().Bool("autologin", false, "Apply only greeter auto-login on startup settings to greetd (no theme or auth sync)")
|
||||||
}
|
}
|
||||||
|
|
||||||
var greeterEnableCmd = &cobra.Command{
|
var greeterEnableCmd = &cobra.Command{
|
||||||
@@ -512,8 +529,8 @@ func runCommandInTerminal(shellCmd string) error {
|
|||||||
return fmt.Errorf("no terminal emulator found (tried: gnome-terminal, konsole, xfce4-terminal, ghostty, wezterm, alacritty, kitty, xterm)")
|
return fmt.Errorf("no terminal emulator found (tried: gnome-terminal, konsole, xfce4-terminal, ghostty, wezterm, alacritty, kitty, xterm)")
|
||||||
}
|
}
|
||||||
|
|
||||||
func syncInTerminal(nonInteractive bool, forceAuth bool, local bool) error {
|
func syncInTerminal(nonInteractive bool, forceAuth bool, local bool, profileOnly bool, autologinOnly bool) error {
|
||||||
syncFlags := make([]string, 0, 3)
|
syncFlags := make([]string, 0, 5)
|
||||||
if nonInteractive {
|
if nonInteractive {
|
||||||
syncFlags = append(syncFlags, "--yes")
|
syncFlags = append(syncFlags, "--yes")
|
||||||
}
|
}
|
||||||
@@ -523,11 +540,22 @@ func syncInTerminal(nonInteractive bool, forceAuth bool, local bool) error {
|
|||||||
if local {
|
if local {
|
||||||
syncFlags = append(syncFlags, "--local")
|
syncFlags = append(syncFlags, "--local")
|
||||||
}
|
}
|
||||||
|
if profileOnly {
|
||||||
|
syncFlags = append(syncFlags, "--profile")
|
||||||
|
}
|
||||||
|
if autologinOnly {
|
||||||
|
syncFlags = append(syncFlags, "--autologin")
|
||||||
|
}
|
||||||
shellSyncCmd := "dms greeter sync"
|
shellSyncCmd := "dms greeter sync"
|
||||||
if len(syncFlags) > 0 {
|
if len(syncFlags) > 0 {
|
||||||
shellSyncCmd += " " + strings.Join(syncFlags, " ")
|
shellSyncCmd += " " + strings.Join(syncFlags, " ")
|
||||||
}
|
}
|
||||||
shellCmd := shellSyncCmd + `; echo; echo "Sync finished. Closing in 3 seconds..."; sleep 3`
|
var shellCmd string
|
||||||
|
if autologinOnly {
|
||||||
|
shellCmd = shellSyncCmd + `; echo; echo "Auto-login update finished. Closing in 3 seconds..."; sleep 3`
|
||||||
|
} else {
|
||||||
|
shellCmd = shellSyncCmd + `; echo; echo "Sync finished. Closing in 3 seconds..."; sleep 3`
|
||||||
|
}
|
||||||
return runCommandInTerminal(shellCmd)
|
return runCommandInTerminal(shellCmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -541,7 +569,54 @@ func resolveLocalWrapperShell() (string, error) {
|
|||||||
return "", fmt.Errorf("could not find bash or sh in PATH for local greeter wrapper")
|
return "", fmt.Errorf("could not find bash or sh in PATH for local greeter wrapper")
|
||||||
}
|
}
|
||||||
|
|
||||||
func syncGreeter(nonInteractive bool, forceAuth bool, local bool) error {
|
func syncGreeterAutoLoginOnly(nonInteractive bool) error {
|
||||||
|
logFunc := func(msg string) {
|
||||||
|
fmt.Println(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
homeDir, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get user home directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
settingsPath := filepath.Join(homeDir, ".config", "DankMaterialShell", "settings.json")
|
||||||
|
cacheSettingsPath := filepath.Join(greeter.GreeterCacheDir, "settings.json")
|
||||||
|
enabled := false
|
||||||
|
for _, path := range []string{cacheSettingsPath, settingsPath} {
|
||||||
|
data, readErr := os.ReadFile(path)
|
||||||
|
if readErr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var cfg struct {
|
||||||
|
GreeterAutoLogin bool `json:"greeterAutoLogin"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(data, &cfg) == nil {
|
||||||
|
enabled = cfg.GreeterAutoLogin
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("=== Greeter Auto-Login ===")
|
||||||
|
fmt.Println()
|
||||||
|
if enabled {
|
||||||
|
fmt.Println("Enabling auto-login on startup in greetd.")
|
||||||
|
fmt.Println("After your next reboot, DMS will skip the greeter password until you sign out.")
|
||||||
|
} else {
|
||||||
|
fmt.Println("Disabling auto-login on startup in greetd.")
|
||||||
|
fmt.Println("After your next reboot, you will enter your password at the greeter again.")
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("Administrator (sudo) access is required to update /etc/greetd/config.toml.")
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
return greeter.SyncGreeterAutoLoginOnly(logFunc, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func syncGreeter(nonInteractive bool, forceAuth bool, local bool, profileOnly bool) error {
|
||||||
|
if profileOnly {
|
||||||
|
return syncGreeterProfileOnly(nonInteractive)
|
||||||
|
}
|
||||||
|
|
||||||
if !nonInteractive {
|
if !nonInteractive {
|
||||||
fmt.Println("=== DMS Greeter Sync ===")
|
fmt.Println("=== DMS Greeter Sync ===")
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
@@ -752,6 +827,26 @@ func syncGreeter(nonInteractive bool, forceAuth bool, local bool) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func syncGreeterProfileOnly(nonInteractive bool) error {
|
||||||
|
logFunc := func(msg string) {
|
||||||
|
fmt.Println(msg)
|
||||||
|
}
|
||||||
|
if !nonInteractive {
|
||||||
|
fmt.Println("=== DMS Greeter Profile Sync ===")
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("Syncing your personal greeter theme slot (no system changes)...")
|
||||||
|
}
|
||||||
|
if err := greeter.SyncUserProfileCache(logFunc); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !nonInteractive {
|
||||||
|
fmt.Println("\n=== Profile Sync Complete ===")
|
||||||
|
fmt.Println("\nYour theme, wallpaper, and profile photo have been synced for the login screen.")
|
||||||
|
fmt.Println("Log out to preview your greeter look when selecting your account.")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func hasDmsShellQml(dir string) bool {
|
func hasDmsShellQml(dir string) bool {
|
||||||
info, err := os.Stat(filepath.Join(dir, "shell.qml"))
|
info, err := os.Stat(filepath.Join(dir, "shell.qml"))
|
||||||
return err == nil && !info.IsDir()
|
return err == nil && !info.IsDir()
|
||||||
@@ -837,7 +932,14 @@ func resolveLocalDMSPath() (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return "", fmt.Errorf("could not locate a local DMS checkout from %s; run from repo root or set DMS_LOCAL_PATH=/absolute/path/to/repo", wd)
|
configuredCommand := readDefaultSessionCommand("/etc/greetd/config.toml")
|
||||||
|
if pathOverride := extractGreeterPathOverrideFromCommand(configuredCommand); pathOverride != "" {
|
||||||
|
if resolved, ok := resolveDMSLocalCandidate(pathOverride); ok {
|
||||||
|
return resolved, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("could not locate a local DMS checkout from %s; run from repo root, set DMS_LOCAL_PATH=/absolute/path/to/repo, or configure greetd with -p /path/to/quickshell", wd)
|
||||||
}
|
}
|
||||||
|
|
||||||
func disableDisplayManager(dmName string) (bool, error) {
|
func disableDisplayManager(dmName string) (bool, error) {
|
||||||
|
|||||||
@@ -51,12 +51,20 @@ var keybindsSetCmd = &cobra.Command{
|
|||||||
|
|
||||||
var keybindsRemoveCmd = &cobra.Command{
|
var keybindsRemoveCmd = &cobra.Command{
|
||||||
Use: "remove <provider> <key>",
|
Use: "remove <provider> <key>",
|
||||||
Short: "Remove a keybind override",
|
Short: "Remove a keybind",
|
||||||
Long: "Remove a keybind override from the specified provider",
|
Long: "Remove a keybind. For Hyprland this writes a negative override to dms/binds-user.lua so the key stays unbound across DMS updates. For other providers it deletes the entry from the managed file.",
|
||||||
Args: cobra.ExactArgs(2),
|
Args: cobra.ExactArgs(2),
|
||||||
Run: runKeybindsRemove,
|
Run: runKeybindsRemove,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var keybindsResetCmd = &cobra.Command{
|
||||||
|
Use: "reset <provider> <key>",
|
||||||
|
Short: "Reset a keybind override to its DMS default",
|
||||||
|
Long: "Drop the user override for the given key so the DMS default re-applies. For providers without a separate default file (Niri, MangoWC) this is equivalent to remove.",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
Run: runKeybindsReset,
|
||||||
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
keybindsListCmd.Flags().BoolP("json", "j", false, "Output as JSON")
|
keybindsListCmd.Flags().BoolP("json", "j", false, "Output as JSON")
|
||||||
keybindsShowCmd.Flags().String("path", "", "Override config path for the provider")
|
keybindsShowCmd.Flags().String("path", "", "Override config path for the provider")
|
||||||
@@ -72,6 +80,7 @@ func init() {
|
|||||||
keybindsCmd.AddCommand(keybindsShowCmd)
|
keybindsCmd.AddCommand(keybindsShowCmd)
|
||||||
keybindsCmd.AddCommand(keybindsSetCmd)
|
keybindsCmd.AddCommand(keybindsSetCmd)
|
||||||
keybindsCmd.AddCommand(keybindsRemoveCmd)
|
keybindsCmd.AddCommand(keybindsRemoveCmd)
|
||||||
|
keybindsCmd.AddCommand(keybindsResetCmd)
|
||||||
|
|
||||||
keybinds.SetJSONProviderFactory(func(filePath string) (keybinds.Provider, error) {
|
keybinds.SetJSONProviderFactory(func(filePath string) (keybinds.Provider, error) {
|
||||||
return providers.NewJSONFileProvider(filePath)
|
return providers.NewJSONFileProvider(filePath)
|
||||||
@@ -263,3 +272,19 @@ func runKeybindsRemove(_ *cobra.Command, args []string) {
|
|||||||
}, "", " ")
|
}, "", " ")
|
||||||
fmt.Fprintln(os.Stdout, string(output))
|
fmt.Fprintln(os.Stdout, string(output))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runKeybindsReset(_ *cobra.Command, args []string) {
|
||||||
|
providerName, key := args[0], args[1]
|
||||||
|
writable := getWritableProvider(providerName)
|
||||||
|
|
||||||
|
if err := writable.ResetBind(key); err != nil {
|
||||||
|
log.Fatalf("Error resetting keybind: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
output, _ := json.MarshalIndent(map[string]any{
|
||||||
|
"success": true,
|
||||||
|
"key": key,
|
||||||
|
"reset": true,
|
||||||
|
}, "", " ")
|
||||||
|
fmt.Fprintln(os.Stdout, string(output))
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
|
||||||
@@ -37,7 +39,7 @@ Modes:
|
|||||||
full - Capture the focused output
|
full - Capture the focused output
|
||||||
all - Capture all outputs combined
|
all - Capture all outputs combined
|
||||||
output - Capture a specific output by name
|
output - Capture a specific output by name
|
||||||
window - Capture the focused window (Hyprland/DWL)
|
window - Capture the focused window (Hyprland/Mango)
|
||||||
last - Capture the last selected region
|
last - Capture the last selected region
|
||||||
|
|
||||||
Output format (--format):
|
Output format (--format):
|
||||||
@@ -95,7 +97,7 @@ If no previous region exists, falls back to interactive selection.`,
|
|||||||
var ssWindowCmd = &cobra.Command{
|
var ssWindowCmd = &cobra.Command{
|
||||||
Use: "window",
|
Use: "window",
|
||||||
Short: "Capture the focused window",
|
Short: "Capture the focused window",
|
||||||
Long: `Capture the currently focused window. Supported on Hyprland and DWL.`,
|
Long: `Capture the currently focused window. Supported on Hyprland and Mango.`,
|
||||||
Run: runScreenshotWindow,
|
Run: runScreenshotWindow,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,9 +181,39 @@ func getScreenshotConfig(mode screenshot.Mode) screenshot.Config {
|
|||||||
return config
|
return config
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setPopoutScreenshotMode toggles the shell handshake so popouts drop their keyboard grab during region select. Best-effort.
|
||||||
|
func setPopoutScreenshotMode(begin bool) {
|
||||||
|
fn := "end"
|
||||||
|
if begin {
|
||||||
|
fn = "begin"
|
||||||
|
}
|
||||||
|
cmdArgs := []string{"ipc"}
|
||||||
|
if pid, ok := getFirstDMSPID(); ok {
|
||||||
|
cmdArgs = append(cmdArgs, "--pid", strconv.Itoa(pid))
|
||||||
|
} else {
|
||||||
|
if err := findConfig(nil, nil); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if qsHasAnyDisplay() {
|
||||||
|
cmdArgs = append(cmdArgs, "--any-display")
|
||||||
|
}
|
||||||
|
cmdArgs = append(cmdArgs, "-p", configPath)
|
||||||
|
}
|
||||||
|
cmdArgs = append(cmdArgs, "call", "screenshot", fn)
|
||||||
|
_ = exec.Command("qs", cmdArgs...).Run()
|
||||||
|
}
|
||||||
|
|
||||||
func runScreenshot(config screenshot.Config) {
|
func runScreenshot(config screenshot.Config) {
|
||||||
sc := screenshot.New(config)
|
// Region select needs the keyboard; drop popout grabs for its duration.
|
||||||
result, err := sc.Run()
|
result, err := func() (*screenshot.CaptureResult, error) {
|
||||||
|
interactive := config.Mode == screenshot.ModeRegion || config.Mode == screenshot.ModeLastRegion
|
||||||
|
if interactive {
|
||||||
|
setPopoutScreenshotMode(true)
|
||||||
|
defer setPopoutScreenshotMode(false)
|
||||||
|
}
|
||||||
|
return screenshot.New(config).Run()
|
||||||
|
}()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var switchUserCmd = &cobra.Command{
|
||||||
|
Use: "switch-user [target]",
|
||||||
|
Short: "Switch to another active session on this seat",
|
||||||
|
Long: `Switch the active VT to another running session.
|
||||||
|
|
||||||
|
With no target, prints the list of switchable sessions. Pass a username or a
|
||||||
|
numeric session ID to switch directly. Requires the target to already be a
|
||||||
|
running session on the same seat (use the greeter for a fresh login).`,
|
||||||
|
Args: cobra.MaximumNArgs(1),
|
||||||
|
Run: runSwitchUser,
|
||||||
|
}
|
||||||
|
|
||||||
|
type sessionInfo struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Seat string
|
||||||
|
TTY string
|
||||||
|
Type string
|
||||||
|
Class string
|
||||||
|
Active bool
|
||||||
|
State string
|
||||||
|
Current bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func runSwitchUser(cmd *cobra.Command, args []string) {
|
||||||
|
currentID := os.Getenv("XDG_SESSION_ID")
|
||||||
|
sessions, err := listSessions(currentID)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
switchable := make([]sessionInfo, 0, len(sessions))
|
||||||
|
for _, s := range sessions {
|
||||||
|
if s.Class != "user" || s.State == "closing" || s.Current {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switchable = append(switchable, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(args) == 0 {
|
||||||
|
if len(switchable) == 0 {
|
||||||
|
fmt.Println("No other active sessions on this seat.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
printSessions(switchable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
target := args[0]
|
||||||
|
picked, err := pickSession(switchable, target)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
if len(switchable) == 0 {
|
||||||
|
fmt.Fprintln(os.Stderr, "No other active sessions on this seat. Only already-running sessions can be switched to.")
|
||||||
|
} else {
|
||||||
|
fmt.Fprintln(os.Stderr, "\nSwitchable sessions:")
|
||||||
|
printSessions(switchable)
|
||||||
|
}
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := activateSession(picked.ID); err != nil {
|
||||||
|
log.Fatalf("loginctl activate %s: %v", picked.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func listSessions(currentID string) ([]sessionInfo, error) {
|
||||||
|
listOut, err := exec.Command("loginctl", "list-sessions", "--no-legend").Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("loginctl list-sessions: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var ids []string
|
||||||
|
scanner := bufio.NewScanner(strings.NewReader(string(listOut)))
|
||||||
|
for scanner.Scan() {
|
||||||
|
fields := strings.Fields(scanner.Text())
|
||||||
|
if len(fields) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ids = append(ids, fields[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]sessionInfo, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
s, err := showSession(id)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.Current = currentID != "" && s.ID == currentID
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
sort.SliceStable(out, func(i, j int) bool {
|
||||||
|
if out[i].Name != out[j].Name {
|
||||||
|
return out[i].Name < out[j].Name
|
||||||
|
}
|
||||||
|
return out[i].ID < out[j].ID
|
||||||
|
})
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func showSession(id string) (sessionInfo, error) {
|
||||||
|
out, err := exec.Command("loginctl", "show-session", id,
|
||||||
|
"-p", "Id", "-p", "Name", "-p", "Seat", "-p", "TTY",
|
||||||
|
"-p", "Type", "-p", "Class", "-p", "Active", "-p", "State").Output()
|
||||||
|
if err != nil {
|
||||||
|
return sessionInfo{}, err
|
||||||
|
}
|
||||||
|
fields := map[string]string{}
|
||||||
|
for _, line := range strings.Split(string(out), "\n") {
|
||||||
|
idx := strings.IndexByte(line, '=')
|
||||||
|
if idx <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fields[line[:idx]] = line[idx+1:]
|
||||||
|
}
|
||||||
|
if fields["Id"] == "" {
|
||||||
|
return sessionInfo{}, fmt.Errorf("session %s: no Id", id)
|
||||||
|
}
|
||||||
|
return sessionInfo{
|
||||||
|
ID: fields["Id"],
|
||||||
|
Name: fields["Name"],
|
||||||
|
Seat: fields["Seat"],
|
||||||
|
TTY: fields["TTY"],
|
||||||
|
Type: fields["Type"],
|
||||||
|
Class: fields["Class"],
|
||||||
|
Active: fields["Active"] == "yes",
|
||||||
|
State: fields["State"],
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func pickSession(sessions []sessionInfo, target string) (sessionInfo, error) {
|
||||||
|
for _, s := range sessions {
|
||||||
|
if s.ID == target {
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
matches := make([]sessionInfo, 0, 2)
|
||||||
|
for _, s := range sessions {
|
||||||
|
if s.Name == target {
|
||||||
|
matches = append(matches, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(matches) == 1 {
|
||||||
|
return matches[0], nil
|
||||||
|
}
|
||||||
|
if len(matches) > 1 {
|
||||||
|
ids := make([]string, len(matches))
|
||||||
|
for i, m := range matches {
|
||||||
|
ids[i] = m.ID
|
||||||
|
}
|
||||||
|
return sessionInfo{}, fmt.Errorf("%s has multiple active sessions (%s); pass a session ID instead", target, strings.Join(ids, ", "))
|
||||||
|
}
|
||||||
|
return sessionInfo{}, fmt.Errorf("no switchable session matches %q", target)
|
||||||
|
}
|
||||||
|
|
||||||
|
func activateSession(id string) error {
|
||||||
|
return exec.Command("loginctl", "activate", id).Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
func printSessions(sessions []sessionInfo) {
|
||||||
|
fmt.Printf("%-6s %-12s %-8s %-8s %-8s\n", "ID", "USER", "TYPE", "SEAT", "TTY")
|
||||||
|
for _, s := range sessions {
|
||||||
|
tty := s.TTY
|
||||||
|
if tty == "" {
|
||||||
|
tty = "-"
|
||||||
|
}
|
||||||
|
seat := s.Seat
|
||||||
|
if seat == "" {
|
||||||
|
seat = "-"
|
||||||
|
}
|
||||||
|
fmt.Printf("%-6s %-12s %-8s %-8s %-8s\n", s.ID, s.Name, s.Type, seat, tty)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -100,56 +100,72 @@ var setupWindowrulesCmd = &cobra.Command{
|
|||||||
}
|
}
|
||||||
|
|
||||||
type dmsConfigSpec struct {
|
type dmsConfigSpec struct {
|
||||||
niriFile string
|
niriFile string
|
||||||
hyprFile string
|
hyprFile string
|
||||||
niriContent func(terminal string) string
|
mangoFile string
|
||||||
hyprContent func(terminal string) string
|
niriContent func(terminal string) string
|
||||||
|
hyprContent func(terminal string) string
|
||||||
|
mangoContent func(terminal string) string
|
||||||
}
|
}
|
||||||
|
|
||||||
var dmsConfigSpecs = map[string]dmsConfigSpec{
|
var dmsConfigSpecs = map[string]dmsConfigSpec{
|
||||||
"binds": {
|
"binds": {
|
||||||
niriFile: "binds.kdl",
|
niriFile: "binds.kdl",
|
||||||
hyprFile: "binds.conf",
|
hyprFile: "binds.lua",
|
||||||
|
mangoFile: "binds.conf",
|
||||||
niriContent: func(t string) string {
|
niriContent: func(t string) string {
|
||||||
return strings.ReplaceAll(config.NiriBindsConfig, "{{TERMINAL_COMMAND}}", t)
|
return strings.ReplaceAll(config.NiriBindsConfig, "{{TERMINAL_COMMAND}}", t)
|
||||||
},
|
},
|
||||||
hyprContent: func(t string) string {
|
hyprContent: func(t string) string {
|
||||||
return strings.ReplaceAll(config.HyprBindsConfig, "{{TERMINAL_COMMAND}}", t)
|
return strings.ReplaceAll(config.DMSBindsLuaConfig, "{{TERMINAL_COMMAND}}", t)
|
||||||
|
},
|
||||||
|
mangoContent: func(t string) string {
|
||||||
|
return strings.ReplaceAll(config.MangoBindsConfig, "{{TERMINAL_COMMAND}}", t)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"layout": {
|
"layout": {
|
||||||
niriFile: "layout.kdl",
|
niriFile: "layout.kdl",
|
||||||
hyprFile: "layout.conf",
|
hyprFile: "layout.lua",
|
||||||
niriContent: func(_ string) string { return config.NiriLayoutConfig },
|
mangoFile: "layout.conf",
|
||||||
hyprContent: func(_ string) string { return config.HyprLayoutConfig },
|
niriContent: func(_ string) string { return config.NiriLayoutConfig },
|
||||||
|
hyprContent: func(_ string) string { return config.DMSLayoutLuaConfig },
|
||||||
|
mangoContent: func(_ string) string { return config.MangoLayoutConfig },
|
||||||
},
|
},
|
||||||
"colors": {
|
"colors": {
|
||||||
niriFile: "colors.kdl",
|
niriFile: "colors.kdl",
|
||||||
hyprFile: "colors.conf",
|
hyprFile: "colors.lua",
|
||||||
niriContent: func(_ string) string { return config.NiriColorsConfig },
|
mangoFile: "colors.conf",
|
||||||
hyprContent: func(_ string) string { return config.HyprColorsConfig },
|
niriContent: func(_ string) string { return config.NiriColorsConfig },
|
||||||
|
hyprContent: func(_ string) string { return config.DMSColorsLuaConfig },
|
||||||
|
mangoContent: func(_ string) string { return config.MangoColorsConfig },
|
||||||
},
|
},
|
||||||
"alttab": {
|
"alttab": {
|
||||||
niriFile: "alttab.kdl",
|
niriFile: "alttab.kdl",
|
||||||
niriContent: func(_ string) string { return config.NiriAlttabConfig },
|
niriContent: func(_ string) string { return config.NiriAlttabConfig },
|
||||||
},
|
},
|
||||||
"outputs": {
|
"outputs": {
|
||||||
niriFile: "outputs.kdl",
|
niriFile: "outputs.kdl",
|
||||||
hyprFile: "outputs.conf",
|
hyprFile: "outputs.lua",
|
||||||
niriContent: func(_ string) string { return "" },
|
mangoFile: "outputs.conf",
|
||||||
hyprContent: func(_ string) string { return "" },
|
niriContent: func(_ string) string { return "" },
|
||||||
|
hyprContent: func(_ string) string { return config.DMSOutputsLuaConfig },
|
||||||
|
mangoContent: func(_ string) string { return "" },
|
||||||
},
|
},
|
||||||
"cursor": {
|
"cursor": {
|
||||||
niriFile: "cursor.kdl",
|
niriFile: "cursor.kdl",
|
||||||
hyprFile: "cursor.conf",
|
hyprFile: "cursor.lua",
|
||||||
niriContent: func(_ string) string { return "" },
|
mangoFile: "cursor.conf",
|
||||||
hyprContent: func(_ string) string { return "" },
|
niriContent: func(_ string) string { return "" },
|
||||||
|
hyprContent: func(_ string) string { return config.DMSCursorLuaConfig },
|
||||||
|
mangoContent: func(_ string) string { return "" },
|
||||||
},
|
},
|
||||||
"windowrules": {
|
"windowrules": {
|
||||||
niriFile: "windowrules.kdl",
|
niriFile: "windowrules.kdl",
|
||||||
hyprFile: "windowrules.conf",
|
hyprFile: "windowrules.lua",
|
||||||
niriContent: func(_ string) string { return "" },
|
mangoFile: "windowrules.conf",
|
||||||
hyprContent: func(_ string) string { return "" },
|
niriContent: func(_ string) string { return "" },
|
||||||
|
hyprContent: func(_ string) string { return config.DMSWindowRulesLuaConfig },
|
||||||
|
mangoContent: func(_ string) string { return "" },
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,7 +208,7 @@ func detectCompositorForSetup() (string, error) {
|
|||||||
|
|
||||||
switch len(compositors) {
|
switch len(compositors) {
|
||||||
case 0:
|
case 0:
|
||||||
return "", fmt.Errorf("no supported compositors found (niri or Hyprland required)")
|
return "", fmt.Errorf("no supported compositors found (niri, Hyprland, or mango required)")
|
||||||
case 1:
|
case 1:
|
||||||
return strings.ToLower(compositors[0]), nil
|
return strings.ToLower(compositors[0]), nil
|
||||||
}
|
}
|
||||||
@@ -224,6 +240,9 @@ func runSetupDmsConfig(name string) error {
|
|||||||
case "hyprland":
|
case "hyprland":
|
||||||
filename = spec.hyprFile
|
filename = spec.hyprFile
|
||||||
contentFn = spec.hyprContent
|
contentFn = spec.hyprContent
|
||||||
|
case "mango", "mangowc":
|
||||||
|
filename = spec.mangoFile
|
||||||
|
contentFn = spec.mangoContent
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unsupported compositor: %s", compositor)
|
return fmt.Errorf("unsupported compositor: %s", compositor)
|
||||||
}
|
}
|
||||||
@@ -235,9 +254,11 @@ func runSetupDmsConfig(name string) error {
|
|||||||
var dmsDir string
|
var dmsDir string
|
||||||
switch compositor {
|
switch compositor {
|
||||||
case "niri":
|
case "niri":
|
||||||
dmsDir = filepath.Join(os.Getenv("HOME"), ".config", "niri", "dms")
|
dmsDir = filepath.Join(utils.XDGConfigHome(), "niri", "dms")
|
||||||
case "hyprland":
|
case "hyprland":
|
||||||
dmsDir = filepath.Join(os.Getenv("HOME"), ".config", "hypr", "dms")
|
dmsDir = filepath.Join(utils.XDGConfigHome(), "hypr", "dms")
|
||||||
|
case "mango", "mangowc":
|
||||||
|
dmsDir = filepath.Join(utils.XDGConfigHome(), "mango", "dms")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
|
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
|
||||||
@@ -273,7 +294,14 @@ func runSetup() error {
|
|||||||
|
|
||||||
wm, wmSelected := promptCompositor()
|
wm, wmSelected := promptCompositor()
|
||||||
terminal, terminalSelected := promptTerminal()
|
terminal, terminalSelected := promptTerminal()
|
||||||
useSystemd := promptSystemd()
|
useSystemd := true
|
||||||
|
if wmSelected {
|
||||||
|
if wm == deps.WindowManagerMango {
|
||||||
|
useSystemd = false
|
||||||
|
} else {
|
||||||
|
useSystemd = promptSystemd()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if !wmSelected && !terminalSelected {
|
if !wmSelected && !terminalSelected {
|
||||||
fmt.Println("No configurations selected. Exiting.")
|
fmt.Println("No configurations selected. Exiting.")
|
||||||
@@ -379,10 +407,11 @@ func promptCompositor() (deps.WindowManager, bool) {
|
|||||||
fmt.Println("Select compositor:")
|
fmt.Println("Select compositor:")
|
||||||
fmt.Println("1) Niri")
|
fmt.Println("1) Niri")
|
||||||
fmt.Println("2) Hyprland")
|
fmt.Println("2) Hyprland")
|
||||||
fmt.Println("3) None")
|
fmt.Println("3) Mango")
|
||||||
|
fmt.Println("4) None")
|
||||||
|
|
||||||
var response string
|
var response string
|
||||||
fmt.Print("\nChoice (1-3): ")
|
fmt.Print("\nChoice (1-4): ")
|
||||||
fmt.Scanln(&response)
|
fmt.Scanln(&response)
|
||||||
response = strings.TrimSpace(response)
|
response = strings.TrimSpace(response)
|
||||||
|
|
||||||
@@ -391,6 +420,8 @@ func promptCompositor() (deps.WindowManager, bool) {
|
|||||||
return deps.WindowManagerNiri, true
|
return deps.WindowManagerNiri, true
|
||||||
case "2":
|
case "2":
|
||||||
return deps.WindowManagerHyprland, true
|
return deps.WindowManagerHyprland, true
|
||||||
|
case "3":
|
||||||
|
return deps.WindowManagerMango, true
|
||||||
default:
|
default:
|
||||||
return deps.WindowManagerNiri, false
|
return deps.WindowManagerNiri, false
|
||||||
}
|
}
|
||||||
@@ -438,16 +469,27 @@ func checkExistingConfigs(wm deps.WindowManager, wmSelected bool, terminal deps.
|
|||||||
willBackup := false
|
willBackup := false
|
||||||
|
|
||||||
if wmSelected {
|
if wmSelected {
|
||||||
var configPath string
|
var configPaths []string
|
||||||
switch wm {
|
switch wm {
|
||||||
case deps.WindowManagerNiri:
|
case deps.WindowManagerNiri:
|
||||||
configPath = filepath.Join(homeDir, ".config", "niri", "config.kdl")
|
configPaths = []string{filepath.Join(homeDir, ".config", "niri", "config.kdl")}
|
||||||
case deps.WindowManagerHyprland:
|
case deps.WindowManagerHyprland:
|
||||||
configPath = filepath.Join(homeDir, ".config", "hypr", "hyprland.conf")
|
configPaths = []string{
|
||||||
|
filepath.Join(homeDir, ".config", "hypr", "hyprland.lua"),
|
||||||
|
filepath.Join(homeDir, ".config", "hypr", "hyprland.conf"),
|
||||||
|
}
|
||||||
|
case deps.WindowManagerMango:
|
||||||
|
configPaths = []string{
|
||||||
|
filepath.Join(homeDir, ".config", "mango", "config.conf"),
|
||||||
|
filepath.Join(homeDir, ".config", "mango", "mango.conf"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := os.Stat(configPath); err == nil {
|
for _, configPath := range configPaths {
|
||||||
willBackup = true
|
if _, err := os.Stat(configPath); err == nil {
|
||||||
|
willBackup = true
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -26,7 +27,7 @@ var windowrulesListCmd = &cobra.Command{
|
|||||||
Args: cobra.MaximumNArgs(1),
|
Args: cobra.MaximumNArgs(1),
|
||||||
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
return []string{"niri"}, cobra.ShellCompDirectiveNoFileComp
|
return []string{"hyprland", "niri", "mango"}, cobra.ShellCompDirectiveNoFileComp
|
||||||
}
|
}
|
||||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
},
|
},
|
||||||
@@ -40,8 +41,7 @@ var windowrulesAddCmd = &cobra.Command{
|
|||||||
Args: cobra.ExactArgs(2),
|
Args: cobra.ExactArgs(2),
|
||||||
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
// ! disabled hyprland return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp
|
return []string{"hyprland", "niri", "mango"}, cobra.ShellCompDirectiveNoFileComp
|
||||||
return []string{"niri"}, cobra.ShellCompDirectiveNoFileComp
|
|
||||||
}
|
}
|
||||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
},
|
},
|
||||||
@@ -55,7 +55,7 @@ var windowrulesUpdateCmd = &cobra.Command{
|
|||||||
Args: cobra.ExactArgs(3),
|
Args: cobra.ExactArgs(3),
|
||||||
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
return []string{"niri"}, cobra.ShellCompDirectiveNoFileComp
|
return []string{"hyprland", "niri", "mango"}, cobra.ShellCompDirectiveNoFileComp
|
||||||
}
|
}
|
||||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
},
|
},
|
||||||
@@ -69,7 +69,7 @@ var windowrulesRemoveCmd = &cobra.Command{
|
|||||||
Args: cobra.ExactArgs(2),
|
Args: cobra.ExactArgs(2),
|
||||||
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
return []string{"niri"}, cobra.ShellCompDirectiveNoFileComp
|
return []string{"hyprland", "niri", "mango"}, cobra.ShellCompDirectiveNoFileComp
|
||||||
}
|
}
|
||||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
},
|
},
|
||||||
@@ -83,7 +83,7 @@ var windowrulesReorderCmd = &cobra.Command{
|
|||||||
Args: cobra.ExactArgs(2),
|
Args: cobra.ExactArgs(2),
|
||||||
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
return []string{"niri"}, cobra.ShellCompDirectiveNoFileComp
|
return []string{"hyprland", "niri", "mango"}, cobra.ShellCompDirectiveNoFileComp
|
||||||
}
|
}
|
||||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
},
|
},
|
||||||
@@ -118,9 +118,12 @@ func getCompositor(args []string) string {
|
|||||||
if os.Getenv("NIRI_SOCKET") != "" {
|
if os.Getenv("NIRI_SOCKET") != "" {
|
||||||
return "niri"
|
return "niri"
|
||||||
}
|
}
|
||||||
// if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") != "" {
|
if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") != "" {
|
||||||
// return "hyprland"
|
return "hyprland"
|
||||||
// }
|
}
|
||||||
|
if os.Getenv("MANGO_INSTANCE_SIGNATURE") != "" {
|
||||||
|
return "mango"
|
||||||
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,17 +143,14 @@ func writeRuleSuccess(id, path string) {
|
|||||||
func runWindowrulesList(cmd *cobra.Command, args []string) {
|
func runWindowrulesList(cmd *cobra.Command, args []string) {
|
||||||
compositor := getCompositor(args)
|
compositor := getCompositor(args)
|
||||||
if compositor == "" {
|
if compositor == "" {
|
||||||
log.Fatalf("Could not detect compositor. Please specify: hyprland or niri")
|
log.Fatalf("Could not detect compositor. Please specify: hyprland, niri, or mango")
|
||||||
}
|
}
|
||||||
|
|
||||||
var result WindowRulesListResult
|
var result WindowRulesListResult
|
||||||
|
|
||||||
switch compositor {
|
switch compositor {
|
||||||
case "niri":
|
case "niri":
|
||||||
configDir, err := utils.ExpandPath("$HOME/.config/niri")
|
configDir := filepath.Join(utils.XDGConfigHome(), "niri")
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Failed to expand niri config path: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
parseResult, err := providers.ParseNiriWindowRules(configDir)
|
parseResult, err := providers.ParseNiriWindowRules(configDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -183,11 +183,7 @@ func runWindowrulesList(cmd *cobra.Command, args []string) {
|
|||||||
result.DMSStatus = parseResult.DMSStatus
|
result.DMSStatus = parseResult.DMSStatus
|
||||||
|
|
||||||
case "hyprland":
|
case "hyprland":
|
||||||
log.Fatalf("Hyprland support is currently disabled.") // ! disabled hyprland
|
configDir := filepath.Join(utils.XDGConfigHome(), "hypr")
|
||||||
configDir, err := utils.ExpandPath("$HOME/.config/hypr")
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Failed to expand hyprland config path: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
parseResult, err := providers.ParseHyprlandWindowRules(configDir)
|
parseResult, err := providers.ParseHyprlandWindowRules(configDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -219,6 +215,38 @@ func runWindowrulesList(cmd *cobra.Command, args []string) {
|
|||||||
result.Rules = allRules
|
result.Rules = allRules
|
||||||
result.DMSStatus = parseResult.DMSStatus
|
result.DMSStatus = parseResult.DMSStatus
|
||||||
|
|
||||||
|
case "mango", "mangowc":
|
||||||
|
configDir := filepath.Join(utils.XDGConfigHome(), "mango")
|
||||||
|
|
||||||
|
parseResult, err := providers.ParseMangoWindowRules(configDir)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to parse mango window rules: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
allRules := providers.ConvertMangoRulesToWindowRules(parseResult.Rules)
|
||||||
|
|
||||||
|
provider := providers.NewMangoWritableProvider(configDir)
|
||||||
|
dmsRules, _ := provider.LoadDMSRules()
|
||||||
|
|
||||||
|
dmsRuleMap := make(map[int]windowrules.WindowRule)
|
||||||
|
for i, dr := range dmsRules {
|
||||||
|
dmsRuleMap[i] = dr
|
||||||
|
}
|
||||||
|
|
||||||
|
dmsIdx := 0
|
||||||
|
for i, r := range allRules {
|
||||||
|
if r.Source == "dms/windowrules.conf" {
|
||||||
|
if dmr, ok := dmsRuleMap[dmsIdx]; ok {
|
||||||
|
allRules[i].ID = dmr.ID
|
||||||
|
allRules[i].Name = dmr.Name
|
||||||
|
}
|
||||||
|
dmsIdx++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Rules = allRules
|
||||||
|
result.DMSStatus = parseResult.DMSStatus
|
||||||
|
|
||||||
default:
|
default:
|
||||||
log.Fatalf("Unknown compositor: %s", compositor)
|
log.Fatalf("Unknown compositor: %s", compositor)
|
||||||
}
|
}
|
||||||
@@ -317,17 +345,14 @@ func runWindowrulesReorder(cmd *cobra.Command, args []string) {
|
|||||||
func getWindowRulesProvider(compositor string) windowrules.WritableProvider {
|
func getWindowRulesProvider(compositor string) windowrules.WritableProvider {
|
||||||
switch compositor {
|
switch compositor {
|
||||||
case "niri":
|
case "niri":
|
||||||
configDir, err := utils.ExpandPath("$HOME/.config/niri")
|
configDir := filepath.Join(utils.XDGConfigHome(), "niri")
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return providers.NewNiriWritableProvider(configDir)
|
return providers.NewNiriWritableProvider(configDir)
|
||||||
case "hyprland":
|
case "hyprland":
|
||||||
configDir, err := utils.ExpandPath("$HOME/.config/hypr")
|
configDir := filepath.Join(utils.XDGConfigHome(), "hypr")
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return providers.NewHyprlandWritableProvider(configDir)
|
return providers.NewHyprlandWritableProvider(configDir)
|
||||||
|
case "mango", "mangowc":
|
||||||
|
configDir := filepath.Join(utils.XDGConfigHome(), "mango")
|
||||||
|
return providers.NewMangoWritableProvider(configDir)
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ import (
|
|||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// maxIPCMessageSize allows room for a 50 MB clipboard entry plus JSON/base64
|
||||||
|
// overhead in the line-delimited IPC response.
|
||||||
|
const maxIPCMessageSize = 96 * 1024 * 1024
|
||||||
|
|
||||||
func sendServerRequest(req models.Request) (*models.Response[any], error) {
|
func sendServerRequest(req models.Request) (*models.Response[any], error) {
|
||||||
socketPath := getServerSocketPath()
|
socketPath := getServerSocketPath()
|
||||||
|
|
||||||
@@ -22,6 +26,7 @@ func sendServerRequest(req models.Request) (*models.Response[any], error) {
|
|||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
|
||||||
scanner := bufio.NewScanner(conn)
|
scanner := bufio.NewScanner(conn)
|
||||||
|
scanner.Buffer(make([]byte, bufio.MaxScanTokenSize), maxIPCMessageSize)
|
||||||
scanner.Scan() // discard initial capabilities message
|
scanner.Scan() // discard initial capabilities message
|
||||||
|
|
||||||
reqData, err := json.Marshal(req)
|
reqData, err := json.Marshal(req)
|
||||||
@@ -61,6 +66,7 @@ func sendServerRequestFireAndForget(req models.Request) error {
|
|||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
|
||||||
scanner := bufio.NewScanner(conn)
|
scanner := bufio.NewScanner(conn)
|
||||||
|
scanner.Buffer(make([]byte, bufio.MaxScanTokenSize), maxIPCMessageSize)
|
||||||
scanner.Scan() // discard initial capabilities message
|
scanner.Scan() // discard initial capabilities message
|
||||||
|
|
||||||
reqData, err := json.Marshal(req)
|
reqData, err := json.Marshal(req)
|
||||||
|
|||||||
+152
-6
@@ -2,7 +2,9 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
@@ -192,6 +194,7 @@ func runShellInteractive(session bool) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
ensureFontCache()
|
||||||
log.Infof("Spawning quickshell with -p %s", configPath)
|
log.Infof("Spawning quickshell with -p %s", configPath)
|
||||||
|
|
||||||
cmd := exec.CommandContext(ctx, "qs", "-p", configPath)
|
cmd := exec.CommandContext(ctx, "qs", "-p", configPath)
|
||||||
@@ -227,8 +230,10 @@ func runShellInteractive(session bool) {
|
|||||||
|
|
||||||
cmd.Stdin = os.Stdin
|
cmd.Stdin = os.Stdin
|
||||||
cmd.Stdout = os.Stdout
|
cmd.Stdout = os.Stdout
|
||||||
cmd.Stderr = os.Stderr
|
tracker := &stderrTracker{parent: os.Stderr}
|
||||||
|
cmd.Stderr = tracker
|
||||||
|
|
||||||
|
startTime := time.Now()
|
||||||
if err := cmd.Start(); err != nil {
|
if err := cmd.Start(); err != nil {
|
||||||
log.Fatalf("Error starting quickshell: %v", err)
|
log.Fatalf("Error starting quickshell: %v", err)
|
||||||
}
|
}
|
||||||
@@ -277,7 +282,9 @@ func runShellInteractive(session bool) {
|
|||||||
case <-errChan:
|
case <-errChan:
|
||||||
cancel()
|
cancel()
|
||||||
os.Remove(socketPath)
|
os.Remove(socketPath)
|
||||||
os.Exit(getProcessExitCode(cmd.ProcessState))
|
exitCode := getProcessExitCode(cmd.ProcessState)
|
||||||
|
logStartupFailure(startTime, exitCode, tracker)
|
||||||
|
os.Exit(exitCode)
|
||||||
case <-time.After(500 * time.Millisecond):
|
case <-time.After(500 * time.Millisecond):
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,7 +301,9 @@ func runShellInteractive(session bool) {
|
|||||||
cmd.Process.Signal(syscall.SIGTERM)
|
cmd.Process.Signal(syscall.SIGTERM)
|
||||||
}
|
}
|
||||||
os.Remove(socketPath)
|
os.Remove(socketPath)
|
||||||
os.Exit(getProcessExitCode(cmd.ProcessState))
|
exitCode := getProcessExitCode(cmd.ProcessState)
|
||||||
|
logStartupFailure(startTime, exitCode, tracker)
|
||||||
|
os.Exit(exitCode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -434,6 +443,7 @@ func runShellDaemon(session bool) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
ensureFontCache()
|
||||||
log.Infof("Spawning quickshell with -p %s", configPath)
|
log.Infof("Spawning quickshell with -p %s", configPath)
|
||||||
|
|
||||||
cmd := exec.CommandContext(ctx, "qs", "-p", configPath)
|
cmd := exec.CommandContext(ctx, "qs", "-p", configPath)
|
||||||
@@ -478,8 +488,10 @@ func runShellDaemon(session bool) {
|
|||||||
|
|
||||||
cmd.Stdin = devNull
|
cmd.Stdin = devNull
|
||||||
cmd.Stdout = devNull
|
cmd.Stdout = devNull
|
||||||
cmd.Stderr = devNull
|
tracker := &stderrTracker{parent: devNull}
|
||||||
|
cmd.Stderr = tracker
|
||||||
|
|
||||||
|
startTime := time.Now()
|
||||||
if err := cmd.Start(); err != nil {
|
if err := cmd.Start(); err != nil {
|
||||||
log.Fatalf("Error starting daemon: %v", err)
|
log.Fatalf("Error starting daemon: %v", err)
|
||||||
}
|
}
|
||||||
@@ -528,7 +540,9 @@ func runShellDaemon(session bool) {
|
|||||||
case <-errChan:
|
case <-errChan:
|
||||||
cancel()
|
cancel()
|
||||||
os.Remove(socketPath)
|
os.Remove(socketPath)
|
||||||
os.Exit(getProcessExitCode(cmd.ProcessState))
|
exitCode := getProcessExitCode(cmd.ProcessState)
|
||||||
|
logStartupFailure(startTime, exitCode, tracker)
|
||||||
|
os.Exit(exitCode)
|
||||||
case <-time.After(500 * time.Millisecond):
|
case <-time.After(500 * time.Millisecond):
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -543,7 +557,9 @@ func runShellDaemon(session bool) {
|
|||||||
cmd.Process.Signal(syscall.SIGTERM)
|
cmd.Process.Signal(syscall.SIGTERM)
|
||||||
}
|
}
|
||||||
os.Remove(socketPath)
|
os.Remove(socketPath)
|
||||||
os.Exit(getProcessExitCode(cmd.ProcessState))
|
exitCode := getProcessExitCode(cmd.ProcessState)
|
||||||
|
logStartupFailure(startTime, exitCode, tracker)
|
||||||
|
os.Exit(exitCode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -748,3 +764,133 @@ func printIPCHelp() {
|
|||||||
fmt.Printf(" %-16s %s\n", targetName, strings.Join(funcNames, ", "))
|
fmt.Printf(" %-16s %s\n", targetName, strings.Join(funcNames, ", "))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ensureFontCache rebuilds the fontconfig cache if user-configured fonts are missing while skipping defaults
|
||||||
|
func ensureFontCache() {
|
||||||
|
if _, err := exec.LookPath("fc-list"); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := exec.LookPath("fc-cache"); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var fontsToCheck []string
|
||||||
|
|
||||||
|
if configDir, err := os.UserConfigDir(); err == nil {
|
||||||
|
settingsPath := filepath.Join(configDir, "DankMaterialShell", "settings.json")
|
||||||
|
if data, err := os.ReadFile(settingsPath); err == nil {
|
||||||
|
var settings struct {
|
||||||
|
FontFamily string `json:"fontFamily"`
|
||||||
|
MonoFontFamily string `json:"monoFontFamily"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &settings); err == nil {
|
||||||
|
if settings.FontFamily != "" && settings.FontFamily != "Inter Variable" {
|
||||||
|
fontsToCheck = append(fontsToCheck, settings.FontFamily)
|
||||||
|
}
|
||||||
|
if settings.MonoFontFamily != "" && settings.MonoFontFamily != "Fira Code" {
|
||||||
|
fontsToCheck = append(fontsToCheck, settings.MonoFontFamily)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(fontsToCheck) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err := exec.Command("fc-list", ":", "family").Output()
|
||||||
|
if err != nil || len(strings.TrimSpace(string(output))) == 0 {
|
||||||
|
log.Warnf("Font cache appears empty or corrupt, rebuilding...")
|
||||||
|
rebuildFontCache()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheFonts := strings.ToLower(string(output))
|
||||||
|
var missing []string
|
||||||
|
for _, font := range fontsToCheck {
|
||||||
|
if !fontInCache(strings.ToLower(font), cacheFonts) {
|
||||||
|
missing = append(missing, font)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(missing) > 0 {
|
||||||
|
log.Warnf("Font(s) not found in cache: %s — rebuilding...", strings.Join(missing, ", "))
|
||||||
|
rebuildFontCache()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fontInCache(target, cache string) bool {
|
||||||
|
for _, line := range strings.Split(cache, "\n") {
|
||||||
|
for _, fam := range strings.Split(strings.TrimSpace(line), ",") {
|
||||||
|
if strings.TrimSpace(fam) == target {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func rebuildFontCache() {
|
||||||
|
cmd := exec.Command("fc-cache", "-f")
|
||||||
|
if output, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
log.Warnf("Failed to rebuild font cache: %v\n%s", err, string(output))
|
||||||
|
} else {
|
||||||
|
log.Infof("Font cache rebuilt successfully")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type stderrTracker struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
buf strings.Builder
|
||||||
|
parent io.Writer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stderrTracker) Write(p []byte) (n int, err error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if s.buf.Len() < 8192 {
|
||||||
|
s.buf.Write(p)
|
||||||
|
}
|
||||||
|
if s.parent != nil {
|
||||||
|
return s.parent.Write(p)
|
||||||
|
}
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stderrTracker) String() string {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// logStartupFailure logs diagnostic advice if qs crashes within 5s of launch.
|
||||||
|
func logStartupFailure(startTime time.Time, exitCode int, tracker *stderrTracker) {
|
||||||
|
if time.Since(startTime) >= 5*time.Second || exitCode == 0 || exitCode > 128 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if containsFontCrashSignature(tracker.String()) {
|
||||||
|
log.Errorf("DMS startup failed due to a potential font/rendering crash. Try running 'fc-cache -fv' and restarting DMS.")
|
||||||
|
} else {
|
||||||
|
log.Errorf("DMS startup failed (exit code %d). Run 'dms doctor' for more diagnostics.", exitCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsFontCrashSignature(logStr string) bool {
|
||||||
|
logStr = strings.ToLower(logStr)
|
||||||
|
signatures := []string{
|
||||||
|
"fontconfig",
|
||||||
|
"freetype",
|
||||||
|
"ft_load_glyph",
|
||||||
|
"ft_face",
|
||||||
|
"fc-list",
|
||||||
|
"fc-cache",
|
||||||
|
"glyph",
|
||||||
|
"typeface",
|
||||||
|
}
|
||||||
|
for _, sig := range signatures {
|
||||||
|
if strings.Contains(logStr, sig) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
+285
-110
@@ -12,6 +12,8 @@ import (
|
|||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const hyprlandBackupDirName = ".dms-backups"
|
||||||
|
|
||||||
type ConfigDeployer struct {
|
type ConfigDeployer struct {
|
||||||
logChan chan<- string
|
logChan chan<- string
|
||||||
}
|
}
|
||||||
@@ -63,12 +65,27 @@ func (cd *ConfigDeployer) deployConfigurationsInternal(ctx context.Context, wm d
|
|||||||
var results []DeploymentResult
|
var results []DeploymentResult
|
||||||
|
|
||||||
// Primary config file paths used to detect fresh installs.
|
// Primary config file paths used to detect fresh installs.
|
||||||
configPrimaryPaths := map[string]string{
|
configPrimaryPaths := map[string][]string{
|
||||||
"Niri": filepath.Join(os.Getenv("HOME"), ".config", "niri", "config.kdl"),
|
"Niri": {
|
||||||
"Hyprland": filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.conf"),
|
filepath.Join(os.Getenv("HOME"), ".config", "niri", "config.kdl"),
|
||||||
"Ghostty": filepath.Join(os.Getenv("HOME"), ".config", "ghostty", "config"),
|
},
|
||||||
"Kitty": filepath.Join(os.Getenv("HOME"), ".config", "kitty", "kitty.conf"),
|
"Hyprland": {
|
||||||
"Alacritty": filepath.Join(os.Getenv("HOME"), ".config", "alacritty", "alacritty.toml"),
|
filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.lua"),
|
||||||
|
filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.conf"),
|
||||||
|
},
|
||||||
|
"Mango": {
|
||||||
|
filepath.Join(os.Getenv("HOME"), ".config", "mango", "config.conf"),
|
||||||
|
filepath.Join(os.Getenv("HOME"), ".config", "mango", "mango.conf"),
|
||||||
|
},
|
||||||
|
"Ghostty": {
|
||||||
|
filepath.Join(os.Getenv("HOME"), ".config", "ghostty", "config"),
|
||||||
|
},
|
||||||
|
"Kitty": {
|
||||||
|
filepath.Join(os.Getenv("HOME"), ".config", "kitty", "kitty.conf"),
|
||||||
|
},
|
||||||
|
"Alacritty": {
|
||||||
|
filepath.Join(os.Getenv("HOME"), ".config", "alacritty", "alacritty.toml"),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
shouldReplaceConfig := func(configType string) bool {
|
shouldReplaceConfig := func(configType string) bool {
|
||||||
@@ -81,8 +98,15 @@ func (cd *ConfigDeployer) deployConfigurationsInternal(ctx context.Context, wm d
|
|||||||
}
|
}
|
||||||
// Config is explicitly set to "don't replace" — but still deploy
|
// Config is explicitly set to "don't replace" — but still deploy
|
||||||
// if the config file doesn't exist yet (fresh install scenario).
|
// if the config file doesn't exist yet (fresh install scenario).
|
||||||
if primaryPath, ok := configPrimaryPaths[configType]; ok {
|
if primaryPaths, ok := configPrimaryPaths[configType]; ok {
|
||||||
if _, err := os.Stat(primaryPath); os.IsNotExist(err) {
|
exists := false
|
||||||
|
for _, primaryPath := range primaryPaths {
|
||||||
|
if _, err := os.Stat(primaryPath); err == nil {
|
||||||
|
exists = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,6 +130,14 @@ func (cd *ConfigDeployer) deployConfigurationsInternal(ctx context.Context, wm d
|
|||||||
return results, fmt.Errorf("failed to deploy Hyprland config: %w", err)
|
return results, fmt.Errorf("failed to deploy Hyprland config: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case deps.WindowManagerMango:
|
||||||
|
if shouldReplaceConfig("Mango") {
|
||||||
|
result, err := cd.deployMangoConfig(terminal, useSystemd)
|
||||||
|
results = append(results, result)
|
||||||
|
if err != nil {
|
||||||
|
return results, fmt.Errorf("failed to deploy Mango config: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
switch terminal {
|
switch terminal {
|
||||||
@@ -249,6 +281,96 @@ func (cd *ConfigDeployer) deployNiriDmsConfigs(dmsDir, terminalCommand string) e
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (cd *ConfigDeployer) deployMangoConfig(terminal deps.Terminal, useSystemd bool) (DeploymentResult, error) {
|
||||||
|
result := DeploymentResult{
|
||||||
|
ConfigType: "Mango",
|
||||||
|
Path: filepath.Join(os.Getenv("HOME"), ".config", "mango", "config.conf"),
|
||||||
|
}
|
||||||
|
|
||||||
|
configDir := filepath.Dir(result.Path)
|
||||||
|
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||||
|
result.Error = fmt.Errorf("failed to create config directory: %w", err)
|
||||||
|
return result, result.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
dmsDir := filepath.Join(configDir, "dms")
|
||||||
|
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
|
||||||
|
result.Error = fmt.Errorf("failed to create dms directory: %w", err)
|
||||||
|
return result, result.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
var terminalCommand string
|
||||||
|
switch terminal {
|
||||||
|
case deps.TerminalGhostty:
|
||||||
|
terminalCommand = "ghostty"
|
||||||
|
case deps.TerminalKitty:
|
||||||
|
terminalCommand = "kitty"
|
||||||
|
case deps.TerminalAlacritty:
|
||||||
|
terminalCommand = "alacritty"
|
||||||
|
default:
|
||||||
|
terminalCommand = "ghostty"
|
||||||
|
}
|
||||||
|
|
||||||
|
// DMS owns config.conf for mango (like niri/hyprland): back up and replace.
|
||||||
|
if existingData, err := os.ReadFile(result.Path); err == nil {
|
||||||
|
cd.log("Found existing Mango configuration")
|
||||||
|
timestamp := time.Now().Format("2006-01-02_15-04-05")
|
||||||
|
result.BackupPath = result.Path + ".backup." + timestamp
|
||||||
|
if err := os.WriteFile(result.BackupPath, existingData, 0o644); err != nil {
|
||||||
|
result.Error = fmt.Errorf("failed to create backup: %w", err)
|
||||||
|
return result, result.Error
|
||||||
|
}
|
||||||
|
cd.log(fmt.Sprintf("Backed up existing config to %s", result.BackupPath))
|
||||||
|
}
|
||||||
|
|
||||||
|
newConfig := strings.ReplaceAll(MangoConfig, "{{TERMINAL_COMMAND}}", terminalCommand)
|
||||||
|
if err := os.WriteFile(result.Path, []byte(newConfig), 0o644); err != nil {
|
||||||
|
result.Error = fmt.Errorf("failed to write config: %w", err)
|
||||||
|
return result, result.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cd.deployMangoDmsConfigs(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 Mango configuration")
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cd *ConfigDeployer) deployMangoDmsConfigs(dmsDir, terminalCommand string) error {
|
||||||
|
configs := []struct {
|
||||||
|
name string
|
||||||
|
content string
|
||||||
|
overwrite bool
|
||||||
|
}{
|
||||||
|
// binds.conf is DMS-owned (overwrite); the rest are runtime/user-managed.
|
||||||
|
{"binds.conf", strings.ReplaceAll(MangoBindsConfig, "{{TERMINAL_COMMAND}}", terminalCommand), true},
|
||||||
|
{"colors.conf", MangoColorsConfig, false},
|
||||||
|
{"layout.conf", MangoLayoutConfig, false},
|
||||||
|
{"outputs.conf", "", false},
|
||||||
|
{"cursor.conf", "", false},
|
||||||
|
{"windowrules.conf", "", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, cfg := range configs {
|
||||||
|
path := filepath.Join(dmsDir, cfg.name)
|
||||||
|
if !cfg.overwrite {
|
||||||
|
if info, err := os.Stat(path); err == nil && info.Size() > 0 {
|
||||||
|
cd.log(fmt.Sprintf("Skipping %s (already exists)", cfg.name))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(cfg.content), 0o644); err != nil {
|
||||||
|
return fmt.Errorf("failed to write %s: %w", cfg.name, err)
|
||||||
|
}
|
||||||
|
cd.log(fmt.Sprintf("Deployed %s", cfg.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (cd *ConfigDeployer) deployGhosttyConfig() ([]DeploymentResult, error) {
|
func (cd *ConfigDeployer) deployGhosttyConfig() ([]DeploymentResult, error) {
|
||||||
var results []DeploymentResult
|
var results []DeploymentResult
|
||||||
|
|
||||||
@@ -495,7 +617,7 @@ func (cd *ConfigDeployer) mergeNiriOutputSections(newConfig, existingConfig, dms
|
|||||||
func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystemd bool) (DeploymentResult, error) {
|
func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystemd bool) (DeploymentResult, error) {
|
||||||
result := DeploymentResult{
|
result := DeploymentResult{
|
||||||
ConfigType: "Hyprland",
|
ConfigType: "Hyprland",
|
||||||
Path: filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.conf"),
|
Path: filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.lua"),
|
||||||
}
|
}
|
||||||
|
|
||||||
configDir := filepath.Dir(result.Path)
|
configDir := filepath.Dir(result.Path)
|
||||||
@@ -510,20 +632,20 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem
|
|||||||
return result, result.Error
|
return result, result.Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
timestamp := time.Now().Format("2006-01-02_15-04-05")
|
||||||
|
backupDir := filepath.Join(configDir, hyprlandBackupDirName, timestamp)
|
||||||
var existingConfig string
|
var existingConfig string
|
||||||
if _, err := os.Stat(result.Path); err == nil {
|
existingData, existingPath, err := readExistingHyprlandConfig(configDir)
|
||||||
cd.log("Found existing Hyprland configuration")
|
if err != nil {
|
||||||
|
result.Error = err
|
||||||
|
return result, result.Error
|
||||||
|
}
|
||||||
|
if existingData != "" {
|
||||||
|
existingConfig = existingData
|
||||||
|
cd.log(fmt.Sprintf("Found existing Hyprland configuration at %s", existingPath))
|
||||||
|
|
||||||
existingData, err := os.ReadFile(result.Path)
|
result.BackupPath = filepath.Join(backupDir, filepath.Base(existingPath))
|
||||||
if err != nil {
|
if err := backupHyprlandConfigFile(existingPath, result.BackupPath, []byte(existingData), strings.EqualFold(filepath.Ext(existingPath), ".conf")); err != nil {
|
||||||
result.Error = fmt.Errorf("failed to read existing config: %w", err)
|
|
||||||
return result, result.Error
|
|
||||||
}
|
|
||||||
existingConfig = string(existingData)
|
|
||||||
|
|
||||||
timestamp := time.Now().Format("2006-01-02_15-04-05")
|
|
||||||
result.BackupPath = result.Path + ".backup." + timestamp
|
|
||||||
if err := os.WriteFile(result.BackupPath, existingData, 0o644); err != nil {
|
|
||||||
result.Error = fmt.Errorf("failed to create backup: %w", err)
|
result.Error = fmt.Errorf("failed to create backup: %w", err)
|
||||||
return result, result.Error
|
return result, result.Error
|
||||||
}
|
}
|
||||||
@@ -542,10 +664,10 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem
|
|||||||
terminalCommand = "ghostty"
|
terminalCommand = "ghostty"
|
||||||
}
|
}
|
||||||
|
|
||||||
newConfig := strings.ReplaceAll(HyprlandConfig, "{{TERMINAL_COMMAND}}", terminalCommand)
|
newConfig := strings.ReplaceAll(HyprlandLuaConfig, "{{TERMINAL_COMMAND}}", terminalCommand)
|
||||||
|
|
||||||
if !useSystemd {
|
if !useSystemd {
|
||||||
newConfig = cd.transformHyprlandConfigForNonSystemd(newConfig, terminalCommand)
|
newConfig = transformHyprlandLuaForNonSystemd(newConfig, terminalCommand)
|
||||||
}
|
}
|
||||||
|
|
||||||
if existingConfig != "" {
|
if existingConfig != "" {
|
||||||
@@ -563,39 +685,144 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem
|
|||||||
return result, result.Error
|
return result, result.Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
movedLegacy, err := backupLegacyHyprlandConfFiles(configDir, dmsDir, backupDir)
|
||||||
|
if err != nil {
|
||||||
|
result.Error = fmt.Errorf("failed to back up legacy hyprlang configs: %w", err)
|
||||||
|
return result, result.Error
|
||||||
|
}
|
||||||
|
if movedLegacy > 0 {
|
||||||
|
if result.BackupPath == "" {
|
||||||
|
result.BackupPath = backupDir
|
||||||
|
}
|
||||||
|
cd.log(fmt.Sprintf("Moved %d legacy hyprlang config(s) to %s", movedLegacy, backupDir))
|
||||||
|
}
|
||||||
|
|
||||||
if err := cd.deployHyprlandDmsConfigs(dmsDir, terminalCommand); err != nil {
|
if err := cd.deployHyprlandDmsConfigs(dmsDir, terminalCommand); err != nil {
|
||||||
result.Error = fmt.Errorf("failed to deploy dms configs: %w", err)
|
result.Error = fmt.Errorf("failed to deploy dms configs: %w", err)
|
||||||
return result, result.Error
|
return result, result.Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CleanupStrayHyprlandConfFile(func(format string, v ...any) {
|
||||||
|
cd.log(fmt.Sprintf(format, v...))
|
||||||
|
})
|
||||||
|
|
||||||
result.Deployed = true
|
result.Deployed = true
|
||||||
cd.log("Successfully deployed Hyprland configuration")
|
cd.log("Successfully deployed Hyprland configuration")
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func backupHyprlandConfigFile(src, dst string, data []byte, removeSource bool) error {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(dst, data, 0o644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if removeSource {
|
||||||
|
if err := os.Remove(src); err != nil && !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func backupLegacyHyprlandConfFiles(configDir, dmsDir, backupDir string) (int, error) {
|
||||||
|
legacyPaths := []string{filepath.Join(configDir, "hyprland.conf")}
|
||||||
|
dmsConfPaths, err := filepath.Glob(filepath.Join(dmsDir, "*.conf"))
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
legacyPaths = append(legacyPaths, dmsConfPaths...)
|
||||||
|
backupPaths, err := adjacentHyprlandBackupFiles(configDir, dmsDir)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
legacyPaths = append(legacyPaths, backupPaths...)
|
||||||
|
|
||||||
|
moved := 0
|
||||||
|
for _, src := range legacyPaths {
|
||||||
|
info, err := os.Lstat(src)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return moved, err
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
rel, err := filepath.Rel(configDir, src)
|
||||||
|
if err != nil {
|
||||||
|
rel = filepath.Base(src)
|
||||||
|
}
|
||||||
|
dst := filepath.Join(backupDir, rel)
|
||||||
|
if err := moveHyprlandConfigFile(src, dst); err != nil {
|
||||||
|
return moved, err
|
||||||
|
}
|
||||||
|
moved++
|
||||||
|
}
|
||||||
|
|
||||||
|
return moved, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func moveHyprlandConfigFile(src, dst string) error {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(src, dst)
|
||||||
|
}
|
||||||
|
|
||||||
|
func adjacentHyprlandBackupFiles(configDir, dmsDir string) ([]string, error) {
|
||||||
|
var paths []string
|
||||||
|
patterns := []string{
|
||||||
|
filepath.Join(configDir, "hyprland.conf.backup.*"),
|
||||||
|
filepath.Join(configDir, "hyprland.lua.backup.*"),
|
||||||
|
filepath.Join(dmsDir, "*.conf.backup.*"),
|
||||||
|
filepath.Join(dmsDir, "*.lua.backup.*"),
|
||||||
|
}
|
||||||
|
for _, pattern := range patterns {
|
||||||
|
matches, err := filepath.Glob(pattern)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
paths = append(paths, matches...)
|
||||||
|
}
|
||||||
|
return paths, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (cd *ConfigDeployer) deployHyprlandDmsConfigs(dmsDir string, terminalCommand string) error {
|
func (cd *ConfigDeployer) deployHyprlandDmsConfigs(dmsDir string, terminalCommand string) error {
|
||||||
configs := []struct {
|
configs := []struct {
|
||||||
name string
|
name string
|
||||||
content string
|
content string
|
||||||
|
overwrite bool
|
||||||
}{
|
}{
|
||||||
{"colors.conf", HyprColorsConfig},
|
{name: "colors.lua", content: DMSColorsLuaConfig},
|
||||||
{"layout.conf", HyprLayoutConfig},
|
{name: "layout.lua", content: DMSLayoutLuaConfig},
|
||||||
{"binds.conf", strings.ReplaceAll(HyprBindsConfig, "{{TERMINAL_COMMAND}}", terminalCommand)},
|
{name: "binds.lua", content: strings.ReplaceAll(DMSBindsLuaConfig, "{{TERMINAL_COMMAND}}", terminalCommand), overwrite: true},
|
||||||
{"outputs.conf", ""},
|
{name: "binds-user.lua", content: DMSBindsUserLuaConfig},
|
||||||
{"cursor.conf", ""},
|
{name: "outputs.lua", content: DMSOutputsLuaConfig},
|
||||||
{"windowrules.conf", ""},
|
{name: "cursor.lua", content: DMSCursorLuaConfig},
|
||||||
|
{name: "windowrules.lua", content: DMSWindowRulesLuaConfig},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, cfg := range configs {
|
for _, cfg := range configs {
|
||||||
path := filepath.Join(dmsDir, cfg.name)
|
path := filepath.Join(dmsDir, cfg.name)
|
||||||
// Skip if file already exists and is not empty to preserve user modifications
|
existed := false
|
||||||
if info, err := os.Stat(path); err == nil && info.Size() > 0 {
|
if info, err := os.Stat(path); err == nil && info.Size() > 0 {
|
||||||
|
existed = true
|
||||||
|
}
|
||||||
|
if existed && !cfg.overwrite {
|
||||||
cd.log(fmt.Sprintf("Skipping %s (already exists)", cfg.name))
|
cd.log(fmt.Sprintf("Skipping %s (already exists)", cfg.name))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(path, []byte(cfg.content), 0o644); err != nil {
|
if err := os.WriteFile(path, []byte(cfg.content), 0o644); err != nil {
|
||||||
return fmt.Errorf("failed to write %s: %w", cfg.name, err)
|
return fmt.Errorf("failed to write %s: %w", cfg.name, err)
|
||||||
}
|
}
|
||||||
|
if existed {
|
||||||
|
cd.log(fmt.Sprintf("Updated %s", cfg.name))
|
||||||
|
continue
|
||||||
|
}
|
||||||
cd.log(fmt.Sprintf("Deployed %s", cfg.name))
|
cd.log(fmt.Sprintf("Deployed %s", cfg.name))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -603,94 +830,42 @@ func (cd *ConfigDeployer) deployHyprlandDmsConfigs(dmsDir string, terminalComman
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (cd *ConfigDeployer) mergeHyprlandMonitorSections(newConfig, existingConfig, dmsDir string) (string, error) {
|
func (cd *ConfigDeployer) mergeHyprlandMonitorSections(newConfig, existingConfig, dmsDir string) (string, error) {
|
||||||
monitorRegex := regexp.MustCompile(`(?m)^#?\s*monitor\s*=.*$`)
|
_ = newConfig
|
||||||
existingMonitors := monitorRegex.FindAllString(existingConfig, -1)
|
lines := extractHyprlangMonitorLines(existingConfig)
|
||||||
|
if len(lines) == 0 {
|
||||||
if len(existingMonitors) == 0 {
|
|
||||||
return newConfig, nil
|
return newConfig, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
outputsPath := filepath.Join(dmsDir, "outputs.conf")
|
outputsPath := filepath.Join(dmsDir, "outputs.lua")
|
||||||
if _, err := os.Stat(outputsPath); err != nil {
|
if info, err := os.Stat(outputsPath); err == nil && info.Size() > 0 {
|
||||||
var outputsContent strings.Builder
|
cd.log("Skipping monitor migration: dms/outputs.lua already exists")
|
||||||
for _, monitor := range existingMonitors {
|
return newConfig, nil
|
||||||
outputsContent.WriteString(monitor)
|
|
||||||
outputsContent.WriteString("\n")
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(outputsPath, []byte(outputsContent.String()), 0o644); err != nil {
|
|
||||||
cd.log(fmt.Sprintf("Warning: Failed to migrate monitors to %s: %v", outputsPath, err))
|
|
||||||
} else {
|
|
||||||
cd.log("Migrated monitor sections to dms/outputs.conf")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
exampleMonitorRegex := regexp.MustCompile(`(?m)^# monitor = eDP-2.*$`)
|
var b strings.Builder
|
||||||
mergedConfig := exampleMonitorRegex.ReplaceAllString(newConfig, "")
|
b.WriteString("-- Migrated from existing hyprlang monitor lines\n\n")
|
||||||
|
ok := 0
|
||||||
monitorHeaderRegex := regexp.MustCompile(`(?m)^# MONITOR CONFIG\n# ==================$`)
|
|
||||||
headerMatch := monitorHeaderRegex.FindStringIndex(mergedConfig)
|
|
||||||
|
|
||||||
if headerMatch == nil {
|
|
||||||
return "", fmt.Errorf("could not find MONITOR CONFIG section")
|
|
||||||
}
|
|
||||||
|
|
||||||
insertPos := headerMatch[1] + 1
|
|
||||||
|
|
||||||
var builder strings.Builder
|
|
||||||
builder.WriteString(mergedConfig[:insertPos])
|
|
||||||
builder.WriteString("# Monitors from existing configuration\n")
|
|
||||||
|
|
||||||
for _, monitor := range existingMonitors {
|
|
||||||
builder.WriteString(monitor)
|
|
||||||
builder.WriteString("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
builder.WriteString(mergedConfig[insertPos:])
|
|
||||||
|
|
||||||
return builder.String(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (cd *ConfigDeployer) transformHyprlandConfigForNonSystemd(config, terminalCommand string) string {
|
|
||||||
lines := strings.Split(config, "\n")
|
|
||||||
var result []string
|
|
||||||
startupSectionFound := false
|
|
||||||
|
|
||||||
for _, line := range lines {
|
for _, line := range lines {
|
||||||
trimmed := strings.TrimSpace(line)
|
lua, err := hyprlangMonitorLineToLua(line)
|
||||||
if strings.HasPrefix(trimmed, "exec-once = dbus-update-activation-environment") {
|
if err != nil {
|
||||||
|
cd.log(fmt.Sprintf("Warning: could not migrate monitor line %q: %v", line, err))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(trimmed, "exec-once = systemctl --user start") {
|
b.WriteString(lua)
|
||||||
startupSectionFound = true
|
b.WriteByte('\n')
|
||||||
result = append(result, "exec-once = dms run")
|
ok++
|
||||||
result = append(result, "env = QT_QPA_PLATFORM,wayland;xcb")
|
|
||||||
result = append(result, "env = ELECTRON_OZONE_PLATFORM_HINT,auto")
|
|
||||||
result = append(result, "env = QT_QPA_PLATFORMTHEME,gtk3")
|
|
||||||
result = append(result, "env = QT_QPA_PLATFORMTHEME_QT6,gtk3")
|
|
||||||
result = append(result, fmt.Sprintf("env = TERMINAL,%s", terminalCommand))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
result = append(result, line)
|
|
||||||
}
|
}
|
||||||
|
if ok == 0 {
|
||||||
if !startupSectionFound {
|
return newConfig, nil
|
||||||
for i, line := range result {
|
|
||||||
if strings.Contains(line, "STARTUP APPS") {
|
|
||||||
insertLines := []string{
|
|
||||||
"exec-once = dms run",
|
|
||||||
"env = QT_QPA_PLATFORM,wayland;xcb",
|
|
||||||
"env = ELECTRON_OZONE_PLATFORM_HINT,auto",
|
|
||||||
"env = QT_QPA_PLATFORMTHEME,gtk3",
|
|
||||||
"env = QT_QPA_PLATFORMTHEME_QT6,gtk3",
|
|
||||||
fmt.Sprintf("env = TERMINAL,%s", terminalCommand),
|
|
||||||
}
|
|
||||||
result = append(result[:i+2], append(insertLines, result[i+2:]...)...)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
b.WriteByte('\n')
|
||||||
return strings.Join(result, "\n")
|
b.WriteString("-- Default fallback\n")
|
||||||
|
b.WriteString("hl.monitor({ output = \"\", mode = \"preferred\", position = \"auto\", scale = \"auto\" })\n")
|
||||||
|
if err := os.WriteFile(outputsPath, []byte(b.String()), 0o644); err != nil {
|
||||||
|
return newConfig, err
|
||||||
|
}
|
||||||
|
cd.log("Migrated monitor sections to dms/outputs.lua")
|
||||||
|
return newConfig, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cd *ConfigDeployer) transformNiriConfigForNonSystemd(config, terminalCommand string) string {
|
func (cd *ConfigDeployer) transformNiriConfigForNonSystemd(config, terminalCommand string) string {
|
||||||
|
|||||||
@@ -11,6 +11,55 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestCleanupStrayHyprlandConfFile(t *testing.T) {
|
||||||
|
if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") == "" {
|
||||||
|
t.Setenv("HYPRLAND_INSTANCE_SIGNATURE", "test-signature")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("leaves conf alone when no hyprland.lua present", func(t *testing.T) {
|
||||||
|
td := t.TempDir()
|
||||||
|
t.Setenv("HOME", td)
|
||||||
|
configDir := filepath.Join(td, ".config", "hypr")
|
||||||
|
dmsDir := filepath.Join(configDir, "dms")
|
||||||
|
require.NoError(t, os.MkdirAll(dmsDir, 0o755))
|
||||||
|
confPath := filepath.Join(configDir, "hyprland.conf")
|
||||||
|
dmsConfPath := filepath.Join(dmsDir, "colors.conf")
|
||||||
|
require.NoError(t, os.WriteFile(confPath, []byte("# legacy user config\n"), 0o644))
|
||||||
|
require.NoError(t, os.WriteFile(dmsConfPath, []byte("$primary = rgba(d0bcffFF)\n"), 0o644))
|
||||||
|
|
||||||
|
CleanupStrayHyprlandConfFile(nil)
|
||||||
|
|
||||||
|
assert.FileExists(t, confPath, "must not touch hyprland.conf when user has not migrated")
|
||||||
|
assert.FileExists(t, dmsConfPath, "must not touch dms/*.conf when user has not migrated")
|
||||||
|
assert.NoDirExists(t, filepath.Join(configDir, hyprlandBackupDirName))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("moves stray conf into backup when hyprland.lua exists", func(t *testing.T) {
|
||||||
|
td := t.TempDir()
|
||||||
|
t.Setenv("HOME", td)
|
||||||
|
configDir := filepath.Join(td, ".config", "hypr")
|
||||||
|
dmsDir := filepath.Join(configDir, "dms")
|
||||||
|
require.NoError(t, os.MkdirAll(dmsDir, 0o755))
|
||||||
|
luaPath := filepath.Join(configDir, "hyprland.lua")
|
||||||
|
require.NoError(t, os.WriteFile(luaPath, []byte("-- dms managed\n"), 0o644))
|
||||||
|
confPath := filepath.Join(configDir, "hyprland.conf")
|
||||||
|
dmsConfPath := filepath.Join(dmsDir, "colors.conf")
|
||||||
|
require.NoError(t, os.WriteFile(confPath, []byte("# autogen\n"), 0o644))
|
||||||
|
require.NoError(t, os.WriteFile(dmsConfPath, []byte("$primary = rgba(d0bcffFF)\n"), 0o644))
|
||||||
|
|
||||||
|
CleanupStrayHyprlandConfFile(nil)
|
||||||
|
|
||||||
|
assert.NoFileExists(t, confPath)
|
||||||
|
assert.NoFileExists(t, dmsConfPath)
|
||||||
|
assert.FileExists(t, luaPath)
|
||||||
|
entries, err := os.ReadDir(filepath.Join(configDir, hyprlandBackupDirName))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, entries, 1)
|
||||||
|
assert.FileExists(t, filepath.Join(configDir, hyprlandBackupDirName, entries[0].Name(), "hyprland.conf"))
|
||||||
|
assert.FileExists(t, filepath.Join(configDir, hyprlandBackupDirName, entries[0].Name(), "dms", "colors.conf"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestMergeNiriOutputSections(t *testing.T) {
|
func TestMergeNiriOutputSections(t *testing.T) {
|
||||||
cd := &ConfigDeployer{}
|
cd := &ConfigDeployer{}
|
||||||
|
|
||||||
@@ -259,130 +308,56 @@ func getGhosttyPath() string {
|
|||||||
func TestMergeHyprlandMonitorSections(t *testing.T) {
|
func TestMergeHyprlandMonitorSections(t *testing.T) {
|
||||||
cd := &ConfigDeployer{}
|
cd := &ConfigDeployer{}
|
||||||
|
|
||||||
tests := []struct {
|
t.Run("no monitors in existing", func(t *testing.T) {
|
||||||
name string
|
tmp := t.TempDir()
|
||||||
newConfig string
|
out, err := cd.mergeHyprlandMonitorSections(`hl.config({})`, `input { kb_layout = us }`, tmp)
|
||||||
existingConfig string
|
require.NoError(t, err)
|
||||||
wantError bool
|
assert.Equal(t, `hl.config({})`, out)
|
||||||
wantContains []string
|
_, e := os.Stat(filepath.Join(tmp, "outputs.lua"))
|
||||||
wantNotContains []string
|
assert.True(t, os.IsNotExist(e))
|
||||||
}{
|
})
|
||||||
{
|
|
||||||
name: "no existing monitors",
|
|
||||||
newConfig: `# ==================
|
|
||||||
# MONITOR CONFIG
|
|
||||||
# ==================
|
|
||||||
# monitor = eDP-2, 2560x1600@239.998993, 2560x0, 1, vrr, 1
|
|
||||||
|
|
||||||
# ==================
|
t.Run("writes outputs lua from hyprlang monitors", func(t *testing.T) {
|
||||||
# ENVIRONMENT VARS
|
tmp := t.TempDir()
|
||||||
# ==================
|
existing := `monitor = DP-1, 1920x1080@144, 0x0, 1
|
||||||
env = XDG_CURRENT_DESKTOP,niri`,
|
|
||||||
existingConfig: `# Some other config
|
|
||||||
input {
|
|
||||||
kb_layout = us
|
|
||||||
}`,
|
|
||||||
wantError: false,
|
|
||||||
wantContains: []string{"MONITOR CONFIG", "ENVIRONMENT VARS"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "merge single monitor",
|
|
||||||
newConfig: `# ==================
|
|
||||||
# MONITOR CONFIG
|
|
||||||
# ==================
|
|
||||||
# monitor = eDP-2, 2560x1600@239.998993, 2560x0, 1, vrr, 1
|
|
||||||
|
|
||||||
# ==================
|
|
||||||
# ENVIRONMENT VARS
|
|
||||||
# ==================`,
|
|
||||||
existingConfig: `# My config
|
|
||||||
monitor = DP-1, 1920x1080@144, 0x0, 1
|
|
||||||
input {
|
|
||||||
kb_layout = us
|
|
||||||
}`,
|
|
||||||
wantError: false,
|
|
||||||
wantContains: []string{
|
|
||||||
"MONITOR CONFIG",
|
|
||||||
"monitor = DP-1, 1920x1080@144, 0x0, 1",
|
|
||||||
"Monitors from existing configuration",
|
|
||||||
},
|
|
||||||
wantNotContains: []string{
|
|
||||||
"monitor = eDP-2", // Example monitor should be removed
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "merge multiple monitors",
|
|
||||||
newConfig: `# ==================
|
|
||||||
# MONITOR CONFIG
|
|
||||||
# ==================
|
|
||||||
# monitor = eDP-2, 2560x1600@239.998993, 2560x0, 1, vrr, 1
|
|
||||||
|
|
||||||
# ==================
|
|
||||||
# ENVIRONMENT VARS
|
|
||||||
# ==================`,
|
|
||||||
existingConfig: `monitor = DP-1, 1920x1080@144, 0x0, 1
|
|
||||||
# monitor = HDMI-A-1, 1920x1080@60, 1920x0, 1
|
# monitor = HDMI-A-1, 1920x1080@60, 1920x0, 1
|
||||||
monitor = eDP-1, 2560x1440@165, auto, 1.25`,
|
monitor = eDP-1, 2560x1440@165, auto, 1.25`
|
||||||
wantError: false,
|
out, err := cd.mergeHyprlandMonitorSections(`return`, existing, tmp)
|
||||||
wantContains: []string{
|
require.NoError(t, err)
|
||||||
"monitor = DP-1",
|
assert.Equal(t, `return`, out)
|
||||||
"# monitor = HDMI-A-1", // Commented monitor preserved
|
b, err := os.ReadFile(filepath.Join(tmp, "outputs.lua"))
|
||||||
"monitor = eDP-1",
|
require.NoError(t, err)
|
||||||
"Monitors from existing configuration",
|
s := string(b)
|
||||||
},
|
assert.Contains(t, s, "hl.monitor")
|
||||||
wantNotContains: []string{
|
assert.Contains(t, s, "DP-1")
|
||||||
"monitor = eDP-2", // Example monitor should be removed
|
assert.Contains(t, s, "HDMI-A-1")
|
||||||
},
|
assert.Contains(t, s, "eDP-1")
|
||||||
},
|
assert.Contains(t, s, "preferred") // fallback rule at end
|
||||||
{
|
})
|
||||||
name: "preserve commented monitors",
|
|
||||||
newConfig: `# ==================
|
|
||||||
# MONITOR CONFIG
|
|
||||||
# ==================
|
|
||||||
# monitor = eDP-2, 2560x1600@239.998993, 2560x0, 1, vrr, 1
|
|
||||||
|
|
||||||
# ==================`,
|
t.Run("skips when outputs lua already exists", func(t *testing.T) {
|
||||||
existingConfig: `# monitor = DP-1, 1920x1080@144, 0x0, 1
|
tmp := t.TempDir()
|
||||||
# monitor = HDMI-A-1, 1920x1080@60, 1920x0, 1`,
|
path := filepath.Join(tmp, "outputs.lua")
|
||||||
wantError: false,
|
require.NoError(t, os.WriteFile(path, []byte("-- keep\n"), 0o644))
|
||||||
wantContains: []string{
|
_, err := cd.mergeHyprlandMonitorSections(`x`, `monitor = DP-1, 1920x1080@144, 0x0, 1`, tmp)
|
||||||
"# monitor = DP-1",
|
require.NoError(t, err)
|
||||||
"# monitor = HDMI-A-1",
|
b, err := os.ReadFile(path)
|
||||||
"Monitors from existing configuration",
|
require.NoError(t, err)
|
||||||
},
|
assert.Equal(t, "-- keep\n", string(b))
|
||||||
},
|
})
|
||||||
{
|
}
|
||||||
name: "no monitor config section",
|
|
||||||
newConfig: `# Some config without monitor section
|
|
||||||
input {
|
|
||||||
kb_layout = us
|
|
||||||
}`,
|
|
||||||
existingConfig: `monitor = DP-1, 1920x1080@144, 0x0, 1`,
|
|
||||||
wantError: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
func TestHyprlangMonitorLineToLuaPreservesOptions(t *testing.T) {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
got, err := hyprlangMonitorLineToLua(`monitor = DP-1, 1920x1080@144, 0x0, 1, transform, 1, vrr, 2, bitdepth, 10, cm, hdr, sdrbrightness, 1.2, sdrsaturation, 0.98`)
|
||||||
tmpDir := t.TempDir()
|
require.NoError(t, err)
|
||||||
result, err := cd.mergeHyprlandMonitorSections(tt.newConfig, tt.existingConfig, tmpDir)
|
|
||||||
|
|
||||||
if tt.wantError {
|
assert.Contains(t, got, `output = "DP-1"`)
|
||||||
assert.Error(t, err)
|
assert.Contains(t, got, `transform = 1`)
|
||||||
return
|
assert.Contains(t, got, `vrr = 2`)
|
||||||
}
|
assert.Contains(t, got, `bitdepth = 10`)
|
||||||
|
assert.Contains(t, got, `cm = "hdr"`)
|
||||||
require.NoError(t, err)
|
assert.Contains(t, got, `sdrbrightness = 1.2`)
|
||||||
|
assert.Contains(t, got, `sdrsaturation = 0.98`)
|
||||||
for _, want := range tt.wantContains {
|
|
||||||
assert.Contains(t, result, want, "merged config should contain: %s", want)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, notWant := range tt.wantNotContains {
|
|
||||||
assert.NotContains(t, result, notWant, "merged config should NOT contain: %s", notWant)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHyprlandConfigDeployment(t *testing.T) {
|
func TestHyprlandConfigDeployment(t *testing.T) {
|
||||||
@@ -398,6 +373,10 @@ func TestHyprlandConfigDeployment(t *testing.T) {
|
|||||||
cd := NewConfigDeployer(logChan)
|
cd := NewConfigDeployer(logChan)
|
||||||
|
|
||||||
t.Run("deploy hyprland config to empty directory", func(t *testing.T) {
|
t.Run("deploy hyprland config to empty directory", func(t *testing.T) {
|
||||||
|
td, err := os.MkdirTemp("", "dankinstall-hyprland-empty")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer os.RemoveAll(td)
|
||||||
|
os.Setenv("HOME", td)
|
||||||
result, err := cd.deployHyprlandConfig(deps.TerminalGhostty, true)
|
result, err := cd.deployHyprlandConfig(deps.TerminalGhostty, true)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -408,12 +387,16 @@ func TestHyprlandConfigDeployment(t *testing.T) {
|
|||||||
|
|
||||||
content, err := os.ReadFile(result.Path)
|
content, err := os.ReadFile(result.Path)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Contains(t, string(content), "# MONITOR CONFIG")
|
assert.Contains(t, string(content), `require("dms.binds")`)
|
||||||
assert.Contains(t, string(content), "source = ./dms/binds.conf")
|
assert.Contains(t, string(content), "DMS_STARTUP_BEGIN")
|
||||||
assert.Contains(t, string(content), "exec-once = ")
|
assert.Contains(t, string(content), "hl.config(")
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("deploy hyprland config with existing monitors", func(t *testing.T) {
|
t.Run("deploy hyprland config with existing monitors", func(t *testing.T) {
|
||||||
|
td, err := os.MkdirTemp("", "dankinstall-hyprland-merge")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer os.RemoveAll(td)
|
||||||
|
os.Setenv("HOME", td)
|
||||||
existingContent := `# My existing Hyprland config
|
existingContent := `# My existing Hyprland config
|
||||||
monitor = DP-1, 1920x1080@144, 0x0, 1
|
monitor = DP-1, 1920x1080@144, 0x0, 1
|
||||||
monitor = HDMI-A-1, 3840x2160@60, 1920x0, 1.5
|
monitor = HDMI-A-1, 3840x2160@60, 1920x0, 1.5
|
||||||
@@ -422,11 +405,18 @@ general {
|
|||||||
gaps_in = 10
|
gaps_in = 10
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
hyprPath := filepath.Join(tempDir, ".config", "hypr", "hyprland.conf")
|
hyprPath := filepath.Join(td, ".config", "hypr", "hyprland.conf")
|
||||||
err := os.MkdirAll(filepath.Dir(hyprPath), 0o755)
|
err = os.MkdirAll(filepath.Dir(hyprPath), 0o755)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
err = os.WriteFile(hyprPath, []byte(existingContent), 0o644)
|
err = os.WriteFile(hyprPath, []byte(existingContent), 0o644)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
dmsDir := filepath.Join(td, ".config", "hypr", "dms")
|
||||||
|
require.NoError(t, os.MkdirAll(dmsDir, 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dmsDir, "binds.conf"), []byte("bind = SUPER, T, exec, foot\n"), 0o644))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dmsDir, "colors.conf"), []byte("$primary = rgba(d0bcffFF)\n"), 0o644))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dmsDir, "cursor.conf"), []byte("env = XCURSOR_SIZE,24\n"), 0o644))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(filepath.Dir(hyprPath), "hyprland.conf.backup.old"), []byte("old backup\n"), 0o644))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dmsDir, "binds.conf.backup.old"), []byte("old dms backup\n"), 0o644))
|
||||||
|
|
||||||
result, err := cd.deployHyprlandConfig(deps.TerminalKitty, true)
|
result, err := cd.deployHyprlandConfig(deps.TerminalKitty, true)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -440,13 +430,78 @@ general {
|
|||||||
backupContent, err := os.ReadFile(result.BackupPath)
|
backupContent, err := os.ReadFile(result.BackupPath)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, existingContent, string(backupContent))
|
assert.Equal(t, existingContent, string(backupContent))
|
||||||
|
assert.Contains(t, result.BackupPath, hyprlandBackupDirName)
|
||||||
|
assert.NoFileExists(t, hyprPath)
|
||||||
|
assert.FileExists(t, filepath.Join(filepath.Dir(result.BackupPath), "dms", "binds.conf"))
|
||||||
|
assert.FileExists(t, filepath.Join(filepath.Dir(result.BackupPath), "dms", "colors.conf"))
|
||||||
|
assert.FileExists(t, filepath.Join(filepath.Dir(result.BackupPath), "dms", "cursor.conf"))
|
||||||
|
assert.FileExists(t, filepath.Join(filepath.Dir(result.BackupPath), "hyprland.conf.backup.old"))
|
||||||
|
assert.FileExists(t, filepath.Join(filepath.Dir(result.BackupPath), "dms", "binds.conf.backup.old"))
|
||||||
|
assert.NoFileExists(t, filepath.Join(dmsDir, "binds.conf"))
|
||||||
|
assert.NoFileExists(t, filepath.Join(dmsDir, "colors.conf"))
|
||||||
|
assert.NoFileExists(t, filepath.Join(dmsDir, "cursor.conf"))
|
||||||
|
assert.NoFileExists(t, filepath.Join(filepath.Dir(hyprPath), "hyprland.conf.backup.old"))
|
||||||
|
assert.NoFileExists(t, filepath.Join(dmsDir, "binds.conf.backup.old"))
|
||||||
|
|
||||||
newContent, err := os.ReadFile(result.Path)
|
newContent, err := os.ReadFile(result.Path)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Contains(t, string(newContent), "monitor = DP-1, 1920x1080@144")
|
assert.Contains(t, string(newContent), `require("dms.binds")`)
|
||||||
assert.Contains(t, string(newContent), "monitor = HDMI-A-1, 3840x2160@60")
|
|
||||||
assert.Contains(t, string(newContent), "source = ./dms/binds.conf")
|
outputsPath := filepath.Join(td, ".config", "hypr", "dms", "outputs.lua")
|
||||||
assert.NotContains(t, string(newContent), "monitor = eDP-2")
|
outBytes, err := os.ReadFile(outputsPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
outs := string(outBytes)
|
||||||
|
assert.Contains(t, outs, `hl.monitor`)
|
||||||
|
assert.Contains(t, outs, "DP-1")
|
||||||
|
assert.Contains(t, outs, "HDMI-A-1")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("deploy hyprland config removes root legacy symlink when lua exists", func(t *testing.T) {
|
||||||
|
td, err := os.MkdirTemp("", "dankinstall-hyprland-lua-conf-symlink")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer os.RemoveAll(td)
|
||||||
|
os.Setenv("HOME", td)
|
||||||
|
|
||||||
|
configDir := filepath.Join(td, ".config", "hypr")
|
||||||
|
require.NoError(t, os.MkdirAll(configDir, 0o755))
|
||||||
|
luaPath := filepath.Join(configDir, "hyprland.lua")
|
||||||
|
confPath := filepath.Join(configDir, "hyprland.conf")
|
||||||
|
require.NoError(t, os.WriteFile(luaPath, []byte(`require("dms.binds")`+"\n"), 0o644))
|
||||||
|
require.NoError(t, os.Symlink(filepath.Join(configDir, "missing-legacy.conf"), confPath))
|
||||||
|
|
||||||
|
result, err := cd.deployHyprlandConfig(deps.TerminalKitty, true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, luaPath, result.Path)
|
||||||
|
_, err = os.Lstat(confPath)
|
||||||
|
assert.True(t, os.IsNotExist(err), "root hyprland.conf symlink should be moved out of the live config directory")
|
||||||
|
_, err = os.Lstat(filepath.Join(filepath.Dir(result.BackupPath), "hyprland.conf"))
|
||||||
|
assert.NoError(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("deploy hyprland config refreshes managed binds but preserves user binds", func(t *testing.T) {
|
||||||
|
td, err := os.MkdirTemp("", "dankinstall-hyprland-refresh-binds")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer os.RemoveAll(td)
|
||||||
|
os.Setenv("HOME", td)
|
||||||
|
|
||||||
|
dmsDir := filepath.Join(td, ".config", "hypr", "dms")
|
||||||
|
require.NoError(t, os.MkdirAll(dmsDir, 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dmsDir, "binds.lua"), []byte("-- stale managed binds\n"), 0o644))
|
||||||
|
userBinds := "-- custom user binds\n"
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dmsDir, "binds-user.lua"), []byte(userBinds), 0o644))
|
||||||
|
|
||||||
|
_, err = cd.deployHyprlandConfig(deps.TerminalKitty, true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
managed, err := os.ReadFile(filepath.Join(dmsDir, "binds.lua"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(managed), `hl.bind("SUPER + F", hl.dsp.window.fullscreen({ mode = "maximized", action = "toggle" }))`)
|
||||||
|
assert.Contains(t, string(managed), `hl.bind("SUPER + minus", hl.dsp.window.resize({ x = -100, y = 0, relative = true }), { repeating = true })`)
|
||||||
|
|
||||||
|
user, err := os.ReadFile(filepath.Join(dmsDir, "binds-user.lua"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, userBinds, string(user))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -459,10 +514,22 @@ func TestNiriConfigStructure(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHyprlandConfigStructure(t *testing.T) {
|
func TestHyprlandConfigStructure(t *testing.T) {
|
||||||
assert.Contains(t, HyprlandConfig, "# MONITOR CONFIG")
|
assert.Contains(t, HyprlandLuaConfig, `require("dms.binds")`)
|
||||||
assert.Contains(t, HyprlandConfig, "# STARTUP APPS")
|
assert.Contains(t, HyprlandLuaConfig, "DMS_STARTUP_BEGIN")
|
||||||
assert.Contains(t, HyprlandConfig, "# INPUT CONFIG")
|
assert.Contains(t, HyprlandLuaConfig, "hl.config(")
|
||||||
assert.Contains(t, HyprlandConfig, "source = ./dms/binds.conf")
|
assert.Contains(t, HyprlandLuaConfig, "input =")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMangoConfigStructure(t *testing.T) {
|
||||||
|
assert.Contains(t, MangoConfig, "exec-once=dms run")
|
||||||
|
assert.NotContains(t, MangoConfig, "exec_once=dms run")
|
||||||
|
assert.Contains(t, MangoConfig, "source=./dms/binds.conf")
|
||||||
|
assert.Contains(t, MangoBindsConfig, "bind=SUPER,H,focusdir,left")
|
||||||
|
assert.Contains(t, MangoBindsConfig, "bind=SUPER,J,focusdir,down")
|
||||||
|
assert.Contains(t, MangoBindsConfig, "bind=SUPER,K,focusdir,up")
|
||||||
|
assert.Contains(t, MangoBindsConfig, "bind=SUPER,L,focusdir,right")
|
||||||
|
assert.Contains(t, MangoBindsConfig, "gesturebind=none,right,3,viewtoleft_have_client")
|
||||||
|
assert.Contains(t, MangoBindsConfig, "gesturebind=none,left,3,viewtoright_have_client")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGhosttyConfigStructure(t *testing.T) {
|
func TestGhosttyConfigStructure(t *testing.T) {
|
||||||
@@ -789,4 +856,37 @@ func TestShouldReplaceConfigDeployIfMissing(t *testing.T) {
|
|||||||
}
|
}
|
||||||
assert.True(t, foundGhostty, "expected Ghostty config to be deployed when replaceConfigs is true")
|
assert.True(t, foundGhostty, "expected Ghostty config to be deployed when replaceConfigs is true")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("hyprland legacy config exists skips when replace false", func(t *testing.T) {
|
||||||
|
tempDir, err := os.MkdirTemp("", "dankinstall-hyprland-legacy-skip-test")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
|
originalHome := os.Getenv("HOME")
|
||||||
|
os.Setenv("HOME", tempDir)
|
||||||
|
defer os.Setenv("HOME", originalHome)
|
||||||
|
|
||||||
|
hyprConf := filepath.Join(tempDir, ".config", "hypr", "hyprland.conf")
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(hyprConf), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(hyprConf, []byte("monitor = , preferred, auto, 1\n"), 0o644))
|
||||||
|
|
||||||
|
logChan := make(chan string, 100)
|
||||||
|
cd := NewConfigDeployer(logChan)
|
||||||
|
results, err := cd.deployConfigurationsInternal(
|
||||||
|
context.Background(),
|
||||||
|
deps.WindowManagerHyprland,
|
||||||
|
deps.TerminalGhostty,
|
||||||
|
nil,
|
||||||
|
allFalse,
|
||||||
|
nil,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
for _, r := range results {
|
||||||
|
if r.ConfigType == "Hyprland" && r.Deployed {
|
||||||
|
t.Fatalf("expected Hyprland deployment to be skipped when legacy config exists and replace=false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
-- Optional per-user keybind overrides (managed by DMS). Loaded after default binds.
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
# === Application Launchers ===
|
|
||||||
bind = SUPER, T, exec, {{TERMINAL_COMMAND}}
|
|
||||||
bind = SUPER, space, exec, dms ipc call spotlight toggle
|
|
||||||
bind = SUPER, V, exec, dms ipc call clipboard toggle
|
|
||||||
bind = SUPER, M, exec, dms ipc call processlist focusOrToggle
|
|
||||||
bind = SUPER, comma, exec, dms ipc call settings focusOrToggle
|
|
||||||
bind = SUPER, N, exec, dms ipc call notifications toggle
|
|
||||||
bind = SUPER SHIFT, N, exec, dms ipc call notepad toggle
|
|
||||||
bind = SUPER, Y, exec, dms ipc call dankdash wallpaper
|
|
||||||
bind = SUPER, TAB, exec, dms ipc call hypr toggleOverview
|
|
||||||
bind = SUPER, X, exec, dms ipc call powermenu toggle
|
|
||||||
|
|
||||||
# === Cheat sheet
|
|
||||||
bind = SUPER SHIFT, Slash, exec, dms ipc call keybinds toggle hyprland
|
|
||||||
|
|
||||||
# === Security ===
|
|
||||||
bind = SUPER ALT, L, exec, dms ipc call lock lock
|
|
||||||
bind = SUPER SHIFT, E, exit
|
|
||||||
bind = CTRL ALT, Delete, exec, dms ipc call processlist focusOrToggle
|
|
||||||
|
|
||||||
# === Audio Controls ===
|
|
||||||
bindel = , XF86AudioRaiseVolume, exec, dms ipc call audio increment 3
|
|
||||||
bindel = , XF86AudioLowerVolume, exec, dms ipc call audio decrement 3
|
|
||||||
bindl = , XF86AudioMute, exec, dms ipc call audio mute
|
|
||||||
bindl = , XF86AudioMicMute, exec, dms ipc call audio micmute
|
|
||||||
bindl = , XF86AudioPause, exec, dms ipc call mpris playPause
|
|
||||||
bindl = , XF86AudioPlay, exec, dms ipc call mpris playPause
|
|
||||||
bindl = , XF86AudioPrev, exec, dms ipc call mpris previous
|
|
||||||
bindl = , XF86AudioNext, exec, dms ipc call mpris next
|
|
||||||
bindel = CTRL, XF86AudioRaiseVolume, exec, dms ipc call mpris increment 3
|
|
||||||
bindel = CTRL, XF86AudioLowerVolume, exec, dms ipc call mpris decrement 3
|
|
||||||
|
|
||||||
# === Brightness Controls ===
|
|
||||||
bindel = , XF86MonBrightnessUp, exec, dms ipc call brightness increment 5 ""
|
|
||||||
bindel = , XF86MonBrightnessDown, exec, dms ipc call brightness decrement 5 ""
|
|
||||||
|
|
||||||
# === Window Management ===
|
|
||||||
bind = SUPER, Q, killactive
|
|
||||||
bind = SUPER, F, fullscreen, 1
|
|
||||||
bind = SUPER SHIFT, F, fullscreen, 0
|
|
||||||
bind = SUPER SHIFT, T, togglefloating
|
|
||||||
bind = SUPER, W, togglegroup
|
|
||||||
bind = SUPER SHIFT, W, exec, dms ipc call window-rules toggle
|
|
||||||
|
|
||||||
# === Focus Navigation ===
|
|
||||||
bind = SUPER, left, movefocus, l
|
|
||||||
bind = SUPER, down, movefocus, d
|
|
||||||
bind = SUPER, up, movefocus, u
|
|
||||||
bind = SUPER, right, movefocus, r
|
|
||||||
bind = SUPER, H, movefocus, l
|
|
||||||
bind = SUPER, J, movefocus, d
|
|
||||||
bind = SUPER, K, movefocus, u
|
|
||||||
bind = SUPER, L, movefocus, r
|
|
||||||
|
|
||||||
# === Window Movement ===
|
|
||||||
bind = SUPER SHIFT, left, movewindow, l
|
|
||||||
bind = SUPER SHIFT, down, movewindow, d
|
|
||||||
bind = SUPER SHIFT, up, movewindow, u
|
|
||||||
bind = SUPER SHIFT, right, movewindow, r
|
|
||||||
bind = SUPER SHIFT, H, movewindow, l
|
|
||||||
bind = SUPER SHIFT, J, movewindow, d
|
|
||||||
bind = SUPER SHIFT, K, movewindow, u
|
|
||||||
bind = SUPER SHIFT, L, movewindow, r
|
|
||||||
|
|
||||||
# === Column Navigation ===
|
|
||||||
bind = SUPER, Home, focuswindow, first
|
|
||||||
bind = SUPER, End, focuswindow, last
|
|
||||||
|
|
||||||
# === Monitor Navigation ===
|
|
||||||
bind = SUPER CTRL, left, focusmonitor, l
|
|
||||||
bind = SUPER CTRL, right, focusmonitor, r
|
|
||||||
bind = SUPER CTRL, H, focusmonitor, l
|
|
||||||
bind = SUPER CTRL, J, focusmonitor, d
|
|
||||||
bind = SUPER CTRL, K, focusmonitor, u
|
|
||||||
bind = SUPER CTRL, L, focusmonitor, r
|
|
||||||
|
|
||||||
# === Move to Monitor ===
|
|
||||||
bind = SUPER SHIFT CTRL, left, movewindow, mon:l
|
|
||||||
bind = SUPER SHIFT CTRL, down, movewindow, mon:d
|
|
||||||
bind = SUPER SHIFT CTRL, up, movewindow, mon:u
|
|
||||||
bind = SUPER SHIFT CTRL, right, movewindow, mon:r
|
|
||||||
bind = SUPER SHIFT CTRL, H, movewindow, mon:l
|
|
||||||
bind = SUPER SHIFT CTRL, J, movewindow, mon:d
|
|
||||||
bind = SUPER SHIFT CTRL, K, movewindow, mon:u
|
|
||||||
bind = SUPER SHIFT CTRL, L, movewindow, mon:r
|
|
||||||
|
|
||||||
# === Workspace Navigation ===
|
|
||||||
bind = SUPER, Page_Down, workspace, e+1
|
|
||||||
bind = SUPER, Page_Up, workspace, e-1
|
|
||||||
bind = SUPER, U, workspace, e+1
|
|
||||||
bind = SUPER, I, workspace, e-1
|
|
||||||
bind = SUPER CTRL, down, movetoworkspace, e+1
|
|
||||||
bind = SUPER CTRL, up, movetoworkspace, e-1
|
|
||||||
bind = SUPER CTRL, U, movetoworkspace, e+1
|
|
||||||
bind = SUPER CTRL, I, movetoworkspace, e-1
|
|
||||||
|
|
||||||
# === Workspace Management ===
|
|
||||||
bind = CTRL SHIFT, R, exec, dms ipc call workspace-rename open
|
|
||||||
|
|
||||||
# === Move Workspaces ===
|
|
||||||
bind = SUPER SHIFT, Page_Down, movetoworkspace, e+1
|
|
||||||
bind = SUPER SHIFT, Page_Up, movetoworkspace, e-1
|
|
||||||
bind = SUPER SHIFT, U, movetoworkspace, e+1
|
|
||||||
bind = SUPER SHIFT, I, movetoworkspace, e-1
|
|
||||||
|
|
||||||
# === Mouse Wheel Navigation ===
|
|
||||||
bind = SUPER, mouse_down, workspace, e+1
|
|
||||||
bind = SUPER, mouse_up, workspace, e-1
|
|
||||||
bind = SUPER CTRL, mouse_down, movetoworkspace, e+1
|
|
||||||
bind = SUPER CTRL, mouse_up, movetoworkspace, e-1
|
|
||||||
|
|
||||||
# === Numbered Workspaces ===
|
|
||||||
bind = SUPER, 1, workspace, 1
|
|
||||||
bind = SUPER, 2, workspace, 2
|
|
||||||
bind = SUPER, 3, workspace, 3
|
|
||||||
bind = SUPER, 4, workspace, 4
|
|
||||||
bind = SUPER, 5, workspace, 5
|
|
||||||
bind = SUPER, 6, workspace, 6
|
|
||||||
bind = SUPER, 7, workspace, 7
|
|
||||||
bind = SUPER, 8, workspace, 8
|
|
||||||
bind = SUPER, 9, workspace, 9
|
|
||||||
|
|
||||||
# === Move to Numbered Workspaces ===
|
|
||||||
bind = SUPER SHIFT, 1, movetoworkspace, 1
|
|
||||||
bind = SUPER SHIFT, 2, movetoworkspace, 2
|
|
||||||
bind = SUPER SHIFT, 3, movetoworkspace, 3
|
|
||||||
bind = SUPER SHIFT, 4, movetoworkspace, 4
|
|
||||||
bind = SUPER SHIFT, 5, movetoworkspace, 5
|
|
||||||
bind = SUPER SHIFT, 6, movetoworkspace, 6
|
|
||||||
bind = SUPER SHIFT, 7, movetoworkspace, 7
|
|
||||||
bind = SUPER SHIFT, 8, movetoworkspace, 8
|
|
||||||
bind = SUPER SHIFT, 9, movetoworkspace, 9
|
|
||||||
|
|
||||||
# === Column Management ===
|
|
||||||
bind = SUPER, bracketleft, layoutmsg, preselect l
|
|
||||||
bind = SUPER, bracketright, layoutmsg, preselect r
|
|
||||||
|
|
||||||
# === Sizing & Layout ===
|
|
||||||
bind = SUPER, R, layoutmsg, togglesplit
|
|
||||||
bind = SUPER CTRL, F, resizeactive, exact 100% 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
|
|
||||||
|
|
||||||
# === Display Profiles ===
|
|
||||||
bind = SUPER, P, exec, dms ipc outputs cycleProfile
|
|
||||||
|
|
||||||
# === System Controls ===
|
|
||||||
bind = SUPER SHIFT, P, dpms, toggle
|
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
-- DMS default keybinds (Hyprland 0.55+ Lua)
|
||||||
|
|
||||||
|
-- === Application Launchers ===
|
||||||
|
hl.bind("SUPER + T", hl.dsp.exec_cmd("{{TERMINAL_COMMAND}}"))
|
||||||
|
hl.bind("SUPER + space", hl.dsp.exec_cmd("dms ipc call spotlight toggle"))
|
||||||
|
hl.bind("ALT + space", hl.dsp.exec_cmd("dms ipc call spotlight-bar toggle"))
|
||||||
|
hl.bind("SUPER + V", hl.dsp.exec_cmd("dms ipc call clipboard toggle"))
|
||||||
|
hl.bind("SUPER + M", hl.dsp.exec_cmd("dms ipc call processlist focusOrToggle"))
|
||||||
|
hl.bind("SUPER + comma", hl.dsp.exec_cmd("dms ipc call settings focusOrToggle"))
|
||||||
|
hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notifications toggle"))
|
||||||
|
hl.bind("SUPER + SHIFT + N", hl.dsp.exec_cmd("dms ipc call notepad toggle"))
|
||||||
|
hl.bind("SUPER + Y", hl.dsp.exec_cmd("dms ipc call dankdash wallpaper"))
|
||||||
|
hl.bind("SUPER + TAB", hl.dsp.exec_cmd("dms ipc call hypr toggleOverview"))
|
||||||
|
hl.bind("SUPER + O", hl.dsp.exec_cmd("dms ipc call hypr toggleOverview"))
|
||||||
|
hl.bind("SUPER + X", hl.dsp.exec_cmd("dms ipc call powermenu toggle"))
|
||||||
|
|
||||||
|
-- === Cheat sheet
|
||||||
|
hl.bind("SUPER + SHIFT + Slash", hl.dsp.exec_cmd("dms ipc call keybinds toggle hyprland"))
|
||||||
|
|
||||||
|
-- === Security ===
|
||||||
|
hl.bind("SUPER + ALT + L", hl.dsp.exec_cmd("dms ipc call lock lock"))
|
||||||
|
hl.bind("SUPER + SHIFT + E", hl.dsp.exit())
|
||||||
|
hl.bind("CTRL + ALT + Delete", hl.dsp.exec_cmd("dms ipc call processlist focusOrToggle"))
|
||||||
|
|
||||||
|
-- === Audio Controls ===
|
||||||
|
hl.bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd("dms ipc call audio increment 3"), { locked = true, repeating = true })
|
||||||
|
hl.bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("dms ipc call audio decrement 3"), { locked = true, repeating = true })
|
||||||
|
hl.bind("XF86AudioMute", hl.dsp.exec_cmd("dms ipc call audio mute"), { locked = true })
|
||||||
|
hl.bind("XF86AudioMicMute", hl.dsp.exec_cmd("dms ipc call audio micmute"), { locked = true })
|
||||||
|
hl.bind("XF86AudioPause", hl.dsp.exec_cmd("dms ipc call mpris playPause"), { locked = true })
|
||||||
|
hl.bind("XF86AudioPlay", hl.dsp.exec_cmd("dms ipc call mpris playPause"), { locked = true })
|
||||||
|
hl.bind("XF86AudioPrev", hl.dsp.exec_cmd("dms ipc call mpris previous"), { locked = true })
|
||||||
|
hl.bind("XF86AudioNext", hl.dsp.exec_cmd("dms ipc call mpris next"), { locked = true })
|
||||||
|
hl.bind("CTRL + XF86AudioRaiseVolume", hl.dsp.exec_cmd("dms ipc call mpris increment 3"), { locked = true, repeating = true })
|
||||||
|
hl.bind("CTRL + XF86AudioLowerVolume", hl.dsp.exec_cmd("dms ipc call mpris decrement 3"), { locked = true, repeating = true })
|
||||||
|
|
||||||
|
-- === Brightness Controls ===
|
||||||
|
hl.bind("XF86MonBrightnessUp", hl.dsp.exec_cmd([[dms ipc call brightness increment 5 ""]]), { locked = true, repeating = true })
|
||||||
|
hl.bind("XF86MonBrightnessDown", hl.dsp.exec_cmd([[dms ipc call brightness decrement 5 ""]]), { locked = true, repeating = true })
|
||||||
|
|
||||||
|
-- === Window Management ===
|
||||||
|
hl.bind("SUPER + Q", hl.dsp.window.close())
|
||||||
|
hl.bind("SUPER + F", hl.dsp.window.fullscreen({ mode = "maximized", action = "toggle" }))
|
||||||
|
hl.bind("SUPER + SHIFT + F", hl.dsp.window.fullscreen({ mode = "fullscreen", action = "toggle" }))
|
||||||
|
hl.bind("SUPER + SHIFT + T", hl.dsp.window.float({ action = "toggle" }))
|
||||||
|
hl.bind("SUPER + W", hl.dsp.group.toggle())
|
||||||
|
hl.bind("SUPER + SHIFT + W", hl.dsp.exec_cmd("dms ipc call window-rules toggle"))
|
||||||
|
|
||||||
|
-- === Focus Navigation ===
|
||||||
|
hl.bind("SUPER + left", hl.dsp.focus({ direction = "l" }))
|
||||||
|
hl.bind("SUPER + down", hl.dsp.focus({ direction = "d" }))
|
||||||
|
hl.bind("SUPER + up", hl.dsp.focus({ direction = "u" }))
|
||||||
|
hl.bind("SUPER + right", hl.dsp.focus({ direction = "r" }))
|
||||||
|
hl.bind("SUPER + H", hl.dsp.focus({ direction = "l" }))
|
||||||
|
hl.bind("SUPER + J", hl.dsp.focus({ direction = "d" }))
|
||||||
|
hl.bind("SUPER + K", hl.dsp.focus({ direction = "u" }))
|
||||||
|
hl.bind("SUPER + L", hl.dsp.focus({ direction = "r" }))
|
||||||
|
|
||||||
|
-- === Window Movement ===
|
||||||
|
hl.bind("SUPER + SHIFT + left", hl.dsp.window.move({ direction = "l" }))
|
||||||
|
hl.bind("SUPER + SHIFT + down", hl.dsp.window.move({ direction = "d" }))
|
||||||
|
hl.bind("SUPER + SHIFT + up", hl.dsp.window.move({ direction = "u" }))
|
||||||
|
hl.bind("SUPER + SHIFT + right", hl.dsp.window.move({ direction = "r" }))
|
||||||
|
hl.bind("SUPER + SHIFT + H", hl.dsp.window.move({ direction = "l" }))
|
||||||
|
hl.bind("SUPER + SHIFT + J", hl.dsp.window.move({ direction = "d" }))
|
||||||
|
hl.bind("SUPER + SHIFT + K", hl.dsp.window.move({ direction = "u" }))
|
||||||
|
hl.bind("SUPER + SHIFT + L", hl.dsp.window.move({ direction = "r" }))
|
||||||
|
|
||||||
|
-- === Column Navigation ===
|
||||||
|
hl.bind("SUPER + Home", hl.dsp.focus({ window = "first" }))
|
||||||
|
hl.bind("SUPER + End", hl.dsp.focus({ window = "last" }))
|
||||||
|
|
||||||
|
-- === Monitor Navigation ===
|
||||||
|
hl.bind("SUPER + CTRL + left", hl.dsp.focus({ monitor = "l" }))
|
||||||
|
hl.bind("SUPER + CTRL + right", hl.dsp.focus({ monitor = "r" }))
|
||||||
|
hl.bind("SUPER + CTRL + H", hl.dsp.focus({ monitor = "l" }))
|
||||||
|
hl.bind("SUPER + CTRL + J", hl.dsp.focus({ monitor = "d" }))
|
||||||
|
hl.bind("SUPER + CTRL + K", hl.dsp.focus({ monitor = "u" }))
|
||||||
|
hl.bind("SUPER + CTRL + L", hl.dsp.focus({ monitor = "r" }))
|
||||||
|
|
||||||
|
-- === Move to Monitor ===
|
||||||
|
hl.bind("SUPER + SHIFT + CTRL + left", hl.dsp.window.move({ monitor = "l" }))
|
||||||
|
hl.bind("SUPER + SHIFT + CTRL + down", hl.dsp.window.move({ monitor = "d" }))
|
||||||
|
hl.bind("SUPER + SHIFT + CTRL + up", hl.dsp.window.move({ monitor = "u" }))
|
||||||
|
hl.bind("SUPER + SHIFT + CTRL + right", hl.dsp.window.move({ monitor = "r" }))
|
||||||
|
hl.bind("SUPER + SHIFT + CTRL + H", hl.dsp.window.move({ monitor = "l" }))
|
||||||
|
hl.bind("SUPER + SHIFT + CTRL + J", hl.dsp.window.move({ monitor = "d" }))
|
||||||
|
hl.bind("SUPER + SHIFT + CTRL + K", hl.dsp.window.move({ monitor = "u" }))
|
||||||
|
hl.bind("SUPER + SHIFT + CTRL + L", hl.dsp.window.move({ monitor = "r" }))
|
||||||
|
|
||||||
|
-- === Workspace Navigation ===
|
||||||
|
hl.bind("SUPER + Page_Down", hl.dsp.focus({ workspace = "e+1" }))
|
||||||
|
hl.bind("SUPER + Page_Up", hl.dsp.focus({ workspace = "e-1" }))
|
||||||
|
hl.bind("SUPER + U", hl.dsp.focus({ workspace = "e+1" }))
|
||||||
|
hl.bind("SUPER + I", hl.dsp.focus({ workspace = "e-1" }))
|
||||||
|
hl.bind("SUPER + CTRL + down", hl.dsp.window.move({ workspace = "e+1" }))
|
||||||
|
hl.bind("SUPER + CTRL + up", hl.dsp.window.move({ workspace = "e-1" }))
|
||||||
|
hl.bind("SUPER + CTRL + U", hl.dsp.window.move({ workspace = "e+1" }))
|
||||||
|
hl.bind("SUPER + CTRL + I", hl.dsp.window.move({ workspace = "e-1" }))
|
||||||
|
|
||||||
|
-- === Workspace Management ===
|
||||||
|
hl.bind("CTRL + SHIFT + R", hl.dsp.exec_cmd("dms ipc call workspace-rename open"))
|
||||||
|
|
||||||
|
-- === Move Workspaces ===
|
||||||
|
hl.bind("SUPER + SHIFT + Page_Down", hl.dsp.window.move({ workspace = "e+1" }))
|
||||||
|
hl.bind("SUPER + SHIFT + Page_Up", hl.dsp.window.move({ workspace = "e-1" }))
|
||||||
|
hl.bind("SUPER + SHIFT + U", hl.dsp.window.move({ workspace = "e+1" }))
|
||||||
|
hl.bind("SUPER + SHIFT + I", hl.dsp.window.move({ workspace = "e-1" }))
|
||||||
|
|
||||||
|
-- === Mouse Wheel Navigation ===
|
||||||
|
hl.bind("SUPER + mouse_down", hl.dsp.focus({ workspace = "e+1" }))
|
||||||
|
hl.bind("SUPER + mouse_up", hl.dsp.focus({ workspace = "e-1" }))
|
||||||
|
hl.bind("SUPER + CTRL + mouse_down", hl.dsp.window.move({ workspace = "e+1" }))
|
||||||
|
hl.bind("SUPER + CTRL + mouse_up", hl.dsp.window.move({ workspace = "e-1" }))
|
||||||
|
|
||||||
|
-- === Touchpad Gestures ===
|
||||||
|
hl.gesture({ fingers = 3, direction = "horizontal", action = "workspace" })
|
||||||
|
|
||||||
|
-- === Numbered Workspaces ===
|
||||||
|
hl.bind("SUPER + 1", hl.dsp.focus({ workspace = "1" }))
|
||||||
|
hl.bind("SUPER + 2", hl.dsp.focus({ workspace = "2" }))
|
||||||
|
hl.bind("SUPER + 3", hl.dsp.focus({ workspace = "3" }))
|
||||||
|
hl.bind("SUPER + 4", hl.dsp.focus({ workspace = "4" }))
|
||||||
|
hl.bind("SUPER + 5", hl.dsp.focus({ workspace = "5" }))
|
||||||
|
hl.bind("SUPER + 6", hl.dsp.focus({ workspace = "6" }))
|
||||||
|
hl.bind("SUPER + 7", hl.dsp.focus({ workspace = "7" }))
|
||||||
|
hl.bind("SUPER + 8", hl.dsp.focus({ workspace = "8" }))
|
||||||
|
hl.bind("SUPER + 9", hl.dsp.focus({ workspace = "9" }))
|
||||||
|
|
||||||
|
-- === Move to Numbered Workspaces ===
|
||||||
|
hl.bind("SUPER + SHIFT + 1", hl.dsp.window.move({ workspace = "1" }))
|
||||||
|
hl.bind("SUPER + SHIFT + 2", hl.dsp.window.move({ workspace = "2" }))
|
||||||
|
hl.bind("SUPER + SHIFT + 3", hl.dsp.window.move({ workspace = "3" }))
|
||||||
|
hl.bind("SUPER + SHIFT + 4", hl.dsp.window.move({ workspace = "4" }))
|
||||||
|
hl.bind("SUPER + SHIFT + 5", hl.dsp.window.move({ workspace = "5" }))
|
||||||
|
hl.bind("SUPER + SHIFT + 6", hl.dsp.window.move({ workspace = "6" }))
|
||||||
|
hl.bind("SUPER + SHIFT + 7", hl.dsp.window.move({ workspace = "7" }))
|
||||||
|
hl.bind("SUPER + SHIFT + 8", hl.dsp.window.move({ workspace = "8" }))
|
||||||
|
hl.bind("SUPER + SHIFT + 9", hl.dsp.window.move({ workspace = "9" }))
|
||||||
|
|
||||||
|
-- === Column Management ===
|
||||||
|
hl.bind("SUPER + bracketleft", hl.dsp.layout("preselect l"))
|
||||||
|
hl.bind("SUPER + bracketright", hl.dsp.layout("preselect r"))
|
||||||
|
|
||||||
|
-- === Sizing & Layout ===
|
||||||
|
hl.bind("SUPER + R", hl.dsp.layout("togglesplit"))
|
||||||
|
hl.bind("SUPER + CTRL + F", hl.dsp.window.fullscreen({ mode = "maximized", action = "set" }))
|
||||||
|
|
||||||
|
-- === Move/resize windows with mainMod + LMB/RMB and dragging ===
|
||||||
|
hl.bind("SUPER + mouse:272", hl.dsp.window.drag(), { mouse = true, description = "Move window" })
|
||||||
|
hl.bind("SUPER + mouse:273", hl.dsp.window.resize(), { mouse = true, description = "Resize window" })
|
||||||
|
|
||||||
|
hl.bind("SUPER + code:20", hl.dsp.window.resize({ x = -100, y = 0, relative = true }), { description = "Expand window left" })
|
||||||
|
hl.bind("SUPER + code:21", hl.dsp.window.resize({ x = 100, y = 0, relative = true }), { description = "Shrink window left" })
|
||||||
|
|
||||||
|
-- === Manual Sizing ===
|
||||||
|
hl.bind("SUPER + minus", hl.dsp.window.resize({ x = -100, y = 0, relative = true }), { repeating = true })
|
||||||
|
hl.bind("SUPER + equal", hl.dsp.window.resize({ x = 100, y = 0, relative = true }), { repeating = true })
|
||||||
|
hl.bind("SUPER + SHIFT + minus", hl.dsp.window.resize({ x = 0, y = -100, relative = true }), { repeating = true })
|
||||||
|
hl.bind("SUPER + SHIFT + equal", hl.dsp.window.resize({ x = 0, y = 100, relative = true }), { repeating = true })
|
||||||
|
|
||||||
|
-- === Screenshots ===
|
||||||
|
hl.bind("Print", hl.dsp.exec_cmd("dms screenshot"))
|
||||||
|
hl.bind("CTRL + Print", hl.dsp.exec_cmd("dms screenshot full"))
|
||||||
|
hl.bind("ALT + Print", hl.dsp.exec_cmd("dms screenshot window"))
|
||||||
|
|
||||||
|
-- === Display Profiles ===
|
||||||
|
hl.bind("SUPER + P", hl.dsp.exec_cmd("dms ipc outputs cycleProfile"))
|
||||||
|
|
||||||
|
-- === System Controls ===
|
||||||
|
hl.bind("SUPER + SHIFT + P", hl.dsp.dpms({ action = "toggle" }))
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
# ! Auto-generated file. Do not edit directly.
|
|
||||||
# Remove source = ./dms/colors.conf from your config to override.
|
|
||||||
|
|
||||||
$primary = rgb(d0bcff)
|
|
||||||
$outline = rgb(948f99)
|
|
||||||
$error = rgb(f2b8b5)
|
|
||||||
|
|
||||||
general {
|
|
||||||
col.active_border = $primary
|
|
||||||
col.inactive_border = $outline
|
|
||||||
}
|
|
||||||
|
|
||||||
group {
|
|
||||||
col.border_active = $primary
|
|
||||||
col.border_inactive = $outline
|
|
||||||
col.border_locked_active = $error
|
|
||||||
col.border_locked_inactive = $outline
|
|
||||||
|
|
||||||
groupbar {
|
|
||||||
col.active = $primary
|
|
||||||
col.inactive = $outline
|
|
||||||
col.locked_active = $error
|
|
||||||
col.locked_inactive = $outline
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
-- ! Auto-generated file. Do not edit directly.
|
||||||
|
-- Regenerate via DMS theme tools or remove require("dms.colors") from hyprland.lua to override.
|
||||||
|
|
||||||
|
hl.config({
|
||||||
|
general = {
|
||||||
|
col = {
|
||||||
|
active_border = "rgb(d0bcff)",
|
||||||
|
inactive_border = "rgb(948f99)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
group = {
|
||||||
|
col = {
|
||||||
|
border_active = "rgb(d0bcff)",
|
||||||
|
border_inactive = "rgb(948f99)",
|
||||||
|
border_locked_active = "rgb(f2b8b5)",
|
||||||
|
border_locked_inactive = "rgb(948f99)",
|
||||||
|
},
|
||||||
|
groupbar = {
|
||||||
|
col = {
|
||||||
|
active = "rgb(d0bcff)",
|
||||||
|
inactive = "rgb(948f99)",
|
||||||
|
locked_active = "rgb(f2b8b5)",
|
||||||
|
locked_inactive = "rgb(948f99)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
-- Cursor theme overrides. Deploy writes ~/.config/hypr/dms/cursor.lua
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# Auto-generated by DMS - do not edit manually
|
|
||||||
|
|
||||||
general {
|
|
||||||
gaps_in = 4
|
|
||||||
gaps_out = 4
|
|
||||||
border_size = 2
|
|
||||||
}
|
|
||||||
|
|
||||||
decoration {
|
|
||||||
rounding = 12
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- Auto-generated by DMS — do not edit manually
|
||||||
|
|
||||||
|
hl.config({
|
||||||
|
general = {
|
||||||
|
gaps_in = 4,
|
||||||
|
gaps_out = 4,
|
||||||
|
border_size = 2,
|
||||||
|
},
|
||||||
|
decoration = {
|
||||||
|
rounding = 12,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- Per-output monitor rules — embedded sibling of the legacy outputs.conf fragment. Deploy writes ~/.config/hypr/dms/outputs.lua
|
||||||
|
|
||||||
|
hl.monitor({ output = "", mode = "preferred", position = "auto", scale = "auto" })
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
-- Window rules. Deploy writes ~/.config/hypr/dms/windowrules.lua
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
# Hyprland Configuration
|
|
||||||
# https://wiki.hypr.land/Configuring/
|
|
||||||
|
|
||||||
# ==================
|
|
||||||
# MONITOR CONFIG
|
|
||||||
# ==================
|
|
||||||
# monitor = eDP-2, 2560x1600@239.998993, 2560x0, 1, vrr, 1
|
|
||||||
monitor = , preferred,auto,auto
|
|
||||||
|
|
||||||
# ==================
|
|
||||||
# STARTUP APPS
|
|
||||||
# ==================
|
|
||||||
exec-once = dbus-update-activation-environment --systemd --all
|
|
||||||
exec-once = systemctl --user start hyprland-session.target
|
|
||||||
|
|
||||||
# ==================
|
|
||||||
# INPUT CONFIG
|
|
||||||
# ==================
|
|
||||||
input {
|
|
||||||
kb_layout = us
|
|
||||||
numlock_by_default = true
|
|
||||||
}
|
|
||||||
|
|
||||||
# ==================
|
|
||||||
# GENERAL LAYOUT
|
|
||||||
# ==================
|
|
||||||
general {
|
|
||||||
gaps_in = 5
|
|
||||||
gaps_out = 5
|
|
||||||
border_size = 2
|
|
||||||
|
|
||||||
layout = dwindle
|
|
||||||
}
|
|
||||||
|
|
||||||
# ==================
|
|
||||||
# DECORATION
|
|
||||||
# ==================
|
|
||||||
decoration {
|
|
||||||
rounding = 12
|
|
||||||
|
|
||||||
active_opacity = 1.0
|
|
||||||
inactive_opacity = 1.0
|
|
||||||
|
|
||||||
shadow {
|
|
||||||
enabled = true
|
|
||||||
range = 30
|
|
||||||
render_power = 5
|
|
||||||
offset = 0 5
|
|
||||||
color = rgba(00000070)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# ==================
|
|
||||||
# ANIMATIONS
|
|
||||||
# ==================
|
|
||||||
animations {
|
|
||||||
enabled = true
|
|
||||||
|
|
||||||
animation = windowsIn, 1, 3, default
|
|
||||||
animation = windowsOut, 1, 3, default
|
|
||||||
animation = workspaces, 1, 5, default
|
|
||||||
animation = windowsMove, 1, 4, default
|
|
||||||
animation = fade, 1, 3, default
|
|
||||||
animation = border, 1, 3, default
|
|
||||||
}
|
|
||||||
|
|
||||||
# ==================
|
|
||||||
# LAYOUTS
|
|
||||||
# ==================
|
|
||||||
dwindle {
|
|
||||||
preserve_split = true
|
|
||||||
}
|
|
||||||
|
|
||||||
master {
|
|
||||||
mfact = 0.5
|
|
||||||
}
|
|
||||||
|
|
||||||
# ==================
|
|
||||||
# MISC
|
|
||||||
# ==================
|
|
||||||
misc {
|
|
||||||
disable_hyprland_logo = true
|
|
||||||
disable_splash_rendering = true
|
|
||||||
}
|
|
||||||
|
|
||||||
# ==================
|
|
||||||
# WINDOW RULES
|
|
||||||
# ==================
|
|
||||||
windowrule = tile on, match:class ^(org\.wezfurlong\.wezterm)$
|
|
||||||
|
|
||||||
windowrule = rounding 12, match:class ^(org\.gnome\.)
|
|
||||||
|
|
||||||
windowrule = tile on, match:class ^(gnome-control-center)$
|
|
||||||
windowrule = tile on, match:class ^(pavucontrol)$
|
|
||||||
windowrule = tile on, match:class ^(nm-connection-editor)$
|
|
||||||
|
|
||||||
windowrule = float on, match:class ^(org\.gnome\.Calculator)$
|
|
||||||
windowrule = float on, match:class ^(gnome-calculator)$
|
|
||||||
windowrule = float on, match:class ^(galculator)$
|
|
||||||
windowrule = float on, match:class ^(blueman-manager)$
|
|
||||||
windowrule = float on, match:class ^(org\.gnome\.Nautilus)$
|
|
||||||
windowrule = float on, match:class ^(xdg-desktop-portal)$
|
|
||||||
|
|
||||||
windowrule = no_initial_focus on, match:class ^(steam)$, match:title ^(notificationtoasts)
|
|
||||||
windowrule = pin on, match:class ^(steam)$, match:title ^(notificationtoasts)
|
|
||||||
|
|
||||||
windowrule = float on, match:class ^(firefox)$, match:title ^(Picture-in-Picture)$
|
|
||||||
windowrule = float on, match:class ^(zoom)$
|
|
||||||
|
|
||||||
layerrule = no_anim on, match:namespace ^(quickshell)$
|
|
||||||
layerrule = no_anim on, match:namespace ^dms:.*
|
|
||||||
|
|
||||||
source = ./dms/colors.conf
|
|
||||||
source = ./dms/outputs.conf
|
|
||||||
source = ./dms/layout.conf
|
|
||||||
source = ./dms/cursor.conf
|
|
||||||
source = ./dms/binds.conf
|
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
-- Hyprland configuration (Lua) — https://wiki.hypr.land/Configuring/Start/
|
||||||
|
|
||||||
|
hl.config({ autogenerated = false })
|
||||||
|
|
||||||
|
-- DMS_STARTUP_BEGIN
|
||||||
|
hl.on("hyprland.start", function()
|
||||||
|
hl.exec_cmd("dbus-update-activation-environment --systemd --all")
|
||||||
|
hl.exec_cmd("systemctl --user start hyprland-session.target")
|
||||||
|
end)
|
||||||
|
-- DMS_STARTUP_END
|
||||||
|
|
||||||
|
hl.config({
|
||||||
|
input = {
|
||||||
|
kb_layout = "us",
|
||||||
|
numlock_by_default = true,
|
||||||
|
touchpad = {
|
||||||
|
tap_to_click = true,
|
||||||
|
natural_scroll = true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
general = {
|
||||||
|
gaps_in = 5,
|
||||||
|
gaps_out = 5,
|
||||||
|
border_size = 2,
|
||||||
|
layout = "dwindle",
|
||||||
|
},
|
||||||
|
decoration = {
|
||||||
|
rounding = 12,
|
||||||
|
active_opacity = 1.0,
|
||||||
|
inactive_opacity = 1.0,
|
||||||
|
shadow = {
|
||||||
|
enabled = true,
|
||||||
|
range = 30,
|
||||||
|
render_power = 5,
|
||||||
|
offset = "0 5",
|
||||||
|
color = "rgba(00000070)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
misc = {
|
||||||
|
disable_hyprland_logo = true,
|
||||||
|
disable_splash_rendering = true,
|
||||||
|
},
|
||||||
|
dwindle = {
|
||||||
|
preserve_split = true,
|
||||||
|
},
|
||||||
|
master = {
|
||||||
|
mfact = 0.5,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
hl.animation({ leaf = "windowsIn", enabled = true, speed = 3, bezier = "default" })
|
||||||
|
hl.animation({ leaf = "windowsOut", enabled = true, speed = 3, bezier = "default" })
|
||||||
|
hl.animation({ leaf = "workspaces", enabled = true, speed = 5, bezier = "default" })
|
||||||
|
hl.animation({ leaf = "windowsMove", enabled = true, speed = 4, bezier = "default" })
|
||||||
|
hl.animation({ leaf = "fade", enabled = true, speed = 3, bezier = "default" })
|
||||||
|
hl.animation({ leaf = "border", enabled = true, speed = 3, bezier = "default" })
|
||||||
|
|
||||||
|
hl.window_rule({ match = { class = "^(org\\.wezfurlong\\.wezterm)$" }, tile = true })
|
||||||
|
hl.window_rule({ match = { class = "^(org\\.gnome\\.)" }, rounding = 12 })
|
||||||
|
hl.window_rule({ match = { class = "^(gnome-control-center)$" }, tile = true })
|
||||||
|
hl.window_rule({ match = { class = "^(pavucontrol)$" }, tile = true })
|
||||||
|
hl.window_rule({ match = { class = "^(nm-connection-editor)$" }, tile = true })
|
||||||
|
hl.window_rule({ match = { class = "^(org\\.gnome\\.Calculator)$" }, float = true })
|
||||||
|
hl.window_rule({ match = { class = "^(gnome-calculator)$" }, float = true })
|
||||||
|
hl.window_rule({ match = { class = "^(galculator)$" }, float = true })
|
||||||
|
hl.window_rule({ match = { class = "^(blueman-manager)$" }, float = true })
|
||||||
|
hl.window_rule({ match = { class = "^(org\\.gnome\\.Nautilus)$" }, float = true })
|
||||||
|
hl.window_rule({ match = { class = "^(xdg-desktop-portal)$" }, float = true })
|
||||||
|
hl.window_rule({
|
||||||
|
match = { class = "^(steam)$", title = "^(notificationtoasts)" },
|
||||||
|
no_initial_focus = true,
|
||||||
|
pin = true,
|
||||||
|
})
|
||||||
|
hl.window_rule({
|
||||||
|
match = { class = "^(firefox)$", title = "^(Picture-in-Picture)$" },
|
||||||
|
float = true,
|
||||||
|
})
|
||||||
|
hl.window_rule({ match = { class = "^(zoom)$" }, float = true })
|
||||||
|
hl.layer_rule({ match = { namespace = "^(quickshell)$" }, no_anim = true })
|
||||||
|
hl.layer_rule({ match = { namespace = "^dms:.*" }, no_anim = true })
|
||||||
|
|
||||||
|
require("dms.colors")
|
||||||
|
require("dms.outputs")
|
||||||
|
require("dms.layout")
|
||||||
|
require("dms.cursor")
|
||||||
|
require("dms.binds")
|
||||||
|
require("dms.binds-user")
|
||||||
|
require("dms.windowrules")
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
# DMS default keybinds (MangoWM) — managed by DMS, regenerated by `dms setup`.
|
||||||
|
# Format: bind=MODS,key,action[,args]
|
||||||
|
# Put bind descriptions above bind lines; inline # comments break Mango spawn args.
|
||||||
|
|
||||||
|
# === Application Launchers ===
|
||||||
|
# Open Terminal
|
||||||
|
bind=SUPER,t,spawn,{{TERMINAL_COMMAND}}
|
||||||
|
# Open Terminal
|
||||||
|
bind=SUPER,Return,spawn,{{TERMINAL_COMMAND}}
|
||||||
|
# Application Launcher
|
||||||
|
bind=SUPER,space,spawn,dms ipc call spotlight toggle
|
||||||
|
# Spotlight Bar
|
||||||
|
bind=ALT,space,spawn,dms ipc call spotlight-bar toggle
|
||||||
|
# Clipboard Manager
|
||||||
|
bind=SUPER,v,spawn,dms ipc call clipboard toggle
|
||||||
|
# Task Manager
|
||||||
|
bind=SUPER,m,spawn,dms ipc call processlist focusOrToggle
|
||||||
|
# Settings
|
||||||
|
bind=SUPER,comma,spawn,dms ipc call settings focusOrToggle
|
||||||
|
# Notification Center
|
||||||
|
bind=SUPER,n,spawn,dms ipc call notifications toggle
|
||||||
|
# Notepad
|
||||||
|
bind=SUPER+SHIFT,n,spawn,dms ipc call notepad toggle
|
||||||
|
# Browse Wallpapers
|
||||||
|
bind=SUPER,y,spawn,dms ipc call dankdash wallpaper
|
||||||
|
# Power Menu
|
||||||
|
bind=SUPER,x,spawn,dms ipc call powermenu toggle
|
||||||
|
# Cycle Display Profile
|
||||||
|
bind=SUPER,p,spawn,dms ipc outputs cycleProfile
|
||||||
|
|
||||||
|
# === Cheat sheet ===
|
||||||
|
# Keyboard Shortcuts
|
||||||
|
bind=SUPER+SHIFT,slash,spawn,dms ipc call keybinds toggle mangowc
|
||||||
|
|
||||||
|
# === Security ===
|
||||||
|
# Lock Screen
|
||||||
|
bind=SUPER+ALT,l,spawn,dms ipc call lock lock
|
||||||
|
# Task Manager
|
||||||
|
bind=CTRL+ALT,Delete,spawn,dms ipc call processlist focusOrToggle
|
||||||
|
|
||||||
|
# === Window Rules ===
|
||||||
|
# Create Window Rule
|
||||||
|
bind=SUPER+SHIFT,w,spawn,dms ipc call window-rules toggle
|
||||||
|
|
||||||
|
# === Screenshots ===
|
||||||
|
# Screenshot: Interactive
|
||||||
|
bind=none,Print,spawn,dms screenshot
|
||||||
|
# Screenshot: Full Screen
|
||||||
|
bind=CTRL,Print,spawn,dms screenshot full
|
||||||
|
# Screenshot: Window
|
||||||
|
bind=ALT,Print,spawn,dms screenshot window
|
||||||
|
|
||||||
|
# === Audio Controls ===
|
||||||
|
bind=none,XF86AudioRaiseVolume,spawn,dms ipc call audio increment 3
|
||||||
|
bind=none,XF86AudioLowerVolume,spawn,dms ipc call audio decrement 3
|
||||||
|
bind=none,XF86AudioMute,spawn,dms ipc call audio mute
|
||||||
|
bind=none,XF86AudioMicMute,spawn,dms ipc call audio micmute
|
||||||
|
bind=none,XF86AudioPlay,spawn,dms ipc call mpris playPause
|
||||||
|
bind=none,XF86AudioPause,spawn,dms ipc call mpris playPause
|
||||||
|
bind=none,XF86AudioPrev,spawn,dms ipc call mpris previous
|
||||||
|
bind=none,XF86AudioNext,spawn,dms ipc call mpris next
|
||||||
|
|
||||||
|
# === Brightness Controls ===
|
||||||
|
bind=none,XF86MonBrightnessUp,spawn,dms ipc call brightness increment 5
|
||||||
|
bind=none,XF86MonBrightnessDown,spawn,dms ipc call brightness decrement 5
|
||||||
|
|
||||||
|
# === Window Management ===
|
||||||
|
# Close Window
|
||||||
|
bind=SUPER,q,killclient,
|
||||||
|
bind=SUPER,f,togglefullscreen,
|
||||||
|
bind=SUPER,a,togglemaximizescreen,
|
||||||
|
bind=SUPER+SHIFT,space,togglefloating,
|
||||||
|
bind=SUPER,o,toggleoverview
|
||||||
|
bind=ALT,Tab,toggleoverview
|
||||||
|
# Exit Compositor
|
||||||
|
bind=SUPER+SHIFT,e,quit,
|
||||||
|
|
||||||
|
# === Focus Navigation ===
|
||||||
|
bind=SUPER,Tab,focusstack,next
|
||||||
|
bind=SUPER+SHIFT,Tab,focusstack,prev
|
||||||
|
bind=SUPER,Left,focusdir,left
|
||||||
|
bind=SUPER,H,focusdir,left
|
||||||
|
bind=SUPER,Right,focusdir,right
|
||||||
|
bind=SUPER,L,focusdir,right
|
||||||
|
bind=SUPER,Up,focusdir,up
|
||||||
|
bind=SUPER,K,focusdir,up
|
||||||
|
bind=SUPER,Down,focusdir,down
|
||||||
|
bind=SUPER,J,focusdir,down
|
||||||
|
|
||||||
|
# === Window Movement ===
|
||||||
|
bind=SUPER+SHIFT,Left,exchange_client,left
|
||||||
|
bind=SUPER+SHIFT,Right,exchange_client,right
|
||||||
|
bind=SUPER+SHIFT,Up,exchange_client,up
|
||||||
|
bind=SUPER+SHIFT,Down,exchange_client,down
|
||||||
|
bind=SUPER+SHIFT,H,exchange_client,left
|
||||||
|
bind=SUPER+SHIFT,L,exchange_client,right
|
||||||
|
bind=SUPER+SHIFT,K,exchange_client,up
|
||||||
|
bind=SUPER+SHIFT,J,exchange_client,down
|
||||||
|
|
||||||
|
# === Monitor Navigation ===
|
||||||
|
bind=SUPER+ALT,Left,focusmon,left
|
||||||
|
bind=SUPER+ALT,Right,focusmon,right
|
||||||
|
bind=SUPER+ALT+SHIFT,Left,tagmon,left
|
||||||
|
bind=SUPER+ALT+SHIFT,Right,tagmon,right
|
||||||
|
|
||||||
|
# === Layout ===
|
||||||
|
# Cycle Layout - Gaps, Floating, Tiling
|
||||||
|
bind=SUPER+ALT,j,switch_layout
|
||||||
|
bind=SUPER+SHIFT,equal,incgaps,1
|
||||||
|
bind=SUPER+SHIFT,minus,incgaps,-1
|
||||||
|
|
||||||
|
# === Tags (1-9): view tag ===
|
||||||
|
bind=SUPER,1,view,1
|
||||||
|
bind=SUPER,2,view,2
|
||||||
|
bind=SUPER,3,view,3
|
||||||
|
bind=SUPER,4,view,4
|
||||||
|
bind=SUPER,5,view,5
|
||||||
|
bind=SUPER,6,view,6
|
||||||
|
bind=SUPER,7,view,7
|
||||||
|
bind=SUPER,8,view,8
|
||||||
|
bind=SUPER,9,view,9
|
||||||
|
|
||||||
|
# === Tags (1-9): move focused window to tag ===
|
||||||
|
bind=SUPER+SHIFT,1,tag,1
|
||||||
|
bind=SUPER+SHIFT,2,tag,2
|
||||||
|
bind=SUPER+SHIFT,3,tag,3
|
||||||
|
bind=SUPER+SHIFT,4,tag,4
|
||||||
|
bind=SUPER+SHIFT,5,tag,5
|
||||||
|
bind=SUPER+SHIFT,6,tag,6
|
||||||
|
bind=SUPER+SHIFT,7,tag,7
|
||||||
|
bind=SUPER+SHIFT,8,tag,8
|
||||||
|
bind=SUPER+SHIFT,9,tag,9
|
||||||
|
|
||||||
|
# === Touchpad Gestures ===
|
||||||
|
# 3-finger horizontal swipe: switch between occupied workspaces
|
||||||
|
gesturebind=none,right,3,viewtoleft_have_client
|
||||||
|
gesturebind=none,left,3,viewtoright_have_client
|
||||||
|
# 4-finger vertical swipe: toggle the overview
|
||||||
|
gesturebind=none,up,4,toggleoverview
|
||||||
|
gesturebind=none,down,4,toggleoverview
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Auto-generated by DMS. Overwritten by matugen (dms/colors.conf).
|
||||||
|
# Remove `source=./dms/colors.conf` from config.conf to override manually.
|
||||||
|
|
||||||
|
bordercolor = 0x595959ff
|
||||||
|
focuscolor = 0x8ab4f8ff
|
||||||
|
urgentcolor = 0xff5555ff
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Auto-generated by DMS. Regenerated from DMS settings (dms/layout.conf).
|
||||||
|
|
||||||
|
border_radius=12
|
||||||
|
gappih=5
|
||||||
|
gappiv=5
|
||||||
|
gappoh=5
|
||||||
|
gappov=5
|
||||||
|
borderpx=2
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# DankMaterialShell — MangoWM configuration (managed by `dms setup`)
|
||||||
|
# Keybinds, colors, layout, outputs, cursor and window rules are pulled from the
|
||||||
|
# ./dms fragments below. Add your own binds/rules here; they sit alongside DMS's.
|
||||||
|
|
||||||
|
env=XDG_CURRENT_DESKTOP,mango
|
||||||
|
env=XDG_SESSION_TYPE,wayland
|
||||||
|
|
||||||
|
# exec-once runs only at startup. Do NOT use exec= for the shell: mango re-runs
|
||||||
|
# every exec= on each config reload, and DMS reloads the config, which would
|
||||||
|
# spawn a new shell on every reload.
|
||||||
|
exec-once=dms run
|
||||||
|
|
||||||
|
source=./dms/colors.conf
|
||||||
|
source=./dms/layout.conf
|
||||||
|
source=./dms/cursor.conf
|
||||||
|
source=./dms/outputs.conf
|
||||||
|
source=./dms/windowrules.conf
|
||||||
|
source=./dms/binds.conf
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
binds {
|
binds {
|
||||||
// === System & Overview ===
|
// === System & Overview ===
|
||||||
Mod+D repeat=false { toggle-overview; }
|
Mod+O repeat=false { toggle-overview; }
|
||||||
Mod+Tab repeat=false { toggle-overview; }
|
Mod+Tab repeat=false { toggle-overview; }
|
||||||
Mod+Shift+Slash { show-hotkey-overlay; }
|
Mod+Shift+Slash { show-hotkey-overlay; }
|
||||||
|
|
||||||
@@ -9,6 +9,9 @@ binds {
|
|||||||
Mod+Space hotkey-overlay-title="Application Launcher" {
|
Mod+Space hotkey-overlay-title="Application Launcher" {
|
||||||
spawn "dms" "ipc" "call" "spotlight" "toggle";
|
spawn "dms" "ipc" "call" "spotlight" "toggle";
|
||||||
}
|
}
|
||||||
|
Alt+Space hotkey-overlay-title="Spotlight Bar" {
|
||||||
|
spawn "dms" "ipc" "call" "spotlight-bar" "toggle";
|
||||||
|
}
|
||||||
Mod+V hotkey-overlay-title="Clipboard Manager" {
|
Mod+V hotkey-overlay-title="Clipboard Manager" {
|
||||||
spawn "dms" "ipc" "call" "clipboard" "toggle";
|
spawn "dms" "ipc" "call" "clipboard" "toggle";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,14 +2,26 @@ package config
|
|||||||
|
|
||||||
import _ "embed"
|
import _ "embed"
|
||||||
|
|
||||||
//go:embed embedded/hyprland.conf
|
//go:embed embedded/hyprland.lua
|
||||||
var HyprlandConfig string
|
var HyprlandLuaConfig string
|
||||||
|
|
||||||
//go:embed embedded/hypr-colors.conf
|
//go:embed embedded/hypr-colors.lua
|
||||||
var HyprColorsConfig string
|
var DMSColorsLuaConfig string
|
||||||
|
|
||||||
//go:embed embedded/hypr-layout.conf
|
//go:embed embedded/hypr-layout.lua
|
||||||
var HyprLayoutConfig string
|
var DMSLayoutLuaConfig string
|
||||||
|
|
||||||
//go:embed embedded/hypr-binds.conf
|
//go:embed embedded/hypr-binds.lua
|
||||||
var HyprBindsConfig string
|
var DMSBindsLuaConfig string
|
||||||
|
|
||||||
|
//go:embed embedded/hypr-outputs.lua
|
||||||
|
var DMSOutputsLuaConfig string
|
||||||
|
|
||||||
|
//go:embed embedded/hypr-cursor.lua
|
||||||
|
var DMSCursorLuaConfig string
|
||||||
|
|
||||||
|
//go:embed embedded/hypr-windowrules.lua
|
||||||
|
var DMSWindowRulesLuaConfig string
|
||||||
|
|
||||||
|
//go:embed embedded/hypr-binds-user.lua
|
||||||
|
var DMSBindsUserLuaConfig string
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
hyprlandStartupBegin = "-- DMS_STARTUP_BEGIN"
|
||||||
|
hyprlandStartupEnd = "-- DMS_STARTUP_END"
|
||||||
|
)
|
||||||
|
|
||||||
|
func extractHyprlangMonitorLines(hyprlang string) []string {
|
||||||
|
re := regexp.MustCompile(`(?m)^\s*#?\s*monitor\s*=.*$`)
|
||||||
|
return re.FindAllString(hyprlang, -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hyprlangMonitorLineToLua(line string) (string, error) {
|
||||||
|
re := regexp.MustCompile(`(?i)^\s*#?\s*monitor\s*=\s*(.*)\s*$`)
|
||||||
|
m := re.FindStringSubmatch(line)
|
||||||
|
if m == nil {
|
||||||
|
return "", fmt.Errorf("not a monitor line")
|
||||||
|
}
|
||||||
|
rest := strings.TrimSpace(m[1])
|
||||||
|
parts := strings.Split(rest, ",")
|
||||||
|
for i := range parts {
|
||||||
|
parts[i] = strings.TrimSpace(parts[i])
|
||||||
|
}
|
||||||
|
if len(parts) < 4 {
|
||||||
|
if len(parts) == 2 && strings.EqualFold(parts[1], "disable") {
|
||||||
|
return fmt.Sprintf(`hl.monitor({ output = %s, disabled = true })`, strconv.Quote(parts[0])), nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("expected at least 4 comma-separated fields")
|
||||||
|
}
|
||||||
|
out := parts[0]
|
||||||
|
mode := parts[1]
|
||||||
|
pos := parts[2]
|
||||||
|
scaleStr := parts[3]
|
||||||
|
|
||||||
|
scaleField := formatMonitorScaleLua(scaleStr)
|
||||||
|
fields := []string{
|
||||||
|
fmt.Sprintf("output = %s", strconv.Quote(out)),
|
||||||
|
fmt.Sprintf("mode = %s", strconv.Quote(mode)),
|
||||||
|
fmt.Sprintf("position = %s", strconv.Quote(pos)),
|
||||||
|
scaleField,
|
||||||
|
}
|
||||||
|
for i := 4; i < len(parts); i += 2 {
|
||||||
|
key := strings.ToLower(strings.TrimSpace(parts[i]))
|
||||||
|
if key == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if i+1 >= len(parts) {
|
||||||
|
fields = append(fields, fmt.Sprintf("%s = true", hyprlangMonitorOptionToLuaKey(key)))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val := strings.TrimSpace(parts[i+1])
|
||||||
|
if converted, ok := formatMonitorOptionLua(key, val); ok {
|
||||||
|
fields = append(fields, converted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Sprintf(`hl.monitor({ %s })`, strings.Join(fields, ", ")), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatMonitorScaleLua(scaleStr string) string {
|
||||||
|
if scaleStr == "auto" {
|
||||||
|
return `scale = "auto"`
|
||||||
|
}
|
||||||
|
if f, err := strconv.ParseFloat(scaleStr, 64); err == nil {
|
||||||
|
return fmt.Sprintf(`scale = %g`, f)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf(`scale = %s`, strconv.Quote(scaleStr))
|
||||||
|
}
|
||||||
|
|
||||||
|
func hyprlangMonitorOptionToLuaKey(key string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(key)) {
|
||||||
|
case "10bit":
|
||||||
|
return "bitdepth"
|
||||||
|
default:
|
||||||
|
return strings.ReplaceAll(strings.ToLower(strings.TrimSpace(key)), "-", "_")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatMonitorOptionLua(key, val string) (string, bool) {
|
||||||
|
luaKey := hyprlangMonitorOptionToLuaKey(key)
|
||||||
|
switch luaKey {
|
||||||
|
case "transform", "vrr", "bitdepth", "supports_wide_color", "supports_hdr", "sdr_max_luminance", "max_luminance", "max_avg_luminance":
|
||||||
|
if _, err := strconv.Atoi(val); err == nil {
|
||||||
|
return fmt.Sprintf("%s = %s", luaKey, val), true
|
||||||
|
}
|
||||||
|
case "sdrbrightness", "sdrsaturation", "sdr_min_luminance", "min_luminance":
|
||||||
|
if _, err := strconv.ParseFloat(val, 64); err == nil {
|
||||||
|
return fmt.Sprintf("%s = %s", luaKey, val), true
|
||||||
|
}
|
||||||
|
case "cm", "sdr_eotf", "icc", "mirror":
|
||||||
|
return fmt.Sprintf("%s = %s", luaKey, strconv.Quote(val)), true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func transformHyprlandLuaForNonSystemd(config, terminalCommand string) string {
|
||||||
|
start := strings.Index(config, hyprlandStartupBegin)
|
||||||
|
end := strings.Index(config, hyprlandStartupEnd)
|
||||||
|
if start == -1 || end == -1 || end <= start {
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
endClose := end + len(hyprlandStartupEnd)
|
||||||
|
replacement := hyprlandStartupBegin + "\n" +
|
||||||
|
`hl.env("QT_QPA_PLATFORM", "wayland;xcb")` + "\n" +
|
||||||
|
`hl.env("ELECTRON_OZONE_PLATFORM_HINT", "auto")` + "\n" +
|
||||||
|
`hl.env("QT_QPA_PLATFORMTHEME", "gtk3")` + "\n" +
|
||||||
|
`hl.env("QT_QPA_PLATFORMTHEME_QT6", "gtk3")` + "\n" +
|
||||||
|
fmt.Sprintf(`hl.env("TERMINAL", %s)`, strconv.Quote(terminalCommand)) + "\n\n" +
|
||||||
|
`hl.on("hyprland.start", function()` + "\n" +
|
||||||
|
` hl.exec_cmd("dms run")` + "\n" +
|
||||||
|
`end)` + "\n" +
|
||||||
|
hyprlandStartupEnd
|
||||||
|
return config[:start] + replacement + config[endClose:]
|
||||||
|
}
|
||||||
|
|
||||||
|
func readExistingHyprlandConfig(configDir string) (data string, sourcePath string, err error) {
|
||||||
|
luaPath := filepath.Join(configDir, "hyprland.lua")
|
||||||
|
if b, e := os.ReadFile(luaPath); e == nil {
|
||||||
|
return string(b), luaPath, nil
|
||||||
|
} else if !os.IsNotExist(e) {
|
||||||
|
return "", "", e
|
||||||
|
}
|
||||||
|
confPath := filepath.Join(configDir, "hyprland.conf")
|
||||||
|
if b, e := os.ReadFile(confPath); e == nil {
|
||||||
|
return string(b), confPath, nil
|
||||||
|
} else if !os.IsNotExist(e) {
|
||||||
|
return "", "", e
|
||||||
|
}
|
||||||
|
return "", "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanupStrayHyprlandConfFile moves stray ~/.config/hypr/hyprland.conf and
|
||||||
|
// top-level ~/.config/hypr/dms/*.conf files into .dms-backups/<timestamp>/ only
|
||||||
|
// when hyprland.lua also exists as the live config.
|
||||||
|
func CleanupStrayHyprlandConfFile(logFn func(format string, v ...any)) {
|
||||||
|
if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
home := os.Getenv("HOME")
|
||||||
|
if home == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
configDir := filepath.Join(home, ".config", "hypr")
|
||||||
|
luaPath := filepath.Join(configDir, "hyprland.lua")
|
||||||
|
if _, err := os.Stat(luaPath); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var strayPaths []string
|
||||||
|
confPath := filepath.Join(configDir, "hyprland.conf")
|
||||||
|
if info, err := os.Lstat(confPath); err == nil && !info.IsDir() {
|
||||||
|
strayPaths = append(strayPaths, confPath)
|
||||||
|
}
|
||||||
|
dmsConfPaths, err := filepath.Glob(filepath.Join(configDir, "dms", "*.conf"))
|
||||||
|
if err == nil {
|
||||||
|
for _, p := range dmsConfPaths {
|
||||||
|
if info, err := os.Lstat(p); err == nil && !info.IsDir() {
|
||||||
|
strayPaths = append(strayPaths, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(strayPaths) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ts := time.Now().Format("2006-01-02_15-04-05")
|
||||||
|
moved := 0
|
||||||
|
for _, src := range strayPaths {
|
||||||
|
rel, err := filepath.Rel(configDir, src)
|
||||||
|
if err != nil {
|
||||||
|
rel = filepath.Base(src)
|
||||||
|
}
|
||||||
|
dst := filepath.Join(configDir, hyprlandBackupDirName, ts, rel)
|
||||||
|
if err := moveHyprlandConfigFile(src, dst); err != nil {
|
||||||
|
if logFn != nil {
|
||||||
|
logFn("Could not move stray Hyprland conf file %s: %v", src, err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
moved++
|
||||||
|
if logFn != nil {
|
||||||
|
logFn("Moved stray Hyprland conf file to %s", dst)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if moved > 0 && logFn != nil {
|
||||||
|
logFn("Moved %d stray Hyprland conf file(s) out of the active Lua config tree", moved)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import _ "embed"
|
||||||
|
|
||||||
|
//go:embed embedded/mango.conf
|
||||||
|
var MangoConfig string
|
||||||
|
|
||||||
|
//go:embed embedded/mango-colors.conf
|
||||||
|
var MangoColorsConfig string
|
||||||
|
|
||||||
|
//go:embed embedded/mango-layout.conf
|
||||||
|
var MangoLayoutConfig string
|
||||||
|
|
||||||
|
//go:embed embedded/mango-binds.conf
|
||||||
|
var MangoBindsConfig string
|
||||||
@@ -35,6 +35,7 @@ type WindowManager int
|
|||||||
const (
|
const (
|
||||||
WindowManagerHyprland WindowManager = iota
|
WindowManagerHyprland WindowManager = iota
|
||||||
WindowManagerNiri
|
WindowManagerNiri
|
||||||
|
WindowManagerMango
|
||||||
)
|
)
|
||||||
|
|
||||||
type Terminal int
|
type Terminal int
|
||||||
|
|||||||
@@ -112,6 +112,11 @@ func (a *ArchDistribution) DetectDependenciesWithTerminal(ctx context.Context, w
|
|||||||
dependencies = append(dependencies, a.detectXwaylandSatellite())
|
dependencies = append(dependencies, a.detectXwaylandSatellite())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mango-specific tools (dwl-based, uses xwayland-satellite like niri)
|
||||||
|
if wm == deps.WindowManagerMango {
|
||||||
|
dependencies = append(dependencies, a.detectXwaylandSatellite())
|
||||||
|
}
|
||||||
|
|
||||||
dependencies = append(dependencies, a.detectMatugen())
|
dependencies = append(dependencies, a.detectMatugen())
|
||||||
dependencies = append(dependencies, a.detectDgop())
|
dependencies = append(dependencies, a.detectDgop())
|
||||||
|
|
||||||
@@ -172,6 +177,11 @@ func (a *ArchDistribution) isInSystemRepo(pkg string) bool {
|
|||||||
return exec.Command("pacman", "-Si", pkg).Run() == nil
|
return exec.Command("pacman", "-Si", pkg).Run() == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isSonameProvides reports whether dep is a shared-library soname
|
||||||
|
func isSonameProvides(dep string) bool {
|
||||||
|
return strings.HasSuffix(dep, ".so") || strings.Contains(dep, ".so.")
|
||||||
|
}
|
||||||
|
|
||||||
func (a *ArchDistribution) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping {
|
func (a *ArchDistribution) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping {
|
||||||
return a.GetPackageMappingWithVariants(wm, make(map[string]deps.PackageVariant))
|
return a.GetPackageMappingWithVariants(wm, make(map[string]deps.PackageVariant))
|
||||||
}
|
}
|
||||||
@@ -199,6 +209,9 @@ func (a *ArchDistribution) GetPackageMappingWithVariants(wm deps.WindowManager,
|
|||||||
case deps.WindowManagerNiri:
|
case deps.WindowManagerNiri:
|
||||||
packages["niri"] = a.getNiriMapping(variants["niri"])
|
packages["niri"] = a.getNiriMapping(variants["niri"])
|
||||||
packages["xwayland-satellite"] = PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeSystem}
|
packages["xwayland-satellite"] = PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeSystem}
|
||||||
|
case deps.WindowManagerMango:
|
||||||
|
packages["mango"] = a.getMangoMapping(variants["mango"])
|
||||||
|
packages["xwayland-satellite"] = PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeSystem}
|
||||||
}
|
}
|
||||||
|
|
||||||
return packages
|
return packages
|
||||||
@@ -222,6 +235,13 @@ func (a *ArchDistribution) getNiriMapping(variant deps.PackageVariant) PackageMa
|
|||||||
return PackageMapping{Name: "niri", Repository: RepoTypeSystem}
|
return PackageMapping{Name: "niri", Repository: RepoTypeSystem}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *ArchDistribution) getMangoMapping(variant deps.PackageVariant) PackageMapping {
|
||||||
|
if variant == deps.VariantGit {
|
||||||
|
return PackageMapping{Name: "mangowm-git", Repository: RepoTypeAUR}
|
||||||
|
}
|
||||||
|
return PackageMapping{Name: "mangowm", Repository: RepoTypeAUR}
|
||||||
|
}
|
||||||
|
|
||||||
func (a *ArchDistribution) getMatugenMapping(variant deps.PackageVariant) PackageMapping {
|
func (a *ArchDistribution) getMatugenMapping(variant deps.PackageVariant) PackageMapping {
|
||||||
if runtime.GOARCH == "arm64" {
|
if runtime.GOARCH == "arm64" {
|
||||||
return PackageMapping{Name: "matugen-git", Repository: RepoTypeAUR}
|
return PackageMapping{Name: "matugen-git", Repository: RepoTypeAUR}
|
||||||
@@ -724,7 +744,7 @@ func (a *ArchDistribution) installSingleAURPackageInternal(ctx context.Context,
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
seen[dep] = true
|
seen[dep] = true
|
||||||
if a.isInSystemRepo(dep) {
|
if isSonameProvides(dep) || a.isInSystemRepo(dep) {
|
||||||
systemPkgs = append(systemPkgs, dep)
|
systemPkgs = append(systemPkgs, dep)
|
||||||
} else {
|
} else {
|
||||||
aurPkgs = append(aurPkgs, dep)
|
aurPkgs = append(aurPkgs, dep)
|
||||||
|
|||||||
@@ -337,6 +337,36 @@ func (b *BaseDistribution) detectWindowManager(wm deps.WindowManager) deps.Depen
|
|||||||
Variant: variant,
|
Variant: variant,
|
||||||
CanToggle: true,
|
CanToggle: true,
|
||||||
}
|
}
|
||||||
|
case deps.WindowManagerMango:
|
||||||
|
status := deps.StatusMissing
|
||||||
|
variant := deps.VariantStable
|
||||||
|
version := ""
|
||||||
|
|
||||||
|
if b.commandExists("mango") {
|
||||||
|
status = deps.StatusInstalled
|
||||||
|
cmd := exec.Command("mango", "-v")
|
||||||
|
if output, err := cmd.Output(); err == nil {
|
||||||
|
outStr := string(output)
|
||||||
|
if strings.Contains(outStr, "git") || strings.Contains(outStr, "dirty") {
|
||||||
|
variant = deps.VariantGit
|
||||||
|
}
|
||||||
|
if versionRegex := regexp.MustCompile(`(\d+\.\d+\.\d+)`); versionRegex.MatchString(outStr) {
|
||||||
|
matches := versionRegex.FindStringSubmatch(outStr)
|
||||||
|
if len(matches) > 1 {
|
||||||
|
version = matches[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return deps.Dependency{
|
||||||
|
Name: "mango",
|
||||||
|
Status: status,
|
||||||
|
Version: version,
|
||||||
|
Description: "dwl-based dynamic tiling Wayland compositor",
|
||||||
|
Required: true,
|
||||||
|
Variant: variant,
|
||||||
|
CanToggle: true,
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return deps.Dependency{
|
return deps.Dependency{
|
||||||
Name: "unknown-wm",
|
Name: "unknown-wm",
|
||||||
|
|||||||
@@ -77,7 +77,11 @@ func (f *FedoraDistribution) DetectDependenciesWithTerminal(ctx context.Context,
|
|||||||
|
|
||||||
// Common detections using base methods
|
// Common detections using base methods
|
||||||
dependencies = append(dependencies, f.detectGit())
|
dependencies = append(dependencies, f.detectGit())
|
||||||
dependencies = append(dependencies, f.detectWindowManager(wm))
|
wmDep := f.detectWindowManager(wm)
|
||||||
|
if wm == deps.WindowManagerMango {
|
||||||
|
wmDep.Description = "MangoWM (Wayland compositor) — the Terra repo will be enabled automatically to install it"
|
||||||
|
}
|
||||||
|
dependencies = append(dependencies, wmDep)
|
||||||
dependencies = append(dependencies, f.detectQuickshell())
|
dependencies = append(dependencies, f.detectQuickshell())
|
||||||
dependencies = append(dependencies, f.detectDMSGreeter())
|
dependencies = append(dependencies, f.detectDMSGreeter())
|
||||||
dependencies = append(dependencies, f.detectXDGPortal())
|
dependencies = append(dependencies, f.detectXDGPortal())
|
||||||
@@ -93,6 +97,11 @@ func (f *FedoraDistribution) DetectDependenciesWithTerminal(ctx context.Context,
|
|||||||
dependencies = append(dependencies, f.detectXwaylandSatellite())
|
dependencies = append(dependencies, f.detectXwaylandSatellite())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mango-specific tools (dwl-based, uses xwayland-satellite like niri)
|
||||||
|
if wm == deps.WindowManagerMango {
|
||||||
|
dependencies = append(dependencies, f.detectXwaylandSatellite())
|
||||||
|
}
|
||||||
|
|
||||||
dependencies = append(dependencies, f.detectMatugen())
|
dependencies = append(dependencies, f.detectMatugen())
|
||||||
dependencies = append(dependencies, f.detectDgop())
|
dependencies = append(dependencies, f.detectDgop())
|
||||||
|
|
||||||
@@ -139,6 +148,10 @@ func (f *FedoraDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
|
|||||||
case deps.WindowManagerNiri:
|
case deps.WindowManagerNiri:
|
||||||
packages["niri"] = f.getNiriMapping(variants["niri"])
|
packages["niri"] = f.getNiriMapping(variants["niri"])
|
||||||
packages["xwayland-satellite"] = PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeSystem}
|
packages["xwayland-satellite"] = PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeSystem}
|
||||||
|
case deps.WindowManagerMango:
|
||||||
|
// mangowm resolves via Terra, enabled automatically by enableTerraRepo.
|
||||||
|
packages["mango"] = PackageMapping{Name: "mangowm", Repository: RepoTypeSystem}
|
||||||
|
packages["xwayland-satellite"] = PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeSystem}
|
||||||
}
|
}
|
||||||
|
|
||||||
return packages
|
return packages
|
||||||
@@ -159,7 +172,7 @@ func (f *FedoraDistribution) getDmsMapping(variant deps.PackageVariant) PackageM
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (f *FedoraDistribution) getHyprlandMapping(_ deps.PackageVariant) PackageMapping {
|
func (f *FedoraDistribution) getHyprlandMapping(_ deps.PackageVariant) PackageMapping {
|
||||||
return PackageMapping{Name: "hyprland", Repository: RepoTypeCOPR, RepoURL: "sdegler/hyprland"}
|
return PackageMapping{Name: "hyprland", Repository: RepoTypeCOPR, RepoURL: "lionheartp/Hyprland"}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *FedoraDistribution) getNiriMapping(variant deps.PackageVariant) PackageMapping {
|
func (f *FedoraDistribution) getNiriMapping(variant deps.PackageVariant) PackageMapping {
|
||||||
@@ -297,6 +310,22 @@ func (f *FedoraDistribution) InstallPackages(ctx context.Context, dependencies [
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase 2b: Enable Terra repo for MangoWM (not in Fedora's repos). Must run
|
||||||
|
// before the DNF phase so `mangowm` resolves.
|
||||||
|
if wm == deps.WindowManagerMango {
|
||||||
|
progressChan <- InstallProgressMsg{
|
||||||
|
Phase: PhaseSystemPackages,
|
||||||
|
Progress: 0.25,
|
||||||
|
Step: "Enabling Terra repository for MangoWM...",
|
||||||
|
IsComplete: false,
|
||||||
|
NeedsSudo: true,
|
||||||
|
LogOutput: "Setting up the Terra repo (fyralabs) to provide mango",
|
||||||
|
}
|
||||||
|
if err := f.enableTerraRepo(ctx, sudoPassword, progressChan); err != nil {
|
||||||
|
return fmt.Errorf("failed to enable Terra repository: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Phase 3: System Packages (DNF)
|
// Phase 3: System Packages (DNF)
|
||||||
if len(dnfPkgs) > 0 {
|
if len(dnfPkgs) > 0 {
|
||||||
progressChan <- InstallProgressMsg{
|
progressChan <- InstallProgressMsg{
|
||||||
@@ -423,6 +452,30 @@ func (f *FedoraDistribution) extractPackageNames(packages []PackageMapping) []st
|
|||||||
return names
|
return names
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// enableTerraRepo registers the persistent Terra repo (via terra-release) so
|
||||||
|
// `mangowm` resolves in the DNF phase. $releasever is single-quoted so dnf, not
|
||||||
|
// the shell, expands it.
|
||||||
|
func (f *FedoraDistribution) enableTerraRepo(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
||||||
|
// Skip if Terra is already configured
|
||||||
|
if exec.CommandContext(ctx, "sh", "-c",
|
||||||
|
"rpm -q terra-release >/dev/null 2>&1 || test -f /etc/yum.repos.d/terra.repo").Run() == nil {
|
||||||
|
f.log("Terra repository already configured, skipping enable")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
f.log("Enabling Terra repository (fyralabs) for mango...")
|
||||||
|
cmd := privesc.ExecCommand(ctx, sudoPassword,
|
||||||
|
`dnf install -y --nogpgcheck --repofrompath 'terra,https://repos.fyralabs.com/terra$releasever' terra-release 2>&1`)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
f.logError("failed to enable Terra repository", err)
|
||||||
|
f.log(fmt.Sprintf("Terra enable output: %s", string(output)))
|
||||||
|
return fmt.Errorf("failed to enable Terra repository: %w", err)
|
||||||
|
}
|
||||||
|
f.log(fmt.Sprintf("Terra repository enabled: %s", string(output)))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *FedoraDistribution) enableCOPRRepos(ctx context.Context, coprPkgs []PackageMapping, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
func (f *FedoraDistribution) enableCOPRRepos(ctx context.Context, coprPkgs []PackageMapping, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
||||||
enabledRepos := make(map[string]bool)
|
enabledRepos := make(map[string]bool)
|
||||||
|
|
||||||
|
|||||||
@@ -106,6 +106,11 @@ func (g *GentooDistribution) DetectDependenciesWithTerminal(ctx context.Context,
|
|||||||
dependencies = append(dependencies, g.detectXwaylandSatellite())
|
dependencies = append(dependencies, g.detectXwaylandSatellite())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mango-specific tools (dwl-based, uses xwayland-satellite like niri)
|
||||||
|
if wm == deps.WindowManagerMango {
|
||||||
|
dependencies = append(dependencies, g.detectXwaylandSatellite())
|
||||||
|
}
|
||||||
|
|
||||||
dependencies = append(dependencies, g.detectMatugen())
|
dependencies = append(dependencies, g.detectMatugen())
|
||||||
dependencies = append(dependencies, g.detectDgop())
|
dependencies = append(dependencies, g.detectDgop())
|
||||||
|
|
||||||
@@ -176,6 +181,10 @@ func (g *GentooDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
|
|||||||
case deps.WindowManagerNiri:
|
case deps.WindowManagerNiri:
|
||||||
packages["niri"] = g.getNiriMapping(variants["niri"])
|
packages["niri"] = g.getNiriMapping(variants["niri"])
|
||||||
packages["xwayland-satellite"] = PackageMapping{Name: "gui-apps/xwayland-satellite", Repository: RepoTypeGURU, AcceptKeywords: archKeyword}
|
packages["xwayland-satellite"] = PackageMapping{Name: "gui-apps/xwayland-satellite", Repository: RepoTypeGURU, AcceptKeywords: archKeyword}
|
||||||
|
case deps.WindowManagerMango:
|
||||||
|
packages["mango"] = g.getMangoMapping(variants["mango"])
|
||||||
|
packages["scenefx"] = PackageMapping{Name: "gui-libs/scenefx", Repository: RepoTypeGURU, AcceptKeywords: archKeyword}
|
||||||
|
packages["xwayland-satellite"] = PackageMapping{Name: "gui-apps/xwayland-satellite", Repository: RepoTypeGURU, AcceptKeywords: archKeyword}
|
||||||
}
|
}
|
||||||
|
|
||||||
return packages
|
return packages
|
||||||
@@ -197,6 +206,10 @@ func (g *GentooDistribution) getNiriMapping(_ deps.PackageVariant) PackageMappin
|
|||||||
return PackageMapping{Name: "gui-wm/niri", Repository: RepoTypeGURU, UseFlags: "dbus screencast", AcceptKeywords: g.getArchKeyword()}
|
return PackageMapping{Name: "gui-wm/niri", Repository: RepoTypeGURU, UseFlags: "dbus screencast", AcceptKeywords: g.getArchKeyword()}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (g *GentooDistribution) getMangoMapping(_ deps.PackageVariant) PackageMapping {
|
||||||
|
return PackageMapping{Name: "gui-wm/mangowm", Repository: RepoTypeGURU, AcceptKeywords: g.getArchKeyword()}
|
||||||
|
}
|
||||||
|
|
||||||
func (g *GentooDistribution) getPrerequisites() []string {
|
func (g *GentooDistribution) getPrerequisites() []string {
|
||||||
return []string{
|
return []string{
|
||||||
"app-eselect/eselect-repository",
|
"app-eselect/eselect-repository",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"os/user"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -191,6 +192,421 @@ func upsertDefaultSession(configContent, greeterUser, command string) string {
|
|||||||
return strings.Join(out, "\n")
|
return strings.Join(out, "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func removeTomlSection(configContent, sectionName string) string {
|
||||||
|
lines := strings.Split(configContent, "\n")
|
||||||
|
var out []string
|
||||||
|
inSection := false
|
||||||
|
|
||||||
|
for _, line := range lines {
|
||||||
|
if section, ok := parseTomlSection(line); ok {
|
||||||
|
inSection = section == sectionName
|
||||||
|
if inSection {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, line)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if inSection {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
out = append(out, line)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := strings.TrimRight(strings.Join(out, "\n"), "\n")
|
||||||
|
if result != "" {
|
||||||
|
result += "\n"
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripDesktopExecCodes(execLine string) string {
|
||||||
|
fields := strings.Fields(execLine)
|
||||||
|
cleaned := make([]string, 0, len(fields))
|
||||||
|
for _, field := range fields {
|
||||||
|
if strings.HasPrefix(field, "%") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cleaned = append(cleaned, field)
|
||||||
|
}
|
||||||
|
return strings.Join(cleaned, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatInitialSessionCommand(sessionExec string) string {
|
||||||
|
execLine := strings.TrimSpace(stripDesktopExecCodes(sessionExec))
|
||||||
|
if execLine == "" {
|
||||||
|
return `command = ""`
|
||||||
|
}
|
||||||
|
escaped := strings.ReplaceAll(execLine, `'`, `'\''`)
|
||||||
|
inner := fmt.Sprintf("env XDG_SESSION_TYPE=wayland sh -c 'exec %s'", escaped)
|
||||||
|
tomlEscaped := strings.ReplaceAll(inner, `\`, `\\`)
|
||||||
|
tomlEscaped = strings.ReplaceAll(tomlEscaped, `"`, `\"`)
|
||||||
|
return fmt.Sprintf(`command = "%s"`, tomlEscaped)
|
||||||
|
}
|
||||||
|
|
||||||
|
func upsertInitialSession(configContent, loginUser, sessionExec string, enabled bool) string {
|
||||||
|
if !enabled {
|
||||||
|
return removeTomlSection(configContent, "initial_session")
|
||||||
|
}
|
||||||
|
|
||||||
|
commandLine := formatInitialSessionCommand(sessionExec)
|
||||||
|
lines := strings.Split(configContent, "\n")
|
||||||
|
var out []string
|
||||||
|
|
||||||
|
inInitialSession := false
|
||||||
|
foundInitialSession := false
|
||||||
|
initialSessionUserSet := false
|
||||||
|
initialSessionCommandSet := false
|
||||||
|
|
||||||
|
appendInitialSessionFields := func() {
|
||||||
|
if !initialSessionUserSet {
|
||||||
|
out = append(out, fmt.Sprintf(`user = "%s"`, loginUser))
|
||||||
|
}
|
||||||
|
if !initialSessionCommandSet {
|
||||||
|
out = append(out, commandLine)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, line := range lines {
|
||||||
|
if section, ok := parseTomlSection(line); ok {
|
||||||
|
if inInitialSession {
|
||||||
|
appendInitialSessionFields()
|
||||||
|
}
|
||||||
|
|
||||||
|
inInitialSession = section == "initial_session"
|
||||||
|
if inInitialSession {
|
||||||
|
foundInitialSession = true
|
||||||
|
initialSessionUserSet = false
|
||||||
|
initialSessionCommandSet = false
|
||||||
|
}
|
||||||
|
|
||||||
|
out = append(out, line)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if inInitialSession {
|
||||||
|
trimmed := stripTomlComment(line)
|
||||||
|
if strings.HasPrefix(trimmed, "user =") || strings.HasPrefix(trimmed, "user=") {
|
||||||
|
out = append(out, fmt.Sprintf(`user = "%s"`, loginUser))
|
||||||
|
initialSessionUserSet = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(trimmed, "command =") || strings.HasPrefix(trimmed, "command=") {
|
||||||
|
if !initialSessionCommandSet {
|
||||||
|
out = append(out, commandLine)
|
||||||
|
initialSessionCommandSet = true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out = append(out, line)
|
||||||
|
}
|
||||||
|
|
||||||
|
if inInitialSession {
|
||||||
|
appendInitialSessionFields()
|
||||||
|
}
|
||||||
|
|
||||||
|
if !foundInitialSession {
|
||||||
|
if len(out) > 0 && strings.TrimSpace(out[len(out)-1]) != "" {
|
||||||
|
out = append(out, "")
|
||||||
|
}
|
||||||
|
out = append(out, "[initial_session]")
|
||||||
|
out = append(out, fmt.Sprintf(`user = "%s"`, loginUser))
|
||||||
|
out = append(out, commandLine)
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(out, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
type greeterAutoLoginConfig struct {
|
||||||
|
GreeterAutoLogin bool `json:"greeterAutoLogin"`
|
||||||
|
GreeterRememberLastUser bool `json:"greeterRememberLastUser"`
|
||||||
|
GreeterRememberLastSession bool `json:"greeterRememberLastSession"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type greeterAutoLoginMemory struct {
|
||||||
|
LastSuccessfulUser string `json:"lastSuccessfulUser"`
|
||||||
|
LastSessionID string `json:"lastSessionId"`
|
||||||
|
LastSessionExec string `json:"lastSessionExec"`
|
||||||
|
AutoLoginEnabled bool `json:"autoLoginEnabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func readGreeterAutoLoginConfig(settingsPath string) (greeterAutoLoginConfig, error) {
|
||||||
|
cfg := greeterAutoLoginConfig{
|
||||||
|
GreeterRememberLastUser: true,
|
||||||
|
GreeterRememberLastSession: true,
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(settingsPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
return cfg, err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||||
|
return cfg, fmt.Errorf("failed to parse settings at %s: %w", settingsPath, err)
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readGreeterAutoLoginMemory(memoryPath string) (greeterAutoLoginMemory, error) {
|
||||||
|
var mem greeterAutoLoginMemory
|
||||||
|
data, err := os.ReadFile(memoryPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return mem, nil
|
||||||
|
}
|
||||||
|
return mem, err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &mem); err != nil {
|
||||||
|
return mem, fmt.Errorf("failed to parse greeter memory at %s: %w", memoryPath, err)
|
||||||
|
}
|
||||||
|
return mem, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func execFromDesktopFile(path string) (string, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
for line := range strings.SplitSeq(string(data), "\n") {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(trimmed, "Exec=") {
|
||||||
|
return strings.TrimSpace(trimmed[len("Exec="):]), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("no Exec= line found in %s", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveGreeterAutoLoginState(cacheDir, homeDir string) (enabled bool, loginUser string, sessionExec string, err error) {
|
||||||
|
settingsPath := filepath.Join(cacheDir, "settings.json")
|
||||||
|
if _, statErr := os.Stat(settingsPath); statErr != nil {
|
||||||
|
settingsPath = filepath.Join(homeDir, ".config", "DankMaterialShell", "settings.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := readGreeterAutoLoginConfig(settingsPath)
|
||||||
|
if err != nil {
|
||||||
|
return false, "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
memoryPath := filepath.Join(cacheDir, ".local/state/memory.json")
|
||||||
|
mem, err := readGreeterAutoLoginMemory(memoryPath)
|
||||||
|
if err != nil {
|
||||||
|
return false, "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
enabled = cfg.GreeterAutoLogin
|
||||||
|
if !enabled {
|
||||||
|
return false, "", "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !cfg.GreeterRememberLastUser || !cfg.GreeterRememberLastSession {
|
||||||
|
return true, "", "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
loginUser = mem.LastSuccessfulUser
|
||||||
|
if loginUser == "" {
|
||||||
|
current, userErr := user.Current()
|
||||||
|
if userErr != nil {
|
||||||
|
return true, "", "", userErr
|
||||||
|
}
|
||||||
|
loginUser = current.Username
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionExec = mem.LastSessionExec
|
||||||
|
if sessionExec == "" && mem.LastSessionID != "" {
|
||||||
|
sessionExec, err = execFromDesktopFile(mem.LastSessionID)
|
||||||
|
if err != nil {
|
||||||
|
sessionExec = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, loginUser, sessionExec, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeGreetdConfig(configPath, content string, logFunc func(string), sudoPassword, successMsg string) error {
|
||||||
|
if err := backupFileIfExists(sudoPassword, configPath, ".backup"); err != nil {
|
||||||
|
return fmt.Errorf("failed to backup greetd config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpFile, err := os.CreateTemp("", "greetd-config-*.toml")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create temp greetd config: %w", err)
|
||||||
|
}
|
||||||
|
defer os.Remove(tmpFile.Name())
|
||||||
|
|
||||||
|
if _, err := tmpFile.WriteString(content); err != nil {
|
||||||
|
_ = tmpFile.Close()
|
||||||
|
return fmt.Errorf("failed to write temp greetd config: %w", err)
|
||||||
|
}
|
||||||
|
if err := tmpFile.Close(); err != nil {
|
||||||
|
return fmt.Errorf("failed to close temp greetd config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := privesc.Run(context.Background(), sudoPassword, "mkdir", "-p", "/etc/greetd"); err != nil {
|
||||||
|
return fmt.Errorf("failed to create /etc/greetd: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := privesc.Run(context.Background(), sudoPassword, "install", "-o", "root", "-g", "root", "-m", "0644", tmpFile.Name(), configPath); err != nil {
|
||||||
|
return fmt.Errorf("failed to install greetd config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if logFunc != nil && successMsg != "" {
|
||||||
|
logFunc(successMsg)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearGreeterAutoLoginMemory(memoryPath, sudoPassword string) error {
|
||||||
|
data, err := readGreeterMemoryFile(memoryPath, sudoPassword)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(strings.TrimSpace(string(data))) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var raw map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(data, &raw); err != nil {
|
||||||
|
return fmt.Errorf("failed to parse greeter memory at %s: %w", memoryPath, err)
|
||||||
|
}
|
||||||
|
if _, ok := raw["autoLoginEnabled"]; !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
delete(raw, "autoLoginEnabled")
|
||||||
|
encoded, err := json.MarshalIndent(raw, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(encoded) == 0 || string(encoded) == "null" {
|
||||||
|
encoded = []byte("{}")
|
||||||
|
}
|
||||||
|
encoded = append(encoded, '\n')
|
||||||
|
|
||||||
|
if err := os.WriteFile(memoryPath, encoded, 0o644); err == nil {
|
||||||
|
return nil
|
||||||
|
} else if !os.IsPermission(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpFile, err := os.CreateTemp("", "greeter-memory-*.json")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create temp greeter memory file: %w", err)
|
||||||
|
}
|
||||||
|
defer os.Remove(tmpFile.Name())
|
||||||
|
|
||||||
|
if _, err := tmpFile.Write(encoded); err != nil {
|
||||||
|
_ = tmpFile.Close()
|
||||||
|
return fmt.Errorf("failed to write temp greeter memory file: %w", err)
|
||||||
|
}
|
||||||
|
if err := tmpFile.Close(); err != nil {
|
||||||
|
return fmt.Errorf("failed to close temp greeter memory file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
greeterUser := DetectGreeterUser()
|
||||||
|
greeterGroup := DetectGreeterGroup()
|
||||||
|
owner := greeterUser + ":" + greeterGroup
|
||||||
|
if err := privesc.Run(context.Background(), sudoPassword, "install", "-o", greeterUser, "-g", greeterGroup, "-m", "0664", tmpFile.Name(), memoryPath); err != nil {
|
||||||
|
if fallbackErr := privesc.Run(context.Background(), sudoPassword, "install", "-o", "root", "-g", greeterGroup, "-m", "0664", tmpFile.Name(), memoryPath); fallbackErr != nil {
|
||||||
|
return fmt.Errorf("failed to install greeter memory file (preferred %s: %w; fallback root:%s: %v)", owner, err, greeterGroup, fallbackErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readGreeterMemoryFile(memoryPath, sudoPassword string) ([]byte, error) {
|
||||||
|
data, err := os.ReadFile(memoryPath)
|
||||||
|
if err == nil || !os.IsPermission(err) {
|
||||||
|
return data, err
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpFile, err := os.CreateTemp("", "greeter-memory-read-*")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create temp file for greeter memory read: %w", err)
|
||||||
|
}
|
||||||
|
tmpPath := tmpFile.Name()
|
||||||
|
_ = tmpFile.Close()
|
||||||
|
defer os.Remove(tmpPath)
|
||||||
|
|
||||||
|
if err := privesc.Run(context.Background(), sudoPassword, "cp", "-f", memoryPath, tmpPath); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read greeter memory at %s: %w", memoryPath, err)
|
||||||
|
}
|
||||||
|
return os.ReadFile(tmpPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func SyncGreetdAutoLogin(cacheDir, homeDir string, logFunc func(string), sudoPassword string) error {
|
||||||
|
enabled, loginUser, sessionExec, err := resolveGreeterAutoLoginState(cacheDir, homeDir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
configPath := "/etc/greetd/config.toml"
|
||||||
|
configContent := ""
|
||||||
|
if data, readErr := os.ReadFile(configPath); readErr == nil {
|
||||||
|
configContent = string(data)
|
||||||
|
} else if !os.IsNotExist(readErr) {
|
||||||
|
return fmt.Errorf("failed to read greetd config: %w", readErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !enabled {
|
||||||
|
memoryPath := filepath.Join(cacheDir, ".local/state/memory.json")
|
||||||
|
if err := clearGreeterAutoLoginMemory(memoryPath, sudoPassword); err != nil && logFunc != nil {
|
||||||
|
logFunc(fmt.Sprintf("⚠ Warning: Failed to clear greeter auto-login memory flag: %v", err))
|
||||||
|
}
|
||||||
|
newConfig := upsertInitialSession(configContent, "", "", false)
|
||||||
|
if newConfig == configContent {
|
||||||
|
if logFunc != nil {
|
||||||
|
logFunc("✓ Greeter auto-login disabled")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return writeGreetdConfig(configPath, newConfig, logFunc, sudoPassword, "✓ Disabled greeter auto-login")
|
||||||
|
}
|
||||||
|
|
||||||
|
if loginUser == "" || sessionExec == "" {
|
||||||
|
if logFunc != nil {
|
||||||
|
logFunc("⚠ Greeter auto-login is enabled but user or session is not configured yet. Log in manually once, then run sync.")
|
||||||
|
}
|
||||||
|
newConfig := upsertInitialSession(configContent, "", "", false)
|
||||||
|
if newConfig != configContent {
|
||||||
|
return writeGreetdConfig(configPath, newConfig, nil, sudoPassword, "")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
newConfig := upsertInitialSession(configContent, loginUser, sessionExec, true)
|
||||||
|
if newConfig == configContent {
|
||||||
|
if logFunc != nil {
|
||||||
|
logFunc(fmt.Sprintf("✓ Greeter auto-login already configured for %s", loginUser))
|
||||||
|
}
|
||||||
|
memoryPath := filepath.Join(cacheDir, ".local/state/memory.json")
|
||||||
|
_ = clearGreeterAutoLoginMemory(memoryPath, sudoPassword)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := writeGreetdConfig(configPath, newConfig, logFunc, sudoPassword, fmt.Sprintf("✓ Configured greeter auto-login for %s", loginUser)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
memoryPath := filepath.Join(cacheDir, ".local/state/memory.json")
|
||||||
|
if err := clearGreeterAutoLoginMemory(memoryPath, sudoPassword); err != nil && logFunc != nil {
|
||||||
|
logFunc(fmt.Sprintf("⚠ Warning: Failed to clear greeter auto-login memory flag: %v", err))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func SyncGreeterAutoLoginOnly(logFunc func(string), sudoPassword string) error {
|
||||||
|
homeDir, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get user home directory: %w", err)
|
||||||
|
}
|
||||||
|
return SyncGreetdAutoLogin(GreeterCacheDir, homeDir, logFunc, sudoPassword)
|
||||||
|
}
|
||||||
|
|
||||||
func DetectGreeterUser() string {
|
func DetectGreeterUser() string {
|
||||||
passwdData, err := os.ReadFile("/etc/passwd")
|
passwdData, err := os.ReadFile("/etc/passwd")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -264,6 +680,9 @@ func DetectCompositors() []string {
|
|||||||
if utils.CommandExists("Hyprland") {
|
if utils.CommandExists("Hyprland") {
|
||||||
compositors = append(compositors, "Hyprland")
|
compositors = append(compositors, "Hyprland")
|
||||||
}
|
}
|
||||||
|
if utils.CommandExists("mango") {
|
||||||
|
compositors = append(compositors, "mango")
|
||||||
|
}
|
||||||
|
|
||||||
return compositors
|
return compositors
|
||||||
}
|
}
|
||||||
@@ -572,6 +991,7 @@ func EnsureGreeterCacheDir(logFunc func(string), sudoPassword string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
runtimeDirs := []string{
|
runtimeDirs := []string{
|
||||||
|
filepath.Join(cacheDir, "users"),
|
||||||
filepath.Join(cacheDir, ".local"),
|
filepath.Join(cacheDir, ".local"),
|
||||||
filepath.Join(cacheDir, ".local", "state"),
|
filepath.Join(cacheDir, ".local", "state"),
|
||||||
filepath.Join(cacheDir, ".local", "share"),
|
filepath.Join(cacheDir, ".local", "share"),
|
||||||
@@ -1255,6 +1675,20 @@ func SyncDMSConfigs(dmsPath, compositor string, logFunc func(string), sudoPasswo
|
|||||||
return fmt.Errorf("greeter wallpaper override sync failed: %w", err)
|
return fmt.Errorf("greeter wallpaper override sync failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
currentUser, err := user.Current()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to resolve syncing user for per-user greeter cache: %w", err)
|
||||||
|
}
|
||||||
|
if err := syncUserGreeterCacheSlot(homeDir, cacheDir, currentUser.Username, state, logFunc, userSlotSyncOpts{
|
||||||
|
sudoPassword: sudoPassword,
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("per-user greeter cache sync failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := SyncGreetdAutoLogin(cacheDir, homeDir, logFunc, sudoPassword); err != nil {
|
||||||
|
logFunc(fmt.Sprintf("⚠ Warning: greeter auto-login sync failed: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
if strings.ToLower(compositor) != "niri" {
|
if strings.ToLower(compositor) != "niri" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -1719,29 +2153,10 @@ vt = 1
|
|||||||
commandLine := fmt.Sprintf(`command = "%s"`, commandValue)
|
commandLine := fmt.Sprintf(`command = "%s"`, commandValue)
|
||||||
newConfig := upsertDefaultSession(configContent, greeterUser, commandLine)
|
newConfig := upsertDefaultSession(configContent, greeterUser, commandLine)
|
||||||
|
|
||||||
tmpFile, err := os.CreateTemp("", "greetd-config-*.toml")
|
if err := writeGreetdConfig(configPath, newConfig, logFunc, sudoPassword, fmt.Sprintf("✓ Updated greetd configuration (user: %s, command: %s)", greeterUser, commandValue)); err != nil {
|
||||||
if err != nil {
|
return err
|
||||||
return fmt.Errorf("failed to create temp greetd config: %w", err)
|
|
||||||
}
|
|
||||||
defer os.Remove(tmpFile.Name())
|
|
||||||
|
|
||||||
if _, err := tmpFile.WriteString(newConfig); err != nil {
|
|
||||||
_ = tmpFile.Close()
|
|
||||||
return fmt.Errorf("failed to write temp greetd config: %w", err)
|
|
||||||
}
|
|
||||||
if err := tmpFile.Close(); err != nil {
|
|
||||||
return fmt.Errorf("failed to close temp greetd config: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := privesc.Run(context.Background(), sudoPassword, "mkdir", "-p", "/etc/greetd"); err != nil {
|
|
||||||
return fmt.Errorf("failed to create /etc/greetd: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := privesc.Run(context.Background(), sudoPassword, "install", "-o", "root", "-g", "root", "-m", "0644", tmpFile.Name(), configPath); err != nil {
|
|
||||||
return fmt.Errorf("failed to install greetd config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
logFunc(fmt.Sprintf("✓ Updated greetd configuration (user: %s, command: %s)", greeterUser, commandValue))
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package greeter
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -96,3 +97,147 @@ func TestResolveGreeterThemeSyncState(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUpsertInitialSession(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
baseConfig := `[terminal]
|
||||||
|
vt = 1
|
||||||
|
|
||||||
|
[default_session]
|
||||||
|
user = "greeter"
|
||||||
|
command = "/usr/bin/dms-greeter --command niri"
|
||||||
|
`
|
||||||
|
|
||||||
|
t.Run("inserts initial session", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
got := upsertInitialSession(baseConfig, "alice", "niri", true)
|
||||||
|
if !strings.Contains(got, "[initial_session]") {
|
||||||
|
t.Fatalf("expected [initial_session] section, got:\n%s", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, `user = "alice"`) {
|
||||||
|
t.Fatalf("expected alice user in initial session, got:\n%s", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, `env XDG_SESSION_TYPE=wayland sh -c 'exec niri'`) {
|
||||||
|
t.Fatalf("expected wrapped session command, got:\n%s", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("updates existing initial session", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
existing := baseConfig + `
|
||||||
|
[initial_session]
|
||||||
|
user = "bob"
|
||||||
|
command = "old-command"
|
||||||
|
`
|
||||||
|
got := upsertInitialSession(existing, "alice", "Hyprland", true)
|
||||||
|
if strings.Contains(got, `user = "bob"`) {
|
||||||
|
t.Fatalf("expected bob to be replaced, got:\n%s", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, `exec Hyprland`) {
|
||||||
|
t.Fatalf("expected Hyprland command, got:\n%s", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("removes initial session when disabled", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
existing := baseConfig + `
|
||||||
|
[initial_session]
|
||||||
|
user = "alice"
|
||||||
|
command = "niri"
|
||||||
|
`
|
||||||
|
got := upsertInitialSession(existing, "", "", false)
|
||||||
|
if strings.Contains(got, "[initial_session]") {
|
||||||
|
t.Fatalf("expected initial session removed, got:\n%s", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "[default_session]") {
|
||||||
|
t.Fatalf("expected default session preserved, got:\n%s", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStripDesktopExecCodes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got := stripDesktopExecCodes("niri --session %f")
|
||||||
|
want := "niri --session"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("stripDesktopExecCodes = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveGreeterAutoLoginState(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cacheDir := t.TempDir()
|
||||||
|
homeDir := t.TempDir()
|
||||||
|
|
||||||
|
writeTestFile(t, filepath.Join(cacheDir, "settings.json"), `{
|
||||||
|
"greeterAutoLogin": true,
|
||||||
|
"greeterRememberLastUser": true,
|
||||||
|
"greeterRememberLastSession": true
|
||||||
|
}`)
|
||||||
|
writeTestFile(t, filepath.Join(cacheDir, ".local/state/memory.json"), `{
|
||||||
|
"lastSuccessfulUser": "alice",
|
||||||
|
"lastSessionExec": "niri"
|
||||||
|
}`)
|
||||||
|
|
||||||
|
enabled, loginUser, sessionExec, err := resolveGreeterAutoLoginState(cacheDir, homeDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolveGreeterAutoLoginState returned error: %v", err)
|
||||||
|
}
|
||||||
|
if !enabled || loginUser != "alice" || sessionExec != "niri" {
|
||||||
|
t.Fatalf("got enabled=%v user=%q exec=%q", enabled, loginUser, sessionExec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveGreeterAutoLoginStateIgnoresMemoryFlag(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cacheDir := t.TempDir()
|
||||||
|
homeDir := t.TempDir()
|
||||||
|
|
||||||
|
writeTestFile(t, filepath.Join(cacheDir, "settings.json"), `{
|
||||||
|
"greeterAutoLogin": false,
|
||||||
|
"greeterRememberLastUser": true,
|
||||||
|
"greeterRememberLastSession": true
|
||||||
|
}`)
|
||||||
|
writeTestFile(t, filepath.Join(cacheDir, ".local/state/memory.json"), `{
|
||||||
|
"autoLoginEnabled": true,
|
||||||
|
"lastSuccessfulUser": "alice",
|
||||||
|
"lastSessionExec": "niri"
|
||||||
|
}`)
|
||||||
|
|
||||||
|
enabled, loginUser, sessionExec, err := resolveGreeterAutoLoginState(cacheDir, homeDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolveGreeterAutoLoginState returned error: %v", err)
|
||||||
|
}
|
||||||
|
if enabled || loginUser != "" || sessionExec != "" {
|
||||||
|
t.Fatalf("expected disabled with empty user/exec, got enabled=%v user=%q exec=%q", enabled, loginUser, sessionExec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClearGreeterAutoLoginMemory(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
memoryPath := filepath.Join(t.TempDir(), "memory.json")
|
||||||
|
writeTestFile(t, memoryPath, `{
|
||||||
|
"autoLoginEnabled": true,
|
||||||
|
"lastSuccessfulUser": "alice"
|
||||||
|
}`)
|
||||||
|
|
||||||
|
if err := clearGreeterAutoLoginMemory(memoryPath, ""); err != nil {
|
||||||
|
t.Fatalf("clearGreeterAutoLoginMemory returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(memoryPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read memory file: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(data), "autoLoginEnabled") {
|
||||||
|
t.Fatalf("expected autoLoginEnabled removed, got: %s", string(data))
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(data), "lastSuccessfulUser") {
|
||||||
|
t.Fatalf("expected other memory fields preserved, got: %s", string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,548 @@
|
|||||||
|
package greeter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"os/user"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
var monitorWallpaperSanitizer = regexp.MustCompile(`[^a-zA-Z0-9]+`)
|
||||||
|
|
||||||
|
func userGreeterCacheDir(cacheDir, username string) string {
|
||||||
|
return filepath.Join(cacheDir, "users", username)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isUserOwnedGreeterCacheSlot(path, username string) bool {
|
||||||
|
if strings.TrimSpace(username) == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
userDir, err := filepath.Abs(userGreeterCacheDir(GreeterCacheDir, username))
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
abs, err := filepath.Abs(path)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return abs == userDir || strings.HasPrefix(abs, userDir+string(filepath.Separator))
|
||||||
|
}
|
||||||
|
|
||||||
|
func UserIsInGreeterGroup(username string) bool {
|
||||||
|
group := DetectGreeterGroup()
|
||||||
|
if !utils.HasGroup(group) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
groupsCmd := exec.Command("groups", username)
|
||||||
|
groupsOutput, err := groupsCmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.Contains(string(groupsOutput), group)
|
||||||
|
}
|
||||||
|
|
||||||
|
func CanSyncOwnUserGreeterProfile(username string) bool {
|
||||||
|
currentUser, err := user.Current()
|
||||||
|
if err != nil || currentUser.Username != username {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !UserIsInGreeterGroup(username) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
usersDir := filepath.Join(GreeterCacheDir, "users")
|
||||||
|
if st, err := os.Stat(usersDir); err != nil || !st.IsDir() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
testFile := filepath.Join(usersDir, ".write-test-"+username)
|
||||||
|
file, err := os.OpenFile(testFile, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o660)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_ = file.Close()
|
||||||
|
_ = os.Remove(testFile)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func GreeterProfileSyncReady() bool {
|
||||||
|
if command := readGreeterSessionCommand(); command != "" && strings.Contains(command, "dms-greeter") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
usersDir := filepath.Join(GreeterCacheDir, "users")
|
||||||
|
st, err := os.Stat(usersDir)
|
||||||
|
return err == nil && st.IsDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
func readGreeterSessionCommand() string {
|
||||||
|
data, err := os.ReadFile("/etc/greetd/config.toml")
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
inDefaultSession := false
|
||||||
|
for line := range strings.SplitSeq(string(data), "\n") {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") {
|
||||||
|
inDefaultSession = strings.EqualFold(strings.Trim(trimmed, "[]"), "default_session")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !inDefaultSession {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if idx := strings.Index(trimmed, "#"); idx >= 0 {
|
||||||
|
trimmed = strings.TrimSpace(trimmed[:idx])
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(trimmed, "command") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(trimmed, "=", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
command := strings.Trim(strings.TrimSpace(parts[1]), `"`)
|
||||||
|
if command != "" {
|
||||||
|
return command
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncUserProfileCache writes the current user's theme slot under users/<username>/
|
||||||
|
// without modifying greetd or other system configuration. Requires membership in the
|
||||||
|
// greeter group and a prior full greeter setup by an administrator.
|
||||||
|
func SyncUserProfileCache(logFunc func(string)) error {
|
||||||
|
if logFunc == nil {
|
||||||
|
logFunc = func(string) {}
|
||||||
|
}
|
||||||
|
if !GreeterProfileSyncReady() {
|
||||||
|
return fmt.Errorf("greeter is not set up on this system yet; an administrator must run 'dms greeter install' or 'dms greeter sync' once first")
|
||||||
|
}
|
||||||
|
|
||||||
|
currentUser, err := user.Current()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to resolve current user: %w", err)
|
||||||
|
}
|
||||||
|
if !CanSyncOwnUserGreeterProfile(currentUser.Username) {
|
||||||
|
group := DetectGreeterGroup()
|
||||||
|
return fmt.Errorf("cannot sync greeter profile: you must be in the %s group with write access to %s/users\nAsk an administrator to run:\n sudo usermod -aG %s %s\nThen log out and back in before running:\n dms greeter sync --profile",
|
||||||
|
group, GreeterCacheDir, group, currentUser.Username)
|
||||||
|
}
|
||||||
|
|
||||||
|
homeDir, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get user home directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
state, err := resolveGreeterThemeSyncState(homeDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to resolve greeter color source: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := syncUserGreeterCacheSlot(homeDir, GreeterCacheDir, currentUser.Username, state, logFunc, userSlotSyncOpts{
|
||||||
|
profileOnly: true,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
logFunc(fmt.Sprintf(" → %s/users/%s/", GreeterCacheDir, currentUser.Username))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func canWriteUserGreeterCacheSlot(dest, username string) bool {
|
||||||
|
return isUserOwnedGreeterCacheSlot(dest, username) && CanSyncOwnUserGreeterProfile(username)
|
||||||
|
}
|
||||||
|
|
||||||
|
type userSlotSyncOpts struct {
|
||||||
|
sudoPassword string
|
||||||
|
profileOnly bool
|
||||||
|
username string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o userSlotSyncOpts) useDirectWrite(dest string) bool {
|
||||||
|
if !o.profileOnly {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return canWriteUserGreeterCacheSlot(dest, o.username)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isGreeterCachePath(path string) bool {
|
||||||
|
abs, err := filepath.Abs(path)
|
||||||
|
if err != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
cacheAbs, err := filepath.Abs(GreeterCacheDir)
|
||||||
|
if err != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if abs == cacheAbs {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return strings.HasPrefix(abs, cacheAbs+string(filepath.Separator))
|
||||||
|
}
|
||||||
|
|
||||||
|
func greeterCacheOwner() string {
|
||||||
|
greeterGroup := DetectGreeterGroup()
|
||||||
|
daemonUser := DetectGreeterUser()
|
||||||
|
return daemonUser + ":" + greeterGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureGreeterCacheSubdir(dir string, opts userSlotSyncOpts) error {
|
||||||
|
if opts.useDirectWrite(dir) {
|
||||||
|
if err := os.MkdirAll(dir, 0o770); err != nil {
|
||||||
|
return fmt.Errorf("failed to create cache directory %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := privesc.Run(context.Background(), opts.sudoPassword, "mkdir", "-p", dir); err != nil {
|
||||||
|
return fmt.Errorf("failed to create cache directory %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
owner := greeterCacheOwner()
|
||||||
|
if err := privesc.Run(context.Background(), opts.sudoPassword, "chown", owner, dir); err != nil {
|
||||||
|
if fallbackErr := privesc.Run(context.Background(), opts.sudoPassword, "chown", "root:"+DetectGreeterGroup(), dir); fallbackErr != nil {
|
||||||
|
return fmt.Errorf("failed to set ownership on %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := privesc.Run(context.Background(), opts.sudoPassword, "chmod", "2770", dir); err != nil {
|
||||||
|
return fmt.Errorf("failed to set permissions on %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func setGreeterCacheFileOwnership(path, sudoPassword string) error {
|
||||||
|
owner := greeterCacheOwner()
|
||||||
|
if err := privesc.Run(context.Background(), sudoPassword, "chown", owner, path); err != nil {
|
||||||
|
if fallbackErr := privesc.Run(context.Background(), sudoPassword, "chown", "root:"+DetectGreeterGroup(), path); fallbackErr != nil {
|
||||||
|
return fmt.Errorf("failed to set ownership on %s: %w", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := privesc.Run(context.Background(), sudoPassword, "chmod", "644", path); err != nil {
|
||||||
|
return fmt.Errorf("failed to set permissions on %s: %w", path, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func syncUserGreeterCacheSlot(homeDir, cacheDir, username string, state greeterThemeSyncState, logFunc func(string), opts userSlotSyncOpts) error {
|
||||||
|
if strings.TrimSpace(username) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
opts.username = username
|
||||||
|
|
||||||
|
userDir := userGreeterCacheDir(cacheDir, username)
|
||||||
|
if err := ensureGreeterCacheSubdir(userDir, opts); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
settingsPath := filepath.Join(homeDir, ".config", "DankMaterialShell", "settings.json")
|
||||||
|
settingsBytes, err := os.ReadFile(settingsPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read settings for user cache slot: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
settingsMap := map[string]any{}
|
||||||
|
if strings.TrimSpace(string(settingsBytes)) != "" {
|
||||||
|
if err := json.Unmarshal(settingsBytes, &settingsMap); err != nil {
|
||||||
|
return fmt.Errorf("failed to parse settings for user cache slot: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if customTheme, ok := settingsMap["customThemeFile"].(string); ok && strings.TrimSpace(customTheme) != "" {
|
||||||
|
resolvedTheme := customTheme
|
||||||
|
if !filepath.IsAbs(resolvedTheme) {
|
||||||
|
resolvedTheme = filepath.Join(homeDir, resolvedTheme)
|
||||||
|
}
|
||||||
|
if st, statErr := os.Stat(resolvedTheme); statErr == nil && !st.IsDir() {
|
||||||
|
destTheme := filepath.Join(userDir, "custom-theme.json")
|
||||||
|
if err := copyFileWithPrivesc(resolvedTheme, destTheme, opts); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
settingsMap["customThemeFile"] = destTheme
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
settingsBytes, err = json.Marshal(settingsMap)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal settings for user cache slot: %w", err)
|
||||||
|
}
|
||||||
|
if err := writeFileWithPrivesc(filepath.Join(userDir, "settings.json"), settingsBytes, opts); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionPath := filepath.Join(homeDir, ".local", "state", "DankMaterialShell", "session.json")
|
||||||
|
sessionBytes, err := os.ReadFile(sessionPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read session for user cache slot: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionMap := map[string]any{}
|
||||||
|
if strings.TrimSpace(string(sessionBytes)) != "" {
|
||||||
|
if err := json.Unmarshal(sessionBytes, &sessionMap); err != nil {
|
||||||
|
return fmt.Errorf("failed to parse session for user cache slot: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := localizeSessionWallpapers(sessionMap, userDir, opts); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionBytes, err = json.Marshal(sessionMap)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal session for user cache slot: %w", err)
|
||||||
|
}
|
||||||
|
if err := writeFileWithPrivesc(filepath.Join(userDir, "session.json"), sessionBytes, opts); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
colorsSource := state.effectiveColorsSource(homeDir)
|
||||||
|
if err := copyFileWithPrivesc(colorsSource, filepath.Join(userDir, "colors.json"), opts); err != nil {
|
||||||
|
return fmt.Errorf("failed to copy colors for user cache slot: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := syncUserProfileImage(homeDir, userDir, opts); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
rootOverride := filepath.Join(cacheDir, "greeter_wallpaper_override.jpg")
|
||||||
|
userOverride := filepath.Join(userDir, "greeter_wallpaper_override.jpg")
|
||||||
|
if st, statErr := os.Stat(rootOverride); statErr == nil && !st.IsDir() {
|
||||||
|
if err := copyFileWithPrivesc(rootOverride, userOverride, opts); err != nil {
|
||||||
|
return fmt.Errorf("failed to copy greeter wallpaper override for user cache slot: %w", err)
|
||||||
|
}
|
||||||
|
} else if opts.useDirectWrite(userOverride) {
|
||||||
|
_ = os.Remove(userOverride)
|
||||||
|
} else {
|
||||||
|
_ = privesc.Run(context.Background(), opts.sudoPassword, "rm", "-f", userOverride)
|
||||||
|
}
|
||||||
|
|
||||||
|
logFunc(fmt.Sprintf("✓ Synced per-user greeter cache for %s", username))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func localizeSessionWallpapers(session map[string]any, userDir string, opts userSlotSyncOpts) error {
|
||||||
|
stringKeys := []struct {
|
||||||
|
key string
|
||||||
|
prefix string
|
||||||
|
}{
|
||||||
|
{"wallpaperPath", "wallpaper"},
|
||||||
|
{"wallpaperPathLight", "wallpaper-light"},
|
||||||
|
{"wallpaperPathDark", "wallpaper-dark"},
|
||||||
|
}
|
||||||
|
for _, item := range stringKeys {
|
||||||
|
if err := localizeWallpaperStringField(session, item.key, userDir, item.prefix, opts); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mapKeys := []struct {
|
||||||
|
key string
|
||||||
|
prefix string
|
||||||
|
}{
|
||||||
|
{"monitorWallpapers", "wallpaper-monitor"},
|
||||||
|
{"monitorWallpapersLight", "wallpaper-monitor-light"},
|
||||||
|
{"monitorWallpapersDark", "wallpaper-monitor-dark"},
|
||||||
|
}
|
||||||
|
for _, item := range mapKeys {
|
||||||
|
if err := localizeWallpaperMapField(session, item.key, userDir, item.prefix, opts); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func localizeWallpaperStringField(session map[string]any, key, userDir, prefix string, opts userSlotSyncOpts) error {
|
||||||
|
raw, ok := session[key]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
path, ok := raw.(string)
|
||||||
|
if !ok || strings.TrimSpace(path) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
dest, err := copyWallpaperIntoUserCache(path, userDir, prefix, opts)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if dest != "" {
|
||||||
|
session[key] = dest
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func localizeWallpaperMapField(session map[string]any, key, userDir, prefix string, opts userSlotSyncOpts) error {
|
||||||
|
raw, ok := session[key]
|
||||||
|
if !ok || raw == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
values, ok := raw.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for monitor, rawPath := range values {
|
||||||
|
path, ok := rawPath.(string)
|
||||||
|
if !ok || strings.TrimSpace(path) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
safeMonitor := monitorWallpaperSanitizer.ReplaceAllString(monitor, "-")
|
||||||
|
dest, err := copyWallpaperIntoUserCache(path, userDir, prefix+"-"+safeMonitor, opts)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if dest != "" {
|
||||||
|
values[monitor] = dest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyWallpaperIntoUserCache(srcPath, userDir, prefix string, opts userSlotSyncOpts) (string, error) {
|
||||||
|
if strings.TrimSpace(srcPath) == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
st, err := os.Stat(srcPath)
|
||||||
|
if err != nil || st.IsDir() {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
ext := filepath.Ext(srcPath)
|
||||||
|
if ext == "" {
|
||||||
|
ext = ".jpg"
|
||||||
|
}
|
||||||
|
dest := filepath.Join(userDir, prefix+ext)
|
||||||
|
if err := copyFileWithPrivesc(srcPath, dest, opts); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return dest, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyFileWithPrivesc(src, dest string, opts userSlotSyncOpts) error {
|
||||||
|
if opts.useDirectWrite(dest) {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dest), 0o770); err != nil {
|
||||||
|
return fmt.Errorf("failed to create parent dir for %s: %w", dest, err)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(src)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read %s: %w", src, err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(dest, data, 0o644); err != nil {
|
||||||
|
return fmt.Errorf("failed to write %s: %w", dest, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isGreeterCachePath(dest) {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create parent dir for %s: %w", dest, err)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(src)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read %s: %w", src, err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(dest, data, 0o644); err != nil {
|
||||||
|
return fmt.Errorf("failed to write %s: %w", dest, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = privesc.Run(context.Background(), opts.sudoPassword, "rm", "-f", dest)
|
||||||
|
if err := privesc.Run(context.Background(), opts.sudoPassword, "cp", src, dest); err != nil {
|
||||||
|
return fmt.Errorf("failed to copy %s to %s: %w", src, dest, err)
|
||||||
|
}
|
||||||
|
return setGreeterCacheFileOwnership(dest, opts.sudoPassword)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeFileWithPrivesc(path string, data []byte, opts userSlotSyncOpts) error {
|
||||||
|
if opts.useDirectWrite(path) {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o770); err != nil {
|
||||||
|
return fmt.Errorf("failed to create parent dir for %s: %w", path, err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||||
|
return fmt.Errorf("failed to write %s: %w", path, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isGreeterCachePath(path) {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create parent dir for %s: %w", path, err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||||
|
return fmt.Errorf("failed to write %s: %w", path, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tmp, err := os.CreateTemp("", "dms-greeter-user-cache-*")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create temp file for %s: %w", path, err)
|
||||||
|
}
|
||||||
|
tmpPath := tmp.Name()
|
||||||
|
if _, err := tmp.Write(data); err != nil {
|
||||||
|
_ = tmp.Close()
|
||||||
|
_ = os.Remove(tmpPath)
|
||||||
|
return fmt.Errorf("failed to write temp file for %s: %w", path, err)
|
||||||
|
}
|
||||||
|
if err := tmp.Close(); err != nil {
|
||||||
|
_ = os.Remove(tmpPath)
|
||||||
|
return fmt.Errorf("failed to close temp file for %s: %w", path, err)
|
||||||
|
}
|
||||||
|
defer os.Remove(tmpPath)
|
||||||
|
|
||||||
|
_ = privesc.Run(context.Background(), opts.sudoPassword, "rm", "-f", path)
|
||||||
|
if err := privesc.Run(context.Background(), opts.sudoPassword, "cp", tmpPath, path); err != nil {
|
||||||
|
return fmt.Errorf("failed to install %s: %w", path, err)
|
||||||
|
}
|
||||||
|
return setGreeterCacheFileOwnership(path, opts.sudoPassword)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveUserProfileImageSource(homeDir string) string {
|
||||||
|
candidates := []string{
|
||||||
|
filepath.Join(homeDir, ".face"),
|
||||||
|
filepath.Join(homeDir, ".face.icon"),
|
||||||
|
}
|
||||||
|
if homeDir != "" {
|
||||||
|
username := filepath.Base(homeDir)
|
||||||
|
if username != "" && username != "." && username != string(filepath.Separator) {
|
||||||
|
candidates = append([]string{filepath.Join("/var/lib/AccountsService/icons", username)}, candidates...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, src := range candidates {
|
||||||
|
st, err := os.Stat(src)
|
||||||
|
if err == nil && !st.IsDir() && st.Size() > 0 {
|
||||||
|
return src
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func syncUserProfileImage(homeDir, userDir string, opts userSlotSyncOpts) error {
|
||||||
|
for _, name := range []string{"profile.jpg", "profile.jpeg", "profile.png", "profile.webp"} {
|
||||||
|
path := filepath.Join(userDir, name)
|
||||||
|
if opts.useDirectWrite(path) {
|
||||||
|
_ = os.Remove(path)
|
||||||
|
} else {
|
||||||
|
_ = privesc.Run(context.Background(), opts.sudoPassword, "rm", "-f", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
src := resolveUserProfileImageSource(homeDir)
|
||||||
|
if src == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ext := filepath.Ext(src)
|
||||||
|
if ext == "" {
|
||||||
|
ext = ".jpg"
|
||||||
|
}
|
||||||
|
dest := filepath.Join(userDir, "profile"+ext)
|
||||||
|
if err := copyFileWithPrivesc(src, dest, opts); err != nil {
|
||||||
|
return fmt.Errorf("failed to copy profile image for user cache slot: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package greeter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUserGreeterCacheDir(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got := userGreeterCacheDir("/var/cache/dms-greeter", "alice")
|
||||||
|
want := filepath.Join("/var/cache/dms-greeter", "users", "alice")
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("userGreeterCacheDir() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveUserProfileImageSource(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
homeDir := t.TempDir()
|
||||||
|
facePath := filepath.Join(homeDir, ".face")
|
||||||
|
writeTestFile(t, facePath, "face")
|
||||||
|
|
||||||
|
got := resolveUserProfileImageSource(homeDir)
|
||||||
|
if got != facePath {
|
||||||
|
t.Fatalf("resolveUserProfileImageSource() = %q, want %q", got, facePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsUserOwnedGreeterCacheSlot(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
slot := filepath.Join(GreeterCacheDir, "users", "alice", "settings.json")
|
||||||
|
if !isUserOwnedGreeterCacheSlot(slot, "alice") {
|
||||||
|
t.Fatalf("expected alice to own %q", slot)
|
||||||
|
}
|
||||||
|
if isUserOwnedGreeterCacheSlot(slot, "bob") {
|
||||||
|
t.Fatalf("expected bob not to own alice slot")
|
||||||
|
}
|
||||||
|
if isUserOwnedGreeterCacheSlot(filepath.Join(GreeterCacheDir, "settings.json"), "alice") {
|
||||||
|
t.Fatalf("expected root cache file not to be a user slot")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalizeSessionWallpapers(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
homeDir := t.TempDir()
|
||||||
|
userDir := filepath.Join(homeDir, "users", "alice")
|
||||||
|
wallpaperPath := filepath.Join(homeDir, "wall.jpg")
|
||||||
|
writeTestFile(t, wallpaperPath, "wallpaper")
|
||||||
|
|
||||||
|
session := map[string]any{
|
||||||
|
"wallpaperPath": wallpaperPath,
|
||||||
|
"monitorWallpapers": map[string]any{
|
||||||
|
"DP-1": wallpaperPath,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := localizeSessionWallpapers(session, userDir, userSlotSyncOpts{}); err != nil {
|
||||||
|
t.Fatalf("localizeSessionWallpapers returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gotPath, ok := session["wallpaperPath"].(string)
|
||||||
|
if !ok || gotPath == "" {
|
||||||
|
t.Fatalf("expected localized wallpaperPath, got %#v", session["wallpaperPath"])
|
||||||
|
}
|
||||||
|
if gotPath == wallpaperPath {
|
||||||
|
t.Fatalf("expected copied wallpaper path, still points to source")
|
||||||
|
}
|
||||||
|
|
||||||
|
monitorMap, ok := session["monitorWallpapers"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected monitorWallpapers map")
|
||||||
|
}
|
||||||
|
monitorPath, ok := monitorMap["DP-1"].(string)
|
||||||
|
if !ok || monitorPath == "" || monitorPath == wallpaperPath {
|
||||||
|
t.Fatalf("expected localized monitor wallpaper, got %#v", monitorMap["DP-1"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -364,8 +364,10 @@ func (r *Runner) parseWindowManager() (deps.WindowManager, error) {
|
|||||||
return deps.WindowManagerNiri, nil
|
return deps.WindowManagerNiri, nil
|
||||||
case "hyprland":
|
case "hyprland":
|
||||||
return deps.WindowManagerHyprland, nil
|
return deps.WindowManagerHyprland, nil
|
||||||
|
case "mango", "mangowc":
|
||||||
|
return deps.WindowManagerMango, nil
|
||||||
default:
|
default:
|
||||||
return 0, fmt.Errorf("invalid --compositor value %q: must be 'niri' or 'hyprland'", r.cfg.Compositor)
|
return 0, fmt.Errorf("invalid --compositor value %q: must be 'niri', 'hyprland', or 'mango'", r.cfg.Compositor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,10 @@ package providers
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestHyprlandAutogenerateComment(t *testing.T) {
|
func TestHyprlandAutogenerateComment(t *testing.T) {
|
||||||
@@ -60,6 +63,544 @@ func TestHyprlandAutogenerateComment(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHyprlandLuaBindRoundTripHelpers(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
expr string
|
||||||
|
wantDispatcher string
|
||||||
|
wantParams string
|
||||||
|
}{
|
||||||
|
{`hl.dsp.exec_cmd([[dms ipc call brightness increment 5 ""]])`, "exec", `dms ipc call brightness increment 5 ""`},
|
||||||
|
{`hl.dsp.exec_cmd([[hyprctl dispatch workspace 1]])`, "workspace", "1"},
|
||||||
|
{`hl.dispatch("workspace 2")`, "workspace", "2"},
|
||||||
|
{`hl.dispatch([[customdispatcher arg one]])`, "customdispatcher", "arg one"},
|
||||||
|
{`hl.dsp.window.fullscreen({ mode = "maximized", action = "toggle" })`, "fullscreen", "1"},
|
||||||
|
{`hl.dsp.window.float({ action = "on" })`, "setfloating", ""},
|
||||||
|
{`hl.dsp.window.close()`, "killactive", ""},
|
||||||
|
{`hl.dsp.window.kill()`, "forcekillactive", ""},
|
||||||
|
{`hl.dsp.window.close({ window = "class:^(kitty)$" })`, "closewindow", "class:^(kitty)$"},
|
||||||
|
{`hl.dsp.focus({ workspace = "e+1" })`, "workspace", "e+1"},
|
||||||
|
{`hl.dsp.focus({ workspace = "2", on_current_monitor = true })`, "focusworkspaceoncurrentmonitor", "2"},
|
||||||
|
{`hl.dsp.window.move({ monitor = "l" })`, "movewindow", "mon:l"},
|
||||||
|
{`hl.dsp.window.move({ direction = "r", group_aware = true })`, "movewindoworgroup", "r"},
|
||||||
|
{`hl.dsp.window.move({ into_group = "l" })`, "moveintogroup", "l"},
|
||||||
|
{`hl.dsp.window.move({ out_of_group = true })`, "moveoutofgroup", ""},
|
||||||
|
{`hl.dsp.window.move({ workspace = "special:magic", follow = false })`, "movetoworkspacesilent", "special:magic"},
|
||||||
|
{`hl.dsp.window.resize({ x = -100, y = 0, relative = true })`, "resizeactive", "-100 0"},
|
||||||
|
{`hl.dsp.window.resize({ x = 1280, y = 720, relative = false })`, "resizeactive", "exact 1280 720"},
|
||||||
|
{`hl.dsp.window.resize({ x = 100, y = 50, relative = true, window = "class:^(app)$" })`, "resizewindowpixel", "100 50,class:^(app)$"},
|
||||||
|
{`hl.dsp.window.cycle_next({ next = false, tiled = true })`, "cyclenext", "prev tiled"},
|
||||||
|
{`hl.dsp.group.next()`, "changegroupactive", "f"},
|
||||||
|
{`hl.dsp.group.prev()`, "changegroupactive", "b"},
|
||||||
|
{`hl.dsp.group.active({ index = 2 })`, "changegroupactive", "2"},
|
||||||
|
{`hl.dsp.group.move_window({ forward = false })`, "movegroupwindow", "b"},
|
||||||
|
{`hl.dsp.group.lock({ action = "on" })`, "lockgroups", "lock"},
|
||||||
|
{`hl.dsp.group.lock_active({ action = "off" })`, "lockactivegroup", "unlock"},
|
||||||
|
{`hl.dsp.window.deny_from_group({ action = "toggle" })`, "denywindowfromgroup", "toggle"},
|
||||||
|
{`function() hl.exec_cmd("hyprctl dispatch splitratio +0.1") end`, "splitratio", "+0.1"},
|
||||||
|
{`hl.dsp.layout("togglesplit")`, "layoutmsg", "togglesplit"},
|
||||||
|
{`hl.dsp.dpms({ action = "toggle" })`, "dpms", "toggle"},
|
||||||
|
{`hl.dsp.workspace.rename({ workspace = "1", name = "work" })`, "renameworkspace", "1 work"},
|
||||||
|
{`hl.dsp.no_op()`, "hl.dsp.no_op()", ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.expr, func(t *testing.T) {
|
||||||
|
gotDispatcher, gotParams := luaExprToDispatcherParams(tt.expr)
|
||||||
|
if gotDispatcher != tt.wantDispatcher || gotParams != tt.wantParams {
|
||||||
|
t.Fatalf("luaExprToDispatcherParams() = %q, %q; want %q, %q", gotDispatcher, gotParams, tt.wantDispatcher, tt.wantParams)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteLuaBindLineOptionsInsideCall(t *testing.T) {
|
||||||
|
var sb strings.Builder
|
||||||
|
writeLuaBindLine(&sb, &hyprlandOverrideBind{
|
||||||
|
Key: "Super+k",
|
||||||
|
Action: "exec kitty",
|
||||||
|
Description: "Open terminal",
|
||||||
|
Flags: "led",
|
||||||
|
})
|
||||||
|
|
||||||
|
want := `hl.unbind("SUPER + K")
|
||||||
|
hl.bind("SUPER + K", hl.dsp.exec_cmd("kitty"), { locked = true, repeating = true, description = "Open terminal" })`
|
||||||
|
if got := strings.TrimSpace(sb.String()); got != want {
|
||||||
|
t.Fatalf("writeLuaBindLine() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteLuaBindLineMapsSpawnActionForHyprland(t *testing.T) {
|
||||||
|
var sb strings.Builder
|
||||||
|
writeLuaBindLine(&sb, &hyprlandOverrideBind{
|
||||||
|
Key: "Super+n",
|
||||||
|
Action: "spawn dms ipc call notepad toggle",
|
||||||
|
Description: "Notepad: Toggle",
|
||||||
|
})
|
||||||
|
|
||||||
|
want := `hl.unbind("SUPER + N")
|
||||||
|
hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notepad toggle"), { description = "Notepad: Toggle" })`
|
||||||
|
if got := strings.TrimSpace(sb.String()); got != want {
|
||||||
|
t.Fatalf("writeLuaBindLine() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteLuaBindLineLeavesCustomLuaDispatcherRaw(t *testing.T) {
|
||||||
|
var sb strings.Builder
|
||||||
|
writeLuaBindLine(&sb, &hyprlandOverrideBind{
|
||||||
|
Key: "Super+u",
|
||||||
|
Action: "hl.dsp.no_op()",
|
||||||
|
Description: "Custom Lua",
|
||||||
|
})
|
||||||
|
|
||||||
|
want := `hl.unbind("SUPER + U")
|
||||||
|
hl.bind("SUPER + U", hl.dsp.no_op(), { description = "Custom Lua" })`
|
||||||
|
if got := strings.TrimSpace(sb.String()); got != want {
|
||||||
|
t.Fatalf("writeLuaBindLine() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLuaActionStringFromHyprlangActionUsesNativeDispatchers(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
action string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"killactive", `hl.dsp.window.close()`},
|
||||||
|
{"forcekillactive", `hl.dsp.window.kill()`},
|
||||||
|
{"workspace 1", `hl.dsp.focus({ workspace = "1" })`},
|
||||||
|
{"movetoworkspace 2", `hl.dsp.window.move({ workspace = "2" })`},
|
||||||
|
{"movetoworkspacesilent special:magic", `hl.dsp.window.move({ workspace = "special:magic", follow = false })`},
|
||||||
|
{"focusmonitor DP-1", `hl.dsp.focus({ monitor = "DP-1" })`},
|
||||||
|
{"resizeactive exact 1280 720", `hl.dsp.window.resize({ x = 1280, y = 720, relative = false })`},
|
||||||
|
{"dpms toggle", `hl.dsp.dpms({ action = "toggle" })`},
|
||||||
|
{"renameworkspace 1 work", `hl.dsp.workspace.rename({ workspace = "1", name = "work" })`},
|
||||||
|
{"changegroupactive f", `hl.dsp.group.next()`},
|
||||||
|
{"changegroupactive b", `hl.dsp.group.prev()`},
|
||||||
|
{"changegroupactive 2", `hl.dsp.group.active({ index = 2 })`},
|
||||||
|
{"moveintogroup l", `hl.dsp.window.move({ into_group = "l" })`},
|
||||||
|
{"moveoutofgroup", `hl.dsp.window.move({ out_of_group = true })`},
|
||||||
|
{"movewindoworgroup r", `hl.dsp.window.move({ direction = "r", group_aware = true })`},
|
||||||
|
{"movegroupwindow b", `hl.dsp.group.move_window({ forward = false })`},
|
||||||
|
{"lockgroups lock", `hl.dsp.group.lock({ action = "on" })`},
|
||||||
|
{"lockactivegroup unlock", `hl.dsp.group.lock_active({ action = "off" })`},
|
||||||
|
{"denywindowfromgroup toggle", `hl.dsp.window.deny_from_group({ action = "toggle" })`},
|
||||||
|
{"cyclenext prev", `hl.dsp.window.cycle_next({ next = false })`},
|
||||||
|
{"setfloating", `hl.dsp.window.float({ action = "on" })`},
|
||||||
|
{"settiled", `hl.dsp.window.float({ action = "off" })`},
|
||||||
|
{"bringactivetotop", `hl.dsp.window.bring_to_top()`},
|
||||||
|
{"toggleswallow", `hl.dsp.window.toggle_swallow()`},
|
||||||
|
{"forceidle 300", `hl.dsp.force_idle(300)`},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.action, func(t *testing.T) {
|
||||||
|
got := luaActionStringFromHyprlangAction(tt.action)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Fatalf("luaActionStringFromHyprlangAction(%q) = %q, want %q", tt.action, got, tt.want)
|
||||||
|
}
|
||||||
|
if strings.Contains(got, "hyprctl dispatch") {
|
||||||
|
t.Fatalf("expected native Lua dispatcher, got legacy dispatch wrapper: %q", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLuaActionStringFallsBackForUnsupportedResizePercentages(t *testing.T) {
|
||||||
|
got := luaActionStringFromHyprlangAction("resizeactive exact 100% 100%")
|
||||||
|
want := `function() hl.exec_cmd("hyprctl dispatch resizeactive exact 100% 100%") end`
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("luaActionStringFromHyprlangAction() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseLuaBindLineHandlesFunctionDispatcherFallback(t *testing.T) {
|
||||||
|
line := `hl.bind("SUPER + R", function() hl.exec_cmd("hyprctl dispatch resizeactive exact 100% 100%") end, { description = "Unsupported Resize" })`
|
||||||
|
got, ok := parseLuaBindOverrideLine(line)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected line to parse")
|
||||||
|
}
|
||||||
|
if got.Action != "resizeactive exact 100% 100%" {
|
||||||
|
t.Fatalf("Action = %q, want resizeactive exact 100%% 100%%", got.Action)
|
||||||
|
}
|
||||||
|
if got.Description != "Unsupported Resize" {
|
||||||
|
t.Fatalf("Description = %q, want Unsupported Resize", got.Description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLuaActionStringLeavesCustomLuaDispatcherRaw(t *testing.T) {
|
||||||
|
got := luaActionStringFromHyprlangAction("hl.dsp.no_op()")
|
||||||
|
want := `hl.dsp.no_op()`
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("luaActionStringFromHyprlangAction() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
if strings.Contains(got, "hl.dispatch") || strings.Contains(got, "hyprctl dispatch") {
|
||||||
|
t.Fatalf("expected custom Lua dispatcher expression to stay raw, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadLuaOverrideMigratesTrailingCommentToDescription(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
overridePath := filepath.Join(tmpDir, "binds-user.lua")
|
||||||
|
contents := `hl.unbind("SUPER + N")
|
||||||
|
hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notepad toggle")) -- Notepad: Toggle
|
||||||
|
hl.bind("SUPER + H", hl.dsp.exec_cmd("app --help"))
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(overridePath, []byte(contents), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
binds, err := readLuaOrHyprlangOverride(overridePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := binds["super+n"]
|
||||||
|
if got == nil {
|
||||||
|
t.Fatalf("expected SUPER+N override, got %#v", binds)
|
||||||
|
}
|
||||||
|
if got.Description != "Notepad: Toggle" {
|
||||||
|
t.Fatalf("expected trailing comment to be preserved as description, got %q", got.Description)
|
||||||
|
}
|
||||||
|
if got := binds["super+h"]; got == nil || got.Description != "" {
|
||||||
|
t.Fatalf("expected -- inside a Lua string to stay out of the description, got %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHyprlandLuaBindsUserOverridesDefaults(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
dmsDir := filepath.Join(tmpDir, "dms")
|
||||||
|
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(tmpDir, "hyprland.lua"), []byte(`
|
||||||
|
require("dms.binds")
|
||||||
|
require("dms.binds-user")
|
||||||
|
`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dmsDir, "binds.lua"), []byte(`hl.bind("SUPER + T", hl.dsp.exec_cmd("kitty"))`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dmsDir, "binds-user.lua"), []byte(`hl.bind("SUPER + T", hl.dsp.exec_cmd("foot"), { description = "User terminal" })`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := ParseHyprlandKeysWithDMS(tmpDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var found []HyprlandKeyBinding
|
||||||
|
var walk func(HyprlandSection)
|
||||||
|
walk = func(section HyprlandSection) {
|
||||||
|
for _, kb := range section.Keybinds {
|
||||||
|
if strings.EqualFold(strings.Join(append(kb.Mods, kb.Key), "+"), "SUPER+T") {
|
||||||
|
found = append(found, kb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, child := range section.Children {
|
||||||
|
walk(child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(*result.Section)
|
||||||
|
|
||||||
|
if len(found) != 1 {
|
||||||
|
t.Fatalf("expected one effective SUPER+T bind, got %d: %#v", len(found), found)
|
||||||
|
}
|
||||||
|
if found[0].Params != "foot" || found[0].Comment != "User terminal" {
|
||||||
|
t.Fatalf("expected user override bind, got %#v", found[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteLuaBindLineEmitsUnbindOnlyForNegativeOverride(t *testing.T) {
|
||||||
|
var sb strings.Builder
|
||||||
|
writeLuaBindLine(&sb, &hyprlandOverrideBind{Key: "Super+i", Unbind: true})
|
||||||
|
|
||||||
|
want := `hl.unbind("SUPER + I")`
|
||||||
|
if got := strings.TrimSpace(sb.String()); got != want {
|
||||||
|
t.Fatalf("writeLuaBindLine() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadLuaOverrideRecognizesLoneUnbindAsNegativeOverride(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
overridePath := filepath.Join(tmpDir, "binds-user.lua")
|
||||||
|
contents := `-- DMS user keybind overrides
|
||||||
|
hl.unbind("SUPER + I")
|
||||||
|
hl.unbind("SUPER + N")
|
||||||
|
hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notepad toggle"))
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(overridePath, []byte(contents), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
binds, err := readLuaOrHyprlangOverride(overridePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, ok := binds["super+i"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected SUPER+I entry in override map, got: %#v", binds)
|
||||||
|
}
|
||||||
|
if !got.Unbind {
|
||||||
|
t.Fatalf("expected SUPER+I to be marked Unbind, got: %#v", got)
|
||||||
|
}
|
||||||
|
if rebind, ok := binds["super+n"]; !ok || rebind.Unbind {
|
||||||
|
t.Fatalf("expected SUPER+N to be a normal rebind override, got: %#v", rebind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParserDropsDMSDefaultsSuppressedByBindsUserUnbind(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
dmsDir := filepath.Join(tmpDir, "dms")
|
||||||
|
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(tmpDir, "hyprland.lua"), []byte(`
|
||||||
|
require("dms.binds")
|
||||||
|
require("dms.binds-user")
|
||||||
|
`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dmsDir, "binds.lua"), []byte(
|
||||||
|
`hl.bind("SUPER + I", hl.dsp.focus({ workspace = "e-1" }))
|
||||||
|
hl.bind("SUPER + T", hl.dsp.exec_cmd("kitty"))`,
|
||||||
|
), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dmsDir, "binds-user.lua"), []byte(`hl.unbind("SUPER + I")`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := ParseHyprlandKeysWithDMS(tmpDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var keys []string
|
||||||
|
var walk func(HyprlandSection)
|
||||||
|
walk = func(section HyprlandSection) {
|
||||||
|
for _, kb := range section.Keybinds {
|
||||||
|
keys = append(keys, strings.ToUpper(strings.Join(append(kb.Mods, kb.Key), "+")))
|
||||||
|
}
|
||||||
|
for _, child := range section.Children {
|
||||||
|
walk(child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(*result.Section)
|
||||||
|
|
||||||
|
for _, k := range keys {
|
||||||
|
if k == "SUPER+I" {
|
||||||
|
t.Fatalf("expected SUPER+I to be suppressed by binds-user.lua unbind, got: %v", keys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foundT := false
|
||||||
|
for _, k := range keys {
|
||||||
|
if k == "SUPER+T" {
|
||||||
|
foundT = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundT {
|
||||||
|
t.Fatalf("expected SUPER+T to remain (only SUPER+I was unbound), got: %v", keys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHyprlandRemoveBindWritesNegativeOverrideForDefault(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
dmsDir := filepath.Join(tmpDir, "dms")
|
||||||
|
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
provider := NewHyprlandProvider(tmpDir)
|
||||||
|
if err := provider.RemoveBind("SUPER+I"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(filepath.Join(dmsDir, "binds-user.lua"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(data), `hl.unbind("SUPER + I")`) {
|
||||||
|
t.Fatalf("expected negative override hl.unbind line, got:\n%s", string(data))
|
||||||
|
}
|
||||||
|
if strings.Contains(string(data), `hl.bind("SUPER + I"`) {
|
||||||
|
t.Fatalf("expected NO hl.bind for SUPER+I, got:\n%s", string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHyprlandSetBindLeavesConfOnlyInstallReadOnly(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(tmpDir, "hyprland.conf"), []byte("bind = SUPER, T, exec, kitty\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
provider := NewHyprlandProvider(tmpDir)
|
||||||
|
err := provider.SetBind("SUPER+N", "workspace 1", "Workspace 1", nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected SetBind to reject conf-only Hyprland config")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "read-only") {
|
||||||
|
t.Fatalf("expected read-only error, got %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(tmpDir, "dms", "binds-user.lua")); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("expected no Lua override to be created for conf-only config, stat err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHyprlandSetBindUpdatesSpacedLuaOverrideWithoutDuplicates(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
dmsDir := filepath.Join(tmpDir, "dms")
|
||||||
|
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
override := `-- DMS user keybind overrides
|
||||||
|
|
||||||
|
hl.unbind("SUPER + SHIFT + S")
|
||||||
|
hl.bind("SUPER + 1", hl.dsp.exec_cmd("hyprctl dispatch workspace 1"))
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(filepath.Join(dmsDir, "binds-user.lua"), []byte(override), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
provider := NewHyprlandProvider(tmpDir)
|
||||||
|
if err := provider.SetBind("SUPER + 1", "workspace 1", "", nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(filepath.Join(dmsDir, "binds-user.lua"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := string(data)
|
||||||
|
if strings.Count(got, `hl.unbind("SUPER + 1")`) != 1 {
|
||||||
|
t.Fatalf("expected one SUPER+1 unbind, got:\n%s", got)
|
||||||
|
}
|
||||||
|
if strings.Count(got, `hl.bind("SUPER + 1", hl.dsp.focus({ workspace = "1" }))`) != 1 {
|
||||||
|
t.Fatalf("expected one native SUPER+1 bind, got:\n%s", got)
|
||||||
|
}
|
||||||
|
if strings.Contains(got, "hyprctl dispatch workspace 1") {
|
||||||
|
t.Fatalf("expected old hyprctl workspace dispatcher to be replaced, got:\n%s", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, `hl.unbind("SUPER + SHIFT + S")`) {
|
||||||
|
t.Fatalf("expected unrelated override to be preserved, got:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHyprlandRemoveBindReplacesExistingOverrideWithNegativeOverride(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
dmsDir := filepath.Join(tmpDir, "dms")
|
||||||
|
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
override := `hl.unbind("SUPER + N")
|
||||||
|
hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notepad toggle"))
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(filepath.Join(dmsDir, "binds-user.lua"), []byte(override), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
provider := NewHyprlandProvider(tmpDir)
|
||||||
|
if err := provider.RemoveBind("SUPER+N"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(filepath.Join(dmsDir, "binds-user.lua"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(data), `hl.unbind("SUPER + N")`) {
|
||||||
|
t.Fatalf("expected negative override hl.unbind line, got:\n%s", string(data))
|
||||||
|
}
|
||||||
|
if strings.Contains(string(data), `hl.bind("SUPER + N"`) {
|
||||||
|
t.Fatalf("expected NO hl.bind for SUPER+N after remove, got:\n%s", string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHyprlandResetBindRevertsExistingOverrideToDefault(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
dmsDir := filepath.Join(tmpDir, "dms")
|
||||||
|
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
override := `hl.unbind("SUPER + N")
|
||||||
|
hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notepad toggle"))
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(filepath.Join(dmsDir, "binds-user.lua"), []byte(override), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
provider := NewHyprlandProvider(tmpDir)
|
||||||
|
if err := provider.ResetBind("SUPER+N"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(filepath.Join(dmsDir, "binds-user.lua"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(data), `SUPER + N`) {
|
||||||
|
t.Fatalf("expected SUPER+N to be fully removed (revert to default), got:\n%s", string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHyprlandHasDefaultSetForOverrideOfDefaultKey(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
dmsDir := filepath.Join(tmpDir, "dms")
|
||||||
|
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(tmpDir, "hyprland.lua"), []byte(`
|
||||||
|
require("dms.binds")
|
||||||
|
require("dms.binds-user")
|
||||||
|
`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dmsDir, "binds.lua"), []byte(
|
||||||
|
`hl.bind("SUPER + T", hl.dsp.exec_cmd("kitty"))`,
|
||||||
|
), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dmsDir, "binds-user.lua"), []byte(
|
||||||
|
`hl.unbind("SUPER + T")
|
||||||
|
hl.bind("SUPER + T", hl.dsp.exec_cmd("foot"))
|
||||||
|
hl.bind("SUPER + Z", hl.dsp.exec_cmd("custom"))`,
|
||||||
|
), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
provider := NewHyprlandProvider(tmpDir)
|
||||||
|
sheet, err := provider.GetCheatSheet()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var foundT, foundZ *keybinds.Keybind
|
||||||
|
for _, group := range sheet.Binds {
|
||||||
|
for i := range group {
|
||||||
|
kb := group[i]
|
||||||
|
keyUpper := strings.ToUpper(kb.Key)
|
||||||
|
if keyUpper == "SUPER+T" {
|
||||||
|
foundT = &group[i]
|
||||||
|
}
|
||||||
|
if keyUpper == "SUPER+Z" {
|
||||||
|
foundZ = &group[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if foundT == nil {
|
||||||
|
t.Fatalf("expected SUPER+T override in cheatsheet")
|
||||||
|
}
|
||||||
|
if !foundT.HasDefault {
|
||||||
|
t.Fatalf("expected SUPER+T HasDefault=true (default exists in binds.lua), got %+v", foundT)
|
||||||
|
}
|
||||||
|
if foundZ == nil {
|
||||||
|
t.Fatalf("expected SUPER+Z (user-only) in cheatsheet")
|
||||||
|
}
|
||||||
|
if foundZ.HasDefault {
|
||||||
|
t.Fatalf("expected SUPER+Z HasDefault=false (no default), got %+v", foundZ)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHyprlandGetKeybindAtLine(t *testing.T) {
|
func TestHyprlandGetKeybindAtLine(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||||
)
|
)
|
||||||
@@ -141,7 +142,7 @@ func (m *MangoWCProvider) convertKeybind(kb *MangoWCKeyBinding, conflicts map[st
|
|||||||
|
|
||||||
source := "config"
|
source := "config"
|
||||||
if strings.Contains(kb.Source, "dms/binds.conf") || strings.Contains(kb.Source, "dms"+string(filepath.Separator)+"binds.conf") {
|
if strings.Contains(kb.Source, "dms/binds.conf") || strings.Contains(kb.Source, "dms"+string(filepath.Separator)+"binds.conf") {
|
||||||
source = "dms"
|
source = "dms-default"
|
||||||
}
|
}
|
||||||
|
|
||||||
bind := keybinds.Keybind{
|
bind := keybinds.Keybind{
|
||||||
@@ -151,7 +152,7 @@ func (m *MangoWCProvider) convertKeybind(kb *MangoWCKeyBinding, conflicts map[st
|
|||||||
Source: source,
|
Source: source,
|
||||||
}
|
}
|
||||||
|
|
||||||
if source == "dms" && conflicts != nil {
|
if source == "dms-default" && conflicts != nil {
|
||||||
normalizedKey := strings.ToLower(keyStr)
|
normalizedKey := strings.ToLower(keyStr)
|
||||||
if conflictKb, ok := conflicts[normalizedKey]; ok {
|
if conflictKb, ok := conflicts[normalizedKey]; ok {
|
||||||
bind.Conflict = &keybinds.Keybind{
|
bind.Conflict = &keybinds.Keybind{
|
||||||
@@ -228,11 +229,20 @@ func (m *MangoWCProvider) SetBind(key, action, description string, options map[s
|
|||||||
}
|
}
|
||||||
|
|
||||||
normalizedKey := strings.ToLower(key)
|
normalizedKey := strings.ToLower(key)
|
||||||
|
prefix := "bind"
|
||||||
|
if existing, ok := existingBinds[normalizedKey]; ok && existing.Prefix != "" {
|
||||||
|
prefix = existing.Prefix
|
||||||
|
}
|
||||||
|
if optionPrefix := m.bindPrefixFromOptions(options); optionPrefix != "" {
|
||||||
|
prefix = optionPrefix
|
||||||
|
}
|
||||||
|
|
||||||
existingBinds[normalizedKey] = &mangowcOverrideBind{
|
existingBinds[normalizedKey] = &mangowcOverrideBind{
|
||||||
Key: key,
|
Key: key,
|
||||||
Action: action,
|
Action: action,
|
||||||
Description: description,
|
Description: description,
|
||||||
Options: options,
|
Options: options,
|
||||||
|
Prefix: prefix,
|
||||||
}
|
}
|
||||||
|
|
||||||
return m.writeOverrideBinds(existingBinds)
|
return m.writeOverrideBinds(existingBinds)
|
||||||
@@ -246,7 +256,11 @@ func (m *MangoWCProvider) RemoveBind(key string) error {
|
|||||||
|
|
||||||
normalizedKey := strings.ToLower(key)
|
normalizedKey := strings.ToLower(key)
|
||||||
delete(existingBinds, normalizedKey)
|
delete(existingBinds, normalizedKey)
|
||||||
return m.writeOverrideBinds(existingBinds)
|
return m.writeOverrideBindsWithRemoved(existingBinds, map[string]bool{normalizedKey: true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MangoWCProvider) ResetBind(key string) error {
|
||||||
|
return m.RemoveBind(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
type mangowcOverrideBind struct {
|
type mangowcOverrideBind struct {
|
||||||
@@ -254,6 +268,7 @@ type mangowcOverrideBind struct {
|
|||||||
Action string
|
Action string
|
||||||
Description string
|
Description string
|
||||||
Options map[string]any
|
Options map[string]any
|
||||||
|
Prefix string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MangoWCProvider) loadOverrideBinds() (map[string]*mangowcOverrideBind, error) {
|
func (m *MangoWCProvider) loadOverrideBinds() (map[string]*mangowcOverrideBind, error) {
|
||||||
@@ -268,62 +283,99 @@ func (m *MangoWCProvider) loadOverrideBinds() (map[string]*mangowcOverrideBind,
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
lines := strings.Split(string(data), "\n")
|
var pendingComment string
|
||||||
for _, line := range lines {
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
line = strings.TrimSpace(line)
|
trimmed := strings.TrimSpace(line)
|
||||||
if line == "" || strings.HasPrefix(line, "#") {
|
if trimmed == "" {
|
||||||
|
pendingComment = ""
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(trimmed, "#") {
|
||||||
|
pendingComment = strings.TrimSpace(strings.TrimPrefix(trimmed, "#"))
|
||||||
|
if isMangoWCSectionComment(pendingComment) {
|
||||||
|
pendingComment = ""
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if !strings.HasPrefix(line, "bind") {
|
bind, ok := m.parseOverrideBindLine(line, pendingComment)
|
||||||
|
pendingComment = ""
|
||||||
|
if !ok || bind == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
parts := strings.SplitN(line, "=", 2)
|
binds[strings.ToLower(bind.Key)] = bind
|
||||||
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
|
return binds, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *MangoWCProvider) parseOverrideBindLine(line, precedingComment string) (*mangowcOverrideBind, bool) {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
parts := strings.SplitN(trimmed, "=", 2)
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix := strings.TrimSpace(parts[0])
|
||||||
|
if !m.isBindPrefix(prefix) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
content := strings.TrimSpace(parts[1])
|
||||||
|
commentParts := strings.SplitN(content, "#", 2)
|
||||||
|
bindContent := strings.TrimSpace(commentParts[0])
|
||||||
|
|
||||||
|
description := strings.TrimSpace(precedingComment)
|
||||||
|
if isMangoWCSectionComment(description) {
|
||||||
|
description = ""
|
||||||
|
}
|
||||||
|
if len(commentParts) > 1 {
|
||||||
|
description = strings.TrimSpace(commentParts[1])
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(description, MangoWCHideComment) {
|
||||||
|
return nil, true
|
||||||
|
}
|
||||||
|
|
||||||
|
fields := strings.SplitN(bindContent, ",", 4)
|
||||||
|
if len(fields) < 3 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
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])
|
||||||
|
}
|
||||||
|
|
||||||
|
action := command
|
||||||
|
if params != "" {
|
||||||
|
action = command + " " + params
|
||||||
|
}
|
||||||
|
|
||||||
|
return &mangowcOverrideBind{
|
||||||
|
Key: m.buildKeyString(mods, keyName),
|
||||||
|
Action: action,
|
||||||
|
Description: description,
|
||||||
|
Prefix: prefix,
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MangoWCProvider) isBindPrefix(prefix string) bool {
|
||||||
|
if !strings.HasPrefix(prefix, "bind") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, ch := range strings.TrimPrefix(prefix, "bind") {
|
||||||
|
if !strings.ContainsRune("lsrp", ch) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func (m *MangoWCProvider) buildKeyString(mods, key string) string {
|
func (m *MangoWCProvider) buildKeyString(mods, key string) string {
|
||||||
if mods == "" || strings.EqualFold(mods, "none") {
|
if mods == "" || strings.EqualFold(mods, "none") {
|
||||||
return key
|
return key
|
||||||
@@ -358,21 +410,113 @@ func (m *MangoWCProvider) getBindSortPriority(action string) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *MangoWCProvider) writeOverrideBinds(binds map[string]*mangowcOverrideBind) error {
|
func (m *MangoWCProvider) writeOverrideBinds(binds map[string]*mangowcOverrideBind) error {
|
||||||
|
return m.writeOverrideBindsWithRemoved(binds, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MangoWCProvider) writeOverrideBindsWithRemoved(binds map[string]*mangowcOverrideBind, removed map[string]bool) error {
|
||||||
overridePath := m.GetOverridePath()
|
overridePath := m.GetOverridePath()
|
||||||
content := m.generateBindsContent(binds)
|
existingContent := ""
|
||||||
|
if data, err := os.ReadFile(overridePath); err == nil {
|
||||||
|
existingContent = string(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
content := m.generatePreservedBindsContent(existingContent, binds, removed)
|
||||||
return os.WriteFile(overridePath, []byte(content), 0o644)
|
return os.WriteFile(overridePath, []byte(content), 0o644)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MangoWCProvider) generateBindsContent(binds map[string]*mangowcOverrideBind) string {
|
func (m *MangoWCProvider) generatePreservedBindsContent(existingContent string, binds map[string]*mangowcOverrideBind, removed map[string]bool) string {
|
||||||
if len(binds) == 0 {
|
useStockScaffold := m.shouldUseStockScaffold(existingContent)
|
||||||
return ""
|
source := existingContent
|
||||||
|
if useStockScaffold {
|
||||||
|
source = m.stockBindsScaffold(binds)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
remaining := make(map[string]*mangowcOverrideBind, len(binds))
|
||||||
|
for key, bind := range binds {
|
||||||
|
remaining[key] = bind
|
||||||
|
}
|
||||||
|
if useStockScaffold {
|
||||||
|
m.dropReplacedStockBinds(remaining)
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines []string
|
||||||
|
for _, line := range strings.Split(source, "\n") {
|
||||||
|
templateBind, ok := m.parseOverrideBindLine(line, m.previousComment(lines))
|
||||||
|
if !ok || templateBind == nil {
|
||||||
|
lines = append(lines, line)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizedKey := strings.ToLower(templateBind.Key)
|
||||||
|
m.dropPreviousDescriptionComment(&lines)
|
||||||
|
|
||||||
|
if bind, exists := remaining[normalizedKey]; exists {
|
||||||
|
if useStockScaffold && bind.Description == "" {
|
||||||
|
bind = m.copyBindWithDescription(bind, templateBind.Description)
|
||||||
|
}
|
||||||
|
m.writeBindLineToLines(&lines, bind)
|
||||||
|
delete(remaining, normalizedKey)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if useStockScaffold && !removed[normalizedKey] {
|
||||||
|
m.writeBindLineToLines(&lines, templateBind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(remaining) > 0 {
|
||||||
|
m.trimTrailingEmptyLines(&lines)
|
||||||
|
if len(lines) > 0 {
|
||||||
|
lines = append(lines, "")
|
||||||
|
}
|
||||||
|
lines = append(lines, "# === Custom Keybinds ===")
|
||||||
|
for _, bind := range m.sortedBinds(remaining) {
|
||||||
|
m.writeBindLineToLines(&lines, bind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
m.trimTrailingEmptyLines(&lines)
|
||||||
|
if len(lines) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n") + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MangoWCProvider) shouldUseStockScaffold(content string) bool {
|
||||||
|
if strings.TrimSpace(content) == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.Contains(content, "gesturebind=") && strings.Contains(content, "# ===") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return !strings.Contains(content, "gesturebind=") && (strings.Count(content, "\nbind=")+strings.Count(content, "\nbindl=")+strings.Count(content, "\nbinds=")+strings.Count(content, "\nbindr=")+strings.Count(content, "\nbindp=") >= 10 || strings.Contains(content, "dms ipc call"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MangoWCProvider) stockBindsScaffold(binds map[string]*mangowcOverrideBind) string {
|
||||||
|
terminalCommand := "ghostty"
|
||||||
|
for _, key := range []string{"super+t", "super+return"} {
|
||||||
|
if bind, ok := binds[key]; ok {
|
||||||
|
command, params := m.parseAction(bind.Action)
|
||||||
|
if command == "spawn" && strings.TrimSpace(params) != "" && !strings.Contains(params, "dms ") {
|
||||||
|
terminalCommand = params
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.ReplaceAll(config.MangoBindsConfig, "{{TERMINAL_COMMAND}}", terminalCommand)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MangoWCProvider) dropReplacedStockBinds(binds map[string]*mangowcOverrideBind) {
|
||||||
|
if bind, ok := binds["super+j"]; ok && bind.Action == "switch_layout" {
|
||||||
|
delete(binds, "super+j")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MangoWCProvider) sortedBinds(binds map[string]*mangowcOverrideBind) []*mangowcOverrideBind {
|
||||||
bindList := make([]*mangowcOverrideBind, 0, len(binds))
|
bindList := make([]*mangowcOverrideBind, 0, len(binds))
|
||||||
for _, bind := range binds {
|
for _, bind := range binds {
|
||||||
bindList = append(bindList, bind)
|
bindList = append(bindList, bind)
|
||||||
}
|
}
|
||||||
|
|
||||||
sort.Slice(bindList, func(i, j int) bool {
|
sort.Slice(bindList, func(i, j int) bool {
|
||||||
pi, pj := m.getBindSortPriority(bindList[i].Action), m.getBindSortPriority(bindList[j].Action)
|
pi, pj := m.getBindSortPriority(bindList[i].Action), m.getBindSortPriority(bindList[j].Action)
|
||||||
if pi != pj {
|
if pi != pj {
|
||||||
@@ -380,20 +524,75 @@ func (m *MangoWCProvider) generateBindsContent(binds map[string]*mangowcOverride
|
|||||||
}
|
}
|
||||||
return bindList[i].Key < bindList[j].Key
|
return bindList[i].Key < bindList[j].Key
|
||||||
})
|
})
|
||||||
|
return bindList
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MangoWCProvider) writeBindLineToLines(lines *[]string, bind *mangowcOverrideBind) {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
for _, bind := range bindList {
|
m.writeBindLine(&sb, bind)
|
||||||
m.writeBindLine(&sb, bind)
|
text := strings.TrimSuffix(sb.String(), "\n")
|
||||||
|
if text == "" {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
*lines = append(*lines, strings.Split(text, "\n")...)
|
||||||
|
}
|
||||||
|
|
||||||
return sb.String()
|
func (m *MangoWCProvider) previousComment(lines []string) string {
|
||||||
|
if len(lines) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
trimmed := strings.TrimSpace(lines[len(lines)-1])
|
||||||
|
if !strings.HasPrefix(trimmed, "#") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
comment := strings.TrimSpace(strings.TrimPrefix(trimmed, "#"))
|
||||||
|
if isMangoWCSectionComment(comment) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return comment
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MangoWCProvider) dropPreviousDescriptionComment(lines *[]string) {
|
||||||
|
if len(*lines) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
trimmed := strings.TrimSpace((*lines)[len(*lines)-1])
|
||||||
|
if !strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, "# ===") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
*lines = (*lines)[:len(*lines)-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MangoWCProvider) trimTrailingEmptyLines(lines *[]string) {
|
||||||
|
for len(*lines) > 0 && strings.TrimSpace((*lines)[len(*lines)-1]) == "" {
|
||||||
|
*lines = (*lines)[:len(*lines)-1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MangoWCProvider) copyBindWithDescription(bind *mangowcOverrideBind, description string) *mangowcOverrideBind {
|
||||||
|
copy := *bind
|
||||||
|
copy.Description = description
|
||||||
|
return ©
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MangoWCProvider) writeBindLine(sb *strings.Builder, bind *mangowcOverrideBind) {
|
func (m *MangoWCProvider) writeBindLine(sb *strings.Builder, bind *mangowcOverrideBind) {
|
||||||
mods, key := m.parseKeyString(bind.Key)
|
mods, key := m.parseKeyString(bind.Key)
|
||||||
command, params := m.parseAction(bind.Action)
|
command, params := m.parseAction(bind.Action)
|
||||||
|
|
||||||
sb.WriteString("bind=")
|
// Description goes on the line ABOVE the bind: mango doesn't strip inline `#`
|
||||||
|
// comments from a value, so a trailing comment would break spawn (extra argv).
|
||||||
|
if bind.Description != "" {
|
||||||
|
sb.WriteString("# ")
|
||||||
|
sb.WriteString(bind.Description)
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix := bind.Prefix
|
||||||
|
if prefix == "" {
|
||||||
|
prefix = "bind"
|
||||||
|
}
|
||||||
|
sb.WriteString(prefix)
|
||||||
|
sb.WriteString("=")
|
||||||
if mods == "" {
|
if mods == "" {
|
||||||
sb.WriteString("none")
|
sb.WriteString("none")
|
||||||
} else {
|
} else {
|
||||||
@@ -409,14 +608,39 @@ func (m *MangoWCProvider) writeBindLine(sb *strings.Builder, bind *mangowcOverri
|
|||||||
sb.WriteString(params)
|
sb.WriteString(params)
|
||||||
}
|
}
|
||||||
|
|
||||||
if bind.Description != "" {
|
|
||||||
sb.WriteString(" # ")
|
|
||||||
sb.WriteString(bind.Description)
|
|
||||||
}
|
|
||||||
|
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *MangoWCProvider) bindPrefixFromOptions(options map[string]any) string {
|
||||||
|
if options == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
value, ok := options["flags"]
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
flags := ""
|
||||||
|
switch v := value.(type) {
|
||||||
|
case string:
|
||||||
|
flags = v
|
||||||
|
case fmt.Stringer:
|
||||||
|
flags = v.String()
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
flags = strings.TrimSpace(flags)
|
||||||
|
if flags == "" {
|
||||||
|
return "bind"
|
||||||
|
}
|
||||||
|
var clean strings.Builder
|
||||||
|
for _, ch := range flags {
|
||||||
|
if strings.ContainsRune("lsrp", ch) && !strings.ContainsRune(clean.String(), ch) {
|
||||||
|
clean.WriteRune(ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "bind" + clean.String()
|
||||||
|
}
|
||||||
|
|
||||||
func (m *MangoWCProvider) parseKeyString(keyStr string) (mods, key string) {
|
func (m *MangoWCProvider) parseKeyString(keyStr string) (mods, key string) {
|
||||||
parts := strings.Split(keyStr, "+")
|
parts := strings.Split(keyStr, "+")
|
||||||
switch len(parts) {
|
switch len(parts) {
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ const (
|
|||||||
|
|
||||||
var MangoWCModSeparators = []rune{'+', ' '}
|
var MangoWCModSeparators = []rune{'+', ' '}
|
||||||
|
|
||||||
|
func isMangoWCSectionComment(comment string) bool {
|
||||||
|
return strings.HasPrefix(strings.TrimSpace(comment), "===")
|
||||||
|
}
|
||||||
|
|
||||||
type MangoWCKeyBinding struct {
|
type MangoWCKeyBinding struct {
|
||||||
Mods []string `json:"mods"`
|
Mods []string `json:"mods"`
|
||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
@@ -216,101 +220,40 @@ func mangowcAutogenerateComment(command, params string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *MangoWCParser) getKeybindAtLine(lineNumber int) *MangoWCKeyBinding {
|
func (p *MangoWCParser) getKeybindAtLine(lineNumber int, precedingComment string) *MangoWCKeyBinding {
|
||||||
if lineNumber >= len(p.contentLines) {
|
if lineNumber >= len(p.contentLines) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
return p.getKeybindAtLineContent(p.contentLines[lineNumber], precedingComment)
|
||||||
line := p.contentLines[lineNumber]
|
|
||||||
|
|
||||||
bindMatch := regexp.MustCompile(`^(bind[lsr]*)\s*=\s*(.+)$`)
|
|
||||||
matches := bindMatch.FindStringSubmatch(line)
|
|
||||||
if len(matches) < 3 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
bindType := matches[1]
|
|
||||||
content := matches[2]
|
|
||||||
|
|
||||||
parts := strings.SplitN(content, "#", 2)
|
|
||||||
keys := parts[0]
|
|
||||||
|
|
||||||
var comment string
|
|
||||||
if len(parts) > 1 {
|
|
||||||
comment = strings.TrimSpace(parts[1])
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.HasPrefix(comment, 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])
|
|
||||||
p := 0
|
|
||||||
for index, char := range modstring {
|
|
||||||
isModSep := false
|
|
||||||
for _, sep := range MangoWCModSeparators {
|
|
||||||
if char == sep {
|
|
||||||
isModSep = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if isModSep {
|
|
||||||
if index-p > 1 {
|
|
||||||
modList = append(modList, modstring[p:index])
|
|
||||||
}
|
|
||||||
p = index + 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = bindType
|
|
||||||
|
|
||||||
return &MangoWCKeyBinding{
|
|
||||||
Mods: modList,
|
|
||||||
Key: key,
|
|
||||||
Command: command,
|
|
||||||
Params: params,
|
|
||||||
Comment: comment,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *MangoWCParser) ParseKeys() []MangoWCKeyBinding {
|
func (p *MangoWCParser) ParseKeys() []MangoWCKeyBinding {
|
||||||
var keybinds []MangoWCKeyBinding
|
var keybinds []MangoWCKeyBinding
|
||||||
|
var pendingComment string
|
||||||
|
|
||||||
for lineNumber := 0; lineNumber < len(p.contentLines); lineNumber++ {
|
for lineNumber := 0; lineNumber < len(p.contentLines); lineNumber++ {
|
||||||
line := p.contentLines[lineNumber]
|
trimmed := strings.TrimSpace(p.contentLines[lineNumber])
|
||||||
if line == "" || strings.HasPrefix(strings.TrimSpace(line), "#") {
|
if trimmed == "" {
|
||||||
|
pendingComment = ""
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(trimmed, "#") {
|
||||||
|
pendingComment = strings.TrimSpace(strings.TrimPrefix(trimmed, "#"))
|
||||||
|
if isMangoWCSectionComment(pendingComment) {
|
||||||
|
pendingComment = ""
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(trimmed, "bind") {
|
||||||
|
pendingComment = ""
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if !strings.HasPrefix(strings.TrimSpace(line), "bind") {
|
keybind := p.getKeybindAtLine(lineNumber, pendingComment)
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
keybind := p.getKeybindAtLine(lineNumber)
|
|
||||||
if keybind != nil {
|
if keybind != nil {
|
||||||
keybinds = append(keybinds, *keybind)
|
keybinds = append(keybinds, *keybind)
|
||||||
}
|
}
|
||||||
|
pendingComment = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
return keybinds
|
return keybinds
|
||||||
@@ -459,21 +402,38 @@ func (p *MangoWCParser) parseFileWithSource(filePath string) ([]MangoWCKeyBindin
|
|||||||
p.currentSource = absPath
|
p.currentSource = absPath
|
||||||
|
|
||||||
var keybinds []MangoWCKeyBinding
|
var keybinds []MangoWCKeyBinding
|
||||||
|
var pendingComment string
|
||||||
lines := strings.Split(string(data), "\n")
|
lines := strings.Split(string(data), "\n")
|
||||||
|
|
||||||
for lineNum, line := range lines {
|
for _, line := range lines {
|
||||||
trimmed := strings.TrimSpace(line)
|
trimmed := strings.TrimSpace(line)
|
||||||
|
|
||||||
|
if trimmed == "" {
|
||||||
|
pendingComment = ""
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
if strings.HasPrefix(trimmed, "source") {
|
if strings.HasPrefix(trimmed, "source") {
|
||||||
p.handleSource(trimmed, filepath.Dir(absPath), &keybinds)
|
p.handleSource(trimmed, filepath.Dir(absPath), &keybinds)
|
||||||
|
pendingComment = ""
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(trimmed, "#") {
|
||||||
|
pendingComment = strings.TrimSpace(strings.TrimPrefix(trimmed, "#"))
|
||||||
|
if isMangoWCSectionComment(pendingComment) {
|
||||||
|
pendingComment = ""
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if !strings.HasPrefix(trimmed, "bind") {
|
if !strings.HasPrefix(trimmed, "bind") {
|
||||||
|
pendingComment = ""
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
kb := p.getKeybindAtLineContent(line, lineNum)
|
kb := p.getKeybindAtLineContent(line, pendingComment)
|
||||||
|
pendingComment = ""
|
||||||
if kb == nil {
|
if kb == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -529,8 +489,11 @@ func (p *MangoWCParser) parseDMSBindsDirectly(dmsBindsPath string) []MangoWCKeyB
|
|||||||
return keybinds
|
return keybinds
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *MangoWCParser) getKeybindAtLineContent(line string, _ int) *MangoWCKeyBinding {
|
// getKeybindAtLineContent parses one `bind=` line. precedingComment (a `# ...`
|
||||||
bindMatch := regexp.MustCompile(`^(bind[lsr]*)\s*=\s*(.+)$`)
|
// line directly above) is the description: mango feeds inline comments to spawn
|
||||||
|
// as argv, so DMS keeps descriptions on the line above; inline `#` is a fallback.
|
||||||
|
func (p *MangoWCParser) getKeybindAtLineContent(line string, precedingComment string) *MangoWCKeyBinding {
|
||||||
|
bindMatch := regexp.MustCompile(`^(bind[lsrp]*)\s*=\s*(.+)$`)
|
||||||
matches := bindMatch.FindStringSubmatch(line)
|
matches := bindMatch.FindStringSubmatch(line)
|
||||||
if len(matches) < 3 {
|
if len(matches) < 3 {
|
||||||
return nil
|
return nil
|
||||||
@@ -544,6 +507,12 @@ func (p *MangoWCParser) getKeybindAtLineContent(line string, _ int) *MangoWCKeyB
|
|||||||
if len(parts) > 1 {
|
if len(parts) > 1 {
|
||||||
comment = strings.TrimSpace(parts[1])
|
comment = strings.TrimSpace(parts[1])
|
||||||
}
|
}
|
||||||
|
if comment == "" {
|
||||||
|
comment = strings.TrimSpace(precedingComment)
|
||||||
|
if isMangoWCSectionComment(comment) {
|
||||||
|
comment = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if strings.HasPrefix(comment, MangoWCHideComment) {
|
if strings.HasPrefix(comment, MangoWCHideComment) {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -71,9 +71,10 @@ func TestMangoWCAutogenerateComment(t *testing.T) {
|
|||||||
|
|
||||||
func TestMangoWCGetKeybindAtLine(t *testing.T) {
|
func TestMangoWCGetKeybindAtLine(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
line string
|
line string
|
||||||
expected *MangoWCKeyBinding
|
precedingComment string
|
||||||
|
expected *MangoWCKeyBinding
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "basic_keybind",
|
name: "basic_keybind",
|
||||||
@@ -157,6 +158,41 @@ func TestMangoWCGetKeybindAtLine(t *testing.T) {
|
|||||||
Comment: "dms ipc call lock lock",
|
Comment: "dms ipc call lock lock",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "bindp_flag",
|
||||||
|
line: "bindp=SUPER,p,spawn,pass-through",
|
||||||
|
expected: &MangoWCKeyBinding{
|
||||||
|
Mods: []string{"SUPER"},
|
||||||
|
Key: "p",
|
||||||
|
Command: "spawn",
|
||||||
|
Params: "pass-through",
|
||||||
|
Comment: "pass-through",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "preceding_comment",
|
||||||
|
line: "bind=SUPER+SHIFT,S,spawn,dms screenshot",
|
||||||
|
precedingComment: "Screenshot: Interactive",
|
||||||
|
expected: &MangoWCKeyBinding{
|
||||||
|
Mods: []string{"SUPER", "SHIFT"},
|
||||||
|
Key: "S",
|
||||||
|
Command: "spawn",
|
||||||
|
Params: "dms screenshot",
|
||||||
|
Comment: "Screenshot: Interactive",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "section_header_not_description",
|
||||||
|
line: "bind=none,XF86AudioRaiseVolume,spawn,dms ipc call audio increment 3",
|
||||||
|
precedingComment: "=== Audio Controls ===",
|
||||||
|
expected: &MangoWCKeyBinding{
|
||||||
|
Mods: []string{},
|
||||||
|
Key: "XF86AudioRaiseVolume",
|
||||||
|
Command: "spawn",
|
||||||
|
Params: "dms ipc call audio increment 3",
|
||||||
|
Comment: "dms ipc call audio increment 3",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "keybind_with_spaces",
|
name: "keybind_with_spaces",
|
||||||
line: "bind = SUPER, r, reload_config",
|
line: "bind = SUPER, r, reload_config",
|
||||||
@@ -174,7 +210,7 @@ func TestMangoWCGetKeybindAtLine(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
parser := NewMangoWCParser("")
|
parser := NewMangoWCParser("")
|
||||||
parser.contentLines = []string{tt.line}
|
parser.contentLines = []string{tt.line}
|
||||||
result := parser.getKeybindAtLine(0)
|
result := parser.getKeybindAtLine(0, tt.precedingComment)
|
||||||
|
|
||||||
if tt.expected == nil {
|
if tt.expected == nil {
|
||||||
if result != nil {
|
if result != nil {
|
||||||
@@ -421,7 +457,7 @@ func TestMangoWCInvalidBindLines(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
parser := NewMangoWCParser("")
|
parser := NewMangoWCParser("")
|
||||||
parser.contentLines = []string{tt.line}
|
parser.contentLines = []string{tt.line}
|
||||||
result := parser.getKeybindAtLine(0)
|
result := parser.getKeybindAtLine(0, "")
|
||||||
|
|
||||||
if result != nil {
|
if result != nil {
|
||||||
t.Errorf("expected nil for invalid line, got %+v", result)
|
t.Errorf("expected nil for invalid line, got %+v", result)
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ package providers
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMangoWCProviderName(t *testing.T) {
|
func TestMangoWCProviderName(t *testing.T) {
|
||||||
@@ -318,3 +321,138 @@ bind=Ctrl,1,view,1,0
|
|||||||
t.Error("Did not find terminal keybind with correct key and description")
|
t.Error("Did not find terminal keybind with correct key and description")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMangoWCSetBindPreservesStockCommentsAndGestures(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
dmsDir := filepath.Join(tmpDir, "dms")
|
||||||
|
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("failed to create dms dir: %v", err)
|
||||||
|
}
|
||||||
|
bindsPath := filepath.Join(dmsDir, "binds.conf")
|
||||||
|
stock := strings.ReplaceAll(config.MangoBindsConfig, "{{TERMINAL_COMMAND}}", "ghostty")
|
||||||
|
if err := os.WriteFile(bindsPath, []byte(stock), 0o644); err != nil {
|
||||||
|
t.Fatalf("failed to write stock binds: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
provider := NewMangoWCProvider(tmpDir)
|
||||||
|
if err := provider.SetBind("SUPER+SHIFT+S", "spawn dms screenshot", "Screenshot: Interactive", nil); err != nil {
|
||||||
|
t.Fatalf("SetBind failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
contentBytes, err := os.ReadFile(bindsPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read binds: %v", err)
|
||||||
|
}
|
||||||
|
content := string(contentBytes)
|
||||||
|
|
||||||
|
for _, want := range []string{
|
||||||
|
"# === Application Launchers ===",
|
||||||
|
"# === Touchpad Gestures ===",
|
||||||
|
"gesturebind=none,right,3,viewtoleft_have_client",
|
||||||
|
"gesturebind=none,left,3,viewtoright_have_client",
|
||||||
|
"# Screenshot: Interactive\nbind=SUPER+SHIFT,S,spawn,dms screenshot",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(content, want) {
|
||||||
|
t.Fatalf("expected saved binds to contain %q\ncontent:\n%s", want, content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(content, "# === Audio Controls ===\n# === Audio Controls ===") {
|
||||||
|
t.Fatalf("section header should not be duplicated as a bind description\ncontent:\n%s", content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMangoWCSetBindRestoresScaffoldForStrippedFile(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
dmsDir := filepath.Join(tmpDir, "dms")
|
||||||
|
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("failed to create dms dir: %v", err)
|
||||||
|
}
|
||||||
|
bindsPath := filepath.Join(dmsDir, "binds.conf")
|
||||||
|
stripped := `bind=SUPER,t,spawn,ghostty
|
||||||
|
bind=SUPER,Return,spawn,ghostty
|
||||||
|
bind=SUPER,space,spawn,dms ipc call spotlight toggle
|
||||||
|
bind=SUPER,v,spawn,dms ipc call clipboard toggle
|
||||||
|
bind=SUPER,q,killclient
|
||||||
|
bind=SUPER,Left,focusdir,left
|
||||||
|
bind=SUPER,Right,focusdir,right
|
||||||
|
bind=SUPER,Up,focusdir,up
|
||||||
|
bind=SUPER,Down,focusdir,down
|
||||||
|
bind=SUPER,1,view,1
|
||||||
|
bind=SUPER,2,view,2
|
||||||
|
bind=SUPER,3,view,3
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(bindsPath, []byte(stripped), 0o644); err != nil {
|
||||||
|
t.Fatalf("failed to write stripped binds: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
provider := NewMangoWCProvider(tmpDir)
|
||||||
|
if err := provider.SetBind("SUPER+SHIFT+S", "spawn dms screenshot", "Screenshot: Interactive", nil); err != nil {
|
||||||
|
t.Fatalf("SetBind failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
contentBytes, err := os.ReadFile(bindsPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read binds: %v", err)
|
||||||
|
}
|
||||||
|
content := string(contentBytes)
|
||||||
|
|
||||||
|
for _, want := range []string{
|
||||||
|
"# DMS default keybinds (MangoWM)",
|
||||||
|
"# === Touchpad Gestures ===",
|
||||||
|
"gesturebind=none,right,3,viewtoleft_have_client",
|
||||||
|
"bind=SUPER,H,focusdir,left",
|
||||||
|
"bind=SUPER,J,focusdir,down",
|
||||||
|
"bind=SUPER,K,focusdir,up",
|
||||||
|
"bind=SUPER,L,focusdir,right",
|
||||||
|
"# === Custom Keybinds ===",
|
||||||
|
"# Screenshot: Interactive\nbind=SUPER+SHIFT,S,spawn,dms screenshot",
|
||||||
|
"bind=SUPER,t,spawn,ghostty",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(content, want) {
|
||||||
|
t.Fatalf("expected restored binds to contain %q\ncontent:\n%s", want, content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(content, "{{TERMINAL_COMMAND}}") {
|
||||||
|
t.Fatalf("terminal placeholder should have been resolved\ncontent:\n%s", content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMangoWCRemoveBindPreservesNonBindLines(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
dmsDir := filepath.Join(tmpDir, "dms")
|
||||||
|
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("failed to create dms dir: %v", err)
|
||||||
|
}
|
||||||
|
bindsPath := filepath.Join(dmsDir, "binds.conf")
|
||||||
|
stock := strings.ReplaceAll(config.MangoBindsConfig, "{{TERMINAL_COMMAND}}", "ghostty")
|
||||||
|
if err := os.WriteFile(bindsPath, []byte(stock), 0o644); err != nil {
|
||||||
|
t.Fatalf("failed to write stock binds: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
provider := NewMangoWCProvider(tmpDir)
|
||||||
|
if err := provider.RemoveBind("SUPER+Tab"); err != nil {
|
||||||
|
t.Fatalf("RemoveBind failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
contentBytes, err := os.ReadFile(bindsPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read binds: %v", err)
|
||||||
|
}
|
||||||
|
content := string(contentBytes)
|
||||||
|
|
||||||
|
if strings.Contains(content, "bind=SUPER,Tab,focusstack,next") {
|
||||||
|
t.Fatalf("removed bind should be absent\ncontent:\n%s", content)
|
||||||
|
}
|
||||||
|
if strings.Contains(content, "# Focus Next Window") {
|
||||||
|
t.Fatalf("removed bind description should be absent\ncontent:\n%s", content)
|
||||||
|
}
|
||||||
|
for _, want := range []string{
|
||||||
|
"# === Focus Navigation ===",
|
||||||
|
"# === Touchpad Gestures ===",
|
||||||
|
"gesturebind=none,down,4,toggleoverview",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(content, want) {
|
||||||
|
t.Fatalf("expected non-bind line %q to be preserved\ncontent:\n%s", want, content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ func (n *NiriProvider) convertKeybind(kb *NiriKeyBinding, subcategory string, co
|
|||||||
|
|
||||||
source := "config"
|
source := "config"
|
||||||
if strings.Contains(kb.Source, "dms/binds.kdl") {
|
if strings.Contains(kb.Source, "dms/binds.kdl") {
|
||||||
source = "dms"
|
source = "dms-default"
|
||||||
}
|
}
|
||||||
|
|
||||||
bind := keybinds.Keybind{
|
bind := keybinds.Keybind{
|
||||||
@@ -165,8 +165,8 @@ func (n *NiriProvider) convertKeybind(kb *NiriKeyBinding, subcategory string, co
|
|||||||
Repeat: kb.Repeat,
|
Repeat: kb.Repeat,
|
||||||
}
|
}
|
||||||
|
|
||||||
if source == "dms" && conflicts != nil {
|
if source == "dms-default" && conflicts != nil {
|
||||||
if conflictKb, ok := conflicts[keyStr]; ok {
|
if conflictKb, ok := conflicts[normalizeNiriBindKey(keyStr)]; ok {
|
||||||
bind.Conflict = &keybinds.Keybind{
|
bind.Conflict = &keybinds.Keybind{
|
||||||
Key: keyStr,
|
Key: keyStr,
|
||||||
Description: conflictKb.Description,
|
Description: conflictKb.Description,
|
||||||
@@ -249,7 +249,7 @@ func (n *NiriProvider) SetBind(key, action, description string, options map[stri
|
|||||||
existingBinds = make(map[string]*overrideBind)
|
existingBinds = make(map[string]*overrideBind)
|
||||||
}
|
}
|
||||||
|
|
||||||
existingBinds[key] = &overrideBind{
|
existingBinds[normalizeNiriBindKey(key)] = &overrideBind{
|
||||||
Key: key,
|
Key: key,
|
||||||
Action: action,
|
Action: action,
|
||||||
Description: description,
|
Description: description,
|
||||||
@@ -265,10 +265,14 @@ func (n *NiriProvider) RemoveBind(key string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
delete(existingBinds, key)
|
delete(existingBinds, normalizeNiriBindKey(key))
|
||||||
return n.writeOverrideBinds(existingBinds)
|
return n.writeOverrideBinds(existingBinds)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (n *NiriProvider) ResetBind(key string) error {
|
||||||
|
return n.RemoveBind(key)
|
||||||
|
}
|
||||||
|
|
||||||
type overrideBind struct {
|
type overrideBind struct {
|
||||||
Key string
|
Key string
|
||||||
Action string
|
Action string
|
||||||
@@ -312,7 +316,7 @@ func (n *NiriProvider) loadOverrideBinds() (map[string]*overrideBind, error) {
|
|||||||
action = n.formatRawAction(kb.Action, kb.Args)
|
action = n.formatRawAction(kb.Action, kb.Args)
|
||||||
}
|
}
|
||||||
|
|
||||||
binds[keyStr] = &overrideBind{
|
binds[normalizeNiriBindKey(keyStr)] = &overrideBind{
|
||||||
Key: keyStr,
|
Key: keyStr,
|
||||||
Action: action,
|
Action: action,
|
||||||
Description: kb.Description,
|
Description: kb.Description,
|
||||||
|
|||||||
@@ -162,6 +162,14 @@ func NewNiriParser(configDir string) *NiriParser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeNiriBindKey(key string) string {
|
||||||
|
parts := strings.Split(key, "+")
|
||||||
|
for i := range parts {
|
||||||
|
parts[i] = strings.ToLower(strings.TrimSpace(parts[i]))
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "+")
|
||||||
|
}
|
||||||
|
|
||||||
func (p *NiriParser) Parse() (*NiriSection, error) {
|
func (p *NiriParser) Parse() (*NiriSection, error) {
|
||||||
dmsBindsPath := filepath.Join(p.configDir, "dms", "binds.kdl")
|
dmsBindsPath := filepath.Join(p.configDir, "dms", "binds.kdl")
|
||||||
if _, err := os.Stat(dmsBindsPath); err == nil {
|
if _, err := os.Stat(dmsBindsPath); err == nil {
|
||||||
@@ -213,24 +221,25 @@ func (p *NiriParser) finalizeBinds() []NiriKeyBinding {
|
|||||||
|
|
||||||
func (p *NiriParser) addBind(kb *NiriKeyBinding) {
|
func (p *NiriParser) addBind(kb *NiriKeyBinding) {
|
||||||
key := p.formatBindKey(kb)
|
key := p.formatBindKey(kb)
|
||||||
|
normalizedKey := normalizeNiriBindKey(key)
|
||||||
isDMSBind := strings.Contains(kb.Source, "dms/binds.kdl")
|
isDMSBind := strings.Contains(kb.Source, "dms/binds.kdl")
|
||||||
|
|
||||||
if isDMSBind {
|
if isDMSBind {
|
||||||
p.dmsBindKeys[key] = true
|
p.dmsBindKeys[normalizedKey] = true
|
||||||
p.dmsBindMap[key] = kb
|
p.dmsBindMap[normalizedKey] = kb
|
||||||
} else if p.dmsBindKeys[key] {
|
} else if p.dmsBindKeys[normalizedKey] {
|
||||||
p.bindsAfterDMS++
|
p.bindsAfterDMS++
|
||||||
p.conflictingConfigs[key] = kb
|
p.conflictingConfigs[normalizedKey] = kb
|
||||||
p.configBindKeys[key] = true
|
p.configBindKeys[normalizedKey] = true
|
||||||
return
|
return
|
||||||
} else {
|
} else {
|
||||||
p.configBindKeys[key] = true
|
p.configBindKeys[normalizedKey] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, exists := p.bindMap[key]; !exists {
|
if _, exists := p.bindMap[normalizedKey]; !exists {
|
||||||
p.bindOrder = append(p.bindOrder, key)
|
p.bindOrder = append(p.bindOrder, normalizedKey)
|
||||||
}
|
}
|
||||||
p.bindMap[key] = kb
|
p.bindMap[normalizedKey] = kb
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *NiriParser) formatBindKey(kb *NiriKeyBinding) string {
|
func (p *NiriParser) formatBindKey(kb *NiriKeyBinding) string {
|
||||||
|
|||||||
@@ -526,6 +526,50 @@ binds {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNiriKeyIdentityIsCaseInsensitive(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
dmsDir := filepath.Join(tmpDir, "dms")
|
||||||
|
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("Failed to create dms dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
config := `binds {
|
||||||
|
Alt+Space hotkey-overlay-title="Spotlight Bar" { spawn "dms" "ipc" "call" "spotlight-bar" "toggle"; }
|
||||||
|
}
|
||||||
|
include "dms/binds.kdl"
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(filepath.Join(tmpDir, "config.kdl"), []byte(config), 0o644); err != nil {
|
||||||
|
t.Fatalf("Failed to write config: %v", err)
|
||||||
|
}
|
||||||
|
include := `binds {
|
||||||
|
Alt+space hotkey-overlay-title="Default Launcher" { spawn "dms" "ipc" "call" "spotlight" "toggle"; }
|
||||||
|
}
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(filepath.Join(dmsDir, "binds.kdl"), []byte(include), 0o644); err != nil {
|
||||||
|
t.Fatalf("Failed to write binds include: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := ParseNiriKeys(tmpDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseNiriKeys failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var altSpaceBinds []NiriKeyBinding
|
||||||
|
parser := NewNiriParser("")
|
||||||
|
for _, kb := range result.Section.Keybinds {
|
||||||
|
if normalizeNiriBindKey(parser.formatBindKey(&kb)) == "alt+space" {
|
||||||
|
altSpaceBinds = append(altSpaceBinds, kb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(altSpaceBinds) != 1 {
|
||||||
|
t.Fatalf("Expected one Alt+Space identity, got %d", len(altSpaceBinds))
|
||||||
|
}
|
||||||
|
if got := altSpaceBinds[0].Args; len(got) < 5 || got[3] != "spotlight" || got[4] != "toggle" {
|
||||||
|
t.Fatalf("Expected later DMS include to win with spotlight toggle, got action=%s args=%v", altSpaceBinds[0].Action, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNiriParseMultipleArgs(t *testing.T) {
|
func TestNiriParseMultipleArgs(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
configFile := filepath.Join(tmpDir, "config.kdl")
|
configFile := filepath.Join(tmpDir, "config.kdl")
|
||||||
|
|||||||
@@ -367,7 +367,7 @@ func TestNiriEmptyArgsPreservation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for key, expected := range binds {
|
for key, expected := range binds {
|
||||||
loaded, ok := loadedBinds[key]
|
loaded, ok := loadedBinds[normalizeNiriBindKey(key)]
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Errorf("Missing bind for key %s", key)
|
t.Errorf("Missing bind for key %s", key)
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ type Keybind struct {
|
|||||||
AllowInhibiting *bool `json:"allowInhibiting,omitempty"` // nil=default(true), false=explicitly disabled
|
AllowInhibiting *bool `json:"allowInhibiting,omitempty"` // nil=default(true), false=explicitly disabled
|
||||||
Repeat *bool `json:"repeat,omitempty"` // nil=default(true), false=explicitly disabled
|
Repeat *bool `json:"repeat,omitempty"` // nil=default(true), false=explicitly disabled
|
||||||
Conflict *Keybind `json:"conflict,omitempty"`
|
Conflict *Keybind `json:"conflict,omitempty"`
|
||||||
|
HasDefault bool `json:"hasDefault,omitempty"` // override has a DMS default to revert to
|
||||||
}
|
}
|
||||||
|
|
||||||
type DMSBindsStatus struct {
|
type DMSBindsStatus struct {
|
||||||
@@ -24,6 +25,8 @@ type DMSBindsStatus struct {
|
|||||||
Effective bool `json:"effective"`
|
Effective bool `json:"effective"`
|
||||||
OverriddenBy int `json:"overriddenBy"`
|
OverriddenBy int `json:"overriddenBy"`
|
||||||
StatusMessage string `json:"statusMessage"`
|
StatusMessage string `json:"statusMessage"`
|
||||||
|
ConfigFormat string `json:"configFormat,omitempty"`
|
||||||
|
ReadOnly bool `json:"readOnly,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CheatSheet struct {
|
type CheatSheet struct {
|
||||||
@@ -42,6 +45,11 @@ type Provider interface {
|
|||||||
type WritableProvider interface {
|
type WritableProvider interface {
|
||||||
Provider
|
Provider
|
||||||
SetBind(key, action, description string, options map[string]any) error
|
SetBind(key, action, description string, options map[string]any) error
|
||||||
|
// RemoveBind removes the bind. Hyprland writes a negative override to
|
||||||
|
// dms/binds-user.lua; single-file providers delete the line.
|
||||||
RemoveBind(key string) error
|
RemoveBind(key string) error
|
||||||
|
// ResetBind reverts a user override to its DMS default. On single-file
|
||||||
|
// providers this aliases to RemoveBind.
|
||||||
|
ResetBind(key string) error
|
||||||
GetOverridePath() string
|
GetOverridePath() string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package luaconfig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var luaRequireRE = regexp.MustCompile(`(?i)\brequire\s*\(\s*["']([^"']+)["']\s*\)`)
|
||||||
|
|
||||||
|
func ModuleToRelPath(module string) string {
|
||||||
|
module = strings.TrimSpace(module)
|
||||||
|
if module == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
module = strings.NewReplacer(".", string(filepath.Separator), "/", string(filepath.Separator)).Replace(module)
|
||||||
|
return filepath.Clean(module + ".lua")
|
||||||
|
}
|
||||||
|
|
||||||
|
func ModuleToPath(baseDir, module string) string {
|
||||||
|
rel := ModuleToRelPath(module)
|
||||||
|
if rel == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return filepath.Clean(filepath.Join(baseDir, rel))
|
||||||
|
}
|
||||||
|
|
||||||
|
func Requires(line string) []string {
|
||||||
|
line = stripLineComment(line)
|
||||||
|
if strings.TrimSpace(line) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
matches := luaRequireRE.FindAllStringSubmatch(line, -1)
|
||||||
|
if len(matches) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
modules := make([]string, 0, len(matches))
|
||||||
|
for _, match := range matches {
|
||||||
|
if len(match) > 1 && strings.TrimSpace(match[1]) != "" {
|
||||||
|
modules = append(modules, strings.TrimSpace(match[1]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return modules
|
||||||
|
}
|
||||||
|
|
||||||
|
func Require(line string) (string, bool) {
|
||||||
|
modules := Requires(line)
|
||||||
|
if len(modules) != 1 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return modules[0], true
|
||||||
|
}
|
||||||
|
|
||||||
|
func RequiresTarget(filePath, targetAbs string, processed map[string]bool) bool {
|
||||||
|
absPath, err := filepath.Abs(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return requiresTarget(absPath, filepath.Dir(absPath), targetAbs, processed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func requiresTarget(filePath, rootDir, targetAbs string, processed map[string]bool) bool {
|
||||||
|
absPath, err := filepath.Abs(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
targetAbsClean := filepath.Clean(targetAbs)
|
||||||
|
|
||||||
|
if processed[absPath] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
processed[absPath] = true
|
||||||
|
|
||||||
|
data, err := os.ReadFile(absPath)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, raw := range strings.Split(string(data), "\n") {
|
||||||
|
for _, module := range Requires(raw) {
|
||||||
|
candidate := ModuleToPath(rootDir, module)
|
||||||
|
if candidate == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if filepath.Clean(candidate) == targetAbsClean {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
|
||||||
|
if requiresTarget(candidate, rootDir, targetAbs, processed) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripLineComment(line string) string {
|
||||||
|
inStr := byte(0)
|
||||||
|
esc := false
|
||||||
|
for i := 0; i+1 < len(line); i++ {
|
||||||
|
c := line[i]
|
||||||
|
if inStr != 0 {
|
||||||
|
if esc {
|
||||||
|
esc = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c == '\\' && inStr == '"' {
|
||||||
|
esc = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c == inStr {
|
||||||
|
inStr = 0
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch c {
|
||||||
|
case '"', '\'':
|
||||||
|
inStr = c
|
||||||
|
case '-':
|
||||||
|
if line[i+1] == '-' {
|
||||||
|
return line[:i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return line
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package luaconfig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestModuleToRelPath(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
"dms.binds": filepath.Join("dms", "binds.lua"),
|
||||||
|
"dms/binds-user": filepath.Join("dms", "binds-user.lua"),
|
||||||
|
"awesome/anim": filepath.Join("awesome", "anim.lua"),
|
||||||
|
"awesome.colors": filepath.Join("awesome", "colors.lua"),
|
||||||
|
" awesome.binds ": filepath.Join("awesome", "binds.lua"),
|
||||||
|
}
|
||||||
|
|
||||||
|
for input, want := range tests {
|
||||||
|
if got := ModuleToRelPath(input); got != want {
|
||||||
|
t.Fatalf("ModuleToRelPath(%q) = %q, want %q", input, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequiresSkipsComments(t *testing.T) {
|
||||||
|
if modules := Requires(`-- require("dms.binds")`); len(modules) != 0 {
|
||||||
|
t.Fatalf("expected commented require to be ignored, got %#v", modules)
|
||||||
|
}
|
||||||
|
|
||||||
|
modules := Requires(`print("-- not a comment") require("dms.binds") -- require("ignored")`)
|
||||||
|
if len(modules) != 1 || modules[0] != "dms.binds" {
|
||||||
|
t.Fatalf("unexpected modules: %#v", modules)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequiresTargetRecurses(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
dmsDir := filepath.Join(tmpDir, "dms")
|
||||||
|
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
target := filepath.Join(dmsDir, "windowrules.lua")
|
||||||
|
if err := os.WriteFile(filepath.Join(tmpDir, "hyprland.lua"), []byte(`require("dms.extra")`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dmsDir, "extra.lua"), []byte(`require("dms.windowrules")`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(target, []byte(`-- rules`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !RequiresTarget(filepath.Join(tmpDir, "hyprland.lua"), target, make(map[string]bool)) {
|
||||||
|
t.Fatal("expected recursive require lookup to find target")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,791 +0,0 @@
|
|||||||
// Generated by go-wayland-scanner
|
|
||||||
// https://github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/cmd/go-wayland-scanner
|
|
||||||
// XML file : internal/proto/xml/dwl-ipc-unstable-v2.xml
|
|
||||||
//
|
|
||||||
// dwl_ipc_unstable_v2 Protocol Copyright:
|
|
||||||
|
|
||||||
package dwl_ipc
|
|
||||||
|
|
||||||
import "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
|
||||||
|
|
||||||
// ZdwlIpcManagerV2InterfaceName is the name of the interface as it appears in the [client.Registry].
|
|
||||||
// It can be used to match the [client.RegistryGlobalEvent.Interface] in the
|
|
||||||
// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies.
|
|
||||||
const ZdwlIpcManagerV2InterfaceName = "zdwl_ipc_manager_v2"
|
|
||||||
|
|
||||||
// ZdwlIpcManagerV2 : manage dwl state
|
|
||||||
//
|
|
||||||
// This interface is exposed as a global in wl_registry.
|
|
||||||
//
|
|
||||||
// Clients can use this interface to get a dwl_ipc_output.
|
|
||||||
// After binding the client will recieve the dwl_ipc_manager.tags and dwl_ipc_manager.layout events.
|
|
||||||
// The dwl_ipc_manager.tags and dwl_ipc_manager.layout events expose tags and layouts to the client.
|
|
||||||
type ZdwlIpcManagerV2 struct {
|
|
||||||
client.BaseProxy
|
|
||||||
tagsHandler ZdwlIpcManagerV2TagsHandlerFunc
|
|
||||||
layoutHandler ZdwlIpcManagerV2LayoutHandlerFunc
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewZdwlIpcManagerV2 : manage dwl state
|
|
||||||
//
|
|
||||||
// This interface is exposed as a global in wl_registry.
|
|
||||||
//
|
|
||||||
// Clients can use this interface to get a dwl_ipc_output.
|
|
||||||
// After binding the client will recieve the dwl_ipc_manager.tags and dwl_ipc_manager.layout events.
|
|
||||||
// The dwl_ipc_manager.tags and dwl_ipc_manager.layout events expose tags and layouts to the client.
|
|
||||||
func NewZdwlIpcManagerV2(ctx *client.Context) *ZdwlIpcManagerV2 {
|
|
||||||
zdwlIpcManagerV2 := &ZdwlIpcManagerV2{}
|
|
||||||
ctx.Register(zdwlIpcManagerV2)
|
|
||||||
return zdwlIpcManagerV2
|
|
||||||
}
|
|
||||||
|
|
||||||
// Release : release dwl_ipc_manager
|
|
||||||
//
|
|
||||||
// Indicates that the client will not the dwl_ipc_manager object anymore.
|
|
||||||
// Objects created through this instance are not affected.
|
|
||||||
func (i *ZdwlIpcManagerV2) Release() error {
|
|
||||||
defer i.MarkZombie()
|
|
||||||
const opcode = 0
|
|
||||||
const _reqBufLen = 8
|
|
||||||
var _reqBuf [_reqBufLen]byte
|
|
||||||
l := 0
|
|
||||||
client.PutUint32(_reqBuf[l:4], i.ID())
|
|
||||||
l += 4
|
|
||||||
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
|
|
||||||
l += 4
|
|
||||||
err := i.Context().WriteMsg(_reqBuf[:], nil)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetOutput : get a dwl_ipc_outout for a wl_output
|
|
||||||
//
|
|
||||||
// Get a dwl_ipc_outout for the specified wl_output.
|
|
||||||
func (i *ZdwlIpcManagerV2) GetOutput(output *client.Output) (*ZdwlIpcOutputV2, error) {
|
|
||||||
id := NewZdwlIpcOutputV2(i.Context())
|
|
||||||
const opcode = 1
|
|
||||||
const _reqBufLen = 8 + 4 + 4
|
|
||||||
var _reqBuf [_reqBufLen]byte
|
|
||||||
l := 0
|
|
||||||
client.PutUint32(_reqBuf[l:4], i.ID())
|
|
||||||
l += 4
|
|
||||||
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
|
|
||||||
l += 4
|
|
||||||
client.PutUint32(_reqBuf[l:l+4], id.ID())
|
|
||||||
l += 4
|
|
||||||
client.PutUint32(_reqBuf[l:l+4], output.ID())
|
|
||||||
l += 4
|
|
||||||
err := i.Context().WriteMsg(_reqBuf[:], nil)
|
|
||||||
return id, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ZdwlIpcManagerV2TagsEvent : Announces tag amount
|
|
||||||
//
|
|
||||||
// This event is sent after binding.
|
|
||||||
// A roundtrip after binding guarantees the client recieved all tags.
|
|
||||||
type ZdwlIpcManagerV2TagsEvent struct {
|
|
||||||
Amount uint32
|
|
||||||
}
|
|
||||||
type ZdwlIpcManagerV2TagsHandlerFunc func(ZdwlIpcManagerV2TagsEvent)
|
|
||||||
|
|
||||||
// SetTagsHandler : sets handler for ZdwlIpcManagerV2TagsEvent
|
|
||||||
func (i *ZdwlIpcManagerV2) SetTagsHandler(f ZdwlIpcManagerV2TagsHandlerFunc) {
|
|
||||||
i.tagsHandler = f
|
|
||||||
}
|
|
||||||
|
|
||||||
// ZdwlIpcManagerV2LayoutEvent : Announces a layout
|
|
||||||
//
|
|
||||||
// This event is sent after binding.
|
|
||||||
// A roundtrip after binding guarantees the client recieved all layouts.
|
|
||||||
type ZdwlIpcManagerV2LayoutEvent struct {
|
|
||||||
Name string
|
|
||||||
}
|
|
||||||
type ZdwlIpcManagerV2LayoutHandlerFunc func(ZdwlIpcManagerV2LayoutEvent)
|
|
||||||
|
|
||||||
// SetLayoutHandler : sets handler for ZdwlIpcManagerV2LayoutEvent
|
|
||||||
func (i *ZdwlIpcManagerV2) SetLayoutHandler(f ZdwlIpcManagerV2LayoutHandlerFunc) {
|
|
||||||
i.layoutHandler = f
|
|
||||||
}
|
|
||||||
|
|
||||||
func (i *ZdwlIpcManagerV2) Dispatch(opcode uint32, fd int, data []byte) {
|
|
||||||
switch opcode {
|
|
||||||
case 0:
|
|
||||||
if i.tagsHandler == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var e ZdwlIpcManagerV2TagsEvent
|
|
||||||
l := 0
|
|
||||||
e.Amount = client.Uint32(data[l : l+4])
|
|
||||||
l += 4
|
|
||||||
|
|
||||||
i.tagsHandler(e)
|
|
||||||
case 1:
|
|
||||||
if i.layoutHandler == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var e ZdwlIpcManagerV2LayoutEvent
|
|
||||||
l := 0
|
|
||||||
nameLen := client.PaddedLen(int(client.Uint32(data[l : l+4])))
|
|
||||||
l += 4
|
|
||||||
e.Name = client.String(data[l : l+nameLen])
|
|
||||||
l += nameLen
|
|
||||||
|
|
||||||
i.layoutHandler(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ZdwlIpcOutputV2InterfaceName is the name of the interface as it appears in the [client.Registry].
|
|
||||||
// It can be used to match the [client.RegistryGlobalEvent.Interface] in the
|
|
||||||
// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies.
|
|
||||||
const ZdwlIpcOutputV2InterfaceName = "zdwl_ipc_output_v2"
|
|
||||||
|
|
||||||
// ZdwlIpcOutputV2 : control dwl output
|
|
||||||
//
|
|
||||||
// Observe and control a dwl output.
|
|
||||||
//
|
|
||||||
// Events are double-buffered:
|
|
||||||
// Clients should cache events and redraw when a dwl_ipc_output.frame event is sent.
|
|
||||||
//
|
|
||||||
// Request are not double-buffered:
|
|
||||||
// The compositor will update immediately upon request.
|
|
||||||
type ZdwlIpcOutputV2 struct {
|
|
||||||
client.BaseProxy
|
|
||||||
toggleVisibilityHandler ZdwlIpcOutputV2ToggleVisibilityHandlerFunc
|
|
||||||
activeHandler ZdwlIpcOutputV2ActiveHandlerFunc
|
|
||||||
tagHandler ZdwlIpcOutputV2TagHandlerFunc
|
|
||||||
layoutHandler ZdwlIpcOutputV2LayoutHandlerFunc
|
|
||||||
titleHandler ZdwlIpcOutputV2TitleHandlerFunc
|
|
||||||
appidHandler ZdwlIpcOutputV2AppidHandlerFunc
|
|
||||||
layoutSymbolHandler ZdwlIpcOutputV2LayoutSymbolHandlerFunc
|
|
||||||
frameHandler ZdwlIpcOutputV2FrameHandlerFunc
|
|
||||||
fullscreenHandler ZdwlIpcOutputV2FullscreenHandlerFunc
|
|
||||||
floatingHandler ZdwlIpcOutputV2FloatingHandlerFunc
|
|
||||||
xHandler ZdwlIpcOutputV2XHandlerFunc
|
|
||||||
yHandler ZdwlIpcOutputV2YHandlerFunc
|
|
||||||
widthHandler ZdwlIpcOutputV2WidthHandlerFunc
|
|
||||||
heightHandler ZdwlIpcOutputV2HeightHandlerFunc
|
|
||||||
lastLayerHandler ZdwlIpcOutputV2LastLayerHandlerFunc
|
|
||||||
kbLayoutHandler ZdwlIpcOutputV2KbLayoutHandlerFunc
|
|
||||||
keymodeHandler ZdwlIpcOutputV2KeymodeHandlerFunc
|
|
||||||
scalefactorHandler ZdwlIpcOutputV2ScalefactorHandlerFunc
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewZdwlIpcOutputV2 : control dwl output
|
|
||||||
//
|
|
||||||
// Observe and control a dwl output.
|
|
||||||
//
|
|
||||||
// Events are double-buffered:
|
|
||||||
// Clients should cache events and redraw when a dwl_ipc_output.frame event is sent.
|
|
||||||
//
|
|
||||||
// Request are not double-buffered:
|
|
||||||
// The compositor will update immediately upon request.
|
|
||||||
func NewZdwlIpcOutputV2(ctx *client.Context) *ZdwlIpcOutputV2 {
|
|
||||||
zdwlIpcOutputV2 := &ZdwlIpcOutputV2{}
|
|
||||||
ctx.Register(zdwlIpcOutputV2)
|
|
||||||
return zdwlIpcOutputV2
|
|
||||||
}
|
|
||||||
|
|
||||||
// Release : release dwl_ipc_outout
|
|
||||||
//
|
|
||||||
// Indicates to that the client no longer needs this dwl_ipc_output.
|
|
||||||
func (i *ZdwlIpcOutputV2) Release() error {
|
|
||||||
defer i.MarkZombie()
|
|
||||||
const opcode = 0
|
|
||||||
const _reqBufLen = 8
|
|
||||||
var _reqBuf [_reqBufLen]byte
|
|
||||||
l := 0
|
|
||||||
client.PutUint32(_reqBuf[l:4], i.ID())
|
|
||||||
l += 4
|
|
||||||
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
|
|
||||||
l += 4
|
|
||||||
err := i.Context().WriteMsg(_reqBuf[:], nil)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetTags : Set the active tags of this output
|
|
||||||
//
|
|
||||||
// tagmask: bitmask of the tags that should be set.
|
|
||||||
// toggleTagset: toggle the selected tagset, zero for invalid, nonzero for valid.
|
|
||||||
func (i *ZdwlIpcOutputV2) SetTags(tagmask, toggleTagset uint32) error {
|
|
||||||
const opcode = 1
|
|
||||||
const _reqBufLen = 8 + 4 + 4
|
|
||||||
var _reqBuf [_reqBufLen]byte
|
|
||||||
l := 0
|
|
||||||
client.PutUint32(_reqBuf[l:4], i.ID())
|
|
||||||
l += 4
|
|
||||||
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
|
|
||||||
l += 4
|
|
||||||
client.PutUint32(_reqBuf[l:l+4], uint32(tagmask))
|
|
||||||
l += 4
|
|
||||||
client.PutUint32(_reqBuf[l:l+4], uint32(toggleTagset))
|
|
||||||
l += 4
|
|
||||||
err := i.Context().WriteMsg(_reqBuf[:], nil)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetClientTags : Set the tags of the focused client.
|
|
||||||
//
|
|
||||||
// The tags are updated as follows:
|
|
||||||
// new_tags = (current_tags AND and_tags) XOR xor_tags
|
|
||||||
func (i *ZdwlIpcOutputV2) SetClientTags(andTags, xorTags uint32) error {
|
|
||||||
const opcode = 2
|
|
||||||
const _reqBufLen = 8 + 4 + 4
|
|
||||||
var _reqBuf [_reqBufLen]byte
|
|
||||||
l := 0
|
|
||||||
client.PutUint32(_reqBuf[l:4], i.ID())
|
|
||||||
l += 4
|
|
||||||
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
|
|
||||||
l += 4
|
|
||||||
client.PutUint32(_reqBuf[l:l+4], uint32(andTags))
|
|
||||||
l += 4
|
|
||||||
client.PutUint32(_reqBuf[l:l+4], uint32(xorTags))
|
|
||||||
l += 4
|
|
||||||
err := i.Context().WriteMsg(_reqBuf[:], nil)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetLayout : Set the layout of this output
|
|
||||||
//
|
|
||||||
// index: index of a layout recieved by dwl_ipc_manager.layout
|
|
||||||
func (i *ZdwlIpcOutputV2) SetLayout(index uint32) error {
|
|
||||||
const opcode = 3
|
|
||||||
const _reqBufLen = 8 + 4
|
|
||||||
var _reqBuf [_reqBufLen]byte
|
|
||||||
l := 0
|
|
||||||
client.PutUint32(_reqBuf[l:4], i.ID())
|
|
||||||
l += 4
|
|
||||||
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
|
|
||||||
l += 4
|
|
||||||
client.PutUint32(_reqBuf[l:l+4], uint32(index))
|
|
||||||
l += 4
|
|
||||||
err := i.Context().WriteMsg(_reqBuf[:], nil)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Quit : Quit mango
|
|
||||||
// This request allows clients to instruct the compositor to quit mango.
|
|
||||||
func (i *ZdwlIpcOutputV2) Quit() error {
|
|
||||||
const opcode = 4
|
|
||||||
const _reqBufLen = 8
|
|
||||||
var _reqBuf [_reqBufLen]byte
|
|
||||||
l := 0
|
|
||||||
client.PutUint32(_reqBuf[l:4], i.ID())
|
|
||||||
l += 4
|
|
||||||
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
|
|
||||||
l += 4
|
|
||||||
err := i.Context().WriteMsg(_reqBuf[:], nil)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// SendDispatch : Set the active tags of this output
|
|
||||||
//
|
|
||||||
// dispatch: dispatch name.
|
|
||||||
// arg1: arg1.
|
|
||||||
// arg2: arg2.
|
|
||||||
// arg3: arg3.
|
|
||||||
// arg4: arg4.
|
|
||||||
// arg5: arg5.
|
|
||||||
func (i *ZdwlIpcOutputV2) SendDispatch(dispatch, arg1, arg2, arg3, arg4, arg5 string) error {
|
|
||||||
const opcode = 5
|
|
||||||
dispatchLen := client.PaddedLen(len(dispatch) + 1)
|
|
||||||
arg1Len := client.PaddedLen(len(arg1) + 1)
|
|
||||||
arg2Len := client.PaddedLen(len(arg2) + 1)
|
|
||||||
arg3Len := client.PaddedLen(len(arg3) + 1)
|
|
||||||
arg4Len := client.PaddedLen(len(arg4) + 1)
|
|
||||||
arg5Len := client.PaddedLen(len(arg5) + 1)
|
|
||||||
_reqBufLen := 8 + (4 + dispatchLen) + (4 + arg1Len) + (4 + arg2Len) + (4 + arg3Len) + (4 + arg4Len) + (4 + arg5Len)
|
|
||||||
_reqBuf := make([]byte, _reqBufLen)
|
|
||||||
l := 0
|
|
||||||
client.PutUint32(_reqBuf[l:4], i.ID())
|
|
||||||
l += 4
|
|
||||||
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
|
|
||||||
l += 4
|
|
||||||
client.PutString(_reqBuf[l:l+(4+dispatchLen)], dispatch)
|
|
||||||
l += (4 + dispatchLen)
|
|
||||||
client.PutString(_reqBuf[l:l+(4+arg1Len)], arg1)
|
|
||||||
l += (4 + arg1Len)
|
|
||||||
client.PutString(_reqBuf[l:l+(4+arg2Len)], arg2)
|
|
||||||
l += (4 + arg2Len)
|
|
||||||
client.PutString(_reqBuf[l:l+(4+arg3Len)], arg3)
|
|
||||||
l += (4 + arg3Len)
|
|
||||||
client.PutString(_reqBuf[l:l+(4+arg4Len)], arg4)
|
|
||||||
l += (4 + arg4Len)
|
|
||||||
client.PutString(_reqBuf[l:l+(4+arg5Len)], arg5)
|
|
||||||
l += (4 + arg5Len)
|
|
||||||
err := i.Context().WriteMsg(_reqBuf, nil)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
type ZdwlIpcOutputV2TagState uint32
|
|
||||||
|
|
||||||
// ZdwlIpcOutputV2TagState :
|
|
||||||
const (
|
|
||||||
// ZdwlIpcOutputV2TagStateNone : no state
|
|
||||||
ZdwlIpcOutputV2TagStateNone ZdwlIpcOutputV2TagState = 0
|
|
||||||
// ZdwlIpcOutputV2TagStateActive : tag is active
|
|
||||||
ZdwlIpcOutputV2TagStateActive ZdwlIpcOutputV2TagState = 1
|
|
||||||
// ZdwlIpcOutputV2TagStateUrgent : tag has at least one urgent client
|
|
||||||
ZdwlIpcOutputV2TagStateUrgent ZdwlIpcOutputV2TagState = 2
|
|
||||||
)
|
|
||||||
|
|
||||||
func (e ZdwlIpcOutputV2TagState) Name() string {
|
|
||||||
switch e {
|
|
||||||
case ZdwlIpcOutputV2TagStateNone:
|
|
||||||
return "none"
|
|
||||||
case ZdwlIpcOutputV2TagStateActive:
|
|
||||||
return "active"
|
|
||||||
case ZdwlIpcOutputV2TagStateUrgent:
|
|
||||||
return "urgent"
|
|
||||||
default:
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e ZdwlIpcOutputV2TagState) Value() string {
|
|
||||||
switch e {
|
|
||||||
case ZdwlIpcOutputV2TagStateNone:
|
|
||||||
return "0"
|
|
||||||
case ZdwlIpcOutputV2TagStateActive:
|
|
||||||
return "1"
|
|
||||||
case ZdwlIpcOutputV2TagStateUrgent:
|
|
||||||
return "2"
|
|
||||||
default:
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e ZdwlIpcOutputV2TagState) String() string {
|
|
||||||
return e.Name() + "=" + e.Value()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ZdwlIpcOutputV2ToggleVisibilityEvent : Toggle client visibilty
|
|
||||||
//
|
|
||||||
// Indicates the client should hide or show themselves.
|
|
||||||
// If the client is visible then hide, if hidden then show.
|
|
||||||
type ZdwlIpcOutputV2ToggleVisibilityEvent struct{}
|
|
||||||
type ZdwlIpcOutputV2ToggleVisibilityHandlerFunc func(ZdwlIpcOutputV2ToggleVisibilityEvent)
|
|
||||||
|
|
||||||
// SetToggleVisibilityHandler : sets handler for ZdwlIpcOutputV2ToggleVisibilityEvent
|
|
||||||
func (i *ZdwlIpcOutputV2) SetToggleVisibilityHandler(f ZdwlIpcOutputV2ToggleVisibilityHandlerFunc) {
|
|
||||||
i.toggleVisibilityHandler = f
|
|
||||||
}
|
|
||||||
|
|
||||||
// ZdwlIpcOutputV2ActiveEvent : Update the selected output.
|
|
||||||
//
|
|
||||||
// Indicates if the output is active. Zero is invalid, nonzero is valid.
|
|
||||||
type ZdwlIpcOutputV2ActiveEvent struct {
|
|
||||||
Active uint32
|
|
||||||
}
|
|
||||||
type ZdwlIpcOutputV2ActiveHandlerFunc func(ZdwlIpcOutputV2ActiveEvent)
|
|
||||||
|
|
||||||
// SetActiveHandler : sets handler for ZdwlIpcOutputV2ActiveEvent
|
|
||||||
func (i *ZdwlIpcOutputV2) SetActiveHandler(f ZdwlIpcOutputV2ActiveHandlerFunc) {
|
|
||||||
i.activeHandler = f
|
|
||||||
}
|
|
||||||
|
|
||||||
// ZdwlIpcOutputV2TagEvent : Update the state of a tag.
|
|
||||||
//
|
|
||||||
// Indicates that a tag has been updated.
|
|
||||||
type ZdwlIpcOutputV2TagEvent struct {
|
|
||||||
Tag uint32
|
|
||||||
State uint32
|
|
||||||
Clients uint32
|
|
||||||
Focused uint32
|
|
||||||
}
|
|
||||||
type ZdwlIpcOutputV2TagHandlerFunc func(ZdwlIpcOutputV2TagEvent)
|
|
||||||
|
|
||||||
// SetTagHandler : sets handler for ZdwlIpcOutputV2TagEvent
|
|
||||||
func (i *ZdwlIpcOutputV2) SetTagHandler(f ZdwlIpcOutputV2TagHandlerFunc) {
|
|
||||||
i.tagHandler = f
|
|
||||||
}
|
|
||||||
|
|
||||||
// ZdwlIpcOutputV2LayoutEvent : Update the layout.
|
|
||||||
//
|
|
||||||
// Indicates a new layout is selected.
|
|
||||||
type ZdwlIpcOutputV2LayoutEvent struct {
|
|
||||||
Layout uint32
|
|
||||||
}
|
|
||||||
type ZdwlIpcOutputV2LayoutHandlerFunc func(ZdwlIpcOutputV2LayoutEvent)
|
|
||||||
|
|
||||||
// SetLayoutHandler : sets handler for ZdwlIpcOutputV2LayoutEvent
|
|
||||||
func (i *ZdwlIpcOutputV2) SetLayoutHandler(f ZdwlIpcOutputV2LayoutHandlerFunc) {
|
|
||||||
i.layoutHandler = f
|
|
||||||
}
|
|
||||||
|
|
||||||
// ZdwlIpcOutputV2TitleEvent : Update the title.
|
|
||||||
//
|
|
||||||
// Indicates the title has changed.
|
|
||||||
type ZdwlIpcOutputV2TitleEvent struct {
|
|
||||||
Title string
|
|
||||||
}
|
|
||||||
type ZdwlIpcOutputV2TitleHandlerFunc func(ZdwlIpcOutputV2TitleEvent)
|
|
||||||
|
|
||||||
// SetTitleHandler : sets handler for ZdwlIpcOutputV2TitleEvent
|
|
||||||
func (i *ZdwlIpcOutputV2) SetTitleHandler(f ZdwlIpcOutputV2TitleHandlerFunc) {
|
|
||||||
i.titleHandler = f
|
|
||||||
}
|
|
||||||
|
|
||||||
// ZdwlIpcOutputV2AppidEvent : Update the appid.
|
|
||||||
//
|
|
||||||
// Indicates the appid has changed.
|
|
||||||
type ZdwlIpcOutputV2AppidEvent struct {
|
|
||||||
Appid string
|
|
||||||
}
|
|
||||||
type ZdwlIpcOutputV2AppidHandlerFunc func(ZdwlIpcOutputV2AppidEvent)
|
|
||||||
|
|
||||||
// SetAppidHandler : sets handler for ZdwlIpcOutputV2AppidEvent
|
|
||||||
func (i *ZdwlIpcOutputV2) SetAppidHandler(f ZdwlIpcOutputV2AppidHandlerFunc) {
|
|
||||||
i.appidHandler = f
|
|
||||||
}
|
|
||||||
|
|
||||||
// ZdwlIpcOutputV2LayoutSymbolEvent : Update the current layout symbol
|
|
||||||
//
|
|
||||||
// Indicates the layout has changed. Since layout symbols are dynamic.
|
|
||||||
// As opposed to the zdwl_ipc_manager.layout event, this should take precendence when displaying.
|
|
||||||
// You can ignore the zdwl_ipc_output.layout event.
|
|
||||||
type ZdwlIpcOutputV2LayoutSymbolEvent struct {
|
|
||||||
Layout string
|
|
||||||
}
|
|
||||||
type ZdwlIpcOutputV2LayoutSymbolHandlerFunc func(ZdwlIpcOutputV2LayoutSymbolEvent)
|
|
||||||
|
|
||||||
// SetLayoutSymbolHandler : sets handler for ZdwlIpcOutputV2LayoutSymbolEvent
|
|
||||||
func (i *ZdwlIpcOutputV2) SetLayoutSymbolHandler(f ZdwlIpcOutputV2LayoutSymbolHandlerFunc) {
|
|
||||||
i.layoutSymbolHandler = f
|
|
||||||
}
|
|
||||||
|
|
||||||
// ZdwlIpcOutputV2FrameEvent : The update sequence is done.
|
|
||||||
//
|
|
||||||
// Indicates that a sequence of status updates have finished and the client should redraw.
|
|
||||||
type ZdwlIpcOutputV2FrameEvent struct{}
|
|
||||||
type ZdwlIpcOutputV2FrameHandlerFunc func(ZdwlIpcOutputV2FrameEvent)
|
|
||||||
|
|
||||||
// SetFrameHandler : sets handler for ZdwlIpcOutputV2FrameEvent
|
|
||||||
func (i *ZdwlIpcOutputV2) SetFrameHandler(f ZdwlIpcOutputV2FrameHandlerFunc) {
|
|
||||||
i.frameHandler = f
|
|
||||||
}
|
|
||||||
|
|
||||||
// ZdwlIpcOutputV2FullscreenEvent : Update fullscreen status
|
|
||||||
//
|
|
||||||
// Indicates if the selected client on this output is fullscreen.
|
|
||||||
type ZdwlIpcOutputV2FullscreenEvent struct {
|
|
||||||
IsFullscreen uint32
|
|
||||||
}
|
|
||||||
type ZdwlIpcOutputV2FullscreenHandlerFunc func(ZdwlIpcOutputV2FullscreenEvent)
|
|
||||||
|
|
||||||
// SetFullscreenHandler : sets handler for ZdwlIpcOutputV2FullscreenEvent
|
|
||||||
func (i *ZdwlIpcOutputV2) SetFullscreenHandler(f ZdwlIpcOutputV2FullscreenHandlerFunc) {
|
|
||||||
i.fullscreenHandler = f
|
|
||||||
}
|
|
||||||
|
|
||||||
// ZdwlIpcOutputV2FloatingEvent : Update the floating status
|
|
||||||
//
|
|
||||||
// Indicates if the selected client on this output is floating.
|
|
||||||
type ZdwlIpcOutputV2FloatingEvent struct {
|
|
||||||
IsFloating uint32
|
|
||||||
}
|
|
||||||
type ZdwlIpcOutputV2FloatingHandlerFunc func(ZdwlIpcOutputV2FloatingEvent)
|
|
||||||
|
|
||||||
// SetFloatingHandler : sets handler for ZdwlIpcOutputV2FloatingEvent
|
|
||||||
func (i *ZdwlIpcOutputV2) SetFloatingHandler(f ZdwlIpcOutputV2FloatingHandlerFunc) {
|
|
||||||
i.floatingHandler = f
|
|
||||||
}
|
|
||||||
|
|
||||||
// ZdwlIpcOutputV2XEvent : Update the x coordinates
|
|
||||||
//
|
|
||||||
// Indicates if x coordinates of the selected client.
|
|
||||||
type ZdwlIpcOutputV2XEvent struct {
|
|
||||||
X int32
|
|
||||||
}
|
|
||||||
type ZdwlIpcOutputV2XHandlerFunc func(ZdwlIpcOutputV2XEvent)
|
|
||||||
|
|
||||||
// SetXHandler : sets handler for ZdwlIpcOutputV2XEvent
|
|
||||||
func (i *ZdwlIpcOutputV2) SetXHandler(f ZdwlIpcOutputV2XHandlerFunc) {
|
|
||||||
i.xHandler = f
|
|
||||||
}
|
|
||||||
|
|
||||||
// ZdwlIpcOutputV2YEvent : Update the y coordinates
|
|
||||||
//
|
|
||||||
// Indicates if y coordinates of the selected client.
|
|
||||||
type ZdwlIpcOutputV2YEvent struct {
|
|
||||||
Y int32
|
|
||||||
}
|
|
||||||
type ZdwlIpcOutputV2YHandlerFunc func(ZdwlIpcOutputV2YEvent)
|
|
||||||
|
|
||||||
// SetYHandler : sets handler for ZdwlIpcOutputV2YEvent
|
|
||||||
func (i *ZdwlIpcOutputV2) SetYHandler(f ZdwlIpcOutputV2YHandlerFunc) {
|
|
||||||
i.yHandler = f
|
|
||||||
}
|
|
||||||
|
|
||||||
// ZdwlIpcOutputV2WidthEvent : Update the width
|
|
||||||
//
|
|
||||||
// Indicates if width of the selected client.
|
|
||||||
type ZdwlIpcOutputV2WidthEvent struct {
|
|
||||||
Width int32
|
|
||||||
}
|
|
||||||
type ZdwlIpcOutputV2WidthHandlerFunc func(ZdwlIpcOutputV2WidthEvent)
|
|
||||||
|
|
||||||
// SetWidthHandler : sets handler for ZdwlIpcOutputV2WidthEvent
|
|
||||||
func (i *ZdwlIpcOutputV2) SetWidthHandler(f ZdwlIpcOutputV2WidthHandlerFunc) {
|
|
||||||
i.widthHandler = f
|
|
||||||
}
|
|
||||||
|
|
||||||
// ZdwlIpcOutputV2HeightEvent : Update the height
|
|
||||||
//
|
|
||||||
// Indicates if height of the selected client.
|
|
||||||
type ZdwlIpcOutputV2HeightEvent struct {
|
|
||||||
Height int32
|
|
||||||
}
|
|
||||||
type ZdwlIpcOutputV2HeightHandlerFunc func(ZdwlIpcOutputV2HeightEvent)
|
|
||||||
|
|
||||||
// SetHeightHandler : sets handler for ZdwlIpcOutputV2HeightEvent
|
|
||||||
func (i *ZdwlIpcOutputV2) SetHeightHandler(f ZdwlIpcOutputV2HeightHandlerFunc) {
|
|
||||||
i.heightHandler = f
|
|
||||||
}
|
|
||||||
|
|
||||||
// ZdwlIpcOutputV2LastLayerEvent : last map layer.
|
|
||||||
//
|
|
||||||
// last map layer.
|
|
||||||
type ZdwlIpcOutputV2LastLayerEvent struct {
|
|
||||||
LastLayer string
|
|
||||||
}
|
|
||||||
type ZdwlIpcOutputV2LastLayerHandlerFunc func(ZdwlIpcOutputV2LastLayerEvent)
|
|
||||||
|
|
||||||
// SetLastLayerHandler : sets handler for ZdwlIpcOutputV2LastLayerEvent
|
|
||||||
func (i *ZdwlIpcOutputV2) SetLastLayerHandler(f ZdwlIpcOutputV2LastLayerHandlerFunc) {
|
|
||||||
i.lastLayerHandler = f
|
|
||||||
}
|
|
||||||
|
|
||||||
// ZdwlIpcOutputV2KbLayoutEvent : current keyboard layout.
|
|
||||||
//
|
|
||||||
// current keyboard layout.
|
|
||||||
type ZdwlIpcOutputV2KbLayoutEvent struct {
|
|
||||||
KbLayout string
|
|
||||||
}
|
|
||||||
type ZdwlIpcOutputV2KbLayoutHandlerFunc func(ZdwlIpcOutputV2KbLayoutEvent)
|
|
||||||
|
|
||||||
// SetKbLayoutHandler : sets handler for ZdwlIpcOutputV2KbLayoutEvent
|
|
||||||
func (i *ZdwlIpcOutputV2) SetKbLayoutHandler(f ZdwlIpcOutputV2KbLayoutHandlerFunc) {
|
|
||||||
i.kbLayoutHandler = f
|
|
||||||
}
|
|
||||||
|
|
||||||
// ZdwlIpcOutputV2KeymodeEvent : current keybind mode.
|
|
||||||
//
|
|
||||||
// current keybind mode.
|
|
||||||
type ZdwlIpcOutputV2KeymodeEvent struct {
|
|
||||||
Keymode string
|
|
||||||
}
|
|
||||||
type ZdwlIpcOutputV2KeymodeHandlerFunc func(ZdwlIpcOutputV2KeymodeEvent)
|
|
||||||
|
|
||||||
// SetKeymodeHandler : sets handler for ZdwlIpcOutputV2KeymodeEvent
|
|
||||||
func (i *ZdwlIpcOutputV2) SetKeymodeHandler(f ZdwlIpcOutputV2KeymodeHandlerFunc) {
|
|
||||||
i.keymodeHandler = f
|
|
||||||
}
|
|
||||||
|
|
||||||
// ZdwlIpcOutputV2ScalefactorEvent : scale factor of monitor.
|
|
||||||
//
|
|
||||||
// scale factor of monitor.
|
|
||||||
type ZdwlIpcOutputV2ScalefactorEvent struct {
|
|
||||||
Scalefactor uint32
|
|
||||||
}
|
|
||||||
type ZdwlIpcOutputV2ScalefactorHandlerFunc func(ZdwlIpcOutputV2ScalefactorEvent)
|
|
||||||
|
|
||||||
// SetScalefactorHandler : sets handler for ZdwlIpcOutputV2ScalefactorEvent
|
|
||||||
func (i *ZdwlIpcOutputV2) SetScalefactorHandler(f ZdwlIpcOutputV2ScalefactorHandlerFunc) {
|
|
||||||
i.scalefactorHandler = f
|
|
||||||
}
|
|
||||||
|
|
||||||
func (i *ZdwlIpcOutputV2) Dispatch(opcode uint32, fd int, data []byte) {
|
|
||||||
switch opcode {
|
|
||||||
case 0:
|
|
||||||
if i.toggleVisibilityHandler == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var e ZdwlIpcOutputV2ToggleVisibilityEvent
|
|
||||||
|
|
||||||
i.toggleVisibilityHandler(e)
|
|
||||||
case 1:
|
|
||||||
if i.activeHandler == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var e ZdwlIpcOutputV2ActiveEvent
|
|
||||||
l := 0
|
|
||||||
e.Active = client.Uint32(data[l : l+4])
|
|
||||||
l += 4
|
|
||||||
|
|
||||||
i.activeHandler(e)
|
|
||||||
case 2:
|
|
||||||
if i.tagHandler == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var e ZdwlIpcOutputV2TagEvent
|
|
||||||
l := 0
|
|
||||||
e.Tag = client.Uint32(data[l : l+4])
|
|
||||||
l += 4
|
|
||||||
e.State = client.Uint32(data[l : l+4])
|
|
||||||
l += 4
|
|
||||||
e.Clients = client.Uint32(data[l : l+4])
|
|
||||||
l += 4
|
|
||||||
e.Focused = client.Uint32(data[l : l+4])
|
|
||||||
l += 4
|
|
||||||
|
|
||||||
i.tagHandler(e)
|
|
||||||
case 3:
|
|
||||||
if i.layoutHandler == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var e ZdwlIpcOutputV2LayoutEvent
|
|
||||||
l := 0
|
|
||||||
e.Layout = client.Uint32(data[l : l+4])
|
|
||||||
l += 4
|
|
||||||
|
|
||||||
i.layoutHandler(e)
|
|
||||||
case 4:
|
|
||||||
if i.titleHandler == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var e ZdwlIpcOutputV2TitleEvent
|
|
||||||
l := 0
|
|
||||||
titleLen := client.PaddedLen(int(client.Uint32(data[l : l+4])))
|
|
||||||
l += 4
|
|
||||||
e.Title = client.String(data[l : l+titleLen])
|
|
||||||
l += titleLen
|
|
||||||
|
|
||||||
i.titleHandler(e)
|
|
||||||
case 5:
|
|
||||||
if i.appidHandler == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var e ZdwlIpcOutputV2AppidEvent
|
|
||||||
l := 0
|
|
||||||
appidLen := client.PaddedLen(int(client.Uint32(data[l : l+4])))
|
|
||||||
l += 4
|
|
||||||
e.Appid = client.String(data[l : l+appidLen])
|
|
||||||
l += appidLen
|
|
||||||
|
|
||||||
i.appidHandler(e)
|
|
||||||
case 6:
|
|
||||||
if i.layoutSymbolHandler == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var e ZdwlIpcOutputV2LayoutSymbolEvent
|
|
||||||
l := 0
|
|
||||||
layoutLen := client.PaddedLen(int(client.Uint32(data[l : l+4])))
|
|
||||||
l += 4
|
|
||||||
e.Layout = client.String(data[l : l+layoutLen])
|
|
||||||
l += layoutLen
|
|
||||||
|
|
||||||
i.layoutSymbolHandler(e)
|
|
||||||
case 7:
|
|
||||||
if i.frameHandler == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var e ZdwlIpcOutputV2FrameEvent
|
|
||||||
|
|
||||||
i.frameHandler(e)
|
|
||||||
case 8:
|
|
||||||
if i.fullscreenHandler == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var e ZdwlIpcOutputV2FullscreenEvent
|
|
||||||
l := 0
|
|
||||||
e.IsFullscreen = client.Uint32(data[l : l+4])
|
|
||||||
l += 4
|
|
||||||
|
|
||||||
i.fullscreenHandler(e)
|
|
||||||
case 9:
|
|
||||||
if i.floatingHandler == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var e ZdwlIpcOutputV2FloatingEvent
|
|
||||||
l := 0
|
|
||||||
e.IsFloating = client.Uint32(data[l : l+4])
|
|
||||||
l += 4
|
|
||||||
|
|
||||||
i.floatingHandler(e)
|
|
||||||
case 10:
|
|
||||||
if i.xHandler == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var e ZdwlIpcOutputV2XEvent
|
|
||||||
l := 0
|
|
||||||
e.X = int32(client.Uint32(data[l : l+4]))
|
|
||||||
l += 4
|
|
||||||
|
|
||||||
i.xHandler(e)
|
|
||||||
case 11:
|
|
||||||
if i.yHandler == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var e ZdwlIpcOutputV2YEvent
|
|
||||||
l := 0
|
|
||||||
e.Y = int32(client.Uint32(data[l : l+4]))
|
|
||||||
l += 4
|
|
||||||
|
|
||||||
i.yHandler(e)
|
|
||||||
case 12:
|
|
||||||
if i.widthHandler == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var e ZdwlIpcOutputV2WidthEvent
|
|
||||||
l := 0
|
|
||||||
e.Width = int32(client.Uint32(data[l : l+4]))
|
|
||||||
l += 4
|
|
||||||
|
|
||||||
i.widthHandler(e)
|
|
||||||
case 13:
|
|
||||||
if i.heightHandler == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var e ZdwlIpcOutputV2HeightEvent
|
|
||||||
l := 0
|
|
||||||
e.Height = int32(client.Uint32(data[l : l+4]))
|
|
||||||
l += 4
|
|
||||||
|
|
||||||
i.heightHandler(e)
|
|
||||||
case 14:
|
|
||||||
if i.lastLayerHandler == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var e ZdwlIpcOutputV2LastLayerEvent
|
|
||||||
l := 0
|
|
||||||
lastLayerLen := client.PaddedLen(int(client.Uint32(data[l : l+4])))
|
|
||||||
l += 4
|
|
||||||
e.LastLayer = client.String(data[l : l+lastLayerLen])
|
|
||||||
l += lastLayerLen
|
|
||||||
|
|
||||||
i.lastLayerHandler(e)
|
|
||||||
case 15:
|
|
||||||
if i.kbLayoutHandler == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var e ZdwlIpcOutputV2KbLayoutEvent
|
|
||||||
l := 0
|
|
||||||
kbLayoutLen := client.PaddedLen(int(client.Uint32(data[l : l+4])))
|
|
||||||
l += 4
|
|
||||||
e.KbLayout = client.String(data[l : l+kbLayoutLen])
|
|
||||||
l += kbLayoutLen
|
|
||||||
|
|
||||||
i.kbLayoutHandler(e)
|
|
||||||
case 16:
|
|
||||||
if i.keymodeHandler == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var e ZdwlIpcOutputV2KeymodeEvent
|
|
||||||
l := 0
|
|
||||||
keymodeLen := client.PaddedLen(int(client.Uint32(data[l : l+4])))
|
|
||||||
l += 4
|
|
||||||
e.Keymode = client.String(data[l : l+keymodeLen])
|
|
||||||
l += keymodeLen
|
|
||||||
|
|
||||||
i.keymodeHandler(e)
|
|
||||||
case 17:
|
|
||||||
if i.scalefactorHandler == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var e ZdwlIpcOutputV2ScalefactorEvent
|
|
||||||
l := 0
|
|
||||||
e.Scalefactor = client.Uint32(data[l : l+4])
|
|
||||||
l += 4
|
|
||||||
|
|
||||||
i.scalefactorHandler(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package qmlchecks
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLockScreenPasswordFieldBypassesTextInputIME(t *testing.T) {
|
||||||
|
data, err := os.ReadFile("../../../quickshell/Modules/Lock/LockScreenContent.qml")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read lock screen QML: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
content := string(data)
|
||||||
|
textInputPasswordField := regexp.MustCompile(`(?s)TextInput\s*\{[^{}]*id:\s*passwordField`)
|
||||||
|
if textInputPasswordField.MatchString(content) {
|
||||||
|
t.Fatalf("passwordField must not be a TextInput because TextInput can route physical keyboard input through IME")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(content, "Keys.onPressed") || !strings.Contains(content, "event.text") {
|
||||||
|
t.Fatalf("passwordField should handle physical key text manually instead of relying on a text input control")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/dwl_ipc"
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_output_management"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_output_management"
|
||||||
wlhelpers "github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/client"
|
wlhelpers "github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/client"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
"github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||||
@@ -19,9 +18,9 @@ const (
|
|||||||
CompositorHyprland
|
CompositorHyprland
|
||||||
CompositorSway
|
CompositorSway
|
||||||
CompositorNiri
|
CompositorNiri
|
||||||
CompositorDWL
|
|
||||||
CompositorScroll
|
CompositorScroll
|
||||||
CompositorMiracle
|
CompositorMiracle
|
||||||
|
CompositorMango
|
||||||
)
|
)
|
||||||
|
|
||||||
var detectedCompositor Compositor = -1
|
var detectedCompositor Compositor = -1
|
||||||
@@ -36,8 +35,14 @@ func DetectCompositor() Compositor {
|
|||||||
swaySocket := os.Getenv("SWAYSOCK")
|
swaySocket := os.Getenv("SWAYSOCK")
|
||||||
scrollSocket := os.Getenv("SCROLLSOCK")
|
scrollSocket := os.Getenv("SCROLLSOCK")
|
||||||
miracleSocket := os.Getenv("MIRACLESOCK")
|
miracleSocket := os.Getenv("MIRACLESOCK")
|
||||||
|
mangoSocket := os.Getenv("MANGO_INSTANCE_SIGNATURE")
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
|
case mangoSocket != "":
|
||||||
|
if _, err := os.Stat(mangoSocket); err == nil {
|
||||||
|
detectedCompositor = CompositorMango
|
||||||
|
return detectedCompositor
|
||||||
|
}
|
||||||
case niriSocket != "":
|
case niriSocket != "":
|
||||||
if _, err := os.Stat(niriSocket); err == nil {
|
if _, err := os.Stat(niriSocket); err == nil {
|
||||||
detectedCompositor = CompositorNiri
|
detectedCompositor = CompositorNiri
|
||||||
@@ -63,66 +68,29 @@ func DetectCompositor() Compositor {
|
|||||||
return detectedCompositor
|
return detectedCompositor
|
||||||
}
|
}
|
||||||
|
|
||||||
if detectDWLProtocol() {
|
|
||||||
detectedCompositor = CompositorDWL
|
|
||||||
return detectedCompositor
|
|
||||||
}
|
|
||||||
|
|
||||||
detectedCompositor = CompositorUnknown
|
detectedCompositor = CompositorUnknown
|
||||||
return detectedCompositor
|
return detectedCompositor
|
||||||
}
|
}
|
||||||
|
|
||||||
func detectDWLProtocol() bool {
|
|
||||||
display, err := client.Connect("")
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
ctx := display.Context()
|
|
||||||
defer ctx.Close()
|
|
||||||
|
|
||||||
registry, err := display.GetRegistry()
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
found := false
|
|
||||||
registry.SetGlobalHandler(func(e client.RegistryGlobalEvent) {
|
|
||||||
if e.Interface == dwl_ipc.ZdwlIpcManagerV2InterfaceName {
|
|
||||||
found = true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if err := wlhelpers.Roundtrip(display, ctx); err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return found
|
|
||||||
}
|
|
||||||
|
|
||||||
func SetCompositorDWL() {
|
|
||||||
detectedCompositor = CompositorDWL
|
|
||||||
}
|
|
||||||
|
|
||||||
type WindowGeometry struct {
|
type WindowGeometry struct {
|
||||||
X int32
|
X int32
|
||||||
Y int32
|
Y int32
|
||||||
Width int32
|
Width int32
|
||||||
Height int32
|
Height int32
|
||||||
Output string
|
Output string
|
||||||
Scale float64
|
Scale float64
|
||||||
OutputX int32
|
OutputX int32
|
||||||
OutputY int32
|
OutputY int32
|
||||||
OutputTransform int32
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetActiveWindow() (*WindowGeometry, error) {
|
func GetActiveWindow() (*WindowGeometry, error) {
|
||||||
switch DetectCompositor() {
|
switch DetectCompositor() {
|
||||||
case CompositorHyprland:
|
case CompositorHyprland:
|
||||||
return getHyprlandActiveWindow()
|
return getHyprlandActiveWindow()
|
||||||
case CompositorDWL:
|
case CompositorMango:
|
||||||
return getDWLActiveWindow()
|
return getMangoActiveWindow()
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("window capture requires Hyprland or DWL")
|
return nil, fmt.Errorf("window capture requires Hyprland or Mango")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,6 +253,93 @@ func getMiracleFocusedMonitor() string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type mangoMonitor struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Active bool `json:"active"`
|
||||||
|
X int32 `json:"x"`
|
||||||
|
Y int32 `json:"y"`
|
||||||
|
Scale float64 `json:"scale"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func getMangoMonitors() []mangoMonitor {
|
||||||
|
output, err := exec.Command("mmsg", "get", "all-monitors").Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var data struct {
|
||||||
|
Monitors []mangoMonitor `json:"monitors"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(output, &data); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return data.Monitors
|
||||||
|
}
|
||||||
|
|
||||||
|
func getMangoFocusedMonitor() string {
|
||||||
|
for _, m := range getMangoMonitors() {
|
||||||
|
if m.Active {
|
||||||
|
return m.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
type mangoClient struct {
|
||||||
|
Monitor string `json:"monitor"`
|
||||||
|
IsFocused bool `json:"is_focused"`
|
||||||
|
X int32 `json:"x"`
|
||||||
|
Y int32 `json:"y"`
|
||||||
|
Width int32 `json:"width"`
|
||||||
|
Height int32 `json:"height"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func getMangoActiveWindow() (*WindowGeometry, error) {
|
||||||
|
output, err := exec.Command("mmsg", "get", "all-clients").Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("mmsg get all-clients: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var data struct {
|
||||||
|
Clients []mangoClient `json:"clients"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(output, &data); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse all-clients: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range data.Clients {
|
||||||
|
if !c.IsFocused {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c.Width <= 0 || c.Height <= 0 {
|
||||||
|
return nil, fmt.Errorf("no active window")
|
||||||
|
}
|
||||||
|
|
||||||
|
geom := &WindowGeometry{
|
||||||
|
X: c.X,
|
||||||
|
Y: c.Y,
|
||||||
|
Width: c.Width,
|
||||||
|
Height: c.Height,
|
||||||
|
Output: c.Monitor,
|
||||||
|
Scale: 1.0,
|
||||||
|
}
|
||||||
|
for _, m := range getMangoMonitors() {
|
||||||
|
if m.Name != c.Monitor {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
geom.OutputX = m.X
|
||||||
|
geom.OutputY = m.Y
|
||||||
|
if m.Scale > 0 {
|
||||||
|
geom.Scale = m.Scale
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return geom, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("no focused window")
|
||||||
|
}
|
||||||
|
|
||||||
type niriWorkspace struct {
|
type niriWorkspace struct {
|
||||||
Output string `json:"output"`
|
Output string `json:"output"`
|
||||||
IsFocused bool `json:"is_focused"`
|
IsFocused bool `json:"is_focused"`
|
||||||
@@ -309,121 +364,6 @@ func getNiriFocusedMonitor() string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
var dwlActiveOutput string
|
|
||||||
|
|
||||||
func SetDWLActiveOutput(name string) {
|
|
||||||
dwlActiveOutput = name
|
|
||||||
}
|
|
||||||
|
|
||||||
func getDWLFocusedMonitor() string {
|
|
||||||
if dwlActiveOutput != "" {
|
|
||||||
return dwlActiveOutput
|
|
||||||
}
|
|
||||||
return queryDWLActiveOutput()
|
|
||||||
}
|
|
||||||
|
|
||||||
func queryDWLActiveOutput() string {
|
|
||||||
display, err := client.Connect("")
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
ctx := display.Context()
|
|
||||||
defer ctx.Close()
|
|
||||||
|
|
||||||
registry, err := display.GetRegistry()
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
var dwlManager *dwl_ipc.ZdwlIpcManagerV2
|
|
||||||
outputs := make(map[uint32]*client.Output)
|
|
||||||
|
|
||||||
registry.SetGlobalHandler(func(e client.RegistryGlobalEvent) {
|
|
||||||
switch e.Interface {
|
|
||||||
case dwl_ipc.ZdwlIpcManagerV2InterfaceName:
|
|
||||||
mgr := dwl_ipc.NewZdwlIpcManagerV2(ctx)
|
|
||||||
if err := registry.Bind(e.Name, e.Interface, e.Version, mgr); err == nil {
|
|
||||||
dwlManager = mgr
|
|
||||||
}
|
|
||||||
case client.OutputInterfaceName:
|
|
||||||
out := client.NewOutput(ctx)
|
|
||||||
version := e.Version
|
|
||||||
if version > 4 {
|
|
||||||
version = 4
|
|
||||||
}
|
|
||||||
if err := registry.Bind(e.Name, e.Interface, version, out); err == nil {
|
|
||||||
outputs[e.Name] = out
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if err := wlhelpers.Roundtrip(display, ctx); err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
if dwlManager == nil || len(outputs) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
outputNames := make(map[uint32]string)
|
|
||||||
for name, out := range outputs {
|
|
||||||
n := name
|
|
||||||
out.SetNameHandler(func(e client.OutputNameEvent) {
|
|
||||||
outputNames[n] = e.Name
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := wlhelpers.Roundtrip(display, ctx); err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
type outputState struct {
|
|
||||||
name string
|
|
||||||
active bool
|
|
||||||
gotFrame bool
|
|
||||||
}
|
|
||||||
states := make(map[uint32]*outputState)
|
|
||||||
|
|
||||||
for name, out := range outputs {
|
|
||||||
dwlOut, err := dwlManager.GetOutput(out)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
state := &outputState{name: outputNames[name]}
|
|
||||||
states[name] = state
|
|
||||||
|
|
||||||
dwlOut.SetActiveHandler(func(e dwl_ipc.ZdwlIpcOutputV2ActiveEvent) {
|
|
||||||
state.active = e.Active != 0
|
|
||||||
})
|
|
||||||
dwlOut.SetFrameHandler(func(e dwl_ipc.ZdwlIpcOutputV2FrameEvent) {
|
|
||||||
state.gotFrame = true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
allFramesReceived := func() bool {
|
|
||||||
for _, s := range states {
|
|
||||||
if !s.gotFrame {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
for !allFramesReceived() {
|
|
||||||
if err := ctx.Dispatch(); err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, state := range states {
|
|
||||||
if state.active {
|
|
||||||
return state.name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetFocusedMonitor() string {
|
func GetFocusedMonitor() string {
|
||||||
switch DetectCompositor() {
|
switch DetectCompositor() {
|
||||||
case CompositorHyprland:
|
case CompositorHyprland:
|
||||||
@@ -436,8 +376,8 @@ func GetFocusedMonitor() string {
|
|||||||
return getMiracleFocusedMonitor()
|
return getMiracleFocusedMonitor()
|
||||||
case CompositorNiri:
|
case CompositorNiri:
|
||||||
return getNiriFocusedMonitor()
|
return getNiriFocusedMonitor()
|
||||||
case CompositorDWL:
|
case CompositorMango:
|
||||||
return getDWLFocusedMonitor()
|
return getMangoFocusedMonitor()
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -534,161 +474,3 @@ func getAllOutputInfos() map[string]*outputInfo {
|
|||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func getOutputInfo(outputName string) (*outputInfo, bool) {
|
|
||||||
infos := getAllOutputInfos()
|
|
||||||
if infos == nil {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
info, ok := infos[outputName]
|
|
||||||
return info, ok
|
|
||||||
}
|
|
||||||
|
|
||||||
func getDWLActiveWindow() (*WindowGeometry, error) {
|
|
||||||
display, err := client.Connect("")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("connect: %w", err)
|
|
||||||
}
|
|
||||||
ctx := display.Context()
|
|
||||||
defer ctx.Close()
|
|
||||||
|
|
||||||
registry, err := display.GetRegistry()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("get registry: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var dwlManager *dwl_ipc.ZdwlIpcManagerV2
|
|
||||||
outputs := make(map[uint32]*client.Output)
|
|
||||||
|
|
||||||
registry.SetGlobalHandler(func(e client.RegistryGlobalEvent) {
|
|
||||||
switch e.Interface {
|
|
||||||
case dwl_ipc.ZdwlIpcManagerV2InterfaceName:
|
|
||||||
mgr := dwl_ipc.NewZdwlIpcManagerV2(ctx)
|
|
||||||
if err := registry.Bind(e.Name, e.Interface, e.Version, mgr); err == nil {
|
|
||||||
dwlManager = mgr
|
|
||||||
}
|
|
||||||
case client.OutputInterfaceName:
|
|
||||||
out := client.NewOutput(ctx)
|
|
||||||
version := e.Version
|
|
||||||
if version > 4 {
|
|
||||||
version = 4
|
|
||||||
}
|
|
||||||
if err := registry.Bind(e.Name, e.Interface, version, out); err == nil {
|
|
||||||
outputs[e.Name] = out
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if err := wlhelpers.Roundtrip(display, ctx); err != nil {
|
|
||||||
return nil, fmt.Errorf("roundtrip: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if dwlManager == nil {
|
|
||||||
return nil, fmt.Errorf("dwl_ipc_manager not available")
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(outputs) == 0 {
|
|
||||||
return nil, fmt.Errorf("no outputs found")
|
|
||||||
}
|
|
||||||
|
|
||||||
outputNames := make(map[uint32]string)
|
|
||||||
for name, out := range outputs {
|
|
||||||
n := name
|
|
||||||
out.SetNameHandler(func(e client.OutputNameEvent) {
|
|
||||||
outputNames[n] = e.Name
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := wlhelpers.Roundtrip(display, ctx); err != nil {
|
|
||||||
return nil, fmt.Errorf("roundtrip: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
type dwlOutputState struct {
|
|
||||||
output *dwl_ipc.ZdwlIpcOutputV2
|
|
||||||
name string
|
|
||||||
active bool
|
|
||||||
x, y int32
|
|
||||||
w, h int32
|
|
||||||
scalefactor uint32
|
|
||||||
gotFrame bool
|
|
||||||
}
|
|
||||||
|
|
||||||
dwlOutputs := make(map[uint32]*dwlOutputState)
|
|
||||||
for name, out := range outputs {
|
|
||||||
dwlOut, err := dwlManager.GetOutput(out)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
state := &dwlOutputState{output: dwlOut, name: outputNames[name]}
|
|
||||||
dwlOutputs[name] = state
|
|
||||||
|
|
||||||
dwlOut.SetActiveHandler(func(e dwl_ipc.ZdwlIpcOutputV2ActiveEvent) {
|
|
||||||
state.active = e.Active != 0
|
|
||||||
})
|
|
||||||
dwlOut.SetXHandler(func(e dwl_ipc.ZdwlIpcOutputV2XEvent) {
|
|
||||||
state.x = e.X
|
|
||||||
})
|
|
||||||
dwlOut.SetYHandler(func(e dwl_ipc.ZdwlIpcOutputV2YEvent) {
|
|
||||||
state.y = e.Y
|
|
||||||
})
|
|
||||||
dwlOut.SetWidthHandler(func(e dwl_ipc.ZdwlIpcOutputV2WidthEvent) {
|
|
||||||
state.w = e.Width
|
|
||||||
})
|
|
||||||
dwlOut.SetHeightHandler(func(e dwl_ipc.ZdwlIpcOutputV2HeightEvent) {
|
|
||||||
state.h = e.Height
|
|
||||||
})
|
|
||||||
dwlOut.SetScalefactorHandler(func(e dwl_ipc.ZdwlIpcOutputV2ScalefactorEvent) {
|
|
||||||
state.scalefactor = e.Scalefactor
|
|
||||||
})
|
|
||||||
dwlOut.SetFrameHandler(func(e dwl_ipc.ZdwlIpcOutputV2FrameEvent) {
|
|
||||||
state.gotFrame = true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
allFramesReceived := func() bool {
|
|
||||||
for _, s := range dwlOutputs {
|
|
||||||
if !s.gotFrame {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
for !allFramesReceived() {
|
|
||||||
if err := ctx.Dispatch(); err != nil {
|
|
||||||
return nil, fmt.Errorf("dispatch: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, state := range dwlOutputs {
|
|
||||||
if !state.active {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if state.w <= 0 || state.h <= 0 {
|
|
||||||
return nil, fmt.Errorf("no active window")
|
|
||||||
}
|
|
||||||
scale := float64(state.scalefactor) / 100.0
|
|
||||||
if scale <= 0 {
|
|
||||||
scale = 1.0
|
|
||||||
}
|
|
||||||
|
|
||||||
geom := &WindowGeometry{
|
|
||||||
X: state.x,
|
|
||||||
Y: state.y,
|
|
||||||
Width: state.w,
|
|
||||||
Height: state.h,
|
|
||||||
Output: state.name,
|
|
||||||
Scale: scale,
|
|
||||||
}
|
|
||||||
|
|
||||||
if info, ok := getOutputInfo(state.name); ok {
|
|
||||||
geom.OutputX = info.x
|
|
||||||
geom.OutputY = info.y
|
|
||||||
geom.OutputTransform = info.transform
|
|
||||||
}
|
|
||||||
|
|
||||||
return geom, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("no active output found")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -156,14 +156,14 @@ func (s *Screenshoter) captureWindow() (*CaptureResult, error) {
|
|||||||
switch DetectCompositor() {
|
switch DetectCompositor() {
|
||||||
case CompositorHyprland:
|
case CompositorHyprland:
|
||||||
return s.captureAndCrop(output, region)
|
return s.captureAndCrop(output, region)
|
||||||
case CompositorDWL:
|
case CompositorMango:
|
||||||
return s.captureDWLWindow(output, region, geom)
|
return s.captureMangoWindow(output, region, geom)
|
||||||
default:
|
default:
|
||||||
return s.captureRegionOnOutput(output, region)
|
return s.captureRegionOnOutput(output, region)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Screenshoter) captureDWLWindow(output *WaylandOutput, region Region, geom *WindowGeometry) (*CaptureResult, error) {
|
func (s *Screenshoter) captureMangoWindow(output *WaylandOutput, region Region, geom *WindowGeometry) (*CaptureResult, error) {
|
||||||
result, err := s.captureWholeOutput(output)
|
result, err := s.captureWholeOutput(output)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -628,7 +628,7 @@ func (s *Screenshoter) captureRegionOnOutput(output *WaylandOutput, region Regio
|
|||||||
w := int32(float64(region.Width) * scale)
|
w := int32(float64(region.Width) * scale)
|
||||||
h := int32(float64(region.Height) * scale)
|
h := int32(float64(region.Height) * scale)
|
||||||
|
|
||||||
if DetectCompositor() == CompositorDWL {
|
if DetectCompositor() == CompositorMango {
|
||||||
scaledOutW := int32(float64(output.width) * scale)
|
scaledOutW := int32(float64(output.width) * scale)
|
||||||
scaledOutH := int32(float64(output.height) * scale)
|
scaledOutH := int32(float64(output.height) * scale)
|
||||||
if localX >= scaledOutW {
|
if localX >= scaledOutW {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package clipboard
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
|
|
||||||
@@ -73,6 +74,10 @@ func handleGetEntry(conn net.Conn, req models.Request, m *Manager) {
|
|||||||
|
|
||||||
entry, err := m.GetEntry(uint64(id))
|
entry, err := m.GetEntry(uint64(id))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, errEntryNotFound) {
|
||||||
|
models.Respond[any](conn, req.ID, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
models.RespondError(conn, req.ID, err.Error())
|
models.RespondError(conn, req.ID, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package clipboard
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"image"
|
"image"
|
||||||
_ "image/gif"
|
_ "image/gif"
|
||||||
@@ -34,6 +35,8 @@ import (
|
|||||||
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var errEntryNotFound = errors.New("entry not found")
|
||||||
|
|
||||||
// These mime types won't be stored in history
|
// These mime types won't be stored in history
|
||||||
var sensitiveMimeTypes = []string{
|
var sensitiveMimeTypes = []string{
|
||||||
"x-kde-passwordManagerHint",
|
"x-kde-passwordManagerHint",
|
||||||
@@ -572,16 +575,16 @@ func (m *Manager) hasSensitiveMimeType(mimes []string) bool {
|
|||||||
func (m *Manager) selectMimeType(mimes []string) string {
|
func (m *Manager) selectMimeType(mimes []string) string {
|
||||||
preferredTypes := []string{
|
preferredTypes := []string{
|
||||||
"text/uri-list",
|
"text/uri-list",
|
||||||
"text/plain;charset=utf-8",
|
|
||||||
"text/plain",
|
|
||||||
"UTF8_STRING",
|
|
||||||
"STRING",
|
|
||||||
"TEXT",
|
|
||||||
"image/png",
|
"image/png",
|
||||||
"image/jpeg",
|
"image/jpeg",
|
||||||
"image/gif",
|
"image/gif",
|
||||||
"image/bmp",
|
"image/bmp",
|
||||||
"image/tiff",
|
"image/tiff",
|
||||||
|
"text/plain;charset=utf-8",
|
||||||
|
"text/plain",
|
||||||
|
"UTF8_STRING",
|
||||||
|
"STRING",
|
||||||
|
"TEXT",
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, pref := range preferredTypes {
|
for _, pref := range preferredTypes {
|
||||||
@@ -764,9 +767,25 @@ func stateEqual(a, b *State) bool {
|
|||||||
if len(a.History) != len(b.History) {
|
if len(a.History) != len(b.History) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
for i := range a.History {
|
||||||
|
if !entryStateEqual(a.History[i], b.History[i]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func entryStateEqual(a, b Entry) bool {
|
||||||
|
return a.ID == b.ID &&
|
||||||
|
a.Hash == b.Hash &&
|
||||||
|
a.Pinned == b.Pinned &&
|
||||||
|
a.IsImage == b.IsImage &&
|
||||||
|
a.MimeType == b.MimeType &&
|
||||||
|
a.Preview == b.Preview &&
|
||||||
|
a.Size == b.Size &&
|
||||||
|
a.Timestamp.Equal(b.Timestamp)
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) GetHistory() []Entry {
|
func (m *Manager) GetHistory() []Entry {
|
||||||
if m.db == nil {
|
if m.db == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -854,7 +873,7 @@ func (m *Manager) GetEntry(id uint64) (*Entry, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if !found {
|
if !found {
|
||||||
return nil, fmt.Errorf("entry not found")
|
return nil, errEntryNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
return &entry, nil
|
return &entry, nil
|
||||||
@@ -916,7 +935,7 @@ func (m *Manager) CreateHistoryEntryFromPinned(pinnedEntry *Entry) error {
|
|||||||
Pinned: false,
|
Pinned: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := m.storeEntryWithoutDedup(newEntry); err != nil {
|
if err := m.storeEntry(newEntry); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -926,36 +945,6 @@ func (m *Manager) CreateHistoryEntryFromPinned(pinnedEntry *Entry) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) storeEntryWithoutDedup(entry Entry) error {
|
|
||||||
if m.db == nil {
|
|
||||||
return fmt.Errorf("database not available")
|
|
||||||
}
|
|
||||||
|
|
||||||
entry.Hash = computeHash(entry.Data)
|
|
||||||
|
|
||||||
return m.db.Update(func(tx *bolt.Tx) error {
|
|
||||||
b := tx.Bucket([]byte("clipboard"))
|
|
||||||
|
|
||||||
id, err := b.NextSequence()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
entry.ID = id
|
|
||||||
|
|
||||||
encoded, err := encodeEntry(entry)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := b.Put(itob(id), encoded); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return m.trimLengthInTx(b)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) ClearHistory() {
|
func (m *Manager) ClearHistory() {
|
||||||
if m.db == nil {
|
if m.db == nil {
|
||||||
return
|
return
|
||||||
@@ -1634,6 +1623,37 @@ func (m *Manager) UnpinEntry(id uint64) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if entry.Pinned {
|
||||||
|
currentKey := itob(id)
|
||||||
|
var keepKey []byte
|
||||||
|
var deleteKeys [][]byte
|
||||||
|
|
||||||
|
c := b.Cursor()
|
||||||
|
for k, v := c.Last(); k != nil; k, v = c.Prev() {
|
||||||
|
if bytes.Equal(k, currentKey) || extractHash(v) != entry.Hash {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
duplicate, err := decodeEntryMeta(v)
|
||||||
|
if err == nil && !duplicate.Pinned {
|
||||||
|
key := append([]byte(nil), k...)
|
||||||
|
if keepKey == nil {
|
||||||
|
keepKey = key
|
||||||
|
} else {
|
||||||
|
deleteKeys = append(deleteKeys, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if keepKey != nil {
|
||||||
|
for _, key := range deleteKeys {
|
||||||
|
if err := b.Delete(key); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.Delete(currentKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
entry.Pinned = false
|
entry.Pinned = false
|
||||||
encoded, err := encodeEntry(entry)
|
encoded, err := encodeEntry(entry)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,17 +1,53 @@
|
|||||||
package clipboard
|
package clipboard
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net"
|
||||||
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/mock"
|
"github.com/stretchr/testify/mock"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
bolt "go.etcd.io/bbolt"
|
||||||
|
|
||||||
mocks_wlcontext "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/wlcontext"
|
mocks_wlcontext "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/wlcontext"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type clipboardTestConn struct {
|
||||||
|
net.Conn
|
||||||
|
writeBuf *bytes.Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
func newClipboardTestConn() *clipboardTestConn {
|
||||||
|
return &clipboardTestConn{writeBuf: &bytes.Buffer{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *clipboardTestConn) Write(b []byte) (int, error) {
|
||||||
|
return c.writeBuf.Write(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestManagerWithDB(t *testing.T) *Manager {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
db, err := openDB(filepath.Join(t.TempDir(), "clipboard.db"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
db.Close()
|
||||||
|
})
|
||||||
|
|
||||||
|
return &Manager{
|
||||||
|
config: DefaultConfig(),
|
||||||
|
db: db,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestEncodeDecodeEntry_Roundtrip(t *testing.T) {
|
func TestEncodeDecodeEntry_Roundtrip(t *testing.T) {
|
||||||
original := Entry{
|
original := Entry{
|
||||||
ID: 12345,
|
ID: 12345,
|
||||||
@@ -131,11 +167,217 @@ func TestStateEqual_HistoryLengthDiffers(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestStateEqual_BothEqual(t *testing.T) {
|
func TestStateEqual_BothEqual(t *testing.T) {
|
||||||
a := &State{Enabled: true, History: []Entry{{ID: 1}, {ID: 2}}}
|
ts := time.Now().Truncate(time.Second)
|
||||||
b := &State{Enabled: true, History: []Entry{{ID: 3}, {ID: 4}}}
|
entry := Entry{
|
||||||
|
ID: 1,
|
||||||
|
Hash: 100,
|
||||||
|
MimeType: "image/png",
|
||||||
|
Preview: "[[ image 1 KiB png 32x32 ]]",
|
||||||
|
Size: 1024,
|
||||||
|
Timestamp: ts,
|
||||||
|
IsImage: true,
|
||||||
|
Pinned: true,
|
||||||
|
}
|
||||||
|
a := &State{Enabled: true, History: []Entry{entry}}
|
||||||
|
b := &State{Enabled: true, History: []Entry{entry}}
|
||||||
assert.True(t, stateEqual(a, b))
|
assert.True(t, stateEqual(a, b))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStateEqual_SameLengthDifferentIDs(t *testing.T) {
|
||||||
|
ts := time.Now().Truncate(time.Second)
|
||||||
|
a := &State{Enabled: true, History: []Entry{{ID: 1, Hash: 100, Timestamp: ts}}}
|
||||||
|
b := &State{Enabled: true, History: []Entry{{ID: 2, Hash: 100, Timestamp: ts}}}
|
||||||
|
|
||||||
|
assert.False(t, stateEqual(a, b))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStateEqual_MetadataDiffers(t *testing.T) {
|
||||||
|
ts := time.Now().Truncate(time.Second)
|
||||||
|
base := Entry{
|
||||||
|
ID: 1,
|
||||||
|
Hash: 100,
|
||||||
|
MimeType: "image/png",
|
||||||
|
Preview: "[[ image 1 KiB png 32x32 ]]",
|
||||||
|
Size: 1024,
|
||||||
|
Timestamp: ts,
|
||||||
|
IsImage: true,
|
||||||
|
Pinned: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
mutate func(*Entry)
|
||||||
|
}{
|
||||||
|
{name: "hash", mutate: func(e *Entry) { e.Hash = 101 }},
|
||||||
|
{name: "pinned", mutate: func(e *Entry) { e.Pinned = true }},
|
||||||
|
{name: "is image", mutate: func(e *Entry) { e.IsImage = false }},
|
||||||
|
{name: "mime type", mutate: func(e *Entry) { e.MimeType = "image/jpeg" }},
|
||||||
|
{name: "preview", mutate: func(e *Entry) { e.Preview = "[[ image 2 KiB jpeg 64x64 ]]" }},
|
||||||
|
{name: "size", mutate: func(e *Entry) { e.Size = 2048 }},
|
||||||
|
{name: "timestamp", mutate: func(e *Entry) { e.Timestamp = ts.Add(time.Second) }},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
changed := base
|
||||||
|
tt.mutate(&changed)
|
||||||
|
|
||||||
|
a := &State{Enabled: true, History: []Entry{base}}
|
||||||
|
b := &State{Enabled: true, History: []Entry{changed}}
|
||||||
|
|
||||||
|
assert.False(t, stateEqual(a, b))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleGetEntry_ReturnsExistingEntry(t *testing.T) {
|
||||||
|
m := newTestManagerWithDB(t)
|
||||||
|
err := m.storeEntry(Entry{
|
||||||
|
Data: []byte("hello world"),
|
||||||
|
MimeType: "text/plain;charset=utf-8",
|
||||||
|
Preview: "hello world",
|
||||||
|
Size: len("hello world"),
|
||||||
|
Timestamp: time.Now().Truncate(time.Second),
|
||||||
|
IsImage: false,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
history := m.GetHistory()
|
||||||
|
require.Len(t, history, 1)
|
||||||
|
|
||||||
|
conn := newClipboardTestConn()
|
||||||
|
handleGetEntry(conn, models.Request{
|
||||||
|
ID: 1,
|
||||||
|
Params: map[string]any{"id": float64(history[0].ID)},
|
||||||
|
}, m)
|
||||||
|
|
||||||
|
var resp models.Response[Entry]
|
||||||
|
require.NoError(t, json.NewDecoder(conn.writeBuf).Decode(&resp))
|
||||||
|
assert.Empty(t, resp.Error)
|
||||||
|
require.NotNil(t, resp.Result)
|
||||||
|
assert.Equal(t, history[0].ID, resp.Result.ID)
|
||||||
|
assert.Equal(t, []byte("hello world"), resp.Result.Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleGetEntry_MissingIDReturnsNullResult(t *testing.T) {
|
||||||
|
m := newTestManagerWithDB(t)
|
||||||
|
conn := newClipboardTestConn()
|
||||||
|
|
||||||
|
handleGetEntry(conn, models.Request{
|
||||||
|
ID: 1,
|
||||||
|
Params: map[string]any{"id": float64(999)},
|
||||||
|
}, m)
|
||||||
|
|
||||||
|
var resp models.Response[any]
|
||||||
|
require.NoError(t, json.NewDecoder(conn.writeBuf).Decode(&resp))
|
||||||
|
assert.Empty(t, resp.Error)
|
||||||
|
assert.Nil(t, resp.Result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnpinEntry_KeepsTopUnpinnedDuplicate(t *testing.T) {
|
||||||
|
m := newTestManagerWithDB(t)
|
||||||
|
|
||||||
|
require.NoError(t, m.storeEntry(Entry{
|
||||||
|
Data: []byte("saved content"),
|
||||||
|
MimeType: "text/plain;charset=utf-8",
|
||||||
|
Preview: "saved content",
|
||||||
|
Size: len("saved content"),
|
||||||
|
Timestamp: time.Now().Add(-time.Minute).Truncate(time.Second),
|
||||||
|
IsImage: false,
|
||||||
|
}))
|
||||||
|
|
||||||
|
history := m.GetHistory()
|
||||||
|
require.Len(t, history, 1)
|
||||||
|
pinnedID := history[0].ID
|
||||||
|
require.NoError(t, m.PinEntry(pinnedID))
|
||||||
|
|
||||||
|
pinnedEntry, err := m.GetEntry(pinnedID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, pinnedEntry.Pinned)
|
||||||
|
|
||||||
|
// Bypass storeEntry to simulate legacy duplicate ordinary history entries.
|
||||||
|
insertLegacyUnpinnedDuplicate := func(timestamp time.Time) Entry {
|
||||||
|
duplicate := Entry{
|
||||||
|
Data: pinnedEntry.Data,
|
||||||
|
MimeType: pinnedEntry.MimeType,
|
||||||
|
Preview: pinnedEntry.Preview,
|
||||||
|
Size: pinnedEntry.Size,
|
||||||
|
Timestamp: timestamp,
|
||||||
|
IsImage: pinnedEntry.IsImage,
|
||||||
|
Pinned: false,
|
||||||
|
}
|
||||||
|
duplicate.Hash = computeHash(duplicate.Data)
|
||||||
|
|
||||||
|
require.NoError(t, m.db.Update(func(tx *bolt.Tx) error {
|
||||||
|
b := tx.Bucket([]byte("clipboard"))
|
||||||
|
id, err := b.NextSequence()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
duplicate.ID = id
|
||||||
|
|
||||||
|
encoded, err := encodeEntry(duplicate)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return b.Put(itob(id), encoded)
|
||||||
|
}))
|
||||||
|
|
||||||
|
return duplicate
|
||||||
|
}
|
||||||
|
|
||||||
|
olderHistoryDuplicate := insertLegacyUnpinnedDuplicate(time.Now().Add(time.Hour))
|
||||||
|
topHistoryDuplicate := insertLegacyUnpinnedDuplicate(time.Now().Add(-time.Hour))
|
||||||
|
require.Greater(t, topHistoryDuplicate.ID, olderHistoryDuplicate.ID)
|
||||||
|
require.True(t, olderHistoryDuplicate.Timestamp.After(topHistoryDuplicate.Timestamp))
|
||||||
|
|
||||||
|
history = m.GetHistory()
|
||||||
|
require.Len(t, history, 3)
|
||||||
|
require.Equal(t, topHistoryDuplicate.ID, history[0].ID)
|
||||||
|
require.NoError(t, m.UnpinEntry(pinnedID))
|
||||||
|
|
||||||
|
history = m.GetHistory()
|
||||||
|
require.Len(t, history, 1)
|
||||||
|
assert.False(t, history[0].Pinned)
|
||||||
|
assert.Equal(t, pinnedEntry.Hash, history[0].Hash)
|
||||||
|
assert.Equal(t, topHistoryDuplicate.ID, history[0].ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateHistoryEntryFromPinned_KeepsLatestUnpinnedDuplicate(t *testing.T) {
|
||||||
|
m := newTestManagerWithDB(t)
|
||||||
|
|
||||||
|
require.NoError(t, m.storeEntry(Entry{
|
||||||
|
Data: []byte("saved content"),
|
||||||
|
MimeType: "text/plain;charset=utf-8",
|
||||||
|
Preview: "saved content",
|
||||||
|
Size: len("saved content"),
|
||||||
|
Timestamp: time.Now().Add(-time.Minute).Truncate(time.Second),
|
||||||
|
IsImage: false,
|
||||||
|
}))
|
||||||
|
|
||||||
|
history := m.GetHistory()
|
||||||
|
require.Len(t, history, 1)
|
||||||
|
pinnedID := history[0].ID
|
||||||
|
require.NoError(t, m.PinEntry(pinnedID))
|
||||||
|
|
||||||
|
pinnedEntry, err := m.GetEntry(pinnedID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, pinnedEntry.Pinned)
|
||||||
|
require.NoError(t, m.CreateHistoryEntryFromPinned(pinnedEntry))
|
||||||
|
firstDuplicate := m.GetHistory()[0]
|
||||||
|
require.NotEqual(t, pinnedID, firstDuplicate.ID)
|
||||||
|
require.NoError(t, m.CreateHistoryEntryFromPinned(pinnedEntry))
|
||||||
|
latestDuplicate := m.GetHistory()[0]
|
||||||
|
|
||||||
|
history = m.GetHistory()
|
||||||
|
require.Len(t, history, 2)
|
||||||
|
assert.Equal(t, latestDuplicate.ID, history[0].ID)
|
||||||
|
assert.False(t, history[0].Pinned)
|
||||||
|
assert.Equal(t, pinnedID, history[1].ID)
|
||||||
|
assert.True(t, history[1].Pinned)
|
||||||
|
assert.NotEqual(t, firstDuplicate.ID, latestDuplicate.ID)
|
||||||
|
}
|
||||||
|
|
||||||
func TestManager_ConcurrentSubscriberAccess(t *testing.T) {
|
func TestManager_ConcurrentSubscriberAccess(t *testing.T) {
|
||||||
m := &Manager{
|
m := &Manager{
|
||||||
subscribers: make(map[string]chan State),
|
subscribers: make(map[string]chan State),
|
||||||
@@ -410,6 +652,8 @@ func TestSelectMimeType(t *testing.T) {
|
|||||||
{[]string{"text/plain;charset=utf-8", "text/html"}, "text/plain;charset=utf-8"},
|
{[]string{"text/plain;charset=utf-8", "text/html"}, "text/plain;charset=utf-8"},
|
||||||
{[]string{"text/html", "text/plain"}, "text/plain"},
|
{[]string{"text/html", "text/plain"}, "text/plain"},
|
||||||
{[]string{"text/html", "image/png"}, "image/png"},
|
{[]string{"text/html", "image/png"}, "image/png"},
|
||||||
|
{[]string{"image/png", "text/plain"}, "image/png"},
|
||||||
|
{[]string{"text/plain", "image/png"}, "image/png"},
|
||||||
{[]string{"image/png", "image/jpeg"}, "image/png"},
|
{[]string{"image/png", "image/jpeg"}, "image/png"},
|
||||||
{[]string{"image/png"}, "image/png"},
|
{[]string{"image/png"}, "image/png"},
|
||||||
{[]string{"application/octet-stream"}, "application/octet-stream"},
|
{[]string{"application/octet-stream"}, "application/octet-stream"},
|
||||||
|
|||||||
@@ -1,138 +0,0 @@
|
|||||||
package dwl
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
type SuccessResult struct {
|
|
||||||
Success bool `json:"success"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func HandleRequest(conn net.Conn, req models.Request, manager *Manager) {
|
|
||||||
if manager == nil {
|
|
||||||
models.RespondError(conn, req.ID, "dwl manager not initialized")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
switch req.Method {
|
|
||||||
case "dwl.getState":
|
|
||||||
handleGetState(conn, req, manager)
|
|
||||||
case "dwl.setTags":
|
|
||||||
handleSetTags(conn, req, manager)
|
|
||||||
case "dwl.setClientTags":
|
|
||||||
handleSetClientTags(conn, req, manager)
|
|
||||||
case "dwl.setLayout":
|
|
||||||
handleSetLayout(conn, req, manager)
|
|
||||||
case "dwl.subscribe":
|
|
||||||
handleSubscribe(conn, req, manager)
|
|
||||||
default:
|
|
||||||
models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleGetState(conn net.Conn, req models.Request, manager *Manager) {
|
|
||||||
state := manager.GetState()
|
|
||||||
models.Respond(conn, req.ID, state)
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleSetTags(conn net.Conn, req models.Request, manager *Manager) {
|
|
||||||
output, ok := models.Get[string](req, "output")
|
|
||||||
if !ok {
|
|
||||||
models.RespondError(conn, req.ID, "missing or invalid 'output' parameter")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
tagmask, ok := models.Get[float64](req, "tagmask")
|
|
||||||
if !ok {
|
|
||||||
models.RespondError(conn, req.ID, "missing or invalid 'tagmask' parameter")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleTagset, ok := models.Get[float64](req, "toggleTagset")
|
|
||||||
if !ok {
|
|
||||||
models.RespondError(conn, req.ID, "missing or invalid 'toggleTagset' parameter")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := manager.SetTags(output, uint32(tagmask), uint32(toggleTagset)); err != nil {
|
|
||||||
models.RespondError(conn, req.ID, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "tags set"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleSetClientTags(conn net.Conn, req models.Request, manager *Manager) {
|
|
||||||
output, ok := models.Get[string](req, "output")
|
|
||||||
if !ok {
|
|
||||||
models.RespondError(conn, req.ID, "missing or invalid 'output' parameter")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
andTags, ok := models.Get[float64](req, "andTags")
|
|
||||||
if !ok {
|
|
||||||
models.RespondError(conn, req.ID, "missing or invalid 'andTags' parameter")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
xorTags, ok := models.Get[float64](req, "xorTags")
|
|
||||||
if !ok {
|
|
||||||
models.RespondError(conn, req.ID, "missing or invalid 'xorTags' parameter")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := manager.SetClientTags(output, uint32(andTags), uint32(xorTags)); err != nil {
|
|
||||||
models.RespondError(conn, req.ID, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "client tags set"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleSetLayout(conn net.Conn, req models.Request, manager *Manager) {
|
|
||||||
output, ok := models.Get[string](req, "output")
|
|
||||||
if !ok {
|
|
||||||
models.RespondError(conn, req.ID, "missing or invalid 'output' parameter")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
index, ok := models.Get[float64](req, "index")
|
|
||||||
if !ok {
|
|
||||||
models.RespondError(conn, req.ID, "missing or invalid 'index' parameter")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := manager.SetLayout(output, uint32(index)); err != nil {
|
|
||||||
models.RespondError(conn, req.ID, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "layout set"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleSubscribe(conn net.Conn, req models.Request, manager *Manager) {
|
|
||||||
clientID := fmt.Sprintf("client-%p", conn)
|
|
||||||
stateChan := manager.Subscribe(clientID)
|
|
||||||
defer manager.Unsubscribe(clientID)
|
|
||||||
|
|
||||||
initialState := manager.GetState()
|
|
||||||
if err := json.NewEncoder(conn).Encode(models.Response[State]{
|
|
||||||
ID: req.ID,
|
|
||||||
Result: &initialState,
|
|
||||||
}); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for state := range stateChan {
|
|
||||||
if err := json.NewEncoder(conn).Encode(models.Response[State]{
|
|
||||||
Result: &state,
|
|
||||||
}); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,522 +0,0 @@
|
|||||||
package dwl
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
|
||||||
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/dwl_ipc"
|
|
||||||
)
|
|
||||||
|
|
||||||
func NewManager(display wlclient.WaylandDisplay) (*Manager, error) {
|
|
||||||
m := &Manager{
|
|
||||||
display: display,
|
|
||||||
ctx: display.Context(),
|
|
||||||
cmdq: make(chan cmd, 128),
|
|
||||||
outputSetupReq: make(chan uint32, 16),
|
|
||||||
stopChan: make(chan struct{}),
|
|
||||||
|
|
||||||
dirty: make(chan struct{}, 1),
|
|
||||||
layouts: make([]string, 0),
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := m.setupRegistry(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
m.updateState()
|
|
||||||
|
|
||||||
m.notifierWg.Add(1)
|
|
||||||
go m.notifier()
|
|
||||||
|
|
||||||
m.wg.Add(1)
|
|
||||||
go m.waylandActor()
|
|
||||||
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) post(fn func()) {
|
|
||||||
select {
|
|
||||||
case m.cmdq <- cmd{fn: fn}:
|
|
||||||
default:
|
|
||||||
log.Warn("DWL actor command queue full, dropping command")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) waylandActor() {
|
|
||||||
defer m.wg.Done()
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-m.stopChan:
|
|
||||||
return
|
|
||||||
case c := <-m.cmdq:
|
|
||||||
c.fn()
|
|
||||||
case outputID := <-m.outputSetupReq:
|
|
||||||
out, exists := m.outputs.Load(outputID)
|
|
||||||
if !exists {
|
|
||||||
log.Warnf("DWL: Output %d no longer exists, skipping setup", outputID)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if out.ipcOutput != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
mgr, ok := m.manager.(*dwl_ipc.ZdwlIpcManagerV2)
|
|
||||||
if !ok || mgr == nil {
|
|
||||||
log.Errorf("DWL: Manager not available for output %d setup", outputID)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Infof("DWL: Setting up ipcOutput for dynamically added output %d", outputID)
|
|
||||||
if err := m.setupOutput(mgr, out.output); err != nil {
|
|
||||||
log.Errorf("DWL: Failed to setup output %d: %v", outputID, err)
|
|
||||||
} else {
|
|
||||||
m.updateState()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) setupRegistry() error {
|
|
||||||
log.Info("DWL: starting registry setup")
|
|
||||||
|
|
||||||
registry, err := m.display.GetRegistry()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get registry: %w", err)
|
|
||||||
}
|
|
||||||
m.registry = registry
|
|
||||||
|
|
||||||
outputs := make([]*wlclient.Output, 0)
|
|
||||||
outputRegNames := make(map[uint32]uint32)
|
|
||||||
var dwlMgr *dwl_ipc.ZdwlIpcManagerV2
|
|
||||||
|
|
||||||
registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) {
|
|
||||||
switch e.Interface {
|
|
||||||
case dwl_ipc.ZdwlIpcManagerV2InterfaceName:
|
|
||||||
log.Infof("DWL: found %s", dwl_ipc.ZdwlIpcManagerV2InterfaceName)
|
|
||||||
manager := dwl_ipc.NewZdwlIpcManagerV2(m.ctx)
|
|
||||||
version := e.Version
|
|
||||||
if version > 2 {
|
|
||||||
version = 2
|
|
||||||
}
|
|
||||||
if err := registry.Bind(e.Name, e.Interface, version, manager); err == nil {
|
|
||||||
dwlMgr = manager
|
|
||||||
log.Info("DWL: manager bound successfully")
|
|
||||||
|
|
||||||
// Set handlers immediately after binding, before roundtrips
|
|
||||||
manager.SetTagsHandler(func(e dwl_ipc.ZdwlIpcManagerV2TagsEvent) {
|
|
||||||
log.Infof("DWL: Tags count: %d", e.Amount)
|
|
||||||
m.tagCount = e.Amount
|
|
||||||
m.updateState()
|
|
||||||
})
|
|
||||||
|
|
||||||
manager.SetLayoutHandler(func(e dwl_ipc.ZdwlIpcManagerV2LayoutEvent) {
|
|
||||||
log.Infof("DWL: Layout: %s", e.Name)
|
|
||||||
m.layouts = append(m.layouts, e.Name)
|
|
||||||
m.updateState()
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
log.Errorf("DWL: failed to bind manager: %v", err)
|
|
||||||
}
|
|
||||||
case "wl_output":
|
|
||||||
log.Debugf("DWL: found wl_output (name=%d)", e.Name)
|
|
||||||
output := wlclient.NewOutput(m.ctx)
|
|
||||||
|
|
||||||
outState := &outputState{
|
|
||||||
registryName: e.Name,
|
|
||||||
output: output,
|
|
||||||
tags: make([]TagState, 0),
|
|
||||||
}
|
|
||||||
|
|
||||||
output.SetNameHandler(func(ev wlclient.OutputNameEvent) {
|
|
||||||
log.Debugf("DWL: Output name: %s (registry=%d)", ev.Name, e.Name)
|
|
||||||
outState.name = ev.Name
|
|
||||||
})
|
|
||||||
|
|
||||||
output.SetDescriptionHandler(func(ev wlclient.OutputDescriptionEvent) {
|
|
||||||
log.Debugf("DWL: Output description: %s", ev.Description)
|
|
||||||
})
|
|
||||||
|
|
||||||
version := e.Version
|
|
||||||
if version > 4 {
|
|
||||||
version = 4
|
|
||||||
}
|
|
||||||
if err := registry.Bind(e.Name, e.Interface, version, output); err == nil {
|
|
||||||
outputID := output.ID()
|
|
||||||
outState.id = outputID
|
|
||||||
log.Infof("DWL: Bound wl_output id=%d registry_name=%d", outputID, e.Name)
|
|
||||||
outputs = append(outputs, output)
|
|
||||||
outputRegNames[outputID] = e.Name
|
|
||||||
|
|
||||||
m.outputs.Store(outputID, outState)
|
|
||||||
|
|
||||||
if m.manager != nil {
|
|
||||||
select {
|
|
||||||
case m.outputSetupReq <- outputID:
|
|
||||||
log.Debugf("DWL: Queued setup for output %d", outputID)
|
|
||||||
default:
|
|
||||||
log.Warnf("DWL: Setup queue full, output %d will not be initialized", outputID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
log.Errorf("DWL: Failed to bind wl_output: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
registry.SetGlobalRemoveHandler(func(e wlclient.RegistryGlobalRemoveEvent) {
|
|
||||||
m.post(func() {
|
|
||||||
var outToRelease *outputState
|
|
||||||
m.outputs.Range(func(id uint32, out *outputState) bool {
|
|
||||||
if out.registryName == e.Name {
|
|
||||||
log.Infof("DWL: Output %d removed", id)
|
|
||||||
outToRelease = out
|
|
||||||
m.outputs.Delete(id)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
if outToRelease != nil {
|
|
||||||
if ipcOut, ok := outToRelease.ipcOutput.(*dwl_ipc.ZdwlIpcOutputV2); ok && ipcOut != nil {
|
|
||||||
m.wlMutex.Lock()
|
|
||||||
ipcOut.Release()
|
|
||||||
m.wlMutex.Unlock()
|
|
||||||
log.Debugf("DWL: Released ipcOutput for removed output %d", outToRelease.id)
|
|
||||||
}
|
|
||||||
m.updateState()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
if err := m.display.Roundtrip(); err != nil {
|
|
||||||
return fmt.Errorf("first roundtrip failed: %w", err)
|
|
||||||
}
|
|
||||||
if err := m.display.Roundtrip(); err != nil {
|
|
||||||
return fmt.Errorf("second roundtrip failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if dwlMgr == nil {
|
|
||||||
log.Info("DWL: manager not found in registry")
|
|
||||||
return fmt.Errorf("dwl_ipc_manager_v2 not available")
|
|
||||||
}
|
|
||||||
|
|
||||||
m.manager = dwlMgr
|
|
||||||
|
|
||||||
for _, output := range outputs {
|
|
||||||
if err := m.setupOutput(dwlMgr, output); err != nil {
|
|
||||||
log.Warnf("DWL: Failed to setup output %d: %v", output.ID(), err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := m.display.Roundtrip(); err != nil {
|
|
||||||
return fmt.Errorf("final roundtrip failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Info("DWL: registry setup complete")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) setupOutput(manager *dwl_ipc.ZdwlIpcManagerV2, output *wlclient.Output) error {
|
|
||||||
m.wlMutex.Lock()
|
|
||||||
ipcOutput, err := manager.GetOutput(output)
|
|
||||||
m.wlMutex.Unlock()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get dwl output: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
outState, exists := m.outputs.Load(output.ID())
|
|
||||||
if !exists {
|
|
||||||
return fmt.Errorf("output state not found for id %d", output.ID())
|
|
||||||
}
|
|
||||||
outState.ipcOutput = ipcOutput
|
|
||||||
|
|
||||||
ipcOutput.SetActiveHandler(func(e dwl_ipc.ZdwlIpcOutputV2ActiveEvent) {
|
|
||||||
outState.active = e.Active
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcOutput.SetTagHandler(func(e dwl_ipc.ZdwlIpcOutputV2TagEvent) {
|
|
||||||
updated := false
|
|
||||||
for i, tag := range outState.tags {
|
|
||||||
if tag.Tag == e.Tag {
|
|
||||||
outState.tags[i] = TagState{
|
|
||||||
Tag: e.Tag,
|
|
||||||
State: e.State,
|
|
||||||
Clients: e.Clients,
|
|
||||||
Focused: e.Focused,
|
|
||||||
}
|
|
||||||
updated = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !updated {
|
|
||||||
outState.tags = append(outState.tags, TagState{
|
|
||||||
Tag: e.Tag,
|
|
||||||
State: e.State,
|
|
||||||
Clients: e.Clients,
|
|
||||||
Focused: e.Focused,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
m.updateState()
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcOutput.SetLayoutHandler(func(e dwl_ipc.ZdwlIpcOutputV2LayoutEvent) {
|
|
||||||
outState.layout = e.Layout
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcOutput.SetTitleHandler(func(e dwl_ipc.ZdwlIpcOutputV2TitleEvent) {
|
|
||||||
outState.title = e.Title
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcOutput.SetAppidHandler(func(e dwl_ipc.ZdwlIpcOutputV2AppidEvent) {
|
|
||||||
outState.appID = e.Appid
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcOutput.SetLayoutSymbolHandler(func(e dwl_ipc.ZdwlIpcOutputV2LayoutSymbolEvent) {
|
|
||||||
outState.layoutSymbol = e.Layout
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcOutput.SetKbLayoutHandler(func(e dwl_ipc.ZdwlIpcOutputV2KbLayoutEvent) {
|
|
||||||
outState.kbLayout = e.KbLayout
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcOutput.SetKeymodeHandler(func(e dwl_ipc.ZdwlIpcOutputV2KeymodeEvent) {
|
|
||||||
outState.keymode = e.Keymode
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcOutput.SetFrameHandler(func(e dwl_ipc.ZdwlIpcOutputV2FrameEvent) {
|
|
||||||
m.updateState()
|
|
||||||
})
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) updateState() {
|
|
||||||
outputs := make(map[string]*OutputState)
|
|
||||||
activeOutput := ""
|
|
||||||
|
|
||||||
m.outputs.Range(func(key uint32, out *outputState) bool {
|
|
||||||
name := out.name
|
|
||||||
if name == "" {
|
|
||||||
name = fmt.Sprintf("output-%d", out.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
tagsCopy := make([]TagState, len(out.tags))
|
|
||||||
copy(tagsCopy, out.tags)
|
|
||||||
|
|
||||||
outputs[name] = &OutputState{
|
|
||||||
Name: name,
|
|
||||||
Active: out.active,
|
|
||||||
Tags: tagsCopy,
|
|
||||||
Layout: out.layout,
|
|
||||||
LayoutSymbol: out.layoutSymbol,
|
|
||||||
Title: out.title,
|
|
||||||
AppID: out.appID,
|
|
||||||
KbLayout: out.kbLayout,
|
|
||||||
Keymode: out.keymode,
|
|
||||||
}
|
|
||||||
|
|
||||||
if out.active != 0 {
|
|
||||||
activeOutput = name
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
newState := State{
|
|
||||||
Outputs: outputs,
|
|
||||||
TagCount: m.tagCount,
|
|
||||||
Layouts: m.layouts,
|
|
||||||
ActiveOutput: activeOutput,
|
|
||||||
}
|
|
||||||
|
|
||||||
m.stateMutex.Lock()
|
|
||||||
m.state = &newState
|
|
||||||
m.stateMutex.Unlock()
|
|
||||||
|
|
||||||
m.notifySubscribers()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) notifier() {
|
|
||||||
defer m.notifierWg.Done()
|
|
||||||
const minGap = 100 * time.Millisecond
|
|
||||||
timer := time.NewTimer(minGap)
|
|
||||||
timer.Stop()
|
|
||||||
var pending bool
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-m.stopChan:
|
|
||||||
timer.Stop()
|
|
||||||
return
|
|
||||||
case <-m.dirty:
|
|
||||||
if pending {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
pending = true
|
|
||||||
timer.Reset(minGap)
|
|
||||||
case <-timer.C:
|
|
||||||
if !pending {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
currentState := m.GetState()
|
|
||||||
|
|
||||||
if m.lastNotified != nil && !stateChanged(m.lastNotified, ¤tState) {
|
|
||||||
pending = false
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
m.subscribers.Range(func(key string, ch chan State) bool {
|
|
||||||
select {
|
|
||||||
case ch <- currentState:
|
|
||||||
default:
|
|
||||||
log.Warn("DWL: subscriber channel full, dropping update")
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
stateCopy := currentState
|
|
||||||
m.lastNotified = &stateCopy
|
|
||||||
pending = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) ensureOutputSetup(out *outputState) error {
|
|
||||||
if out.ipcOutput != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Errorf("output not yet initialized - setup in progress, retry in a moment")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) SetTags(outputName string, tagmask uint32, toggleTagset uint32) error {
|
|
||||||
availableOutputs := make([]string, 0)
|
|
||||||
var targetOut *outputState
|
|
||||||
m.outputs.Range(func(key uint32, out *outputState) bool {
|
|
||||||
name := out.name
|
|
||||||
if name == "" {
|
|
||||||
name = fmt.Sprintf("output-%d", out.id)
|
|
||||||
}
|
|
||||||
availableOutputs = append(availableOutputs, name)
|
|
||||||
if name == outputName {
|
|
||||||
targetOut = out
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
if targetOut == nil {
|
|
||||||
return fmt.Errorf("output not found: %s (available: %v)", outputName, availableOutputs)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := m.ensureOutputSetup(targetOut); err != nil {
|
|
||||||
return fmt.Errorf("failed to setup output %s: %w", outputName, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ipcOut, ok := targetOut.ipcOutput.(*dwl_ipc.ZdwlIpcOutputV2)
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("output %s has invalid ipcOutput type", outputName)
|
|
||||||
}
|
|
||||||
|
|
||||||
m.wlMutex.Lock()
|
|
||||||
err := ipcOut.SetTags(tagmask, toggleTagset)
|
|
||||||
m.wlMutex.Unlock()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) SetClientTags(outputName string, andTags uint32, xorTags uint32) error {
|
|
||||||
var targetOut *outputState
|
|
||||||
m.outputs.Range(func(key uint32, out *outputState) bool {
|
|
||||||
name := out.name
|
|
||||||
if name == "" {
|
|
||||||
name = fmt.Sprintf("output-%d", out.id)
|
|
||||||
}
|
|
||||||
if name == outputName {
|
|
||||||
targetOut = out
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
if targetOut == nil {
|
|
||||||
return fmt.Errorf("output not found: %s", outputName)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := m.ensureOutputSetup(targetOut); err != nil {
|
|
||||||
return fmt.Errorf("failed to setup output %s: %w", outputName, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ipcOut, ok := targetOut.ipcOutput.(*dwl_ipc.ZdwlIpcOutputV2)
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("output %s has invalid ipcOutput type", outputName)
|
|
||||||
}
|
|
||||||
|
|
||||||
m.wlMutex.Lock()
|
|
||||||
err := ipcOut.SetClientTags(andTags, xorTags)
|
|
||||||
m.wlMutex.Unlock()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) SetLayout(outputName string, index uint32) error {
|
|
||||||
var targetOut *outputState
|
|
||||||
m.outputs.Range(func(key uint32, out *outputState) bool {
|
|
||||||
name := out.name
|
|
||||||
if name == "" {
|
|
||||||
name = fmt.Sprintf("output-%d", out.id)
|
|
||||||
}
|
|
||||||
if name == outputName {
|
|
||||||
targetOut = out
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
if targetOut == nil {
|
|
||||||
return fmt.Errorf("output not found: %s", outputName)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := m.ensureOutputSetup(targetOut); err != nil {
|
|
||||||
return fmt.Errorf("failed to setup output %s: %w", outputName, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ipcOut, ok := targetOut.ipcOutput.(*dwl_ipc.ZdwlIpcOutputV2)
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("output %s has invalid ipcOutput type", outputName)
|
|
||||||
}
|
|
||||||
|
|
||||||
m.wlMutex.Lock()
|
|
||||||
err := ipcOut.SetLayout(index)
|
|
||||||
m.wlMutex.Unlock()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) Close() {
|
|
||||||
close(m.stopChan)
|
|
||||||
m.wg.Wait()
|
|
||||||
m.notifierWg.Wait()
|
|
||||||
|
|
||||||
m.subscribers.Range(func(key string, ch chan State) bool {
|
|
||||||
close(ch)
|
|
||||||
m.subscribers.Delete(key)
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
m.outputs.Range(func(key uint32, out *outputState) bool {
|
|
||||||
if ipcOut, ok := out.ipcOutput.(*dwl_ipc.ZdwlIpcOutputV2); ok {
|
|
||||||
ipcOut.Release()
|
|
||||||
}
|
|
||||||
m.outputs.Delete(key)
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
if mgr, ok := m.manager.(*dwl_ipc.ZdwlIpcManagerV2); ok {
|
|
||||||
mgr.Release()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,366 +0,0 @@
|
|||||||
package dwl
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
|
|
||||||
mocks_wlclient "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/wlclient"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestStateChanged_BothNil(t *testing.T) {
|
|
||||||
assert.True(t, stateChanged(nil, nil))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStateChanged_OneNil(t *testing.T) {
|
|
||||||
s := &State{TagCount: 9}
|
|
||||||
assert.True(t, stateChanged(s, nil))
|
|
||||||
assert.True(t, stateChanged(nil, s))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStateChanged_TagCountDiffers(t *testing.T) {
|
|
||||||
a := &State{TagCount: 9, Outputs: make(map[string]*OutputState), Layouts: []string{}}
|
|
||||||
b := &State{TagCount: 10, Outputs: make(map[string]*OutputState), Layouts: []string{}}
|
|
||||||
assert.True(t, stateChanged(a, b))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStateChanged_LayoutLengthDiffers(t *testing.T) {
|
|
||||||
a := &State{TagCount: 9, Layouts: []string{"tile"}, Outputs: make(map[string]*OutputState)}
|
|
||||||
b := &State{TagCount: 9, Layouts: []string{"tile", "monocle"}, Outputs: make(map[string]*OutputState)}
|
|
||||||
assert.True(t, stateChanged(a, b))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStateChanged_ActiveOutputDiffers(t *testing.T) {
|
|
||||||
a := &State{TagCount: 9, ActiveOutput: "eDP-1", Outputs: make(map[string]*OutputState), Layouts: []string{}}
|
|
||||||
b := &State{TagCount: 9, ActiveOutput: "HDMI-A-1", Outputs: make(map[string]*OutputState), Layouts: []string{}}
|
|
||||||
assert.True(t, stateChanged(a, b))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStateChanged_OutputCountDiffers(t *testing.T) {
|
|
||||||
a := &State{
|
|
||||||
TagCount: 9,
|
|
||||||
Outputs: map[string]*OutputState{"eDP-1": {}},
|
|
||||||
Layouts: []string{},
|
|
||||||
}
|
|
||||||
b := &State{
|
|
||||||
TagCount: 9,
|
|
||||||
Outputs: map[string]*OutputState{},
|
|
||||||
Layouts: []string{},
|
|
||||||
}
|
|
||||||
assert.True(t, stateChanged(a, b))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStateChanged_OutputFieldsDiffer(t *testing.T) {
|
|
||||||
a := &State{
|
|
||||||
TagCount: 9,
|
|
||||||
Layouts: []string{},
|
|
||||||
Outputs: map[string]*OutputState{
|
|
||||||
"eDP-1": {Active: 1, Layout: 0, Title: "Firefox"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
b := &State{
|
|
||||||
TagCount: 9,
|
|
||||||
Layouts: []string{},
|
|
||||||
Outputs: map[string]*OutputState{
|
|
||||||
"eDP-1": {Active: 0, Layout: 0, Title: "Firefox"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
assert.True(t, stateChanged(a, b))
|
|
||||||
|
|
||||||
b.Outputs["eDP-1"].Active = 1
|
|
||||||
b.Outputs["eDP-1"].Layout = 1
|
|
||||||
assert.True(t, stateChanged(a, b))
|
|
||||||
|
|
||||||
b.Outputs["eDP-1"].Layout = 0
|
|
||||||
b.Outputs["eDP-1"].Title = "Code"
|
|
||||||
assert.True(t, stateChanged(a, b))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStateChanged_TagsDiffer(t *testing.T) {
|
|
||||||
a := &State{
|
|
||||||
TagCount: 9,
|
|
||||||
Layouts: []string{},
|
|
||||||
Outputs: map[string]*OutputState{
|
|
||||||
"eDP-1": {Tags: []TagState{{Tag: 1, State: 1, Clients: 2, Focused: 1}}},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
b := &State{
|
|
||||||
TagCount: 9,
|
|
||||||
Layouts: []string{},
|
|
||||||
Outputs: map[string]*OutputState{
|
|
||||||
"eDP-1": {Tags: []TagState{{Tag: 1, State: 2, Clients: 2, Focused: 1}}},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
assert.True(t, stateChanged(a, b))
|
|
||||||
|
|
||||||
b.Outputs["eDP-1"].Tags[0].State = 1
|
|
||||||
b.Outputs["eDP-1"].Tags[0].Clients = 3
|
|
||||||
assert.True(t, stateChanged(a, b))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStateChanged_Equal(t *testing.T) {
|
|
||||||
a := &State{
|
|
||||||
TagCount: 9,
|
|
||||||
ActiveOutput: "eDP-1",
|
|
||||||
Layouts: []string{"tile", "monocle"},
|
|
||||||
Outputs: map[string]*OutputState{
|
|
||||||
"eDP-1": {
|
|
||||||
Name: "eDP-1",
|
|
||||||
Active: 1,
|
|
||||||
Layout: 0,
|
|
||||||
LayoutSymbol: "[]=",
|
|
||||||
Title: "Firefox",
|
|
||||||
AppID: "firefox",
|
|
||||||
KbLayout: "us",
|
|
||||||
Keymode: "",
|
|
||||||
Tags: []TagState{{Tag: 1, State: 1, Clients: 2, Focused: 1}},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
b := &State{
|
|
||||||
TagCount: 9,
|
|
||||||
ActiveOutput: "eDP-1",
|
|
||||||
Layouts: []string{"tile", "monocle"},
|
|
||||||
Outputs: map[string]*OutputState{
|
|
||||||
"eDP-1": {
|
|
||||||
Name: "eDP-1",
|
|
||||||
Active: 1,
|
|
||||||
Layout: 0,
|
|
||||||
LayoutSymbol: "[]=",
|
|
||||||
Title: "Firefox",
|
|
||||||
AppID: "firefox",
|
|
||||||
KbLayout: "us",
|
|
||||||
Keymode: "",
|
|
||||||
Tags: []TagState{{Tag: 1, State: 1, Clients: 2, Focused: 1}},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
assert.False(t, stateChanged(a, b))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestManager_ConcurrentGetState(t *testing.T) {
|
|
||||||
m := &Manager{
|
|
||||||
state: &State{
|
|
||||||
TagCount: 9,
|
|
||||||
Layouts: []string{"tile"},
|
|
||||||
Outputs: map[string]*OutputState{"eDP-1": {Name: "eDP-1"}},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
const goroutines = 50
|
|
||||||
const iterations = 100
|
|
||||||
|
|
||||||
for i := 0; i < goroutines/2; i++ {
|
|
||||||
wg.Add(1)
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
for j := 0; j < iterations; j++ {
|
|
||||||
s := m.GetState()
|
|
||||||
_ = s.TagCount
|
|
||||||
_ = s.Outputs
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := 0; i < goroutines/2; i++ {
|
|
||||||
wg.Add(1)
|
|
||||||
go func(i int) {
|
|
||||||
defer wg.Done()
|
|
||||||
for j := 0; j < iterations; j++ {
|
|
||||||
m.stateMutex.Lock()
|
|
||||||
m.state = &State{
|
|
||||||
TagCount: uint32(j % 10),
|
|
||||||
Layouts: []string{"tile", "monocle"},
|
|
||||||
Outputs: map[string]*OutputState{"eDP-1": {Active: uint32(j % 2)}},
|
|
||||||
}
|
|
||||||
m.stateMutex.Unlock()
|
|
||||||
}
|
|
||||||
}(i)
|
|
||||||
}
|
|
||||||
|
|
||||||
wg.Wait()
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestManager_ConcurrentSubscriberAccess(t *testing.T) {
|
|
||||||
m := &Manager{
|
|
||||||
stopChan: make(chan struct{}),
|
|
||||||
dirty: make(chan struct{}, 1),
|
|
||||||
}
|
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
const goroutines = 20
|
|
||||||
|
|
||||||
for i := 0; i < goroutines; i++ {
|
|
||||||
wg.Add(1)
|
|
||||||
go func(id int) {
|
|
||||||
defer wg.Done()
|
|
||||||
subID := string(rune('a' + id))
|
|
||||||
ch := m.Subscribe(subID)
|
|
||||||
assert.NotNil(t, ch)
|
|
||||||
time.Sleep(time.Millisecond)
|
|
||||||
m.Unsubscribe(subID)
|
|
||||||
}(i)
|
|
||||||
}
|
|
||||||
|
|
||||||
wg.Wait()
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestManager_SyncmapOutputsConcurrentAccess(t *testing.T) {
|
|
||||||
m := &Manager{}
|
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
const goroutines = 30
|
|
||||||
const iterations = 50
|
|
||||||
|
|
||||||
for i := 0; i < goroutines; i++ {
|
|
||||||
wg.Add(1)
|
|
||||||
go func(id int) {
|
|
||||||
defer wg.Done()
|
|
||||||
key := uint32(id)
|
|
||||||
|
|
||||||
for j := 0; j < iterations; j++ {
|
|
||||||
state := &outputState{
|
|
||||||
id: key,
|
|
||||||
name: "test-output",
|
|
||||||
active: uint32(j % 2),
|
|
||||||
tags: []TagState{{Tag: uint32(j), State: 1}},
|
|
||||||
}
|
|
||||||
m.outputs.Store(key, state)
|
|
||||||
|
|
||||||
if loaded, ok := m.outputs.Load(key); ok {
|
|
||||||
assert.Equal(t, key, loaded.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
m.outputs.Range(func(k uint32, v *outputState) bool {
|
|
||||||
_ = v.name
|
|
||||||
_ = v.active
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
m.outputs.Delete(key)
|
|
||||||
}(i)
|
|
||||||
}
|
|
||||||
|
|
||||||
wg.Wait()
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestManager_NotifySubscribersNonBlocking(t *testing.T) {
|
|
||||||
m := &Manager{
|
|
||||||
dirty: make(chan struct{}, 1),
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := 0; i < 10; i++ {
|
|
||||||
m.notifySubscribers()
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Len(t, m.dirty, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestManager_PostQueueFull(t *testing.T) {
|
|
||||||
m := &Manager{
|
|
||||||
cmdq: make(chan cmd, 2),
|
|
||||||
stopChan: make(chan struct{}),
|
|
||||||
}
|
|
||||||
|
|
||||||
m.post(func() {})
|
|
||||||
m.post(func() {})
|
|
||||||
m.post(func() {})
|
|
||||||
m.post(func() {})
|
|
||||||
|
|
||||||
assert.Len(t, m.cmdq, 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestManager_GetStateNilState(t *testing.T) {
|
|
||||||
m := &Manager{}
|
|
||||||
|
|
||||||
s := m.GetState()
|
|
||||||
assert.NotNil(t, s.Outputs)
|
|
||||||
assert.NotNil(t, s.Layouts)
|
|
||||||
assert.Equal(t, uint32(0), s.TagCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTagState_Fields(t *testing.T) {
|
|
||||||
tag := TagState{
|
|
||||||
Tag: 1,
|
|
||||||
State: 2,
|
|
||||||
Clients: 3,
|
|
||||||
Focused: 1,
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t, uint32(1), tag.Tag)
|
|
||||||
assert.Equal(t, uint32(2), tag.State)
|
|
||||||
assert.Equal(t, uint32(3), tag.Clients)
|
|
||||||
assert.Equal(t, uint32(1), tag.Focused)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOutputState_Fields(t *testing.T) {
|
|
||||||
out := OutputState{
|
|
||||||
Name: "eDP-1",
|
|
||||||
Active: 1,
|
|
||||||
Tags: []TagState{{Tag: 1}},
|
|
||||||
Layout: 0,
|
|
||||||
LayoutSymbol: "[]=",
|
|
||||||
Title: "Firefox",
|
|
||||||
AppID: "firefox",
|
|
||||||
KbLayout: "us",
|
|
||||||
Keymode: "",
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t, "eDP-1", out.Name)
|
|
||||||
assert.Equal(t, uint32(1), out.Active)
|
|
||||||
assert.Len(t, out.Tags, 1)
|
|
||||||
assert.Equal(t, "[]=", out.LayoutSymbol)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStateChanged_NewOutputAppears(t *testing.T) {
|
|
||||||
a := &State{
|
|
||||||
TagCount: 9,
|
|
||||||
Layouts: []string{},
|
|
||||||
Outputs: map[string]*OutputState{
|
|
||||||
"eDP-1": {Name: "eDP-1"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
b := &State{
|
|
||||||
TagCount: 9,
|
|
||||||
Layouts: []string{},
|
|
||||||
Outputs: map[string]*OutputState{
|
|
||||||
"eDP-1": {Name: "eDP-1"},
|
|
||||||
"HDMI-A-1": {Name: "HDMI-A-1"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
assert.True(t, stateChanged(a, b))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStateChanged_TagsLengthDiffers(t *testing.T) {
|
|
||||||
a := &State{
|
|
||||||
TagCount: 9,
|
|
||||||
Layouts: []string{},
|
|
||||||
Outputs: map[string]*OutputState{
|
|
||||||
"eDP-1": {Tags: []TagState{{Tag: 1}}},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
b := &State{
|
|
||||||
TagCount: 9,
|
|
||||||
Layouts: []string{},
|
|
||||||
Outputs: map[string]*OutputState{
|
|
||||||
"eDP-1": {Tags: []TagState{{Tag: 1}, {Tag: 2}}},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
assert.True(t, stateChanged(a, b))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewManager_GetRegistryError(t *testing.T) {
|
|
||||||
mockDisplay := mocks_wlclient.NewMockWaylandDisplay(t)
|
|
||||||
|
|
||||||
mockDisplay.EXPECT().Context().Return(nil)
|
|
||||||
mockDisplay.EXPECT().GetRegistry().Return(nil, errors.New("failed to get registry"))
|
|
||||||
|
|
||||||
_, err := NewManager(mockDisplay)
|
|
||||||
assert.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "failed to get registry")
|
|
||||||
}
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
package dwl
|
|
||||||
|
|
||||||
import (
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
|
|
||||||
)
|
|
||||||
|
|
||||||
type TagState struct {
|
|
||||||
Tag uint32 `json:"tag"`
|
|
||||||
State uint32 `json:"state"`
|
|
||||||
Clients uint32 `json:"clients"`
|
|
||||||
Focused uint32 `json:"focused"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type OutputState struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Active uint32 `json:"active"`
|
|
||||||
Tags []TagState `json:"tags"`
|
|
||||||
Layout uint32 `json:"layout"`
|
|
||||||
LayoutSymbol string `json:"layoutSymbol"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
AppID string `json:"appId"`
|
|
||||||
KbLayout string `json:"kbLayout"`
|
|
||||||
Keymode string `json:"keymode"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type State struct {
|
|
||||||
Outputs map[string]*OutputState `json:"outputs"`
|
|
||||||
TagCount uint32 `json:"tagCount"`
|
|
||||||
Layouts []string `json:"layouts"`
|
|
||||||
ActiveOutput string `json:"activeOutput"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type cmd struct {
|
|
||||||
fn func()
|
|
||||||
}
|
|
||||||
|
|
||||||
type Manager struct {
|
|
||||||
display wlclient.WaylandDisplay
|
|
||||||
ctx *wlclient.Context
|
|
||||||
registry *wlclient.Registry
|
|
||||||
manager any
|
|
||||||
|
|
||||||
outputs syncmap.Map[uint32, *outputState]
|
|
||||||
|
|
||||||
tagCount uint32
|
|
||||||
layouts []string
|
|
||||||
|
|
||||||
wlMutex sync.Mutex
|
|
||||||
cmdq chan cmd
|
|
||||||
outputSetupReq chan uint32
|
|
||||||
stopChan chan struct{}
|
|
||||||
wg sync.WaitGroup
|
|
||||||
|
|
||||||
subscribers syncmap.Map[string, chan State]
|
|
||||||
dirty chan struct{}
|
|
||||||
notifierWg sync.WaitGroup
|
|
||||||
lastNotified *State
|
|
||||||
|
|
||||||
stateMutex sync.RWMutex
|
|
||||||
state *State
|
|
||||||
}
|
|
||||||
|
|
||||||
type outputState struct {
|
|
||||||
id uint32
|
|
||||||
registryName uint32
|
|
||||||
output *wlclient.Output
|
|
||||||
ipcOutput any
|
|
||||||
name string
|
|
||||||
active uint32
|
|
||||||
tags []TagState
|
|
||||||
layout uint32
|
|
||||||
layoutSymbol string
|
|
||||||
title string
|
|
||||||
appID string
|
|
||||||
kbLayout string
|
|
||||||
keymode string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) GetState() State {
|
|
||||||
m.stateMutex.RLock()
|
|
||||||
defer m.stateMutex.RUnlock()
|
|
||||||
if m.state == nil {
|
|
||||||
return State{
|
|
||||||
Outputs: make(map[string]*OutputState),
|
|
||||||
Layouts: []string{},
|
|
||||||
TagCount: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
stateCopy := *m.state
|
|
||||||
return stateCopy
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) Subscribe(id string) chan State {
|
|
||||||
ch := make(chan State, 64)
|
|
||||||
|
|
||||||
m.subscribers.Store(id, ch)
|
|
||||||
|
|
||||||
return ch
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) Unsubscribe(id string) {
|
|
||||||
if val, ok := m.subscribers.LoadAndDelete(id); ok {
|
|
||||||
close(val)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) notifySubscribers() {
|
|
||||||
select {
|
|
||||||
case m.dirty <- struct{}{}:
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func stateChanged(old, new *State) bool {
|
|
||||||
if old == nil || new == nil {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if old.TagCount != new.TagCount {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if len(old.Layouts) != len(new.Layouts) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if old.ActiveOutput != new.ActiveOutput {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if len(old.Outputs) != len(new.Outputs) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
for name, newOut := range new.Outputs {
|
|
||||||
oldOut, exists := old.Outputs[name]
|
|
||||||
if !exists {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if oldOut.Active != newOut.Active {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if oldOut.Layout != newOut.Layout {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if oldOut.LayoutSymbol != newOut.LayoutSymbol {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if oldOut.Title != newOut.Title {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if oldOut.AppID != newOut.AppID {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if oldOut.KbLayout != newOut.KbLayout {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if oldOut.Keymode != newOut.Keymode {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if len(oldOut.Tags) != len(newOut.Tags) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
for i, newTag := range newOut.Tags {
|
|
||||||
if i >= len(oldOut.Tags) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
oldTag := oldOut.Tags[i]
|
|
||||||
if oldTag.Tag != newTag.Tag || oldTag.State != newTag.State ||
|
|
||||||
oldTag.Clients != newTag.Clients || oldTag.Focused != newTag.Focused {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package network
|
package network
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -18,10 +19,44 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type linkInfo struct {
|
type linkInfo struct {
|
||||||
ifindex int32
|
ifindex int32
|
||||||
name string
|
name string
|
||||||
path dbus.ObjectPath
|
path dbus.ObjectPath
|
||||||
opState string
|
opState string
|
||||||
|
linkType string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *linkInfo) isWired() bool {
|
||||||
|
if looksVirtual(l.name) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if l.linkType != "" {
|
||||||
|
return l.linkType == "ether"
|
||||||
|
}
|
||||||
|
return !strings.HasPrefix(l.name, "wlan") && !strings.HasPrefix(l.name, "wlp")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *linkInfo) isWireless() bool {
|
||||||
|
if looksVirtual(l.name) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if l.linkType != "" {
|
||||||
|
return l.linkType == "wlan"
|
||||||
|
}
|
||||||
|
return strings.HasPrefix(l.name, "wlan") || strings.HasPrefix(l.name, "wlp")
|
||||||
|
}
|
||||||
|
|
||||||
|
func looksVirtual(name string) bool {
|
||||||
|
virtualPrefixes := []string{
|
||||||
|
"lo", "docker", "podman", "veth", "virbr", "br-", "vnet", "tun", "tap",
|
||||||
|
"vboxnet", "vmnet", "kube", "cni", "flannel", "cali",
|
||||||
|
}
|
||||||
|
for _, prefix := range virtualPrefixes {
|
||||||
|
if strings.HasPrefix(name, prefix) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
type SystemdNetworkdBackend struct {
|
type SystemdNetworkdBackend struct {
|
||||||
@@ -78,6 +113,12 @@ func (b *SystemdNetworkdBackend) Close() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type enumeratedLink struct {
|
||||||
|
ifindex int32
|
||||||
|
name string
|
||||||
|
path dbus.ObjectPath
|
||||||
|
}
|
||||||
|
|
||||||
func (b *SystemdNetworkdBackend) enumerateLinks() error {
|
func (b *SystemdNetworkdBackend) enumerateLinks() error {
|
||||||
obj := b.conn.Object(networkdBusName, b.managerPath)
|
obj := b.conn.Object(networkdBusName, b.managerPath)
|
||||||
|
|
||||||
@@ -91,21 +132,77 @@ func (b *SystemdNetworkdBackend) enumerateLinks() error {
|
|||||||
return fmt.Errorf("ListLinks: %w", err)
|
return fmt.Errorf("ListLinks: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
b.linksMutex.Lock()
|
fresh := make([]enumeratedLink, len(links))
|
||||||
defer b.linksMutex.Unlock()
|
for i, l := range links {
|
||||||
|
fresh[i] = enumeratedLink{ifindex: l.Ifindex, name: l.Name, path: l.Path}
|
||||||
for _, l := range links {
|
|
||||||
b.links[l.Name] = &linkInfo{
|
|
||||||
ifindex: l.Ifindex,
|
|
||||||
name: l.Name,
|
|
||||||
path: l.Path,
|
|
||||||
}
|
|
||||||
log.Debugf("networkd: enumerated link %s (ifindex=%d, path=%s)", l.Name, l.Ifindex, l.Path)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
b.linksMutex.Lock()
|
||||||
|
defer b.linksMutex.Unlock()
|
||||||
|
b.syncLinks(fresh)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// syncLinks reconciles the cached link map against the freshly enumerated set:
|
||||||
|
// it adds links not seen before (querying their Type once), refreshes the
|
||||||
|
// ifindex of survivors, and prunes links that no longer appear. Pruning is what
|
||||||
|
// keeps torn-down container interfaces (podman bridges, veth pairs) from
|
||||||
|
// lingering as routable and being mistaken for the wired uplink.
|
||||||
|
// Callers must hold linksMutex.
|
||||||
|
func (b *SystemdNetworkdBackend) syncLinks(fresh []enumeratedLink) {
|
||||||
|
present := make(map[string]bool, len(fresh))
|
||||||
|
for _, l := range fresh {
|
||||||
|
present[l.name] = true
|
||||||
|
if existing, ok := b.links[l.name]; ok && existing.path == l.path {
|
||||||
|
existing.ifindex = l.ifindex
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
info := &linkInfo{
|
||||||
|
ifindex: l.ifindex,
|
||||||
|
name: l.name,
|
||||||
|
path: l.path,
|
||||||
|
linkType: b.fetchLinkType(l.path),
|
||||||
|
}
|
||||||
|
b.links[l.name] = info
|
||||||
|
log.Debugf("networkd: enumerated link %s (ifindex=%d, path=%s, type=%q)", l.name, l.ifindex, l.path, info.linkType)
|
||||||
|
}
|
||||||
|
|
||||||
|
for name := range b.links {
|
||||||
|
if !present[name] {
|
||||||
|
log.Debugf("networkd: pruned stale link %s", name)
|
||||||
|
delete(b.links, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchLinkType queries networkd's Describe method and extracts the link Type
|
||||||
|
// (e.g. "ether", "wlan", "loopback", "none"). Returns empty on failure; callers
|
||||||
|
// fall back to name-prefix heuristics in that case. The Type is fixed at link
|
||||||
|
// creation by the kernel, so callers cache the result for the lifetime of the
|
||||||
|
// linkInfo and only refetch when a link is re-created at a new D-Bus path.
|
||||||
|
func (b *SystemdNetworkdBackend) fetchLinkType(path dbus.ObjectPath) string {
|
||||||
|
linkObj := b.conn.Object(networkdBusName, path)
|
||||||
|
var describeJSON string
|
||||||
|
if err := linkObj.Call(networkdLinkIface+".Describe", 0).Store(&describeJSON); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return parseDescribeType(describeJSON)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDescribeType extracts the top-level "Type" field from a networkd
|
||||||
|
// Describe payload. Returns empty when the JSON is malformed or the field is
|
||||||
|
// absent, signalling callers to fall back to name-prefix heuristics.
|
||||||
|
func parseDescribeType(describeJSON string) string {
|
||||||
|
var parsed struct {
|
||||||
|
Type string `json:"Type"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(describeJSON), &parsed); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return parsed.Type
|
||||||
|
}
|
||||||
|
|
||||||
func (b *SystemdNetworkdBackend) updateState() error {
|
func (b *SystemdNetworkdBackend) updateState() error {
|
||||||
b.linksMutex.RLock()
|
b.linksMutex.RLock()
|
||||||
defer b.linksMutex.RUnlock()
|
defer b.linksMutex.RUnlock()
|
||||||
@@ -113,8 +210,8 @@ func (b *SystemdNetworkdBackend) updateState() error {
|
|||||||
var wiredIface *linkInfo
|
var wiredIface *linkInfo
|
||||||
var wifiIface *linkInfo
|
var wifiIface *linkInfo
|
||||||
|
|
||||||
for name, link := range b.links {
|
for _, link := range b.links {
|
||||||
if b.isVirtualInterface(name) {
|
if !link.isWired() && !link.isWireless() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,11 +223,11 @@ func (b *SystemdNetworkdBackend) updateState() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.HasPrefix(name, "wlan") || strings.HasPrefix(name, "wlp") {
|
if link.isWireless() {
|
||||||
if wifiIface == nil || link.opState == "routable" || link.opState == "carrier" {
|
if wifiIface == nil || link.opState == "routable" || link.opState == "carrier" {
|
||||||
wifiIface = link
|
wifiIface = link
|
||||||
}
|
}
|
||||||
} else if !b.isVirtualInterface(name) {
|
} else if link.isWired() {
|
||||||
if wiredIface == nil || link.opState == "routable" || link.opState == "carrier" {
|
if wiredIface == nil || link.opState == "routable" || link.opState == "carrier" {
|
||||||
wiredIface = link
|
wiredIface = link
|
||||||
}
|
}
|
||||||
@@ -140,7 +237,7 @@ func (b *SystemdNetworkdBackend) updateState() error {
|
|||||||
var wiredConns []WiredConnection
|
var wiredConns []WiredConnection
|
||||||
var ethernetDevices []EthernetDevice
|
var ethernetDevices []EthernetDevice
|
||||||
for name, link := range b.links {
|
for name, link := range b.links {
|
||||||
if b.isVirtualInterface(name) || strings.HasPrefix(name, "wlan") || strings.HasPrefix(name, "wlp") {
|
if !link.isWired() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,19 +326,6 @@ func (b *SystemdNetworkdBackend) updateState() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *SystemdNetworkdBackend) isVirtualInterface(name string) bool {
|
|
||||||
virtualPrefixes := []string{
|
|
||||||
"lo", "docker", "veth", "virbr", "br-", "vnet", "tun", "tap",
|
|
||||||
"vboxnet", "vmnet", "kube", "cni", "flannel", "cali",
|
|
||||||
}
|
|
||||||
for _, prefix := range virtualPrefixes {
|
|
||||||
if strings.HasPrefix(name, prefix) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *SystemdNetworkdBackend) getAddresses(ifname string) []string {
|
func (b *SystemdNetworkdBackend) getAddresses(ifname string) []string {
|
||||||
iface, err := net.InterfaceByName(ifname)
|
iface, err := net.InterfaceByName(ifname)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ func (b *SystemdNetworkdBackend) GetWiredConnections() ([]WiredConnection, error
|
|||||||
|
|
||||||
var conns []WiredConnection
|
var conns []WiredConnection
|
||||||
for name, link := range b.links {
|
for name, link := range b.links {
|
||||||
if b.isVirtualInterface(name) || strings.HasPrefix(name, "wlan") || strings.HasPrefix(name, "wlp") {
|
if !link.isWired() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,8 +73,8 @@ func (b *SystemdNetworkdBackend) GetWiredNetworkDetails(id string) (*WiredNetwor
|
|||||||
func (b *SystemdNetworkdBackend) ConnectEthernet() error {
|
func (b *SystemdNetworkdBackend) ConnectEthernet() error {
|
||||||
b.linksMutex.RLock()
|
b.linksMutex.RLock()
|
||||||
var primaryWired *linkInfo
|
var primaryWired *linkInfo
|
||||||
for name, l := range b.links {
|
for _, l := range b.links {
|
||||||
if strings.HasPrefix(name, "lo") || strings.HasPrefix(name, "wlan") || strings.HasPrefix(name, "wlp") {
|
if !l.isWired() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
primaryWired = l
|
primaryWired = l
|
||||||
|
|||||||
@@ -145,3 +145,117 @@ func TestSystemdNetworkdBackend_DisconnectEthernetDevice(t *testing.T) {
|
|||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "not supported")
|
assert.Contains(t, err.Error(), "not supported")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLinkInfo_Classify(t *testing.T) {
|
||||||
|
// When networkd reports a Type via Describe, classification is exact.
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
ifname string
|
||||||
|
linkType string
|
||||||
|
wantWired bool
|
||||||
|
wantWifi bool
|
||||||
|
}{
|
||||||
|
{"ether type", "dock", "ether", true, false},
|
||||||
|
{"wlan type", "wifi", "wlan", false, true},
|
||||||
|
{"loopback type", "lo", "loopback", false, false},
|
||||||
|
{"none type (tun overlay)", "nebula.homelab", "none", false, false},
|
||||||
|
{"none type (wireguard)", "wg0", "none", false, false},
|
||||||
|
// Virtual interfaces report Type=ether but must never be mistaken for
|
||||||
|
// the wired uplink — stale podman/veth links would otherwise poison
|
||||||
|
// ethernet detection.
|
||||||
|
{"veth ether excluded", "veth1234", "ether", false, false},
|
||||||
|
{"podman bridge ether excluded", "podman3", "ether", false, false},
|
||||||
|
{"docker bridge ether excluded", "docker0", "ether", false, false},
|
||||||
|
// Fallback path: linkType unavailable, name-prefix heuristic applies.
|
||||||
|
{"fallback enp wired", "enp141s0", "", true, false},
|
||||||
|
{"fallback wlan wireless", "wlan0", "", false, true},
|
||||||
|
{"fallback wlp wireless", "wlp3s0", "", false, true},
|
||||||
|
{"fallback lo skipped", "lo", "", false, false},
|
||||||
|
{"fallback docker skipped", "docker0", "", false, false},
|
||||||
|
{"fallback tun skipped", "tun0", "", false, false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
l := &linkInfo{name: tc.ifname, linkType: tc.linkType}
|
||||||
|
assert.Equal(t, tc.wantWired, l.isWired(), "isWired")
|
||||||
|
assert.Equal(t, tc.wantWifi, l.isWireless(), "isWireless")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseDescribeType(t *testing.T) {
|
||||||
|
// parseDescribeType is the seam between networkd's Describe RPC and the
|
||||||
|
// classifier. On any failure path it must return "" so callers fall back
|
||||||
|
// to name-prefix heuristics rather than misclassifying the link.
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"ether", `{"Type":"ether","Name":"enp141s0"}`, "ether"},
|
||||||
|
{"wlan", `{"Type":"wlan","Name":"wlan0"}`, "wlan"},
|
||||||
|
{"loopback", `{"Type":"loopback","Name":"lo"}`, "loopback"},
|
||||||
|
{"none with kind", `{"Type":"none","Kind":"tun","Name":"nebula.homelab"}`, "none"},
|
||||||
|
{"empty payload", ``, ""},
|
||||||
|
{"empty object", `{}`, ""},
|
||||||
|
{"missing Type field", `{"Name":"wlan0","Kind":""}`, ""},
|
||||||
|
{"explicit empty Type", `{"Type":"","Name":"wlan0"}`, ""},
|
||||||
|
{"malformed json", `{"Type":"ether"`, ""},
|
||||||
|
{"non-string Type", `{"Type":42}`, ""},
|
||||||
|
{"unrelated payload", `"just a string"`, ""},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tc.want, parseDescribeType(tc.in))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSyncLinks_PrunesRemovedLinks(t *testing.T) {
|
||||||
|
// Stale container interfaces (torn-down podman bridges, veth pairs) must
|
||||||
|
// not linger in the link map after they disappear from ListLinks — kept as
|
||||||
|
// routable, they stole the wired-uplink slot from the real ethernet NIC.
|
||||||
|
backend, _ := NewSystemdNetworkdBackend()
|
||||||
|
backend.links = map[string]*linkInfo{
|
||||||
|
"eno1": {ifindex: 2, name: "eno1", path: "/org/freedesktop/network1/link/_32", linkType: "ether", opState: "routable"},
|
||||||
|
"podman3": {ifindex: 9, name: "podman3", path: "/org/freedesktop/network1/link/_39", linkType: "ether", opState: "routable"},
|
||||||
|
"veth0": {ifindex: 10, name: "veth0", path: "/org/freedesktop/network1/link/_310", linkType: "ether", opState: "routable"},
|
||||||
|
}
|
||||||
|
|
||||||
|
backend.syncLinks([]enumeratedLink{
|
||||||
|
{ifindex: 2, name: "eno1", path: "/org/freedesktop/network1/link/_32"},
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Len(t, backend.links, 1)
|
||||||
|
assert.Contains(t, backend.links, "eno1")
|
||||||
|
assert.NotContains(t, backend.links, "podman3")
|
||||||
|
assert.NotContains(t, backend.links, "veth0")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSyncLinks_RefreshesSurvivingLink(t *testing.T) {
|
||||||
|
// A link that survives keeps its cached Type — Describe is only queried for
|
||||||
|
// newly seen links — while picking up a refreshed ifindex.
|
||||||
|
backend, _ := NewSystemdNetworkdBackend()
|
||||||
|
backend.links = map[string]*linkInfo{
|
||||||
|
"eno1": {ifindex: 2, name: "eno1", path: "/org/freedesktop/network1/link/_32", linkType: "ether"},
|
||||||
|
}
|
||||||
|
|
||||||
|
backend.syncLinks([]enumeratedLink{
|
||||||
|
{ifindex: 7, name: "eno1", path: "/org/freedesktop/network1/link/_32"},
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Len(t, backend.links, 1)
|
||||||
|
assert.Equal(t, int32(7), backend.links["eno1"].ifindex)
|
||||||
|
assert.Equal(t, "ether", backend.links["eno1"].linkType)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLooksVirtual(t *testing.T) {
|
||||||
|
virtual := []string{"lo", "docker0", "veth123", "virbr0", "br-abc", "vnet0", "tun0", "tap0", "vboxnet0", "vmnet1", "kube-ipvs0", "cni0", "flannel.1", "cali-abc", "podman0", "podman3"}
|
||||||
|
for _, n := range virtual {
|
||||||
|
assert.True(t, looksVirtual(n), "%s should look virtual", n)
|
||||||
|
}
|
||||||
|
real := []string{"enp141s0", "eno1", "wlan0", "wlp3s0", "wifi", "dock", "nebula.homelab", "wg0"}
|
||||||
|
for _, n := range real {
|
||||||
|
assert.False(t, looksVirtual(n), "%s should not look virtual", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import (
|
|||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/clipboard"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/clipboard"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/cups"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/cups"
|
||||||
serverDbus "github.com/AvengeMedia/DankMaterialShell/core/internal/server/dbus"
|
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/evdev"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/freedesktop"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/freedesktop"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/location"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/location"
|
||||||
@@ -125,15 +124,6 @@ func RouteRequest(conn net.Conn, req models.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.HasPrefix(req.Method, "dwl.") {
|
|
||||||
if dwlManager == nil {
|
|
||||||
models.RespondError(conn, req.ID, "dwl manager not initialized")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
dwl.HandleRequest(conn, req, dwlManager)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.HasPrefix(req.Method, "brightness.") {
|
if strings.HasPrefix(req.Method, "brightness.") {
|
||||||
if brightnessManager == nil {
|
if brightnessManager == nil {
|
||||||
models.RespondError(conn, req.ID, "brightness manager not initialized")
|
models.RespondError(conn, req.ID, "brightness manager not initialized")
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import (
|
|||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/clipboard"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/clipboard"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/cups"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/cups"
|
||||||
serverDbus "github.com/AvengeMedia/DankMaterialShell/core/internal/server/dbus"
|
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/evdev"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/freedesktop"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/freedesktop"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/location"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/location"
|
||||||
@@ -39,7 +38,7 @@ import (
|
|||||||
"github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
|
"github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
|
||||||
)
|
)
|
||||||
|
|
||||||
const APIVersion = 24
|
const APIVersion = 25
|
||||||
|
|
||||||
var CLIVersion = "dev"
|
var CLIVersion = "dev"
|
||||||
|
|
||||||
@@ -66,7 +65,6 @@ var bluezManager *bluez.Manager
|
|||||||
var appPickerManager *apppicker.Manager
|
var appPickerManager *apppicker.Manager
|
||||||
var cupsManager *cups.Manager
|
var cupsManager *cups.Manager
|
||||||
var tailscaleManager *tailscale.Manager
|
var tailscaleManager *tailscale.Manager
|
||||||
var dwlManager *dwl.Manager
|
|
||||||
var brightnessManager *brightness.Manager
|
var brightnessManager *brightness.Manager
|
||||||
var wlrOutputManager *wlroutput.Manager
|
var wlrOutputManager *wlroutput.Manager
|
||||||
var evdevManager *evdev.Manager
|
var evdevManager *evdev.Manager
|
||||||
@@ -252,30 +250,6 @@ func InitializeCupsManager() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func InitializeDwlManager() error {
|
|
||||||
log.Info("Attempting to initialize DWL IPC...")
|
|
||||||
|
|
||||||
if wlContext == nil {
|
|
||||||
ctx, err := wlcontext.New()
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("Failed to create shared Wayland context: %v", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
wlContext = ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
manager, err := dwl.NewManager(wlContext.Display())
|
|
||||||
if err != nil {
|
|
||||||
log.Debug("Failed to initialize dwl manager: %v", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
dwlManager = manager
|
|
||||||
|
|
||||||
log.Info("DWL IPC initialized successfully")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func InitializeBrightnessManager() error {
|
func InitializeBrightnessManager() error {
|
||||||
manager, err := brightness.NewManager()
|
manager, err := brightness.NewManager()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -418,6 +392,7 @@ func handleConnection(conn net.Conn) {
|
|||||||
conn.Write(capsData)
|
conn.Write(capsData)
|
||||||
conn.Write([]byte("\n"))
|
conn.Write([]byte("\n"))
|
||||||
scanner := bufio.NewScanner(conn)
|
scanner := bufio.NewScanner(conn)
|
||||||
|
scanner.Buffer(make([]byte, bufio.MaxScanTokenSize), 64*1024*1024) // grow up to 64 MB for large clipboard payloads
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
line := scanner.Bytes()
|
line := scanner.Bytes()
|
||||||
|
|
||||||
@@ -467,10 +442,6 @@ func getCapabilities() Capabilities {
|
|||||||
caps = append(caps, "tailscale")
|
caps = append(caps, "tailscale")
|
||||||
}
|
}
|
||||||
|
|
||||||
if dwlManager != nil {
|
|
||||||
caps = append(caps, "dwl")
|
|
||||||
}
|
|
||||||
|
|
||||||
if brightnessManager != nil {
|
if brightnessManager != nil {
|
||||||
caps = append(caps, "brightness")
|
caps = append(caps, "brightness")
|
||||||
}
|
}
|
||||||
@@ -537,10 +508,6 @@ func getServerInfo() ServerInfo {
|
|||||||
caps = append(caps, "tailscale")
|
caps = append(caps, "tailscale")
|
||||||
}
|
}
|
||||||
|
|
||||||
if dwlManager != nil {
|
|
||||||
caps = append(caps, "dwl")
|
|
||||||
}
|
|
||||||
|
|
||||||
if brightnessManager != nil {
|
if brightnessManager != nil {
|
||||||
caps = append(caps, "brightness")
|
caps = append(caps, "brightness")
|
||||||
}
|
}
|
||||||
@@ -1045,38 +1012,6 @@ func handleSubscribe(conn net.Conn, req models.Request) {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
if shouldSubscribe("dwl") && dwlManager != nil {
|
|
||||||
wg.Add(1)
|
|
||||||
dwlChan := dwlManager.Subscribe(clientID + "-dwl")
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
defer dwlManager.Unsubscribe(clientID + "-dwl")
|
|
||||||
|
|
||||||
initialState := dwlManager.GetState()
|
|
||||||
select {
|
|
||||||
case eventChan <- ServiceEvent{Service: "dwl", Data: initialState}:
|
|
||||||
case <-stopChan:
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case state, ok := <-dwlChan:
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case eventChan <- ServiceEvent{Service: "dwl", Data: state}:
|
|
||||||
case <-stopChan:
|
|
||||||
return
|
|
||||||
}
|
|
||||||
case <-stopChan:
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
if shouldSubscribe("brightness") && brightnessManager != nil {
|
if shouldSubscribe("brightness") && brightnessManager != nil {
|
||||||
wg.Add(2)
|
wg.Add(2)
|
||||||
brightnessStateChan := brightnessManager.Subscribe(clientID + "-brightness-state")
|
brightnessStateChan := brightnessManager.Subscribe(clientID + "-brightness-state")
|
||||||
@@ -1332,9 +1267,6 @@ func cleanupManagers() {
|
|||||||
if cupsManager != nil {
|
if cupsManager != nil {
|
||||||
cupsManager.Close()
|
cupsManager.Close()
|
||||||
}
|
}
|
||||||
if dwlManager != nil {
|
|
||||||
dwlManager.Close()
|
|
||||||
}
|
|
||||||
if brightnessManager != nil {
|
if brightnessManager != nil {
|
||||||
brightnessManager.Close()
|
brightnessManager.Close()
|
||||||
}
|
}
|
||||||
@@ -1501,19 +1433,6 @@ func Start(printDocs bool) error {
|
|||||||
log.Info(" cups.resumePrinter - Resume printer (params: printerName)")
|
log.Info(" cups.resumePrinter - Resume printer (params: printerName)")
|
||||||
log.Info(" cups.cancelJob - Cancel job (params: printerName, jobID)")
|
log.Info(" cups.cancelJob - Cancel job (params: printerName, jobID)")
|
||||||
log.Info(" cups.purgeJobs - Cancel all jobs (params: printerName)")
|
log.Info(" cups.purgeJobs - Cancel all jobs (params: printerName)")
|
||||||
log.Info("DWL:")
|
|
||||||
log.Info(" dwl.getState - Get current dwl state (tags, windows, layouts, keyboard)")
|
|
||||||
log.Info(" dwl.setTags - Set active tags (params: output, tagmask, toggleTagset)")
|
|
||||||
log.Info(" dwl.setClientTags - Set focused client tags (params: output, andTags, xorTags)")
|
|
||||||
log.Info(" dwl.setLayout - Set layout (params: output, index)")
|
|
||||||
log.Info(" dwl.subscribe - Subscribe to dwl state changes (streaming)")
|
|
||||||
log.Info(" Output state includes:")
|
|
||||||
log.Info(" - tags : Tag states (active, clients, focused)")
|
|
||||||
log.Info(" - layoutSymbol : Current layout name")
|
|
||||||
log.Info(" - title : Focused window title")
|
|
||||||
log.Info(" - appId : Focused window app ID")
|
|
||||||
log.Info(" - kbLayout : Current keyboard layout")
|
|
||||||
log.Info(" - keymode : Current keybind mode")
|
|
||||||
log.Info("Brightness:")
|
log.Info("Brightness:")
|
||||||
log.Info(" brightness.getState - Get current brightness state for all devices")
|
log.Info(" brightness.getState - Get current brightness state for all devices")
|
||||||
log.Info(" brightness.setBrightness - Set device brightness (params: device, percent)")
|
log.Info(" brightness.setBrightness - Set device brightness (params: device, percent)")
|
||||||
@@ -1690,10 +1609,6 @@ func Start(printDocs bool) error {
|
|||||||
log.Debugf("AppPicker manager unavailable: %v", err)
|
log.Debugf("AppPicker manager unavailable: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := InitializeDwlManager(); err != nil {
|
|
||||||
log.Debugf("DWL manager unavailable: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := InitializeWlrOutputManager(); err != nil {
|
if err := InitializeWlrOutputManager(); err != nil {
|
||||||
log.Debugf("WlrOutput manager unavailable: %v", err)
|
log.Debugf("WlrOutput manager unavailable: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,15 +103,7 @@ func (m Model) updateDeployingConfigsState(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
func (m Model) deployConfigurations() tea.Cmd {
|
func (m Model) deployConfigurations() tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
// Determine the selected window manager
|
// Determine the selected window manager
|
||||||
var wm deps.WindowManager
|
wm := m.selectedWindowManager()
|
||||||
switch m.selectedWM {
|
|
||||||
case 0:
|
|
||||||
wm = deps.WindowManagerNiri
|
|
||||||
case 1:
|
|
||||||
wm = deps.WindowManagerHyprland
|
|
||||||
default:
|
|
||||||
wm = deps.WindowManagerNiri
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine the selected terminal
|
// Determine the selected terminal
|
||||||
var terminal deps.Terminal
|
var terminal deps.Terminal
|
||||||
@@ -288,7 +280,8 @@ func (m Model) checkExistingConfigurations() tea.Cmd {
|
|||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
var configs []ExistingConfigInfo
|
var configs []ExistingConfigInfo
|
||||||
|
|
||||||
if m.selectedWM == 0 {
|
switch m.selectedWindowManager() {
|
||||||
|
case deps.WindowManagerNiri:
|
||||||
niriPath := filepath.Join(os.Getenv("HOME"), ".config", "niri", "config.kdl")
|
niriPath := filepath.Join(os.Getenv("HOME"), ".config", "niri", "config.kdl")
|
||||||
niriExists := false
|
niriExists := false
|
||||||
if _, err := os.Stat(niriPath); err == nil {
|
if _, err := os.Stat(niriPath); err == nil {
|
||||||
@@ -299,10 +292,31 @@ func (m Model) checkExistingConfigurations() tea.Cmd {
|
|||||||
Path: niriPath,
|
Path: niriPath,
|
||||||
Exists: niriExists,
|
Exists: niriExists,
|
||||||
})
|
})
|
||||||
} else {
|
case deps.WindowManagerMango:
|
||||||
hyprlandPath := filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.conf")
|
mangoConfPath := filepath.Join(os.Getenv("HOME"), ".config", "mango", "config.conf")
|
||||||
|
mangoMainPath := filepath.Join(os.Getenv("HOME"), ".config", "mango", "mango.conf")
|
||||||
|
mangoPath := mangoConfPath
|
||||||
|
mangoExists := false
|
||||||
|
if _, err := os.Stat(mangoConfPath); err == nil {
|
||||||
|
mangoExists = true
|
||||||
|
} else if _, err := os.Stat(mangoMainPath); err == nil {
|
||||||
|
mangoPath = mangoMainPath
|
||||||
|
mangoExists = true
|
||||||
|
}
|
||||||
|
configs = append(configs, ExistingConfigInfo{
|
||||||
|
ConfigType: "Mango",
|
||||||
|
Path: mangoPath,
|
||||||
|
Exists: mangoExists,
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
hyprlandLuaPath := filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.lua")
|
||||||
|
hyprlandConfPath := filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.conf")
|
||||||
|
hyprlandPath := hyprlandLuaPath
|
||||||
hyprlandExists := false
|
hyprlandExists := false
|
||||||
if _, err := os.Stat(hyprlandPath); err == nil {
|
if _, err := os.Stat(hyprlandLuaPath); err == nil {
|
||||||
|
hyprlandExists = true
|
||||||
|
} else if _, err := os.Stat(hyprlandConfPath); err == nil {
|
||||||
|
hyprlandPath = hyprlandConfPath
|
||||||
hyprlandExists = true
|
hyprlandExists = true
|
||||||
}
|
}
|
||||||
configs = append(configs, ExistingConfigInfo{
|
configs = append(configs, ExistingConfigInfo{
|
||||||
|
|||||||
@@ -209,12 +209,7 @@ func (m Model) installPackages() tea.Cmd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Convert TUI selection to deps enum
|
// Convert TUI selection to deps enum
|
||||||
var wm deps.WindowManager
|
wm := m.selectedWindowManager()
|
||||||
if m.selectedWM == 0 {
|
|
||||||
wm = deps.WindowManagerNiri
|
|
||||||
} else {
|
|
||||||
wm = deps.WindowManagerHyprland
|
|
||||||
}
|
|
||||||
|
|
||||||
installerProgressChan := make(chan distros.InstallProgressMsg, 100)
|
installerProgressChan := make(chan distros.InstallProgressMsg, 100)
|
||||||
|
|
||||||
@@ -245,8 +240,11 @@ func (m Model) installPackages() tea.Cmd {
|
|||||||
}
|
}
|
||||||
if greeterSelected {
|
if greeterSelected {
|
||||||
compositorName := "niri"
|
compositorName := "niri"
|
||||||
if m.selectedWM == 1 {
|
switch m.selectedWindowManager() {
|
||||||
|
case deps.WindowManagerHyprland:
|
||||||
compositorName = "Hyprland"
|
compositorName = "Hyprland"
|
||||||
|
case deps.WindowManagerMango:
|
||||||
|
compositorName = "mango"
|
||||||
}
|
}
|
||||||
m.packageProgressChan <- packageInstallProgressMsg{
|
m.packageProgressChan <- packageInstallProgressMsg{
|
||||||
progress: 0.92,
|
progress: 0.92,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
)
|
)
|
||||||
@@ -65,7 +66,7 @@ func (m Model) updateGentooUseFlagsState(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.skipGentooUseFlags = !m.skipGentooUseFlags
|
m.skipGentooUseFlags = !m.skipGentooUseFlags
|
||||||
return m, nil
|
return m, nil
|
||||||
case "enter":
|
case "enter":
|
||||||
if m.selectedWM == 1 {
|
if m.selectedWindowManager() == deps.WindowManagerHyprland {
|
||||||
return m, m.checkGCCVersion()
|
return m, m.checkGCCVersion()
|
||||||
}
|
}
|
||||||
return m.enterAuthPhase()
|
return m.enterAuthPhase()
|
||||||
|
|||||||
@@ -199,8 +199,21 @@ func (m Model) viewInstallComplete() string {
|
|||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
wm := m.selectedWindowManager()
|
||||||
|
|
||||||
|
// mango launches DMS via `exec-once=dms run` (not a systemd session target)
|
||||||
|
loginHint := "If you do not have a greeter, login with \"niri-session\" or \"Hyprland\""
|
||||||
|
switch wm {
|
||||||
|
case deps.WindowManagerNiri:
|
||||||
|
loginHint = "If you do not have a greeter, login with \"niri-session\""
|
||||||
|
case deps.WindowManagerHyprland:
|
||||||
|
loginHint = "If you do not have a greeter, login with \"Hyprland\""
|
||||||
|
case deps.WindowManagerMango:
|
||||||
|
loginHint = "If you do not have a greeter, login with \"mango\""
|
||||||
|
}
|
||||||
|
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
info := m.styles.Normal.Render("Your system is ready! Log out and log back in to start using\nyour new desktop environment.\nIf you do not have a greeter, login with \"niri-session\" or \"Hyprland\"")
|
info := m.styles.Normal.Render("Your system is ready! Log out and log back in to start using\nyour new desktop environment.\n" + loginHint)
|
||||||
b.WriteString(info)
|
b.WriteString(info)
|
||||||
b.WriteString("\n\n")
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
@@ -209,8 +222,13 @@ func (m Model) viewInstallComplete() string {
|
|||||||
labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(theme.Subtle))
|
labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(theme.Subtle))
|
||||||
|
|
||||||
b.WriteString(labelStyle.Render("Troubleshooting:") + "\n")
|
b.WriteString(labelStyle.Render("Troubleshooting:") + "\n")
|
||||||
b.WriteString(labelStyle.Render(" Disable autostart: ") + cmdStyle.Render("systemctl --user disable dms") + "\n")
|
if wm == deps.WindowManagerMango {
|
||||||
b.WriteString(labelStyle.Render(" View logs: ") + cmdStyle.Render("journalctl --user -u dms") + "\n")
|
b.WriteString(labelStyle.Render(" Disable autostart: ") + cmdStyle.Render("remove 'exec-once=dms run' from ~/.config/mango/config.conf") + "\n")
|
||||||
|
b.WriteString(labelStyle.Render(" View logs: ") + cmdStyle.Render("qs -p ~/.config/quickshell/dms log") + "\n")
|
||||||
|
} else {
|
||||||
|
b.WriteString(labelStyle.Render(" Disable autostart: ") + cmdStyle.Render("systemctl --user disable dms") + "\n")
|
||||||
|
b.WriteString(labelStyle.Render(" View logs: ") + cmdStyle.Render("journalctl --user -u dms") + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
if m.osInfo != nil {
|
if m.osInfo != nil {
|
||||||
if cmd := uninstallCommand(m.osInfo.Distribution.ID, m.dependencies); cmd != "" {
|
if cmd := uninstallCommand(m.osInfo.Distribution.ID, m.dependencies); cmd != "" {
|
||||||
|
|||||||
@@ -10,6 +10,26 @@ import (
|
|||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// windowManagerOptions returns the WM enums in selection-list order (debian omits
|
||||||
|
// Hyprland). selectedWM indexes into this, so all index->WM conversions use it.
|
||||||
|
func (m Model) windowManagerOptions() []deps.WindowManager {
|
||||||
|
opts := []deps.WindowManager{deps.WindowManagerNiri}
|
||||||
|
if m.osInfo == nil || m.osInfo.Distribution.ID != "debian" {
|
||||||
|
opts = append(opts, deps.WindowManagerHyprland)
|
||||||
|
}
|
||||||
|
opts = append(opts, deps.WindowManagerMango)
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
|
||||||
|
// selectedWindowManager maps the current selectedWM index to its WM enum.
|
||||||
|
func (m Model) selectedWindowManager() deps.WindowManager {
|
||||||
|
opts := m.windowManagerOptions()
|
||||||
|
if m.selectedWM >= 0 && m.selectedWM < len(opts) {
|
||||||
|
return opts[m.selectedWM]
|
||||||
|
}
|
||||||
|
return deps.WindowManagerNiri
|
||||||
|
}
|
||||||
|
|
||||||
func (m Model) viewSelectWindowManager() string {
|
func (m Model) viewSelectWindowManager() string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
|
|
||||||
@@ -34,6 +54,11 @@ func (m Model) viewSelectWindowManager() string {
|
|||||||
}{"Hyprland", "Dynamic tiling Wayland compositor."})
|
}{"Hyprland", "Dynamic tiling Wayland compositor."})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
options = append(options, struct {
|
||||||
|
name string
|
||||||
|
description string
|
||||||
|
}{"mango", "dwl-based dynamic tiling Wayland compositor."})
|
||||||
|
|
||||||
for i, option := range options {
|
for i, option := range options {
|
||||||
if i == m.selectedWM {
|
if i == m.selectedWM {
|
||||||
selected := m.styles.SelectedOption.Render("▶ " + option.name)
|
selected := m.styles.SelectedOption.Render("▶ " + option.name)
|
||||||
@@ -152,10 +177,7 @@ func (m Model) updateSelectTerminalState(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
func (m Model) updateSelectWindowManagerState(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m Model) updateSelectWindowManagerState(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
if keyMsg, ok := msg.(tea.KeyMsg); ok {
|
if keyMsg, ok := msg.(tea.KeyMsg); ok {
|
||||||
maxWMIndex := 1
|
maxWMIndex := len(m.windowManagerOptions()) - 1
|
||||||
if m.osInfo != nil && m.osInfo.Distribution.ID == "debian" {
|
|
||||||
maxWMIndex = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
switch keyMsg.String() {
|
switch keyMsg.String() {
|
||||||
case "up":
|
case "up":
|
||||||
@@ -190,12 +212,7 @@ func (m Model) detectDependencies() tea.Cmd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Convert TUI selection to deps enum
|
// Convert TUI selection to deps enum
|
||||||
var wm deps.WindowManager
|
wm := m.selectedWindowManager()
|
||||||
if m.selectedWM == 0 {
|
|
||||||
wm = deps.WindowManagerNiri // First option is Niri
|
|
||||||
} else {
|
|
||||||
wm = deps.WindowManagerHyprland // Second option is Hyprland
|
|
||||||
}
|
|
||||||
|
|
||||||
var terminal deps.Terminal
|
var terminal deps.Terminal
|
||||||
if m.osInfo != nil && m.osInfo.Distribution.ID == "gentoo" {
|
if m.osInfo != nil && m.osInfo.Distribution.ID == "gentoo" {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,10 @@ package providers
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/windowrules"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParseWindowRuleV1(t *testing.T) {
|
func TestParseWindowRuleV1(t *testing.T) {
|
||||||
@@ -151,7 +154,7 @@ func TestHyprlandWritableProvider(t *testing.T) {
|
|||||||
t.Errorf("Name() = %q, want hyprland", provider.Name())
|
t.Errorf("Name() = %q, want hyprland", provider.Name())
|
||||||
}
|
}
|
||||||
|
|
||||||
expectedPath := filepath.Join(tmpDir, "dms", "windowrules.conf")
|
expectedPath := filepath.Join(tmpDir, "dms", "windowrules.lua")
|
||||||
if provider.GetOverridePath() != expectedPath {
|
if provider.GetOverridePath() != expectedPath {
|
||||||
t.Errorf("GetOverridePath() = %q, want %q", provider.GetOverridePath(), expectedPath)
|
t.Errorf("GetOverridePath() = %q, want %q", provider.GetOverridePath(), expectedPath)
|
||||||
}
|
}
|
||||||
@@ -185,6 +188,27 @@ func TestHyprlandSetAndLoadDMSRules(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHyprlandSetRuleLeavesConfOnlyInstallReadOnly(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(tmpDir, "hyprland.conf"), []byte("windowrulev2 = float, class:^(kitty)$\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
provider := NewHyprlandWritableProvider(tmpDir)
|
||||||
|
rule := newTestWindowRule("test_id", "Test Rule", "^(firefox)$")
|
||||||
|
rule.Actions.OpenFloating = boolPtr(true)
|
||||||
|
|
||||||
|
err := provider.SetRule(rule)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected SetRule to reject conf-only Hyprland config")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "read-only") {
|
||||||
|
t.Fatalf("expected read-only error, got %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(tmpDir, "dms", "windowrules.lua")); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("expected no Lua windowrules file to be created for conf-only config, stat err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHyprlandRemoveRule(t *testing.T) {
|
func TestHyprlandRemoveRule(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
provider := NewHyprlandWritableProvider(tmpDir)
|
provider := NewHyprlandWritableProvider(tmpDir)
|
||||||
@@ -270,6 +294,104 @@ windowrulev2 = tile, class:^(extraapp)$
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseHyprlandLuaRequiresFragment(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
dmsDir := filepath.Join(tmpDir, "dms")
|
||||||
|
if err := os.MkdirAll(dmsDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mainLua := filepath.Join(tmpDir, "hyprland.lua")
|
||||||
|
fragLua := filepath.Join(dmsDir, "windowrules.lua")
|
||||||
|
|
||||||
|
if err := os.WriteFile(fragLua, []byte(`
|
||||||
|
hl.window_rule({ match = { class = "^test$" }, float = true })
|
||||||
|
`), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(mainLua, []byte(`
|
||||||
|
require("dms.windowrules")
|
||||||
|
`), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := ParseHyprlandWindowRules(tmpDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseHyprlandWindowRules: %v", err)
|
||||||
|
}
|
||||||
|
if len(res.Rules) != 1 {
|
||||||
|
t.Fatalf("expected 1 rule, got %d", len(res.Rules))
|
||||||
|
}
|
||||||
|
if !res.DMSRulesIncluded {
|
||||||
|
t.Fatal("expected dms.windowrules fragment to be marked included")
|
||||||
|
}
|
||||||
|
wr := ConvertHyprlandRulesToWindowRules(res.Rules)[0]
|
||||||
|
if wr.MatchCriteria.AppID != "^test$" || wr.Actions.OpenFloating == nil || !*wr.Actions.OpenFloating {
|
||||||
|
t.Fatalf("unexpected merged rule: %#v", wr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseHyprlandLuaNoInitialFocusAlias(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(tmpDir, "hyprland.lua"), []byte(`
|
||||||
|
hl.window_rule({
|
||||||
|
match = { class = "^steam$" },
|
||||||
|
no_initial_focus = true,
|
||||||
|
})
|
||||||
|
`), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := ParseHyprlandWindowRules(tmpDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseHyprlandWindowRules: %v", err)
|
||||||
|
}
|
||||||
|
if len(res.Rules) != 1 {
|
||||||
|
t.Fatalf("expected 1 rule, got %d", len(res.Rules))
|
||||||
|
}
|
||||||
|
wr := ConvertHyprlandRulesToWindowRules(res.Rules)[0]
|
||||||
|
if wr.Actions.NoFocus == nil || !*wr.Actions.NoFocus {
|
||||||
|
t.Fatalf("expected no_initial_focus to populate NoFocus action: %#v", wr.Actions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatLuaManagedHyprRuleUsesLuaFieldNames(t *testing.T) {
|
||||||
|
enabled := true
|
||||||
|
rule := windowrules.WindowRule{
|
||||||
|
ID: "test-rule",
|
||||||
|
Enabled: true,
|
||||||
|
MatchCriteria: windowrules.MatchCriteria{
|
||||||
|
AppID: "^app$",
|
||||||
|
},
|
||||||
|
Actions: windowrules.Actions{
|
||||||
|
NoFocus: &enabled,
|
||||||
|
NoShadow: &enabled,
|
||||||
|
NoDim: &enabled,
|
||||||
|
NoBlur: &enabled,
|
||||||
|
NoAnim: &enabled,
|
||||||
|
ForcergbX: &enabled,
|
||||||
|
Idleinhibit: "focus",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := formatLuaManagedHyprRule(rule)
|
||||||
|
joined := strings.Join(lines, "\n")
|
||||||
|
for _, want := range []string{
|
||||||
|
"no_focus = true",
|
||||||
|
"no_shadow = true",
|
||||||
|
"no_dim = true",
|
||||||
|
"no_blur = true",
|
||||||
|
"no_anim = true",
|
||||||
|
"force_rgbx = true",
|
||||||
|
`idle_inhibit = "focus"`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(joined, want) {
|
||||||
|
t.Fatalf("formatted rule missing %q: %s", want, joined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBoolToInt(t *testing.T) {
|
func TestBoolToInt(t *testing.T) {
|
||||||
if boolToInt(true) != 1 {
|
if boolToInt(true) != 1 {
|
||||||
t.Error("boolToInt(true) should be 1")
|
t.Error("boolToInt(true) should be 1")
|
||||||
|
|||||||
@@ -0,0 +1,378 @@
|
|||||||
|
package providers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/windowrules"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Mango window rules are flat `windowrule=key:value,...` lines. DMS-managed rules
|
||||||
|
// live in dms/windowrules.conf (sourced from config.conf), each preceded by an
|
||||||
|
// `# @id=<id> @name=<name>` comment so they round-trip.
|
||||||
|
|
||||||
|
type MangoWindowRule struct {
|
||||||
|
Source string
|
||||||
|
Fields map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
var mangoWindowRuleRegex = regexp.MustCompile(`^windowrule\s*=\s*(.+)$`)
|
||||||
|
var mangoMetaCommentRegex = regexp.MustCompile(`^#\s*@id=(\S*)\s*@name=(.*)$`)
|
||||||
|
|
||||||
|
func parseMangoWindowRuleLine(value string) map[string]string {
|
||||||
|
fields := map[string]string{}
|
||||||
|
for _, pair := range strings.Split(value, ",") {
|
||||||
|
pair = strings.TrimSpace(pair)
|
||||||
|
if pair == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
colon := strings.Index(pair, ":")
|
||||||
|
if colon < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := strings.TrimSpace(pair[:colon])
|
||||||
|
val := strings.TrimSpace(pair[colon+1:])
|
||||||
|
if key != "" {
|
||||||
|
fields[key] = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fields
|
||||||
|
}
|
||||||
|
|
||||||
|
// mangoConfigPath returns the main mango config (config.conf or mango.conf).
|
||||||
|
func mangoConfigPath(configDir string) string {
|
||||||
|
candidates := []string{
|
||||||
|
filepath.Join(configDir, "config.conf"),
|
||||||
|
filepath.Join(configDir, "mango.conf"),
|
||||||
|
}
|
||||||
|
for _, c := range candidates {
|
||||||
|
if _, err := os.Stat(c); err == nil {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return candidates[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func mangoOverridePath(configDir string) string {
|
||||||
|
return filepath.Join(configDir, "dms", "windowrules.conf")
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseMangoRulesFile reads a config file and returns its windowrule= lines.
|
||||||
|
func parseMangoRulesFile(path, source string) []MangoWindowRule {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var rules []MangoWindowRule
|
||||||
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if m := mangoWindowRuleRegex.FindStringSubmatch(trimmed); m != nil {
|
||||||
|
rules = append(rules, MangoWindowRule{Source: source, Fields: parseMangoWindowRuleLine(m[1])})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rules
|
||||||
|
}
|
||||||
|
|
||||||
|
type MangoRulesParseResult struct {
|
||||||
|
Rules []MangoWindowRule
|
||||||
|
DMSRulesIncluded bool
|
||||||
|
DMSStatus *windowrules.DMSRulesStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseMangoWindowRules(configDir string) (*MangoRulesParseResult, error) {
|
||||||
|
mainPath := mangoConfigPath(configDir)
|
||||||
|
overridePath := mangoOverridePath(configDir)
|
||||||
|
|
||||||
|
var rules []MangoWindowRule
|
||||||
|
rules = append(rules, parseMangoRulesFile(mainPath, "config.conf")...)
|
||||||
|
rules = append(rules, parseMangoRulesFile(overridePath, "dms/windowrules.conf")...)
|
||||||
|
|
||||||
|
included := mangoDMSRulesIncluded(mainPath)
|
||||||
|
return &MangoRulesParseResult{
|
||||||
|
Rules: rules,
|
||||||
|
DMSRulesIncluded: included,
|
||||||
|
DMSStatus: &windowrules.DMSRulesStatus{
|
||||||
|
Exists: fileExists(overridePath),
|
||||||
|
Included: included,
|
||||||
|
Effective: included,
|
||||||
|
ConfigFormat: "conf",
|
||||||
|
StatusMessage: mangoIncludeMessage(included),
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileExists(path string) bool {
|
||||||
|
_, err := os.Stat(path)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mangoDMSRulesIncluded(mainPath string) bool {
|
||||||
|
data, err := os.ReadFile(mainPath)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(trimmed, "source") && strings.Contains(trimmed, "dms/windowrules.conf") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func mangoIncludeMessage(included bool) string {
|
||||||
|
if included {
|
||||||
|
return "DMS window rules are sourced from config.conf"
|
||||||
|
}
|
||||||
|
return "Add `source=./dms/windowrules.conf` to config.conf to apply DMS window rules"
|
||||||
|
}
|
||||||
|
|
||||||
|
func mangoBoolField(fields map[string]string, key string) *bool {
|
||||||
|
v, ok := fields[key]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
b := v == "1" || strings.EqualFold(v, "true")
|
||||||
|
return &b
|
||||||
|
}
|
||||||
|
|
||||||
|
func mangoBoolStr(b *bool) string {
|
||||||
|
if b != nil && *b {
|
||||||
|
return "1"
|
||||||
|
}
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
|
||||||
|
func ConvertMangoRulesToWindowRules(mangoRules []MangoWindowRule) []windowrules.WindowRule {
|
||||||
|
result := make([]windowrules.WindowRule, 0, len(mangoRules))
|
||||||
|
for i, mr := range mangoRules {
|
||||||
|
f := mr.Fields
|
||||||
|
actions := windowrules.Actions{
|
||||||
|
OpenFloating: mangoBoolField(f, "isfloating"),
|
||||||
|
OpenFullscreen: mangoBoolField(f, "isfullscreen"),
|
||||||
|
NoBlur: mangoBoolField(f, "noblur"),
|
||||||
|
NoBorder: mangoBoolField(f, "isnoborder"),
|
||||||
|
NoShadow: mangoBoolField(f, "isnoshadow"),
|
||||||
|
NoRounding: mangoBoolField(f, "isnoradius"),
|
||||||
|
NoAnim: mangoBoolField(f, "isnoanimation"),
|
||||||
|
}
|
||||||
|
if tags, ok := f["tags"]; ok {
|
||||||
|
actions.Workspace = tags
|
||||||
|
}
|
||||||
|
if mon, ok := f["monitor"]; ok {
|
||||||
|
actions.Monitor = mon
|
||||||
|
}
|
||||||
|
if w, ok := f["width"]; ok {
|
||||||
|
if h, ok2 := f["height"]; ok2 {
|
||||||
|
actions.Size = w + "x" + h
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result = append(result, windowrules.WindowRule{
|
||||||
|
ID: fmt.Sprintf("rule_%d", i),
|
||||||
|
Enabled: true,
|
||||||
|
Source: mr.Source,
|
||||||
|
MatchCriteria: windowrules.MatchCriteria{
|
||||||
|
AppID: f["appid"],
|
||||||
|
Title: f["title"],
|
||||||
|
},
|
||||||
|
Actions: actions,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatMangoRule serializes a shared WindowRule into a mango windowrule= line.
|
||||||
|
func formatMangoRule(rule windowrules.WindowRule) string {
|
||||||
|
var parts []string
|
||||||
|
add := func(k, v string) {
|
||||||
|
if v != "" {
|
||||||
|
parts = append(parts, k+":"+v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
add("appid", rule.MatchCriteria.AppID)
|
||||||
|
add("title", rule.MatchCriteria.Title)
|
||||||
|
add("tags", rule.Actions.Workspace)
|
||||||
|
add("monitor", rule.Actions.Monitor)
|
||||||
|
|
||||||
|
if rule.Actions.Size != "" {
|
||||||
|
if w, h, ok := splitSize(rule.Actions.Size); ok {
|
||||||
|
add("width", w)
|
||||||
|
add("height", h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addBool := func(k string, b *bool) {
|
||||||
|
if b != nil {
|
||||||
|
parts = append(parts, k+":"+mangoBoolStr(b))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addBool("isfloating", rule.Actions.OpenFloating)
|
||||||
|
addBool("isfullscreen", rule.Actions.OpenFullscreen)
|
||||||
|
addBool("noblur", rule.Actions.NoBlur)
|
||||||
|
addBool("isnoborder", rule.Actions.NoBorder)
|
||||||
|
addBool("isnoshadow", rule.Actions.NoShadow)
|
||||||
|
addBool("isnoradius", rule.Actions.NoRounding)
|
||||||
|
addBool("isnoanimation", rule.Actions.NoAnim)
|
||||||
|
|
||||||
|
return "windowrule=" + strings.Join(parts, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitSize(size string) (w, h string, ok bool) {
|
||||||
|
for _, sep := range []string{"x", "X", " "} {
|
||||||
|
if parts := strings.Split(size, sep); len(parts) == 2 {
|
||||||
|
w = strings.TrimSpace(parts[0])
|
||||||
|
h = strings.TrimSpace(parts[1])
|
||||||
|
if _, err := strconv.ParseFloat(w, 64); err == nil {
|
||||||
|
return w, h, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
type MangoWritableProvider struct {
|
||||||
|
configDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMangoWritableProvider(configDir string) *MangoWritableProvider {
|
||||||
|
return &MangoWritableProvider{configDir: configDir}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *MangoWritableProvider) Name() string { return "mango" }
|
||||||
|
|
||||||
|
func (p *MangoWritableProvider) GetOverridePath() string {
|
||||||
|
return mangoOverridePath(p.configDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *MangoWritableProvider) GetRuleSet() (*windowrules.RuleSet, error) {
|
||||||
|
result, err := ParseMangoWindowRules(p.configDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &windowrules.RuleSet{
|
||||||
|
Title: "Mango Window Rules",
|
||||||
|
Provider: "mango",
|
||||||
|
Rules: ConvertMangoRulesToWindowRules(result.Rules),
|
||||||
|
DMSRulesIncluded: result.DMSRulesIncluded,
|
||||||
|
DMSStatus: result.DMSStatus,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *MangoWritableProvider) SetRule(rule windowrules.WindowRule) error {
|
||||||
|
rules, err := p.LoadDMSRules()
|
||||||
|
if err != nil {
|
||||||
|
rules = []windowrules.WindowRule{}
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for i, r := range rules {
|
||||||
|
if r.ID == rule.ID {
|
||||||
|
rules[i] = rule
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
rules = append(rules, rule)
|
||||||
|
}
|
||||||
|
return p.writeDMSRules(rules)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *MangoWritableProvider) RemoveRule(id string) error {
|
||||||
|
rules, err := p.LoadDMSRules()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
newRules := make([]windowrules.WindowRule, 0, len(rules))
|
||||||
|
for _, r := range rules {
|
||||||
|
if r.ID != id {
|
||||||
|
newRules = append(newRules, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p.writeDMSRules(newRules)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *MangoWritableProvider) ReorderRules(ids []string) error {
|
||||||
|
rules, err := p.LoadDMSRules()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ruleMap := make(map[string]windowrules.WindowRule, len(rules))
|
||||||
|
for _, r := range rules {
|
||||||
|
ruleMap[r.ID] = r
|
||||||
|
}
|
||||||
|
newRules := make([]windowrules.WindowRule, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
if r, ok := ruleMap[id]; ok {
|
||||||
|
newRules = append(newRules, r)
|
||||||
|
delete(ruleMap, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, r := range ruleMap {
|
||||||
|
newRules = append(newRules, r)
|
||||||
|
}
|
||||||
|
return p.writeDMSRules(newRules)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadDMSRules parses only the DMS override file, preserving @id/@name metadata.
|
||||||
|
func (p *MangoWritableProvider) LoadDMSRules() ([]windowrules.WindowRule, error) {
|
||||||
|
data, err := os.ReadFile(p.GetOverridePath())
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return []windowrules.WindowRule{}, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var rules []windowrules.WindowRule
|
||||||
|
var curID, curName string
|
||||||
|
idx := 0
|
||||||
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if m := mangoMetaCommentRegex.FindStringSubmatch(trimmed); m != nil {
|
||||||
|
curID = m[1]
|
||||||
|
curName = strings.TrimSpace(m[2])
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if m := mangoWindowRuleRegex.FindStringSubmatch(trimmed); m != nil {
|
||||||
|
converted := ConvertMangoRulesToWindowRules([]MangoWindowRule{{Source: "dms/windowrules.conf", Fields: parseMangoWindowRuleLine(m[1])}})
|
||||||
|
wr := converted[0]
|
||||||
|
if curID != "" {
|
||||||
|
wr.ID = curID
|
||||||
|
} else {
|
||||||
|
wr.ID = fmt.Sprintf("rule_%d", idx)
|
||||||
|
}
|
||||||
|
wr.Name = curName
|
||||||
|
rules = append(rules, wr)
|
||||||
|
curID, curName = "", ""
|
||||||
|
idx++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rules, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *MangoWritableProvider) writeDMSRules(rules []windowrules.WindowRule) error {
|
||||||
|
overridePath := p.GetOverridePath()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(overridePath), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("# Auto-generated by DMS - DMS-managed mango window rules\n\n")
|
||||||
|
for i, r := range rules {
|
||||||
|
id := r.ID
|
||||||
|
if id == "" {
|
||||||
|
id = fmt.Sprintf("rule_%d", i)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&sb, "# @id=%s @name=%s\n", id, r.Name)
|
||||||
|
sb.WriteString(formatMangoRule(r))
|
||||||
|
sb.WriteString("\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.WriteFile(overridePath, []byte(sb.String()), 0o644)
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package providers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/windowrules"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseMangoWindowRuleLine(t *testing.T) {
|
||||||
|
fields := parseMangoWindowRuleLine("appid:firefox,title:Gmail,isfloating:1,tags:2,monitor:HDMI-A-1")
|
||||||
|
if fields["appid"] != "firefox" {
|
||||||
|
t.Errorf("appid = %q, want firefox", fields["appid"])
|
||||||
|
}
|
||||||
|
if fields["title"] != "Gmail" {
|
||||||
|
t.Errorf("title = %q, want Gmail", fields["title"])
|
||||||
|
}
|
||||||
|
if fields["isfloating"] != "1" {
|
||||||
|
t.Errorf("isfloating = %q, want 1", fields["isfloating"])
|
||||||
|
}
|
||||||
|
if fields["tags"] != "2" {
|
||||||
|
t.Errorf("tags = %q, want 2", fields["tags"])
|
||||||
|
}
|
||||||
|
if fields["monitor"] != "HDMI-A-1" {
|
||||||
|
t.Errorf("monitor = %q, want HDMI-A-1", fields["monitor"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertMangoRulesToWindowRules(t *testing.T) {
|
||||||
|
mangoRules := []MangoWindowRule{
|
||||||
|
{Source: "config.conf", Fields: parseMangoWindowRuleLine("appid:discord,tags:9,isfloating:1,noblur:1")},
|
||||||
|
}
|
||||||
|
rules := ConvertMangoRulesToWindowRules(mangoRules)
|
||||||
|
if len(rules) != 1 {
|
||||||
|
t.Fatalf("got %d rules, want 1", len(rules))
|
||||||
|
}
|
||||||
|
r := rules[0]
|
||||||
|
if r.MatchCriteria.AppID != "discord" {
|
||||||
|
t.Errorf("AppID = %q, want discord", r.MatchCriteria.AppID)
|
||||||
|
}
|
||||||
|
if r.Actions.Workspace != "9" {
|
||||||
|
t.Errorf("Workspace = %q, want 9", r.Actions.Workspace)
|
||||||
|
}
|
||||||
|
if r.Actions.OpenFloating == nil || !*r.Actions.OpenFloating {
|
||||||
|
t.Errorf("OpenFloating = %v, want true", r.Actions.OpenFloating)
|
||||||
|
}
|
||||||
|
if r.Actions.NoBlur == nil || !*r.Actions.NoBlur {
|
||||||
|
t.Errorf("NoBlur = %v, want true", r.Actions.NoBlur)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMangoSetAndLoadRoundTrip(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
provider := NewMangoWritableProvider(tmpDir)
|
||||||
|
|
||||||
|
floating := true
|
||||||
|
rule := windowrules.WindowRule{
|
||||||
|
ID: "rule_test",
|
||||||
|
Name: "Float Discord",
|
||||||
|
Enabled: true,
|
||||||
|
MatchCriteria: windowrules.MatchCriteria{
|
||||||
|
AppID: "discord",
|
||||||
|
},
|
||||||
|
Actions: windowrules.Actions{
|
||||||
|
OpenFloating: &floating,
|
||||||
|
Workspace: "9",
|
||||||
|
Size: "1000x900",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := provider.SetRule(rule); err != nil {
|
||||||
|
t.Fatalf("SetRule: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedPath := filepath.Join(tmpDir, "dms", "windowrules.conf")
|
||||||
|
if _, err := os.Stat(expectedPath); err != nil {
|
||||||
|
t.Fatalf("override file not written: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
loaded, err := provider.LoadDMSRules()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadDMSRules: %v", err)
|
||||||
|
}
|
||||||
|
if len(loaded) != 1 {
|
||||||
|
t.Fatalf("got %d rules, want 1", len(loaded))
|
||||||
|
}
|
||||||
|
got := loaded[0]
|
||||||
|
if got.ID != "rule_test" {
|
||||||
|
t.Errorf("ID = %q, want rule_test", got.ID)
|
||||||
|
}
|
||||||
|
if got.Name != "Float Discord" {
|
||||||
|
t.Errorf("Name = %q, want 'Float Discord'", got.Name)
|
||||||
|
}
|
||||||
|
if got.MatchCriteria.AppID != "discord" {
|
||||||
|
t.Errorf("AppID = %q, want discord", got.MatchCriteria.AppID)
|
||||||
|
}
|
||||||
|
if got.Actions.Workspace != "9" {
|
||||||
|
t.Errorf("Workspace = %q, want 9", got.Actions.Workspace)
|
||||||
|
}
|
||||||
|
if got.Actions.Size != "1000x900" {
|
||||||
|
t.Errorf("Size = %q, want 1000x900", got.Actions.Size)
|
||||||
|
}
|
||||||
|
if got.Actions.OpenFloating == nil || !*got.Actions.OpenFloating {
|
||||||
|
t.Errorf("OpenFloating = %v, want true", got.Actions.OpenFloating)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove and confirm empty.
|
||||||
|
if err := provider.RemoveRule("rule_test"); err != nil {
|
||||||
|
t.Fatalf("RemoveRule: %v", err)
|
||||||
|
}
|
||||||
|
loaded, _ = provider.LoadDMSRules()
|
||||||
|
if len(loaded) != 0 {
|
||||||
|
t.Errorf("after remove got %d rules, want 0", len(loaded))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,18 @@ import (
|
|||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/windowrules"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/windowrules"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type NiriMatch struct {
|
||||||
|
AppID string
|
||||||
|
Title string
|
||||||
|
IsFloating *bool
|
||||||
|
IsActive *bool
|
||||||
|
IsFocused *bool
|
||||||
|
IsActiveInColumn *bool
|
||||||
|
IsWindowCastTarget *bool
|
||||||
|
IsUrgent *bool
|
||||||
|
AtStartup *bool
|
||||||
|
}
|
||||||
|
|
||||||
type NiriWindowRule struct {
|
type NiriWindowRule struct {
|
||||||
MatchAppID string
|
MatchAppID string
|
||||||
MatchTitle string
|
MatchTitle string
|
||||||
@@ -24,6 +36,7 @@ type NiriWindowRule struct {
|
|||||||
MatchIsWindowCastTarget *bool
|
MatchIsWindowCastTarget *bool
|
||||||
MatchIsUrgent *bool
|
MatchIsUrgent *bool
|
||||||
MatchAtStartup *bool
|
MatchAtStartup *bool
|
||||||
|
Matches []NiriMatch
|
||||||
Opacity *float64
|
Opacity *float64
|
||||||
OpenFloating *bool
|
OpenFloating *bool
|
||||||
OpenMaximized *bool
|
OpenMaximized *bool
|
||||||
@@ -50,6 +63,13 @@ type NiriWindowRule struct {
|
|||||||
FocusRingOff *bool
|
FocusRingOff *bool
|
||||||
BorderOff *bool
|
BorderOff *bool
|
||||||
DrawBorderWithBg *bool
|
DrawBorderWithBg *bool
|
||||||
|
BgBlur *bool
|
||||||
|
BgXray *bool
|
||||||
|
BgNoise *float64
|
||||||
|
BgSaturation *float64
|
||||||
|
DefaultFloatingX *int
|
||||||
|
DefaultFloatingY *int
|
||||||
|
DefaultFloatingRelative string
|
||||||
Source string
|
Source string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,7 +211,7 @@ func (p *NiriRulesParser) parseWindowRuleNode(node *document.Node) {
|
|||||||
|
|
||||||
switch childName {
|
switch childName {
|
||||||
case "match":
|
case "match":
|
||||||
p.parseMatchNode(child, &rule)
|
rule.Matches = append(rule.Matches, p.parseMatchNode(child))
|
||||||
case "opacity":
|
case "opacity":
|
||||||
if len(child.Arguments) > 0 {
|
if len(child.Arguments) > 0 {
|
||||||
val := child.Arguments[0].ResolvedValue()
|
val := child.Arguments[0].ResolvedValue()
|
||||||
@@ -297,9 +317,26 @@ func (p *NiriRulesParser) parseWindowRuleNode(node *document.Node) {
|
|||||||
case "draw-border-with-background":
|
case "draw-border-with-background":
|
||||||
b := p.parseBoolArg(child)
|
b := p.parseBoolArg(child)
|
||||||
rule.DrawBorderWithBg = &b
|
rule.DrawBorderWithBg = &b
|
||||||
|
case "background-effect":
|
||||||
|
p.parseBackgroundEffectNode(child, &rule)
|
||||||
|
case "default-floating-position":
|
||||||
|
p.parseFloatingPositionNode(child, &rule)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(rule.Matches) > 0 {
|
||||||
|
first := rule.Matches[0]
|
||||||
|
rule.MatchAppID = first.AppID
|
||||||
|
rule.MatchTitle = first.Title
|
||||||
|
rule.MatchIsFloating = first.IsFloating
|
||||||
|
rule.MatchIsActive = first.IsActive
|
||||||
|
rule.MatchIsFocused = first.IsFocused
|
||||||
|
rule.MatchIsActiveInColumn = first.IsActiveInColumn
|
||||||
|
rule.MatchIsWindowCastTarget = first.IsWindowCastTarget
|
||||||
|
rule.MatchIsUrgent = first.IsUrgent
|
||||||
|
rule.MatchAtStartup = first.AtStartup
|
||||||
|
}
|
||||||
|
|
||||||
p.rules = append(p.rules, rule)
|
p.rules = append(p.rules, rule)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,45 +363,47 @@ func (p *NiriRulesParser) parseSizeNode(node *document.Node) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *NiriRulesParser) parseMatchNode(node *document.Node, rule *NiriWindowRule) {
|
func (p *NiriRulesParser) parseMatchNode(node *document.Node) NiriMatch {
|
||||||
|
m := NiriMatch{}
|
||||||
if node.Properties == nil {
|
if node.Properties == nil {
|
||||||
return
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
if val, ok := node.Properties.Get("app-id"); ok {
|
if val, ok := node.Properties.Get("app-id"); ok {
|
||||||
rule.MatchAppID = val.ValueString()
|
m.AppID = val.ValueString()
|
||||||
}
|
}
|
||||||
if val, ok := node.Properties.Get("title"); ok {
|
if val, ok := node.Properties.Get("title"); ok {
|
||||||
rule.MatchTitle = val.ValueString()
|
m.Title = val.ValueString()
|
||||||
}
|
}
|
||||||
if val, ok := node.Properties.Get("is-floating"); ok {
|
if val, ok := node.Properties.Get("is-floating"); ok {
|
||||||
b := val.ValueString() == "true"
|
b := val.ValueString() == "true"
|
||||||
rule.MatchIsFloating = &b
|
m.IsFloating = &b
|
||||||
}
|
}
|
||||||
if val, ok := node.Properties.Get("is-active"); ok {
|
if val, ok := node.Properties.Get("is-active"); ok {
|
||||||
b := val.ValueString() == "true"
|
b := val.ValueString() == "true"
|
||||||
rule.MatchIsActive = &b
|
m.IsActive = &b
|
||||||
}
|
}
|
||||||
if val, ok := node.Properties.Get("is-focused"); ok {
|
if val, ok := node.Properties.Get("is-focused"); ok {
|
||||||
b := val.ValueString() == "true"
|
b := val.ValueString() == "true"
|
||||||
rule.MatchIsFocused = &b
|
m.IsFocused = &b
|
||||||
}
|
}
|
||||||
if val, ok := node.Properties.Get("is-active-in-column"); ok {
|
if val, ok := node.Properties.Get("is-active-in-column"); ok {
|
||||||
b := val.ValueString() == "true"
|
b := val.ValueString() == "true"
|
||||||
rule.MatchIsActiveInColumn = &b
|
m.IsActiveInColumn = &b
|
||||||
}
|
}
|
||||||
if val, ok := node.Properties.Get("is-window-cast-target"); ok {
|
if val, ok := node.Properties.Get("is-window-cast-target"); ok {
|
||||||
b := val.ValueString() == "true"
|
b := val.ValueString() == "true"
|
||||||
rule.MatchIsWindowCastTarget = &b
|
m.IsWindowCastTarget = &b
|
||||||
}
|
}
|
||||||
if val, ok := node.Properties.Get("is-urgent"); ok {
|
if val, ok := node.Properties.Get("is-urgent"); ok {
|
||||||
b := val.ValueString() == "true"
|
b := val.ValueString() == "true"
|
||||||
rule.MatchIsUrgent = &b
|
m.IsUrgent = &b
|
||||||
}
|
}
|
||||||
if val, ok := node.Properties.Get("at-startup"); ok {
|
if val, ok := node.Properties.Get("at-startup"); ok {
|
||||||
b := val.ValueString() == "true"
|
b := val.ValueString() == "true"
|
||||||
rule.MatchAtStartup = &b
|
m.AtStartup = &b
|
||||||
}
|
}
|
||||||
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *NiriRulesParser) parseBorderNode(node *document.Node, rule *NiriWindowRule) {
|
func (p *NiriRulesParser) parseBorderNode(node *document.Node, rule *NiriWindowRule) {
|
||||||
@@ -385,6 +424,64 @@ func (p *NiriRulesParser) parseBorderNode(node *document.Node, rule *NiriWindowR
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *NiriRulesParser) parseBackgroundEffectNode(node *document.Node, rule *NiriWindowRule) {
|
||||||
|
if node.Children == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, child := range node.Children {
|
||||||
|
switch child.Name.String() {
|
||||||
|
case "blur":
|
||||||
|
b := p.parseBoolArg(child)
|
||||||
|
rule.BgBlur = &b
|
||||||
|
case "xray":
|
||||||
|
b := p.parseBoolArg(child)
|
||||||
|
rule.BgXray = &b
|
||||||
|
case "noise":
|
||||||
|
if f, ok := p.parseFloatArg(child); ok {
|
||||||
|
rule.BgNoise = &f
|
||||||
|
}
|
||||||
|
case "saturation":
|
||||||
|
if f, ok := p.parseFloatArg(child); ok {
|
||||||
|
rule.BgSaturation = &f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *NiriRulesParser) parseFloatingPositionNode(node *document.Node, rule *NiriWindowRule) {
|
||||||
|
if node.Properties == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if val, ok := node.Properties.Get("x"); ok {
|
||||||
|
if n, err := strconv.Atoi(strings.TrimSpace(val.ValueString())); err == nil {
|
||||||
|
rule.DefaultFloatingX = &n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if val, ok := node.Properties.Get("y"); ok {
|
||||||
|
if n, err := strconv.Atoi(strings.TrimSpace(val.ValueString())); err == nil {
|
||||||
|
rule.DefaultFloatingY = &n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if val, ok := node.Properties.Get("relative-to"); ok {
|
||||||
|
rule.DefaultFloatingRelative = val.ValueString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *NiriRulesParser) parseFloatArg(node *document.Node) (float64, bool) {
|
||||||
|
if len(node.Arguments) == 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
val := node.Arguments[0].ResolvedValue()
|
||||||
|
switch v := val.(type) {
|
||||||
|
case float64:
|
||||||
|
return v, true
|
||||||
|
case int64:
|
||||||
|
return float64(v), true
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
func (p *NiriRulesParser) parseFocusRingNode(node *document.Node, rule *NiriWindowRule) {
|
func (p *NiriRulesParser) parseFocusRingNode(node *document.Node, rule *NiriWindowRule) {
|
||||||
if node.Children == nil {
|
if node.Children == nil {
|
||||||
return
|
return
|
||||||
@@ -461,6 +558,27 @@ func ParseNiriWindowRules(configDir string) (*NiriRulesParseResult, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func convertNiriMatches(matches []NiriMatch) []windowrules.MatchCriteria {
|
||||||
|
if len(matches) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := make([]windowrules.MatchCriteria, 0, len(matches))
|
||||||
|
for _, m := range matches {
|
||||||
|
result = append(result, windowrules.MatchCriteria{
|
||||||
|
AppID: m.AppID,
|
||||||
|
Title: m.Title,
|
||||||
|
IsFloating: m.IsFloating,
|
||||||
|
IsActive: m.IsActive,
|
||||||
|
IsFocused: m.IsFocused,
|
||||||
|
IsActiveInColumn: m.IsActiveInColumn,
|
||||||
|
IsWindowCastTarget: m.IsWindowCastTarget,
|
||||||
|
IsUrgent: m.IsUrgent,
|
||||||
|
AtStartup: m.AtStartup,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
func ConvertNiriRulesToWindowRules(niriRules []NiriWindowRule) []windowrules.WindowRule {
|
func ConvertNiriRulesToWindowRules(niriRules []NiriWindowRule) []windowrules.WindowRule {
|
||||||
result := make([]windowrules.WindowRule, 0, len(niriRules))
|
result := make([]windowrules.WindowRule, 0, len(niriRules))
|
||||||
for i, nr := range niriRules {
|
for i, nr := range niriRules {
|
||||||
@@ -479,33 +597,41 @@ func ConvertNiriRulesToWindowRules(niriRules []NiriWindowRule) []windowrules.Win
|
|||||||
IsUrgent: nr.MatchIsUrgent,
|
IsUrgent: nr.MatchIsUrgent,
|
||||||
AtStartup: nr.MatchAtStartup,
|
AtStartup: nr.MatchAtStartup,
|
||||||
},
|
},
|
||||||
|
Matches: convertNiriMatches(nr.Matches),
|
||||||
Actions: windowrules.Actions{
|
Actions: windowrules.Actions{
|
||||||
Opacity: nr.Opacity,
|
Opacity: nr.Opacity,
|
||||||
OpenFloating: nr.OpenFloating,
|
OpenFloating: nr.OpenFloating,
|
||||||
OpenMaximized: nr.OpenMaximized,
|
OpenMaximized: nr.OpenMaximized,
|
||||||
OpenMaximizedToEdges: nr.OpenMaximizedToEdges,
|
OpenMaximizedToEdges: nr.OpenMaximizedToEdges,
|
||||||
OpenFullscreen: nr.OpenFullscreen,
|
OpenFullscreen: nr.OpenFullscreen,
|
||||||
OpenFocused: nr.OpenFocused,
|
OpenFocused: nr.OpenFocused,
|
||||||
OpenOnOutput: nr.OpenOnOutput,
|
OpenOnOutput: nr.OpenOnOutput,
|
||||||
OpenOnWorkspace: nr.OpenOnWorkspace,
|
OpenOnWorkspace: nr.OpenOnWorkspace,
|
||||||
DefaultColumnWidth: nr.DefaultColumnWidth,
|
DefaultColumnWidth: nr.DefaultColumnWidth,
|
||||||
DefaultWindowHeight: nr.DefaultWindowHeight,
|
DefaultWindowHeight: nr.DefaultWindowHeight,
|
||||||
VariableRefreshRate: nr.VariableRefreshRate,
|
VariableRefreshRate: nr.VariableRefreshRate,
|
||||||
BlockOutFrom: nr.BlockOutFrom,
|
BlockOutFrom: nr.BlockOutFrom,
|
||||||
DefaultColumnDisplay: nr.DefaultColumnDisplay,
|
DefaultColumnDisplay: nr.DefaultColumnDisplay,
|
||||||
ScrollFactor: nr.ScrollFactor,
|
ScrollFactor: nr.ScrollFactor,
|
||||||
CornerRadius: nr.CornerRadius,
|
CornerRadius: nr.CornerRadius,
|
||||||
ClipToGeometry: nr.ClipToGeometry,
|
ClipToGeometry: nr.ClipToGeometry,
|
||||||
TiledState: nr.TiledState,
|
TiledState: nr.TiledState,
|
||||||
MinWidth: nr.MinWidth,
|
MinWidth: nr.MinWidth,
|
||||||
MaxWidth: nr.MaxWidth,
|
MaxWidth: nr.MaxWidth,
|
||||||
MinHeight: nr.MinHeight,
|
MinHeight: nr.MinHeight,
|
||||||
MaxHeight: nr.MaxHeight,
|
MaxHeight: nr.MaxHeight,
|
||||||
BorderColor: nr.BorderColor,
|
BorderColor: nr.BorderColor,
|
||||||
FocusRingColor: nr.FocusRingColor,
|
FocusRingColor: nr.FocusRingColor,
|
||||||
FocusRingOff: nr.FocusRingOff,
|
FocusRingOff: nr.FocusRingOff,
|
||||||
BorderOff: nr.BorderOff,
|
BorderOff: nr.BorderOff,
|
||||||
DrawBorderWithBg: nr.DrawBorderWithBg,
|
DrawBorderWithBg: nr.DrawBorderWithBg,
|
||||||
|
BackgroundBlur: nr.BgBlur,
|
||||||
|
BackgroundXray: nr.BgXray,
|
||||||
|
BackgroundNoise: nr.BgNoise,
|
||||||
|
BackgroundSaturation: nr.BgSaturation,
|
||||||
|
DefaultFloatingX: nr.DefaultFloatingX,
|
||||||
|
DefaultFloatingY: nr.DefaultFloatingY,
|
||||||
|
DefaultFloatingRelativeTo: nr.DefaultFloatingRelative,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
result = append(result, wr)
|
result = append(result, wr)
|
||||||
@@ -684,33 +810,41 @@ func (p *NiriWritableProvider) LoadDMSRules() ([]windowrules.WindowRule, error)
|
|||||||
IsUrgent: nr.MatchIsUrgent,
|
IsUrgent: nr.MatchIsUrgent,
|
||||||
AtStartup: nr.MatchAtStartup,
|
AtStartup: nr.MatchAtStartup,
|
||||||
},
|
},
|
||||||
|
Matches: convertNiriMatches(nr.Matches),
|
||||||
Actions: windowrules.Actions{
|
Actions: windowrules.Actions{
|
||||||
Opacity: nr.Opacity,
|
Opacity: nr.Opacity,
|
||||||
OpenFloating: nr.OpenFloating,
|
OpenFloating: nr.OpenFloating,
|
||||||
OpenMaximized: nr.OpenMaximized,
|
OpenMaximized: nr.OpenMaximized,
|
||||||
OpenMaximizedToEdges: nr.OpenMaximizedToEdges,
|
OpenMaximizedToEdges: nr.OpenMaximizedToEdges,
|
||||||
OpenFullscreen: nr.OpenFullscreen,
|
OpenFullscreen: nr.OpenFullscreen,
|
||||||
OpenFocused: nr.OpenFocused,
|
OpenFocused: nr.OpenFocused,
|
||||||
OpenOnOutput: nr.OpenOnOutput,
|
OpenOnOutput: nr.OpenOnOutput,
|
||||||
OpenOnWorkspace: nr.OpenOnWorkspace,
|
OpenOnWorkspace: nr.OpenOnWorkspace,
|
||||||
DefaultColumnWidth: nr.DefaultColumnWidth,
|
DefaultColumnWidth: nr.DefaultColumnWidth,
|
||||||
DefaultWindowHeight: nr.DefaultWindowHeight,
|
DefaultWindowHeight: nr.DefaultWindowHeight,
|
||||||
VariableRefreshRate: nr.VariableRefreshRate,
|
VariableRefreshRate: nr.VariableRefreshRate,
|
||||||
BlockOutFrom: nr.BlockOutFrom,
|
BlockOutFrom: nr.BlockOutFrom,
|
||||||
DefaultColumnDisplay: nr.DefaultColumnDisplay,
|
DefaultColumnDisplay: nr.DefaultColumnDisplay,
|
||||||
ScrollFactor: nr.ScrollFactor,
|
ScrollFactor: nr.ScrollFactor,
|
||||||
CornerRadius: nr.CornerRadius,
|
CornerRadius: nr.CornerRadius,
|
||||||
ClipToGeometry: nr.ClipToGeometry,
|
ClipToGeometry: nr.ClipToGeometry,
|
||||||
TiledState: nr.TiledState,
|
TiledState: nr.TiledState,
|
||||||
MinWidth: nr.MinWidth,
|
MinWidth: nr.MinWidth,
|
||||||
MaxWidth: nr.MaxWidth,
|
MaxWidth: nr.MaxWidth,
|
||||||
MinHeight: nr.MinHeight,
|
MinHeight: nr.MinHeight,
|
||||||
MaxHeight: nr.MaxHeight,
|
MaxHeight: nr.MaxHeight,
|
||||||
BorderColor: nr.BorderColor,
|
BorderColor: nr.BorderColor,
|
||||||
FocusRingColor: nr.FocusRingColor,
|
FocusRingColor: nr.FocusRingColor,
|
||||||
FocusRingOff: nr.FocusRingOff,
|
FocusRingOff: nr.FocusRingOff,
|
||||||
BorderOff: nr.BorderOff,
|
BorderOff: nr.BorderOff,
|
||||||
DrawBorderWithBg: nr.DrawBorderWithBg,
|
DrawBorderWithBg: nr.DrawBorderWithBg,
|
||||||
|
BackgroundBlur: nr.BgBlur,
|
||||||
|
BackgroundXray: nr.BgXray,
|
||||||
|
BackgroundNoise: nr.BgNoise,
|
||||||
|
BackgroundSaturation: nr.BgSaturation,
|
||||||
|
DefaultFloatingX: nr.DefaultFloatingX,
|
||||||
|
DefaultFloatingY: nr.DefaultFloatingY,
|
||||||
|
DefaultFloatingRelativeTo: nr.DefaultFloatingRelative,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -740,44 +874,54 @@ func (p *NiriWritableProvider) writeDMSRules(rules []windowrules.WindowRule) err
|
|||||||
return os.WriteFile(rulesPath, []byte(strings.Join(lines, "\n")), 0644)
|
return os.WriteFile(rulesPath, []byte(strings.Join(lines, "\n")), 0644)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func formatNiriMatchLine(m windowrules.MatchCriteria) (string, bool) {
|
||||||
|
var matchProps []string
|
||||||
|
if m.AppID != "" {
|
||||||
|
matchProps = append(matchProps, fmt.Sprintf("app-id=%q", m.AppID))
|
||||||
|
}
|
||||||
|
if m.Title != "" {
|
||||||
|
matchProps = append(matchProps, fmt.Sprintf("title=%q", m.Title))
|
||||||
|
}
|
||||||
|
if m.IsFloating != nil {
|
||||||
|
matchProps = append(matchProps, fmt.Sprintf("is-floating=%t", *m.IsFloating))
|
||||||
|
}
|
||||||
|
if m.IsActive != nil {
|
||||||
|
matchProps = append(matchProps, fmt.Sprintf("is-active=%t", *m.IsActive))
|
||||||
|
}
|
||||||
|
if m.IsFocused != nil {
|
||||||
|
matchProps = append(matchProps, fmt.Sprintf("is-focused=%t", *m.IsFocused))
|
||||||
|
}
|
||||||
|
if m.IsActiveInColumn != nil {
|
||||||
|
matchProps = append(matchProps, fmt.Sprintf("is-active-in-column=%t", *m.IsActiveInColumn))
|
||||||
|
}
|
||||||
|
if m.IsWindowCastTarget != nil {
|
||||||
|
matchProps = append(matchProps, fmt.Sprintf("is-window-cast-target=%t", *m.IsWindowCastTarget))
|
||||||
|
}
|
||||||
|
if m.IsUrgent != nil {
|
||||||
|
matchProps = append(matchProps, fmt.Sprintf("is-urgent=%t", *m.IsUrgent))
|
||||||
|
}
|
||||||
|
if m.AtStartup != nil {
|
||||||
|
matchProps = append(matchProps, fmt.Sprintf("at-startup=%t", *m.AtStartup))
|
||||||
|
}
|
||||||
|
if len(matchProps) == 0 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return " match " + strings.Join(matchProps, " "), true
|
||||||
|
}
|
||||||
|
|
||||||
func (p *NiriWritableProvider) formatRule(rule windowrules.WindowRule) string {
|
func (p *NiriWritableProvider) formatRule(rule windowrules.WindowRule) string {
|
||||||
var lines []string
|
var lines []string
|
||||||
lines = append(lines, fmt.Sprintf("// @id=%s @name=%s", rule.ID, rule.Name))
|
lines = append(lines, fmt.Sprintf("// @id=%s @name=%s", rule.ID, rule.Name))
|
||||||
lines = append(lines, "window-rule {")
|
lines = append(lines, "window-rule {")
|
||||||
|
|
||||||
m := rule.MatchCriteria
|
matches := rule.Matches
|
||||||
if m.AppID != "" || m.Title != "" || m.IsFloating != nil || m.IsActive != nil ||
|
if len(matches) == 0 {
|
||||||
m.IsFocused != nil || m.IsActiveInColumn != nil || m.IsWindowCastTarget != nil ||
|
matches = []windowrules.MatchCriteria{rule.MatchCriteria}
|
||||||
m.IsUrgent != nil || m.AtStartup != nil {
|
}
|
||||||
var matchProps []string
|
for _, m := range matches {
|
||||||
if m.AppID != "" {
|
if line, ok := formatNiriMatchLine(m); ok {
|
||||||
matchProps = append(matchProps, fmt.Sprintf("app-id=%q", m.AppID))
|
lines = append(lines, line)
|
||||||
}
|
}
|
||||||
if m.Title != "" {
|
|
||||||
matchProps = append(matchProps, fmt.Sprintf("title=%q", m.Title))
|
|
||||||
}
|
|
||||||
if m.IsFloating != nil {
|
|
||||||
matchProps = append(matchProps, fmt.Sprintf("is-floating=%t", *m.IsFloating))
|
|
||||||
}
|
|
||||||
if m.IsActive != nil {
|
|
||||||
matchProps = append(matchProps, fmt.Sprintf("is-active=%t", *m.IsActive))
|
|
||||||
}
|
|
||||||
if m.IsFocused != nil {
|
|
||||||
matchProps = append(matchProps, fmt.Sprintf("is-focused=%t", *m.IsFocused))
|
|
||||||
}
|
|
||||||
if m.IsActiveInColumn != nil {
|
|
||||||
matchProps = append(matchProps, fmt.Sprintf("is-active-in-column=%t", *m.IsActiveInColumn))
|
|
||||||
}
|
|
||||||
if m.IsWindowCastTarget != nil {
|
|
||||||
matchProps = append(matchProps, fmt.Sprintf("is-window-cast-target=%t", *m.IsWindowCastTarget))
|
|
||||||
}
|
|
||||||
if m.IsUrgent != nil {
|
|
||||||
matchProps = append(matchProps, fmt.Sprintf("is-urgent=%t", *m.IsUrgent))
|
|
||||||
}
|
|
||||||
if m.AtStartup != nil {
|
|
||||||
matchProps = append(matchProps, fmt.Sprintf("at-startup=%t", *m.AtStartup))
|
|
||||||
}
|
|
||||||
lines = append(lines, " match "+strings.Join(matchProps, " "))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
a := rule.Actions
|
a := rule.Actions
|
||||||
@@ -858,10 +1002,39 @@ func (p *NiriWritableProvider) formatRule(rule windowrules.WindowRule) string {
|
|||||||
lines = append(lines, fmt.Sprintf(" draw-border-with-background %t", *a.DrawBorderWithBg))
|
lines = append(lines, fmt.Sprintf(" draw-border-with-background %t", *a.DrawBorderWithBg))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if a.BackgroundBlur != nil || a.BackgroundXray != nil || a.BackgroundNoise != nil || a.BackgroundSaturation != nil {
|
||||||
|
lines = append(lines, " background-effect {")
|
||||||
|
if a.BackgroundBlur != nil {
|
||||||
|
lines = append(lines, fmt.Sprintf(" blur %t", *a.BackgroundBlur))
|
||||||
|
}
|
||||||
|
if a.BackgroundXray != nil {
|
||||||
|
lines = append(lines, fmt.Sprintf(" xray %t", *a.BackgroundXray))
|
||||||
|
}
|
||||||
|
if a.BackgroundNoise != nil {
|
||||||
|
lines = append(lines, fmt.Sprintf(" noise %s", formatFloat(*a.BackgroundNoise)))
|
||||||
|
}
|
||||||
|
if a.BackgroundSaturation != nil {
|
||||||
|
lines = append(lines, fmt.Sprintf(" saturation %s", formatFloat(*a.BackgroundSaturation)))
|
||||||
|
}
|
||||||
|
lines = append(lines, " }")
|
||||||
|
}
|
||||||
|
|
||||||
|
if a.DefaultFloatingX != nil && a.DefaultFloatingY != nil {
|
||||||
|
line := fmt.Sprintf(" default-floating-position x=%d y=%d", *a.DefaultFloatingX, *a.DefaultFloatingY)
|
||||||
|
if a.DefaultFloatingRelativeTo != "" {
|
||||||
|
line += fmt.Sprintf(" relative-to=%q", a.DefaultFloatingRelativeTo)
|
||||||
|
}
|
||||||
|
lines = append(lines, line)
|
||||||
|
}
|
||||||
|
|
||||||
lines = append(lines, "}")
|
lines = append(lines, "}")
|
||||||
return strings.Join(lines, "\n")
|
return strings.Join(lines, "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func formatFloat(f float64) string {
|
||||||
|
return strconv.FormatFloat(f, 'f', -1, 64)
|
||||||
|
}
|
||||||
|
|
||||||
func formatSizeProperty(name, value string) string {
|
func formatSizeProperty(name, value string) string {
|
||||||
parts := strings.SplitN(value, " ", 2)
|
parts := strings.SplitN(value, " ", 2)
|
||||||
if len(parts) == 2 {
|
if len(parts) == 2 {
|
||||||
|
|||||||
@@ -43,31 +43,40 @@ type Actions struct {
|
|||||||
FocusRingOff *bool `json:"focusRingOff,omitempty"`
|
FocusRingOff *bool `json:"focusRingOff,omitempty"`
|
||||||
BorderOff *bool `json:"borderOff,omitempty"`
|
BorderOff *bool `json:"borderOff,omitempty"`
|
||||||
DrawBorderWithBg *bool `json:"drawBorderWithBackground,omitempty"`
|
DrawBorderWithBg *bool `json:"drawBorderWithBackground,omitempty"`
|
||||||
Size string `json:"size,omitempty"`
|
BackgroundBlur *bool `json:"backgroundBlur,omitempty"`
|
||||||
Move string `json:"move,omitempty"`
|
BackgroundXray *bool `json:"backgroundXray,omitempty"`
|
||||||
Monitor string `json:"monitor,omitempty"`
|
BackgroundNoise *float64 `json:"backgroundNoise,omitempty"`
|
||||||
Workspace string `json:"workspace,omitempty"`
|
BackgroundSaturation *float64 `json:"backgroundSaturation,omitempty"`
|
||||||
Tile *bool `json:"tile,omitempty"`
|
|
||||||
NoFocus *bool `json:"nofocus,omitempty"`
|
DefaultFloatingX *int `json:"defaultFloatingX,omitempty"`
|
||||||
NoBorder *bool `json:"noborder,omitempty"`
|
DefaultFloatingY *int `json:"defaultFloatingY,omitempty"`
|
||||||
NoShadow *bool `json:"noshadow,omitempty"`
|
DefaultFloatingRelativeTo string `json:"defaultFloatingRelativeTo,omitempty"`
|
||||||
NoDim *bool `json:"nodim,omitempty"`
|
Size string `json:"size,omitempty"`
|
||||||
NoBlur *bool `json:"noblur,omitempty"`
|
Move string `json:"move,omitempty"`
|
||||||
NoAnim *bool `json:"noanim,omitempty"`
|
Monitor string `json:"monitor,omitempty"`
|
||||||
NoRounding *bool `json:"norounding,omitempty"`
|
Workspace string `json:"workspace,omitempty"`
|
||||||
Pin *bool `json:"pin,omitempty"`
|
Tile *bool `json:"tile,omitempty"`
|
||||||
Opaque *bool `json:"opaque,omitempty"`
|
NoFocus *bool `json:"nofocus,omitempty"`
|
||||||
ForcergbX *bool `json:"forcergbx,omitempty"`
|
NoBorder *bool `json:"noborder,omitempty"`
|
||||||
Idleinhibit string `json:"idleinhibit,omitempty"`
|
NoShadow *bool `json:"noshadow,omitempty"`
|
||||||
|
NoDim *bool `json:"nodim,omitempty"`
|
||||||
|
NoBlur *bool `json:"noblur,omitempty"`
|
||||||
|
NoAnim *bool `json:"noanim,omitempty"`
|
||||||
|
NoRounding *bool `json:"norounding,omitempty"`
|
||||||
|
Pin *bool `json:"pin,omitempty"`
|
||||||
|
Opaque *bool `json:"opaque,omitempty"`
|
||||||
|
ForcergbX *bool `json:"forcergbx,omitempty"`
|
||||||
|
Idleinhibit string `json:"idleinhibit,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type WindowRule struct {
|
type WindowRule struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
MatchCriteria MatchCriteria `json:"matchCriteria"`
|
MatchCriteria MatchCriteria `json:"matchCriteria"`
|
||||||
Actions Actions `json:"actions"`
|
Matches []MatchCriteria `json:"matches,omitempty"`
|
||||||
Source string `json:"source,omitempty"`
|
Actions Actions `json:"actions"`
|
||||||
|
Source string `json:"source,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DMSRulesStatus struct {
|
type DMSRulesStatus struct {
|
||||||
@@ -79,6 +88,8 @@ type DMSRulesStatus struct {
|
|||||||
Effective bool `json:"effective"`
|
Effective bool `json:"effective"`
|
||||||
OverriddenBy int `json:"overriddenBy"`
|
OverriddenBy int `json:"overriddenBy"`
|
||||||
StatusMessage string `json:"statusMessage"`
|
StatusMessage string `json:"statusMessage"`
|
||||||
|
ConfigFormat string `json:"configFormat,omitempty"`
|
||||||
|
ReadOnly bool `json:"readOnly,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type RuleSet struct {
|
type RuleSet struct {
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ override_dh_auto_install:
|
|||||||
install -Dm644 $$SOURCE_DIR/LICENSE \
|
install -Dm644 $$SOURCE_DIR/LICENSE \
|
||||||
debian/dms-greeter/usr/share/doc/dms-greeter/LICENSE && \
|
debian/dms-greeter/usr/share/doc/dms-greeter/LICENSE && \
|
||||||
install -Dpm0644 $$SOURCE_DIR/systemd/tmpfiles-dms-greeter.conf \
|
install -Dpm0644 $$SOURCE_DIR/systemd/tmpfiles-dms-greeter.conf \
|
||||||
debian/dms-greeter/usr/lib/tmpfiles.d/dms-greeter.conf; \
|
debian/dms-greeter/usr/lib/tmpfiles.d/dms-greeter.conf && \
|
||||||
|
install -Dm644 $$SOURCE_DIR/systemd/sysusers-dms-greeter.conf \
|
||||||
|
debian/dms-greeter/usr/lib/sysusers.d/dms-greeter.conf; \
|
||||||
else \
|
else \
|
||||||
echo "ERROR: No upstream source (dms-qml or Modules/Greetd/assets/dms-greeter)!" && \
|
echo "ERROR: No upstream source (dms-qml or Modules/Greetd/assets/dms-greeter)!" && \
|
||||||
echo "Contents of current directory:" && ls -la && exit 1; \
|
echo "Contents of current directory:" && ls -la && exit 1; \
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ install -Dm644 %{_builddir}/dms-qml/Modules/Greetd/README.md %{buildroot}%{_docd
|
|||||||
|
|
||||||
install -Dpm0644 %{_builddir}/dms-qml/systemd/tmpfiles-dms-greeter.conf %{buildroot}%{_tmpfilesdir}/dms-greeter.conf
|
install -Dpm0644 %{_builddir}/dms-qml/systemd/tmpfiles-dms-greeter.conf %{buildroot}%{_tmpfilesdir}/dms-greeter.conf
|
||||||
|
|
||||||
|
install -Dm644 %{_builddir}/dms-qml/systemd/sysusers-dms-greeter.conf %{buildroot}%{_sysusersdir}/dms-greeter.conf
|
||||||
|
|
||||||
install -Dm644 %{_builddir}/dms-qml/LICENSE %{buildroot}%{_docdir}/dms-greeter/LICENSE
|
install -Dm644 %{_builddir}/dms-qml/LICENSE %{buildroot}%{_docdir}/dms-greeter/LICENSE
|
||||||
|
|
||||||
install -dm755 %{buildroot}%{_sharedstatedir}/greeter
|
install -dm755 %{buildroot}%{_sharedstatedir}/greeter
|
||||||
@@ -78,6 +80,7 @@ fi
|
|||||||
%{_bindir}/dms-greeter
|
%{_bindir}/dms-greeter
|
||||||
%{_datadir}/quickshell/dms-greeter/
|
%{_datadir}/quickshell/dms-greeter/
|
||||||
%{_tmpfilesdir}/%{name}.conf
|
%{_tmpfilesdir}/%{name}.conf
|
||||||
|
%{_sysusersdir}/dms-greeter.conf
|
||||||
|
|
||||||
%pre
|
%pre
|
||||||
# Create greeter user/group if they don't exist
|
# Create greeter user/group if they don't exist
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ install -Dm644 %{_builddir}/dms-qml/Modules/Greetd/README.md %{buildroot}%{_docd
|
|||||||
|
|
||||||
install -Dpm0644 %{_builddir}/dms-qml/systemd/tmpfiles-dms-greeter.conf %{buildroot}%{_tmpfilesdir}/dms-greeter.conf
|
install -Dpm0644 %{_builddir}/dms-qml/systemd/tmpfiles-dms-greeter.conf %{buildroot}%{_tmpfilesdir}/dms-greeter.conf
|
||||||
|
|
||||||
|
install -Dm644 %{_builddir}/dms-qml/systemd/sysusers-dms-greeter.conf %{buildroot}%{_sysusersdir}/dms-greeter.conf
|
||||||
|
|
||||||
install -Dm644 %{_builddir}/dms-qml/LICENSE %{buildroot}%{_docdir}/dms-greeter/LICENSE
|
install -Dm644 %{_builddir}/dms-qml/LICENSE %{buildroot}%{_docdir}/dms-greeter/LICENSE
|
||||||
|
|
||||||
install -dm755 %{buildroot}%{_sharedstatedir}/greeter
|
install -dm755 %{buildroot}%{_sharedstatedir}/greeter
|
||||||
@@ -78,6 +80,7 @@ fi
|
|||||||
%dir %{_datadir}/quickshell
|
%dir %{_datadir}/quickshell
|
||||||
%{_datadir}/quickshell/dms-greeter/
|
%{_datadir}/quickshell/dms-greeter/
|
||||||
%{_tmpfilesdir}/%{name}.conf
|
%{_tmpfilesdir}/%{name}.conf
|
||||||
|
%{_sysusersdir}/dms-greeter.conf
|
||||||
|
|
||||||
%pre
|
%pre
|
||||||
# Create greeter user/group if they don't exist
|
# Create greeter user/group if they don't exist
|
||||||
|
|||||||
@@ -40,6 +40,11 @@ override_dh_auto_install:
|
|||||||
install -Dm644 DankMaterialShell-$(BASE_VERSION)/LICENSE \
|
install -Dm644 DankMaterialShell-$(BASE_VERSION)/LICENSE \
|
||||||
debian/dms-greeter/usr/share/doc/dms-greeter/LICENSE
|
debian/dms-greeter/usr/share/doc/dms-greeter/LICENSE
|
||||||
|
|
||||||
|
install -Dpm0644 DankMaterialShell-$(BASE_VERSION)/quickshell/systemd/tmpfiles-dms-greeter.conf \
|
||||||
|
debian/dms-greeter/usr/lib/tmpfiles.d/dms-greeter.conf
|
||||||
|
install -Dm644 DankMaterialShell-$(BASE_VERSION)/quickshell/systemd/sysusers-dms-greeter.conf \
|
||||||
|
debian/dms-greeter/usr/lib/sysusers.d/dms-greeter.conf
|
||||||
|
|
||||||
# Create cache directory structure (will be created by postinst)
|
# Create cache directory structure (will be created by postinst)
|
||||||
mkdir -p debian/dms-greeter/var/cache/dms-greeter
|
mkdir -p debian/dms-greeter/var/cache/dms-greeter
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
# Hyprland Lua Migration
|
||||||
|
|
||||||
|
Hyprland 0.55 moved configuration toward Lua. DMS now follows that path for new
|
||||||
|
Hyprland setup and migration.
|
||||||
|
|
||||||
|
This guide covers what changes, where files live, and how to check that your
|
||||||
|
session is using the new config.
|
||||||
|
|
||||||
|
## Quick Summary
|
||||||
|
|
||||||
|
DMS now deploys Hyprland as:
|
||||||
|
|
||||||
|
```text
|
||||||
|
~/.config/hypr/hyprland.lua
|
||||||
|
~/.config/hypr/dms/*.lua
|
||||||
|
```
|
||||||
|
|
||||||
|
The old hyprlang files are moved out of the active config tree:
|
||||||
|
|
||||||
|
```text
|
||||||
|
~/.config/hypr/hyprland.conf
|
||||||
|
~/.config/hypr/dms/*.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
Backups are stored here:
|
||||||
|
|
||||||
|
```text
|
||||||
|
~/.config/hypr/.dms-backups/<timestamp>/
|
||||||
|
```
|
||||||
|
|
||||||
|
## What `dms setup` Does
|
||||||
|
|
||||||
|
When Hyprland is selected, `dms setup` writes a Lua main config and DMS Lua
|
||||||
|
fragments.
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `hyprland.lua` | Main Hyprland config. |
|
||||||
|
| `dms/colors.lua` | Theme colors. |
|
||||||
|
| `dms/outputs.lua` | Monitors and display settings. |
|
||||||
|
| `dms/layout.lua` | Layout, gaps, borders, and decoration. |
|
||||||
|
| `dms/cursor.lua` | Cursor settings. |
|
||||||
|
| `dms/binds.lua` | DMS-managed default shortcuts. |
|
||||||
|
| `dms/binds-user.lua` | User shortcut overrides. |
|
||||||
|
| `dms/windowrules.lua` | Window rules. |
|
||||||
|
|
||||||
|
`dms/binds.lua` is managed by DMS and may be refreshed by setup. Put custom
|
||||||
|
keyboard shortcuts in `dms/binds-user.lua`, or use the Keyboard Shortcuts page in
|
||||||
|
DMS Settings.
|
||||||
|
|
||||||
|
Stock configs include a 3-finger horizontal touchpad gesture for workspace
|
||||||
|
switching (`hl.gesture` in `dms/binds.lua`) and basic touchpad settings
|
||||||
|
(`tap_to_click`, `natural_scroll` in `hyprland.lua`). To customize or disable
|
||||||
|
gestures, add your own `hl.gesture(...)` lines to `dms/binds-user.lua`, or unset
|
||||||
|
a stock gesture with `action = "unset"` matching the original fingers,
|
||||||
|
direction, and modifiers.
|
||||||
|
|
||||||
|
Most other existing non-empty Lua fragments are preserved.
|
||||||
|
|
||||||
|
## Legacy Config Migration
|
||||||
|
|
||||||
|
During migration, DMS moves legacy active files into the backup folder so
|
||||||
|
Hyprland does not see both config formats at once.
|
||||||
|
|
||||||
|
DMS also migrates legacy `monitor = ...` lines from `hyprland.conf` into
|
||||||
|
`dms/outputs.lua` when `outputs.lua` is empty or missing. If you already have a
|
||||||
|
custom `outputs.lua`, DMS leaves it alone.
|
||||||
|
|
||||||
|
## DMS Settings Support
|
||||||
|
|
||||||
|
DMS Settings now targets Lua files for Hyprland:
|
||||||
|
|
||||||
|
| Settings page | Lua file |
|
||||||
|
| --- | --- |
|
||||||
|
| Keyboard Shortcuts | `dms/binds-user.lua` |
|
||||||
|
| Displays | `dms/outputs.lua` |
|
||||||
|
| Theme Colors | `dms/colors.lua` |
|
||||||
|
| Cursor | `dms/cursor.lua` |
|
||||||
|
| Window Rules | `dms/windowrules.lua` |
|
||||||
|
|
||||||
|
The main config should include the DMS fragments:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
require("dms.colors")
|
||||||
|
require("dms.outputs")
|
||||||
|
require("dms.layout")
|
||||||
|
require("dms.cursor")
|
||||||
|
require("dms.binds")
|
||||||
|
require("dms.binds-user")
|
||||||
|
require("dms.windowrules")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Keyboard Shortcuts: Delete and Reset
|
||||||
|
|
||||||
|
The Keyboard Shortcuts page exposes two actions on any DMS-managed bind:
|
||||||
|
|
||||||
|
- **Delete** — removes the shortcut entirely. For default DMS shortcuts (from
|
||||||
|
`dms/binds.lua`), this saves an `hl.unbind("KEY")` line into
|
||||||
|
`dms/binds-user.lua` so the removal sticks across `dms setup` runs.
|
||||||
|
- **Reset to default** — only visible when you are editing a user override of
|
||||||
|
a DMS default. It drops your override so the original DMS default re-applies.
|
||||||
|
|
||||||
|
Binds from your own `hyprland.lua` (outside the `dms/` folder) are read-only
|
||||||
|
in Settings — DMS does not write into files it does not manage.
|
||||||
|
|
||||||
|
## Starting Hyprland
|
||||||
|
|
||||||
|
For the Lua config to be active, Hyprland must start with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
Hyprland -c ~/.config/hypr/hyprland.lua
|
||||||
|
```
|
||||||
|
|
||||||
|
If Hyprland warns that it is using an autogenerated config, or the warning
|
||||||
|
mentions `hyprland.conf`, the session is not using the DMS Lua config yet.
|
||||||
|
|
||||||
|
## Verify Everything
|
||||||
|
|
||||||
|
After updating DMS, run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
dms setup
|
||||||
|
hyprctl reload
|
||||||
|
hyprctl configerrors
|
||||||
|
```
|
||||||
|
|
||||||
|
If the current session was not started from `hyprland.lua`, restart Hyprland with
|
||||||
|
the Lua config and check again.
|
||||||
|
|
||||||
|
Useful file checks:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
test -f ~/.config/hypr/hyprland.lua
|
||||||
|
test ! -f ~/.config/hypr/hyprland.conf
|
||||||
|
ls ~/.config/hypr/dms
|
||||||
|
```
|
||||||
|
|
||||||
|
The live `dms` folder should contain Lua files like `binds.lua`,
|
||||||
|
`binds-user.lua`, `outputs.lua`, and `windowrules.lua`.
|
||||||
|
|
||||||
|
Note: Hyprland 0.55 still auto-generates `hyprland.conf` if you launch it
|
||||||
|
without `-c ~/.config/hypr/hyprland.lua`. DMS sweeps any stray
|
||||||
|
`hyprland.conf` into `.dms-backups/<timestamp>/` on the next `dms run`
|
||||||
|
startup, so the second check above is the right long-term state. If you see
|
||||||
|
`hyprland.conf` persist between `dms run` invocations, the session was not
|
||||||
|
started from `hyprland.lua` — restart Hyprland with the `-c` flag (or update
|
||||||
|
your session/desktop entry to include it).
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
If shortcuts do not work, confirm `hyprland.lua` includes both:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
require("dms.binds")
|
||||||
|
require("dms.binds-user")
|
||||||
|
```
|
||||||
|
|
||||||
|
If `hyprctl configerrors` reports errors in `dms/binds.lua`, rerun `dms setup`
|
||||||
|
with the latest DMS binary so the DMS-managed shortcut file is refreshed.
|
||||||
|
|
||||||
|
If a migrated monitor setup looks wrong, compare:
|
||||||
|
|
||||||
|
```text
|
||||||
|
~/.config/hypr/dms/outputs.lua
|
||||||
|
~/.config/hypr/.dms-backups/<timestamp>/
|
||||||
|
```
|
||||||
|
|
||||||
|
Your previous config should be available in the timestamped backup folder.
|
||||||
|
|
||||||
|
## Reference Map
|
||||||
|
|
||||||
|
```text
|
||||||
|
~/.config/hypr/
|
||||||
|
|-- hyprland.lua # Main DMS Hyprland config
|
||||||
|
|-- .dms-backups/ # Timestamped backups from setup/migration
|
||||||
|
`-- dms/
|
||||||
|
|-- colors.lua # Theme colors
|
||||||
|
|-- outputs.lua # Monitor/output config
|
||||||
|
|-- layout.lua # Layout, gaps, borders, decoration
|
||||||
|
|-- cursor.lua # Cursor settings
|
||||||
|
|-- binds.lua # DMS-managed default shortcuts
|
||||||
|
|-- binds-user.lua # User shortcut overrides
|
||||||
|
`-- windowrules.lua # DMS-managed window rules
|
||||||
|
```
|
||||||
|
|
||||||
|
Legacy files such as `hyprland.conf` and `dms/*.conf` should live in
|
||||||
|
`.dms-backups/<timestamp>/` after migration, not in the active config tree.
|
||||||
|
|
||||||
|
## Maintainer Note
|
||||||
|
|
||||||
|
Embedded source files live in `core/internal/config/embedded/` and use names like
|
||||||
|
`hypr-binds.lua`. Installed user files use shorter names like `dms/binds.lua`.
|
||||||
|
|
||||||
|
After changing Hyprland config deployment or parsing, run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd core
|
||||||
|
go test ./internal/config ./internal/keybinds/providers ./internal/windowrules/providers
|
||||||
|
go test ./...
|
||||||
|
```
|
||||||
+157
@@ -212,6 +212,52 @@ dms ipc call lock lock
|
|||||||
dms ipc call lock isLocked
|
dms ipc call lock isLocked
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Target: `sessions`
|
||||||
|
|
||||||
|
Logind session enumeration and seat-local session switching. Wraps `loginctl list-sessions` and `loginctl activate`. Only switches between sessions that are *already running* on the current seat — creating a fresh login as another user requires a multi-session greeter setup (greetd-flexiserver / GDM / LightDM) and is out of scope.
|
||||||
|
|
||||||
|
### Functions
|
||||||
|
|
||||||
|
**`list`**
|
||||||
|
- Print every session DMS knows about as tab-separated columns: `sessionId\tusername\tseat\ttty\ttype\tcurrent-marker`
|
||||||
|
- Returns: Multi-line string. The current session is marked with `*current*`.
|
||||||
|
|
||||||
|
**`refresh`**
|
||||||
|
- Re-enumerate sessions in the background (the picker also refreshes itself on open)
|
||||||
|
- Returns: `"ok"`
|
||||||
|
|
||||||
|
**`open`**
|
||||||
|
- Refresh and open the Switch User picker on the focused screen
|
||||||
|
- Returns: `"ok"`
|
||||||
|
|
||||||
|
**`activate <sessionId>`**
|
||||||
|
- Activate a session by its numeric logind ID (the `Id=` field from `loginctl show-session`). Performs a VT switch
|
||||||
|
- Parameters: `sessionId` - Numeric session ID
|
||||||
|
- Returns: `"ok"` on dispatch, `"ERROR: missing session id"` if blank
|
||||||
|
- Note: Failures from `loginctl activate` surface through the `switchFailed` QML signal and a Log warning — the IPC call returns success once the spawn is queued, not after activation completes
|
||||||
|
|
||||||
|
**`switchTo <target>`**
|
||||||
|
- Switch to another session by username *or* session ID. The first non-current session matching the username wins; if there's no match, the call fails through the same logging path as `activate`
|
||||||
|
- Parameters: `target` - Username (e.g. `testuser2`) or numeric session ID
|
||||||
|
- Returns: `"ok"` on dispatch, `"ERROR: missing target (username or session id)"` if blank
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
```bash
|
||||||
|
# Inspect what's switchable
|
||||||
|
dms ipc call sessions list
|
||||||
|
|
||||||
|
# Open the picker (useful for a keybind)
|
||||||
|
dms ipc call sessions open
|
||||||
|
|
||||||
|
# Jump straight to another logged-in user without the picker
|
||||||
|
dms ipc call sessions switchTo testuser2
|
||||||
|
|
||||||
|
# Or by session ID, when the user has multiple sessions
|
||||||
|
dms ipc call sessions activate 4
|
||||||
|
```
|
||||||
|
|
||||||
|
The dedicated `dms switch-user [target]` CLI command wraps the same behavior with a friendlier error path (it prints the switchable list when no target matches).
|
||||||
|
|
||||||
## Target: `inhibit`
|
## Target: `inhibit`
|
||||||
|
|
||||||
Idle inhibitor control to prevent automatic sleep/lock.
|
Idle inhibitor control to prevent automatic sleep/lock.
|
||||||
@@ -236,6 +282,53 @@ dms ipc call inhibit toggle
|
|||||||
dms ipc call inhibit enable
|
dms ipc call inhibit enable
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Target: `powerprofile`
|
||||||
|
|
||||||
|
Power profile control via `power-profiles-daemon`. Changes stay in sync with DMS UI and trigger the power profile OSD when enabled.
|
||||||
|
|
||||||
|
Requires `power-profiles-daemon` to be installed and running. Works on all compositors.
|
||||||
|
|
||||||
|
### Functions
|
||||||
|
|
||||||
|
**`open`**
|
||||||
|
- Show the power profile picker modal
|
||||||
|
- Returns: Success confirmation or error if daemon unavailable
|
||||||
|
|
||||||
|
**`close`**
|
||||||
|
- Close the power profile picker modal
|
||||||
|
- Returns: Success confirmation
|
||||||
|
|
||||||
|
**`toggle`**
|
||||||
|
- Toggle power profile picker modal visibility
|
||||||
|
- Returns: Success confirmation or error if daemon unavailable
|
||||||
|
|
||||||
|
**`list`**
|
||||||
|
- List available profile slugs, one per line
|
||||||
|
- Returns: `power-saver`, `balanced`, and `performance` when supported
|
||||||
|
|
||||||
|
**`status`**
|
||||||
|
- Get the currently active profile slug
|
||||||
|
- Returns: `power-saver`, `balanced`, `performance`, or error if daemon unavailable
|
||||||
|
|
||||||
|
**`set <profile>`**
|
||||||
|
- Set the active power profile
|
||||||
|
- Parameters: Profile slug or alias — `power-saver` (`powersaver`, `saver`, `0`), `balanced` (`1`), `performance` (`2`)
|
||||||
|
- Returns: Success confirmation or error if profile unknown, unsupported, or write failed
|
||||||
|
|
||||||
|
**`cycle`**
|
||||||
|
- Cycle to the next available profile in order: power-saver → balanced → performance → power-saver
|
||||||
|
- Returns: Success confirmation or error if daemon unavailable or write failed
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
```bash
|
||||||
|
dms ipc call powerprofile status
|
||||||
|
dms ipc call powerprofile list
|
||||||
|
dms ipc call powerprofile cycle
|
||||||
|
dms ipc call powerprofile set balanced
|
||||||
|
dms ipc call powerprofile set performance
|
||||||
|
dms ipc call powerprofile toggle
|
||||||
|
```
|
||||||
|
|
||||||
## Target: `wallpaper`
|
## Target: `wallpaper`
|
||||||
|
|
||||||
Wallpaper management and retrieval with support for per-monitor configurations.
|
Wallpaper management and retrieval with support for per-monitor configurations.
|
||||||
@@ -439,6 +532,54 @@ dms ipc call systemupdater close
|
|||||||
dms ipc call systemupdater updatestatus
|
dms ipc call systemupdater updatestatus
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Target: `defaultApp`
|
||||||
|
|
||||||
|
Launch applications configured in Settings > Default Apps.
|
||||||
|
|
||||||
|
### Functions
|
||||||
|
|
||||||
|
**`browser`**
|
||||||
|
- Launch the configured default web browser
|
||||||
|
- Returns: Launch request confirmation
|
||||||
|
|
||||||
|
**`fileManager`**
|
||||||
|
- Launch the configured default file manager
|
||||||
|
- Returns: Launch request confirmation
|
||||||
|
|
||||||
|
**`textEditor`**
|
||||||
|
- Launch the configured default text editor
|
||||||
|
- Returns: Launch request confirmation
|
||||||
|
|
||||||
|
**`pdfReader`**
|
||||||
|
- Launch the configured default PDF reader
|
||||||
|
- Returns: Launch request confirmation
|
||||||
|
|
||||||
|
**`imageViewer`**
|
||||||
|
- Launch the configured default image viewer
|
||||||
|
- Returns: Launch request confirmation
|
||||||
|
|
||||||
|
**`videoPlayer`**
|
||||||
|
- Launch the configured default video player
|
||||||
|
- Returns: Launch request confirmation
|
||||||
|
|
||||||
|
**`musicPlayer`**
|
||||||
|
- Launch the configured default music player
|
||||||
|
- Returns: Launch request confirmation
|
||||||
|
|
||||||
|
**`mail`**
|
||||||
|
- Launch the configured default mail client
|
||||||
|
- Returns: Launch request confirmation
|
||||||
|
|
||||||
|
**`calendar`**
|
||||||
|
- Launch the configured default calendar application
|
||||||
|
- Returns: Launch request confirmation
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
```bash
|
||||||
|
dms ipc call defaultApp browser
|
||||||
|
dms ipc call defaultApp fileManager
|
||||||
|
```
|
||||||
|
|
||||||
## Modal Controls
|
## Modal Controls
|
||||||
|
|
||||||
These targets control various modal windows and overlays.
|
These targets control various modal windows and overlays.
|
||||||
@@ -497,6 +638,18 @@ Power menu modal control for system power actions.
|
|||||||
- `close` - Hide power menu modal
|
- `close` - Hide power menu modal
|
||||||
- `toggle` - Toggle power menu modal visibility
|
- `toggle` - Toggle power menu modal visibility
|
||||||
|
|
||||||
|
### Target: `powerprofile`
|
||||||
|
Power profile picker modal and profile control via `power-profiles-daemon`.
|
||||||
|
|
||||||
|
**Functions:**
|
||||||
|
- `open` - Show power profile picker modal
|
||||||
|
- `close` - Hide power profile picker modal
|
||||||
|
- `toggle` - Toggle power profile picker modal visibility
|
||||||
|
- `list` - List available profile slugs
|
||||||
|
- `status` - Get current profile slug
|
||||||
|
- `set <profile>` - Set profile by slug or alias (`power-saver`, `balanced`, `performance`)
|
||||||
|
- `cycle` - Cycle to the next available profile
|
||||||
|
|
||||||
### Target: `control-center`
|
### Target: `control-center`
|
||||||
Control Center popout containing network, bluetooth, audio, power, and other quick settings.
|
Control Center popout containing network, bluetooth, audio, power, and other quick settings.
|
||||||
|
|
||||||
@@ -627,6 +780,10 @@ dms ipc call processlist toggle
|
|||||||
# Show power menu
|
# Show power menu
|
||||||
dms ipc call powermenu toggle
|
dms ipc call powermenu toggle
|
||||||
|
|
||||||
|
# Cycle or set power profile (requires power-profiles-daemon)
|
||||||
|
dms ipc call powerprofile cycle
|
||||||
|
dms ipc call powerprofile toggle
|
||||||
|
|
||||||
# Open notepad
|
# Open notepad
|
||||||
dms ipc call notepad toggle
|
dms ipc call notepad toggle
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ Singleton {
|
|||||||
readonly property int durMed: 450
|
readonly property int durMed: 450
|
||||||
readonly property int durLong: 600
|
readonly property int durLong: 600
|
||||||
|
|
||||||
|
// Navigation feedback stays responsive even when ambient shell motion is slow.
|
||||||
|
readonly property int settingsNavigationStateDuration: 180
|
||||||
|
readonly property int settingsNavigationRippleDuration: 200
|
||||||
|
|
||||||
readonly property int slidePx: 80
|
readonly property int slidePx: 80
|
||||||
|
|
||||||
readonly property var emphasized: [0.05, 0.00, 0.133333, 0.06, 0.166667, 0.40, 0.208333, 0.82, 0.25, 1.00, 1.00, 1.00]
|
readonly property var emphasized: [0.05, 0.00, 0.133333, 0.06, 0.166667, 0.40, 0.208333, 0.82, 0.25, 1.00, 1.00, 1.00]
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
function shQuote(value) {
|
||||||
|
return "'" + String(value ?? "").replace(/'/g, "'\\''") + "'";
|
||||||
|
}
|
||||||
|
|
||||||
|
function dirname(path) {
|
||||||
|
const idx = String(path ?? "").lastIndexOf("/");
|
||||||
|
return idx > 0 ? path.substring(0, idx) : ".";
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRepairScript(options) {
|
||||||
|
const configFile = options.configFile;
|
||||||
|
const backupFile = options.backupFile;
|
||||||
|
const fragments = options.fragmentFiles || (options.fragmentFile ? [options.fragmentFile] : []);
|
||||||
|
const includes = options.includes || [{
|
||||||
|
grepPattern: options.grepPattern,
|
||||||
|
includeLine: options.includeLine
|
||||||
|
}];
|
||||||
|
|
||||||
|
const commands = [];
|
||||||
|
if (backupFile)
|
||||||
|
commands.push(`cp ${shQuote(configFile)} ${shQuote(backupFile)} 2>/dev/null || true`);
|
||||||
|
|
||||||
|
const dirs = {};
|
||||||
|
for (const fragment of fragments)
|
||||||
|
dirs[dirname(fragment)] = true;
|
||||||
|
for (const dir in dirs)
|
||||||
|
commands.push(`mkdir -p ${shQuote(dir)}`);
|
||||||
|
if (fragments.length > 0)
|
||||||
|
commands.push("touch " + fragments.map(shQuote).join(" "));
|
||||||
|
|
||||||
|
for (const include of includes) {
|
||||||
|
if (!include.grepPattern || !include.includeLine)
|
||||||
|
continue;
|
||||||
|
commands.push(`if ! grep -v '^[[:space:]]*\\(//\\|#\\|--\\)' ${shQuote(configFile)} 2>/dev/null | grep -q ${shQuote(include.grepPattern)}; then echo '' >> ${shQuote(configFile)} && printf '%s\\n' ${shQuote(include.includeLine)} >> ${shQuote(configFile)}; fi`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return commands.join("; ");
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
pragma ComponentBehavior: Bound
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
required property var modalHandle
|
||||||
|
required property string claimPrefix
|
||||||
|
property string surfaceKind: "modal"
|
||||||
|
property string screenName: ""
|
||||||
|
property bool enabled: false
|
||||||
|
property bool active: false
|
||||||
|
property bool presented: false
|
||||||
|
property bool dockBlocked: false
|
||||||
|
property string dockSide: ""
|
||||||
|
|
||||||
|
property alias claimId: lease.claimId
|
||||||
|
property alias claimedScreenName: lease.claimedScreenName
|
||||||
|
|
||||||
|
signal recoveryRequested
|
||||||
|
|
||||||
|
visible: false
|
||||||
|
|
||||||
|
function _isCurrentModal(name) {
|
||||||
|
return !!name && ModalManager.isCurrentModal(modalHandle, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
ConnectedSurfaceLease {
|
||||||
|
id: lease
|
||||||
|
claimPrefix: root.claimPrefix
|
||||||
|
screenName: root.screenName
|
||||||
|
enabled: root.enabled
|
||||||
|
active: root.active
|
||||||
|
presented: root.presented
|
||||||
|
dockBlocked: root.dockBlocked
|
||||||
|
dockSide: root.dockSide
|
||||||
|
isCurrentOwner: function(name) {
|
||||||
|
return root._isCurrentModal(name);
|
||||||
|
}
|
||||||
|
hasOwner: function(name, ownerId) {
|
||||||
|
return ConnectedModeState.hasModalOwner(name, ownerId);
|
||||||
|
}
|
||||||
|
statePresent: function(name, ownerId) {
|
||||||
|
return ConnectedModeState.hasModalOwner(name, ownerId) && ConnectedModeState.hasSurfaceDescriptor(name, root.surfaceKind, ownerId);
|
||||||
|
}
|
||||||
|
claimState: function(name, state, ownerId) {
|
||||||
|
return ConnectedModeState.claimModalState(name, state, ownerId);
|
||||||
|
}
|
||||||
|
ensureState: function(name, state, ownerId) {
|
||||||
|
return ConnectedModeState.ensureModalState(name, state, ownerId);
|
||||||
|
}
|
||||||
|
releaseState: function(name, ownerId) {
|
||||||
|
return ConnectedModeState.clearModalState(name, ownerId);
|
||||||
|
}
|
||||||
|
updateAnimationState: function(name, ownerId, animX, animY) {
|
||||||
|
return ConnectedModeState.setModalAnim(name, animX, animY, ownerId);
|
||||||
|
}
|
||||||
|
updateBodyState: function(name, ownerId, bodyX, bodyY, bodyW, bodyH) {
|
||||||
|
return ConnectedModeState.setModalBody(name, bodyX, bodyY, bodyW, bodyH, ownerId);
|
||||||
|
}
|
||||||
|
requestDockRetract: function(ownerId, name, side) {
|
||||||
|
return ConnectedModeState.requestDockRetract(ownerId, name, side);
|
||||||
|
}
|
||||||
|
releaseDockRetract: function(ownerId) {
|
||||||
|
return ConnectedModeState.releaseDockRetract(ownerId);
|
||||||
|
}
|
||||||
|
onRecoveryRequested: root.recoveryRequested()
|
||||||
|
}
|
||||||
|
|
||||||
|
function publish(state) {
|
||||||
|
return lease.publish(Object.assign({}, state, {
|
||||||
|
"kind": root.surfaceKind,
|
||||||
|
"screenName": root.screenName,
|
||||||
|
"presented": root.presented,
|
||||||
|
"dockRetractSide": root.dockBlocked ? root.dockSide : ""
|
||||||
|
}), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAnim(animX, animY) {
|
||||||
|
return lease.updateAnim(animX, animY);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBody(bodyX, bodyY, bodyW, bodyH) {
|
||||||
|
return lease.updateBody(bodyX, bodyY, bodyW, bodyH);
|
||||||
|
}
|
||||||
|
|
||||||
|
function release() {
|
||||||
|
return lease.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: ModalManager
|
||||||
|
function onModalChanged() {
|
||||||
|
lease.requestRecovery();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: ConnectedModeState
|
||||||
|
function onModalOwnersChanged() {
|
||||||
|
lease.checkOwnershipRecovery();
|
||||||
|
}
|
||||||
|
function onModalStatesChanged() {
|
||||||
|
lease.checkStateRecovery();
|
||||||
|
}
|
||||||
|
function onSurfaceDescriptorsChanged() {
|
||||||
|
lease.checkStateRecovery();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,10 +3,123 @@ pragma ComponentBehavior: Bound
|
|||||||
|
|
||||||
import QtQuick
|
import QtQuick
|
||||||
import Quickshell
|
import Quickshell
|
||||||
|
import "ConnectedSurfaceDescriptor.js" as SurfaceDescriptor
|
||||||
|
|
||||||
Singleton {
|
Singleton {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
|
property var surfaceDescriptors: ({})
|
||||||
|
|
||||||
|
function _surfaceSlot(kind) {
|
||||||
|
return SurfaceDescriptor.slotForKind(kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
function surfaceDescriptor(screenName, kind) {
|
||||||
|
const slot = _surfaceSlot(kind);
|
||||||
|
const screenDescriptors = screenName ? surfaceDescriptors[screenName] : null;
|
||||||
|
const descriptor = screenDescriptors && screenDescriptors[slot] ? screenDescriptors[slot] : SurfaceDescriptor.empty(kind, screenName);
|
||||||
|
let bodyRect = descriptor.bodyRect;
|
||||||
|
let animationOffset = descriptor.animationOffset;
|
||||||
|
if (slot === "popout" && popoutScreen === screenName) {
|
||||||
|
bodyRect = {
|
||||||
|
"x": popoutBodyX,
|
||||||
|
"y": popoutBodyY,
|
||||||
|
"width": popoutBodyW,
|
||||||
|
"height": popoutBodyH
|
||||||
|
};
|
||||||
|
animationOffset = {
|
||||||
|
"x": popoutAnimX,
|
||||||
|
"y": popoutAnimY
|
||||||
|
};
|
||||||
|
} else if (slot === "modal" && modalStates[screenName]) {
|
||||||
|
const modal = modalStates[screenName];
|
||||||
|
bodyRect = {
|
||||||
|
"x": modal.bodyX,
|
||||||
|
"y": modal.bodyY,
|
||||||
|
"width": modal.bodyW,
|
||||||
|
"height": modal.bodyH
|
||||||
|
};
|
||||||
|
animationOffset = {
|
||||||
|
"x": modal.animX,
|
||||||
|
"y": modal.animY
|
||||||
|
};
|
||||||
|
} else if (slot === "dock" && dockStates[screenName]) {
|
||||||
|
const dock = dockStates[screenName];
|
||||||
|
const slide = dockSlides[screenName] || {
|
||||||
|
"x": dock.slideX,
|
||||||
|
"y": dock.slideY
|
||||||
|
};
|
||||||
|
bodyRect = {
|
||||||
|
"x": dock.bodyX,
|
||||||
|
"y": dock.bodyY,
|
||||||
|
"width": dock.bodyW,
|
||||||
|
"height": dock.bodyH
|
||||||
|
};
|
||||||
|
animationOffset = {
|
||||||
|
"x": slide.x,
|
||||||
|
"y": slide.y
|
||||||
|
};
|
||||||
|
} else if (slot === "notification" && notificationStates[screenName]) {
|
||||||
|
const notification = notificationStates[screenName];
|
||||||
|
bodyRect = {
|
||||||
|
"x": notification.bodyX,
|
||||||
|
"y": notification.bodyY,
|
||||||
|
"width": notification.bodyW,
|
||||||
|
"height": notification.bodyH
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return SurfaceDescriptor.normalize({
|
||||||
|
"bodyRect": bodyRect,
|
||||||
|
"animationOffset": animationOffset
|
||||||
|
}, descriptor);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasSurfaceDescriptor(screenName, kind, ownerId) {
|
||||||
|
const descriptor = surfaceDescriptor(screenName, kind);
|
||||||
|
return descriptor.phase !== "hidden" && (!ownerId || descriptor.ownerId === ownerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _setSurfaceDescriptor(screenName, slotKind, state, ownerId) {
|
||||||
|
if (!screenName || !state)
|
||||||
|
return false;
|
||||||
|
const slot = _surfaceSlot(slotKind);
|
||||||
|
const currentScreen = surfaceDescriptors[screenName] || {};
|
||||||
|
const previous = currentScreen[slot] || SurfaceDescriptor.empty(state.kind || slotKind, screenName);
|
||||||
|
let normalized = SurfaceDescriptor.normalize(Object.assign({}, state, {
|
||||||
|
"ownerId": ownerId !== undefined ? ownerId : previous.ownerId,
|
||||||
|
"screenName": screenName,
|
||||||
|
"revision": previous.revision
|
||||||
|
}), previous);
|
||||||
|
if (SurfaceDescriptor.same(previous, normalized))
|
||||||
|
return true;
|
||||||
|
normalized = SurfaceDescriptor.withRevision(normalized, previous.revision + 1);
|
||||||
|
const nextScreen = _cloneDict(currentScreen);
|
||||||
|
nextScreen[slot] = normalized;
|
||||||
|
const next = _cloneDict(surfaceDescriptors);
|
||||||
|
next[screenName] = nextScreen;
|
||||||
|
surfaceDescriptors = next;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _clearSurfaceDescriptor(screenName, kind, ownerId) {
|
||||||
|
if (!screenName)
|
||||||
|
return false;
|
||||||
|
const slot = _surfaceSlot(kind);
|
||||||
|
const currentScreen = surfaceDescriptors[screenName];
|
||||||
|
const current = currentScreen ? currentScreen[slot] : null;
|
||||||
|
if (!current || (ownerId && current.ownerId !== ownerId))
|
||||||
|
return false;
|
||||||
|
const nextScreen = _cloneDict(currentScreen);
|
||||||
|
delete nextScreen[slot];
|
||||||
|
const next = _cloneDict(surfaceDescriptors);
|
||||||
|
if (Object.keys(nextScreen).length > 0)
|
||||||
|
next[screenName] = nextScreen;
|
||||||
|
else
|
||||||
|
delete next[screenName];
|
||||||
|
surfaceDescriptors = next;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
readonly property var emptyDockState: ({
|
readonly property var emptyDockState: ({
|
||||||
"reveal": false,
|
"reveal": false,
|
||||||
"barSide": "bottom",
|
"barSide": "bottom",
|
||||||
@@ -18,7 +131,6 @@ Singleton {
|
|||||||
"slideY": 0
|
"slideY": 0
|
||||||
})
|
})
|
||||||
|
|
||||||
// Popout state (updated by DankPopout when connectedFrameModeActive)
|
|
||||||
property string popoutOwnerId: ""
|
property string popoutOwnerId: ""
|
||||||
property bool popoutVisible: false
|
property bool popoutVisible: false
|
||||||
property string popoutBarSide: "top"
|
property string popoutBarSide: "top"
|
||||||
@@ -32,12 +144,12 @@ Singleton {
|
|||||||
property bool popoutOmitStartConnector: false
|
property bool popoutOmitStartConnector: false
|
||||||
property bool popoutOmitEndConnector: false
|
property bool popoutOmitEndConnector: false
|
||||||
|
|
||||||
// Dock state (updated by Dock when connectedFrameModeActive), keyed by screen.name
|
|
||||||
property var dockStates: ({})
|
property var dockStates: ({})
|
||||||
|
|
||||||
// Dock slide offsets — hot-path updates separated from full geometry state
|
|
||||||
property var dockSlides: ({})
|
property var dockSlides: ({})
|
||||||
|
|
||||||
|
property var surfaceRevisions: ({})
|
||||||
|
|
||||||
function _cloneDict(src) {
|
function _cloneDict(src) {
|
||||||
const next = {};
|
const next = {};
|
||||||
for (const k in src)
|
for (const k in src)
|
||||||
@@ -45,16 +157,33 @@ Singleton {
|
|||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _bumpSurfaceRevision(screenName) {
|
||||||
|
if (!screenName)
|
||||||
|
return;
|
||||||
|
const next = _cloneDict(surfaceRevisions);
|
||||||
|
next[screenName] = Number(next[screenName] || 0) + 1;
|
||||||
|
surfaceRevisions = next;
|
||||||
|
}
|
||||||
|
|
||||||
function hasPopoutOwner(claimId) {
|
function hasPopoutOwner(claimId) {
|
||||||
return !!claimId && popoutOwnerId === claimId;
|
return !!claimId && popoutOwnerId === claimId;
|
||||||
}
|
}
|
||||||
|
|
||||||
function claimPopout(claimId, state) {
|
function claimPopout(claimId, state) {
|
||||||
if (!claimId)
|
if (!claimId || !state)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
|
const previousScreen = popoutScreen;
|
||||||
popoutOwnerId = claimId;
|
popoutOwnerId = claimId;
|
||||||
return updatePopout(claimId, state);
|
const ok = updatePopout(claimId, state);
|
||||||
|
if (ok) {
|
||||||
|
if (previousScreen && previousScreen !== popoutScreen) {
|
||||||
|
_clearSurfaceDescriptor(previousScreen, "popout");
|
||||||
|
_bumpSurfaceRevision(previousScreen);
|
||||||
|
}
|
||||||
|
_bumpSurfaceRevision(popoutScreen);
|
||||||
|
}
|
||||||
|
return ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updatePopout(claimId, state) {
|
function updatePopout(claimId, state) {
|
||||||
@@ -84,6 +213,21 @@ Singleton {
|
|||||||
if (state.omitEndConnector !== undefined)
|
if (state.omitEndConnector !== undefined)
|
||||||
popoutOmitEndConnector = !!state.omitEndConnector;
|
popoutOmitEndConnector = !!state.omitEndConnector;
|
||||||
|
|
||||||
|
_setSurfaceDescriptor(popoutScreen, "popout", Object.assign({}, state, {
|
||||||
|
"kind": "popout",
|
||||||
|
"screenName": popoutScreen,
|
||||||
|
"visible": popoutVisible,
|
||||||
|
"presented": state.presented !== undefined ? !!state.presented : popoutVisible,
|
||||||
|
"barSide": popoutBarSide,
|
||||||
|
"bodyX": popoutBodyX,
|
||||||
|
"bodyY": popoutBodyY,
|
||||||
|
"bodyW": popoutBodyW,
|
||||||
|
"bodyH": popoutBodyH,
|
||||||
|
"animX": popoutAnimX,
|
||||||
|
"animY": popoutAnimY,
|
||||||
|
"omitStartConnector": popoutOmitStartConnector,
|
||||||
|
"omitEndConnector": popoutOmitEndConnector
|
||||||
|
}), claimId);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,6 +235,7 @@ Singleton {
|
|||||||
if (!hasPopoutOwner(claimId))
|
if (!hasPopoutOwner(claimId))
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
|
const releasedScreen = popoutScreen;
|
||||||
popoutOwnerId = "";
|
popoutOwnerId = "";
|
||||||
popoutVisible = false;
|
popoutVisible = false;
|
||||||
popoutBarSide = "top";
|
popoutBarSide = "top";
|
||||||
@@ -103,6 +248,8 @@ Singleton {
|
|||||||
popoutScreen = "";
|
popoutScreen = "";
|
||||||
popoutOmitStartConnector = false;
|
popoutOmitStartConnector = false;
|
||||||
popoutOmitEndConnector = false;
|
popoutOmitEndConnector = false;
|
||||||
|
_clearSurfaceDescriptor(releasedScreen, "popout", claimId);
|
||||||
|
_bumpSurfaceRevision(releasedScreen);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,12 +319,23 @@ Singleton {
|
|||||||
return false;
|
return false;
|
||||||
|
|
||||||
const normalized = _normalizeDockState(state);
|
const normalized = _normalizeDockState(state);
|
||||||
if (_sameDockState(dockStates[screenName], normalized))
|
const descriptorState = Object.assign({}, state, normalized, {
|
||||||
return true;
|
"kind": "dock",
|
||||||
|
"screenName": screenName,
|
||||||
const next = _cloneDict(dockStates);
|
"visible": normalized.reveal,
|
||||||
next[screenName] = normalized;
|
"presented": normalized.reveal,
|
||||||
dockStates = next;
|
"phase": normalized.reveal ? (state.phase || "open") : "hidden"
|
||||||
|
});
|
||||||
|
const previous = dockStates[screenName] || emptyDockState;
|
||||||
|
const stateChanged = !_sameDockState(dockStates[screenName], normalized);
|
||||||
|
if (stateChanged) {
|
||||||
|
const next = _cloneDict(dockStates);
|
||||||
|
next[screenName] = normalized;
|
||||||
|
dockStates = next;
|
||||||
|
}
|
||||||
|
_setSurfaceDescriptor(screenName, "dock", descriptorState, "dock:" + screenName);
|
||||||
|
if (!!previous.reveal !== !!normalized.reveal)
|
||||||
|
_bumpSurfaceRevision(screenName);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,13 +346,14 @@ Singleton {
|
|||||||
const next = _cloneDict(dockStates);
|
const next = _cloneDict(dockStates);
|
||||||
delete next[screenName];
|
delete next[screenName];
|
||||||
dockStates = next;
|
dockStates = next;
|
||||||
|
_clearSurfaceDescriptor(screenName, "dock");
|
||||||
|
|
||||||
// Also clear corresponding slide
|
|
||||||
if (dockSlides[screenName]) {
|
if (dockSlides[screenName]) {
|
||||||
const nextSlides = _cloneDict(dockSlides);
|
const nextSlides = _cloneDict(dockSlides);
|
||||||
delete nextSlides[screenName];
|
delete nextSlides[screenName];
|
||||||
dockSlides = nextSlides;
|
dockSlides = nextSlides;
|
||||||
}
|
}
|
||||||
|
_bumpSurfaceRevision(screenName);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,12 +417,22 @@ Singleton {
|
|||||||
return false;
|
return false;
|
||||||
|
|
||||||
const normalized = _normalizeNotificationState(state);
|
const normalized = _normalizeNotificationState(state);
|
||||||
if (_sameNotificationState(notificationStates[screenName], normalized))
|
const descriptorState = Object.assign({}, state, normalized, {
|
||||||
return true;
|
"kind": "notification",
|
||||||
|
"screenName": screenName,
|
||||||
const next = _cloneDict(notificationStates);
|
"presented": normalized.visible,
|
||||||
next[screenName] = normalized;
|
"phase": normalized.visible ? (state.phase || "open") : "hidden"
|
||||||
notificationStates = next;
|
});
|
||||||
|
const previous = notificationStates[screenName] || emptyNotificationState;
|
||||||
|
const stateChanged = !_sameNotificationState(notificationStates[screenName], normalized);
|
||||||
|
if (stateChanged) {
|
||||||
|
const next = _cloneDict(notificationStates);
|
||||||
|
next[screenName] = normalized;
|
||||||
|
notificationStates = next;
|
||||||
|
}
|
||||||
|
_setSurfaceDescriptor(screenName, "notification", descriptorState, "notification:" + screenName);
|
||||||
|
if (!!previous.visible !== !!normalized.visible)
|
||||||
|
_bumpSurfaceRevision(screenName);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,10 +443,11 @@ Singleton {
|
|||||||
const next = _cloneDict(notificationStates);
|
const next = _cloneDict(notificationStates);
|
||||||
delete next[screenName];
|
delete next[screenName];
|
||||||
notificationStates = next;
|
notificationStates = next;
|
||||||
|
_clearSurfaceDescriptor(screenName, "notification");
|
||||||
|
_bumpSurfaceRevision(screenName);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// DankModal / DankLauncherV2Modal State
|
|
||||||
readonly property var emptyModalState: ({
|
readonly property var emptyModalState: ({
|
||||||
"visible": false,
|
"visible": false,
|
||||||
"barSide": "bottom",
|
"barSide": "bottom",
|
||||||
@@ -330,52 +500,77 @@ Singleton {
|
|||||||
modalOwners = nextOwners;
|
modalOwners = nextOwners;
|
||||||
}
|
}
|
||||||
const normalized = _normalizeModalState(state);
|
const normalized = _normalizeModalState(state);
|
||||||
if (_sameModalState(modalStates[screenName], normalized))
|
|
||||||
return true;
|
|
||||||
const next = _cloneDict(modalStates);
|
const next = _cloneDict(modalStates);
|
||||||
next[screenName] = normalized;
|
next[screenName] = normalized;
|
||||||
modalStates = next;
|
modalStates = next;
|
||||||
|
_setSurfaceDescriptor(screenName, "modal", Object.assign({}, state, normalized, {
|
||||||
|
"kind": state.kind || "modal",
|
||||||
|
"screenName": screenName
|
||||||
|
}), ownerId || "");
|
||||||
|
_bumpSurfaceRevision(screenName);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateModalState(screenName, state, ownerId) {
|
function updateModalState(screenName, state, ownerId) {
|
||||||
if (!screenName || !state)
|
if (!screenName || !state)
|
||||||
return false;
|
return false;
|
||||||
if (ownerId && modalOwners[screenName] && modalOwners[screenName] !== ownerId)
|
if (ownerId && modalOwners[screenName] !== ownerId)
|
||||||
return false;
|
return false;
|
||||||
const normalized = _normalizeModalState(state);
|
const normalized = _normalizeModalState(state);
|
||||||
if (_sameModalState(modalStates[screenName], normalized))
|
const descriptorState = Object.assign({}, state, normalized, {
|
||||||
return true;
|
"kind": state.kind || (surfaceDescriptor(screenName, "modal").kind || "modal"),
|
||||||
const next = _cloneDict(modalStates);
|
"screenName": screenName
|
||||||
next[screenName] = normalized;
|
});
|
||||||
modalStates = next;
|
if (!_sameModalState(modalStates[screenName], normalized)) {
|
||||||
|
const next = _cloneDict(modalStates);
|
||||||
|
next[screenName] = normalized;
|
||||||
|
modalStates = next;
|
||||||
|
}
|
||||||
|
_setSurfaceDescriptor(screenName, "modal", descriptorState, ownerId || modalOwners[screenName] || "");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setModalState(screenName, state) {
|
function hasModalOwner(screenName, ownerId) {
|
||||||
return updateModalState(screenName, state, null);
|
return !!screenName && !!ownerId && modalOwners[screenName] === ownerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureModalState(screenName, state, ownerId) {
|
||||||
|
if (!screenName || !state || !ownerId)
|
||||||
|
return false;
|
||||||
|
const currentOwner = modalOwners[screenName] || "";
|
||||||
|
if (currentOwner && currentOwner !== ownerId)
|
||||||
|
return false;
|
||||||
|
if (!currentOwner)
|
||||||
|
return claimModalState(screenName, state, ownerId);
|
||||||
|
return updateModalState(screenName, state, ownerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearModalState(screenName, ownerId) {
|
function clearModalState(screenName, ownerId) {
|
||||||
if (!screenName || !modalStates[screenName])
|
if (!screenName)
|
||||||
return false;
|
return false;
|
||||||
if (ownerId && modalOwners[screenName] && modalOwners[screenName] !== ownerId)
|
if (ownerId && modalOwners[screenName] !== ownerId)
|
||||||
|
return false;
|
||||||
|
if (!modalStates[screenName] && !modalOwners[screenName])
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
const next = _cloneDict(modalStates);
|
if (modalStates[screenName]) {
|
||||||
delete next[screenName];
|
const next = _cloneDict(modalStates);
|
||||||
modalStates = next;
|
delete next[screenName];
|
||||||
|
modalStates = next;
|
||||||
|
}
|
||||||
|
|
||||||
if (modalOwners[screenName]) {
|
if (modalOwners[screenName]) {
|
||||||
const nextOwners = _cloneDict(modalOwners);
|
const nextOwners = _cloneDict(modalOwners);
|
||||||
delete nextOwners[screenName];
|
delete nextOwners[screenName];
|
||||||
modalOwners = nextOwners;
|
modalOwners = nextOwners;
|
||||||
}
|
}
|
||||||
|
_clearSurfaceDescriptor(screenName, "modal", ownerId);
|
||||||
|
_bumpSurfaceRevision(screenName);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setModalAnim(screenName, animX, animY, ownerId) {
|
function setModalAnim(screenName, animX, animY, ownerId) {
|
||||||
if (ownerId && modalOwners[screenName] && modalOwners[screenName] !== ownerId)
|
if (ownerId && modalOwners[screenName] !== ownerId)
|
||||||
return false;
|
return false;
|
||||||
const cur = screenName ? modalStates[screenName] : null;
|
const cur = screenName ? modalStates[screenName] : null;
|
||||||
if (!cur)
|
if (!cur)
|
||||||
@@ -394,7 +589,7 @@ Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setModalBody(screenName, bodyX, bodyY, bodyW, bodyH, ownerId) {
|
function setModalBody(screenName, bodyX, bodyY, bodyW, bodyH, ownerId) {
|
||||||
if (ownerId && modalOwners[screenName] && modalOwners[screenName] !== ownerId)
|
if (ownerId && modalOwners[screenName] !== ownerId)
|
||||||
return false;
|
return false;
|
||||||
const cur = screenName ? modalStates[screenName] : null;
|
const cur = screenName ? modalStates[screenName] : null;
|
||||||
if (!cur)
|
if (!cur)
|
||||||
@@ -453,9 +648,6 @@ Singleton {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prune state for screens that are no longer connected. Stale entries
|
|
||||||
// accumulate across hotplug cycles otherwise — Frame's per-screen
|
|
||||||
// FrameInstance doesn't notice when its peer dicts go orphan.
|
|
||||||
function _pruneToLiveScreens() {
|
function _pruneToLiveScreens() {
|
||||||
const live = {};
|
const live = {};
|
||||||
const screens = Quickshell.screens || [];
|
const screens = Quickshell.screens || [];
|
||||||
@@ -492,6 +684,12 @@ Singleton {
|
|||||||
const nextModalOwners = pruneKeyed(modalOwners);
|
const nextModalOwners = pruneKeyed(modalOwners);
|
||||||
if (nextModalOwners !== null)
|
if (nextModalOwners !== null)
|
||||||
modalOwners = nextModalOwners;
|
modalOwners = nextModalOwners;
|
||||||
|
const nextSurfaceRevisions = pruneKeyed(surfaceRevisions);
|
||||||
|
if (nextSurfaceRevisions !== null)
|
||||||
|
surfaceRevisions = nextSurfaceRevisions;
|
||||||
|
const nextDescriptors = pruneKeyed(surfaceDescriptors);
|
||||||
|
if (nextDescriptors !== null)
|
||||||
|
surfaceDescriptors = nextDescriptors;
|
||||||
|
|
||||||
let retractChanged = false;
|
let retractChanged = false;
|
||||||
const nextRetract = {};
|
const nextRetract = {};
|
||||||
@@ -512,7 +710,12 @@ Singleton {
|
|||||||
Connections {
|
Connections {
|
||||||
target: Quickshell
|
target: Quickshell
|
||||||
function onScreensChanged() {
|
function onScreensChanged() {
|
||||||
root._pruneToLiveScreens();
|
screenPruneAction.schedule();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DeferredAction {
|
||||||
|
id: screenPruneAction
|
||||||
|
onTriggered: root._pruneToLiveScreens()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
.pragma library
|
||||||
|
|
||||||
|
var VALID_KINDS = {
|
||||||
|
"popout": true,
|
||||||
|
"modal": true,
|
||||||
|
"launcher": true,
|
||||||
|
"dock": true,
|
||||||
|
"notification": true
|
||||||
|
};
|
||||||
|
|
||||||
|
var VALID_PHASES = {
|
||||||
|
"opening": true,
|
||||||
|
"open": true,
|
||||||
|
"closing": true,
|
||||||
|
"hidden": true,
|
||||||
|
"recovering": true
|
||||||
|
};
|
||||||
|
|
||||||
|
function _number(value, fallback) {
|
||||||
|
var n = Number(value);
|
||||||
|
return isNaN(n) ? fallback : n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _bool(value, fallback) {
|
||||||
|
return value === undefined ? fallback : !!value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _kind(value, fallback) {
|
||||||
|
if (VALID_KINDS[value])
|
||||||
|
return value;
|
||||||
|
return VALID_KINDS[fallback] ? fallback : "modal";
|
||||||
|
}
|
||||||
|
|
||||||
|
function _defaultBarSide(kind) {
|
||||||
|
return kind === "popout" || kind === "notification" ? "top" : "bottom";
|
||||||
|
}
|
||||||
|
|
||||||
|
function _barSide(value, fallback) {
|
||||||
|
if (value === "top" || value === "bottom" || value === "left" || value === "right")
|
||||||
|
return value;
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function slotForKind(kind) {
|
||||||
|
return kind === "launcher" ? "modal" : _kind(kind, "modal");
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferPhase(visible, presented, requestedPhase) {
|
||||||
|
if (VALID_PHASES[requestedPhase])
|
||||||
|
return requestedPhase;
|
||||||
|
if (!visible && !presented)
|
||||||
|
return "hidden";
|
||||||
|
if (!visible && presented)
|
||||||
|
return "closing";
|
||||||
|
return "open";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalize(input, defaults) {
|
||||||
|
var source = input || {};
|
||||||
|
var base = defaults || {};
|
||||||
|
var kind = _kind(source.kind, base.kind);
|
||||||
|
var defaultSide = _defaultBarSide(kind);
|
||||||
|
var sourceRect = source.bodyRect || {};
|
||||||
|
var baseRect = base.bodyRect || {};
|
||||||
|
var sourceOffset = source.animationOffset || {};
|
||||||
|
var baseOffset = base.animationOffset || {};
|
||||||
|
var visible = _bool(source.visible !== undefined ? source.visible : source.reveal, _bool(base.visible !== undefined ? base.visible : base.reveal, false));
|
||||||
|
var presented = _bool(source.presented, _bool(base.presented, visible));
|
||||||
|
var bodyRect = {
|
||||||
|
"x": _number(sourceRect.x !== undefined ? sourceRect.x : source.bodyX, _number(baseRect.x !== undefined ? baseRect.x : base.bodyX, 0)),
|
||||||
|
"y": _number(sourceRect.y !== undefined ? sourceRect.y : source.bodyY, _number(baseRect.y !== undefined ? baseRect.y : base.bodyY, 0)),
|
||||||
|
"width": Math.max(0, _number(sourceRect.width !== undefined ? sourceRect.width : source.bodyW, _number(baseRect.width !== undefined ? baseRect.width : base.bodyW, 0))),
|
||||||
|
"height": Math.max(0, _number(sourceRect.height !== undefined ? sourceRect.height : source.bodyH, _number(baseRect.height !== undefined ? baseRect.height : base.bodyH, 0)))
|
||||||
|
};
|
||||||
|
var animationOffset = {
|
||||||
|
"x": _number(sourceOffset.x !== undefined ? sourceOffset.x : (source.animX !== undefined ? source.animX : source.slideX), _number(baseOffset.x !== undefined ? baseOffset.x : (base.animX !== undefined ? base.animX : base.slideX), 0)),
|
||||||
|
"y": _number(sourceOffset.y !== undefined ? sourceOffset.y : (source.animY !== undefined ? source.animY : source.slideY), _number(baseOffset.y !== undefined ? baseOffset.y : (base.animY !== undefined ? base.animY : base.slideY), 0))
|
||||||
|
};
|
||||||
|
var screenName = source.screenName !== undefined ? source.screenName : (source.screen !== undefined ? source.screen : (base.screenName !== undefined ? base.screenName : base.screen));
|
||||||
|
var opacity = Math.max(0, Math.min(1, _number(source.opacity, _number(base.opacity, 1))));
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ownerId": String(source.ownerId !== undefined ? source.ownerId : (base.ownerId || "")),
|
||||||
|
"kind": kind,
|
||||||
|
"screenName": String(screenName || ""),
|
||||||
|
"phase": inferPhase(visible, presented, source.phase !== undefined ? source.phase : base.phase),
|
||||||
|
"visible": visible,
|
||||||
|
"presented": presented,
|
||||||
|
"barSide": _barSide(source.barSide, _barSide(base.barSide, defaultSide)),
|
||||||
|
"bodyRect": bodyRect,
|
||||||
|
"animationOffset": animationOffset,
|
||||||
|
"scale": Math.max(0, _number(source.scale, _number(base.scale, 1))),
|
||||||
|
"opacity": opacity,
|
||||||
|
"omitStartConnector": _bool(source.omitStartConnector, _bool(base.omitStartConnector, false)),
|
||||||
|
"omitEndConnector": _bool(source.omitEndConnector, _bool(base.omitEndConnector, false)),
|
||||||
|
"dockRetractSide": String(source.dockRetractSide !== undefined ? source.dockRetractSide : (base.dockRetractSide || "")),
|
||||||
|
"revision": Math.max(0, Math.floor(_number(source.revision, _number(base.revision, 0))))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function empty(kind, screenName) {
|
||||||
|
return normalize({
|
||||||
|
"kind": kind,
|
||||||
|
"screenName": screenName || "",
|
||||||
|
"phase": "hidden",
|
||||||
|
"visible": false,
|
||||||
|
"presented": false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function withRevision(descriptor, revision) {
|
||||||
|
var next = normalize(descriptor);
|
||||||
|
next.revision = Math.max(0, Math.floor(_number(revision, next.revision)));
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function withAnimationOffset(descriptor, x, y) {
|
||||||
|
var next = normalize(descriptor);
|
||||||
|
next.animationOffset = {
|
||||||
|
"x": x === undefined ? next.animationOffset.x : _number(x, next.animationOffset.x),
|
||||||
|
"y": y === undefined ? next.animationOffset.y : _number(y, next.animationOffset.y)
|
||||||
|
};
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function withBodyRect(descriptor, x, y, width, height) {
|
||||||
|
var next = normalize(descriptor);
|
||||||
|
next.bodyRect = {
|
||||||
|
"x": x === undefined ? next.bodyRect.x : _number(x, next.bodyRect.x),
|
||||||
|
"y": y === undefined ? next.bodyRect.y : _number(y, next.bodyRect.y),
|
||||||
|
"width": width === undefined ? next.bodyRect.width : Math.max(0, _number(width, next.bodyRect.width)),
|
||||||
|
"height": height === undefined ? next.bodyRect.height : Math.max(0, _number(height, next.bodyRect.height))
|
||||||
|
};
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function same(a, b, threshold) {
|
||||||
|
if (!a || !b)
|
||||||
|
return false;
|
||||||
|
var epsilon = threshold === undefined ? 0.5 : Math.max(0, Number(threshold));
|
||||||
|
return a.ownerId === b.ownerId
|
||||||
|
&& a.kind === b.kind
|
||||||
|
&& a.screenName === b.screenName
|
||||||
|
&& a.phase === b.phase
|
||||||
|
&& a.visible === b.visible
|
||||||
|
&& a.presented === b.presented
|
||||||
|
&& a.barSide === b.barSide
|
||||||
|
&& Math.abs(a.bodyRect.x - b.bodyRect.x) < epsilon
|
||||||
|
&& Math.abs(a.bodyRect.y - b.bodyRect.y) < epsilon
|
||||||
|
&& Math.abs(a.bodyRect.width - b.bodyRect.width) < epsilon
|
||||||
|
&& Math.abs(a.bodyRect.height - b.bodyRect.height) < epsilon
|
||||||
|
&& Math.abs(a.animationOffset.x - b.animationOffset.x) < epsilon
|
||||||
|
&& Math.abs(a.animationOffset.y - b.animationOffset.y) < epsilon
|
||||||
|
&& Math.abs(a.scale - b.scale) < 0.0001
|
||||||
|
&& Math.abs(a.opacity - b.opacity) < 0.0001
|
||||||
|
&& a.omitStartConnector === b.omitStartConnector
|
||||||
|
&& a.omitEndConnector === b.omitEndConnector
|
||||||
|
&& a.dockRetractSide === b.dockRetractSide;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user