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

Compare commits

...

52 Commits

Author SHA1 Message Date
bbedward
ac509933d7 i18n: fix sound missing erorr message 2026-01-28 23:35:53 -05:00
bbedward
f49f98ff85 settings: undo mono font filtering 2026-01-28 22:07:08 -05:00
bbedward
10923346d7 clipboard: fix watch command 2026-01-28 21:36:16 -05:00
bbedward
f27bffc387 displays: add disable snap option in settings
fixes #1438
2026-01-28 21:08:16 -05:00
bbedward
36b43f93a3 displays: support for multiple output profiles
- add support for deleting unplugged configs
- Option to hide disconnected displays
fixes #1453
2026-01-28 20:51:29 -05:00
bbedward
2deeab9d08 clipboard: touch copied history entry
- makes it appear at the top of the history
2026-01-28 16:09:32 -05:00
bbedward
f00854879c workspaces: fix overflow with grouped apps + icons
fixes #1530
2026-01-28 16:00:44 -05:00
bbedward
75fd62865b core/dbus: fix arg types in calls 2026-01-28 15:25:16 -05:00
bbedward
757054e140 core/dbus: support Normalize for more dbus types 2026-01-28 13:28:13 -05:00
bbedward
eda59b348c clipboard: react to changes 2026-01-27 22:50:28 -05:00
bbedward
d19e81ffac clipboard: fix duplicate clear dialog 2026-01-27 22:41:01 -05:00
purian23
60c6872aec workflow: Update dms-git run times 2026-01-27 22:38:09 -05:00
bbedward
a9cb2fe912 clipboard: fix hash duplication check, set isOwner for CopyFile 2026-01-27 22:35:20 -05:00
purian23
a168a8160c feat: appsDock Widget Overflow & Config Options 2026-01-27 21:15:33 -05:00
bbedward
78662f9613 window-rules: fix checkbox alignment 2026-01-27 19:44:17 -05:00
bbedward
d9d7bb8dcc i18n: update settings search index 2026-01-27 19:39:29 -05:00
bbedward
3136f48b30 settings: make dock position match dankbar
fixes #1527
2026-01-27 19:33:32 -05:00
sin-1337
0c46711b01 Update Makefile (#1524)
Stop assuming the user's primary group matches their username.
2026-01-27 19:29:17 -05:00
bbedward
68159b5c41 niri: add window-rule management
- settings UI for creating, editing, deleting window ruels
- IPC to create a window rule for the currently focused toplevel

fixes #1292
2026-01-27 19:28:58 -05:00
purian23
6557d66f94 dms-git: It shall be beta 2026-01-27 17:56:08 -05:00
purian23
9553cb06d3 feat: Dock Overflow/Updated Settings Options 2026-01-27 00:52:15 -05:00
bbedward
122fb16dfb clipboard: simplify copyFile, fix copy image from history 2026-01-26 21:49:34 -05:00
niz
511502220f keyboard-layout: fixed hyprland keyboard compact mode (#1512) 2026-01-26 18:07:09 -05:00
bbedward
8bfe7439c0 ci: fix pre-commit go path 2026-01-26 18:01:21 -05:00
bbedward
8499033221 clipboard: fix file transfer & export functionality
- grants read to all installed flatpak apps
2026-01-26 17:58:06 -05:00
bbedward
705d5b04dd pre-commit: add go mod tidy 2026-01-26 16:46:48 -05:00
dms-ci[bot]
17eaa761f8 nix: update vendorHash for go.mod changes 2026-01-26 21:46:10 +00:00
bbedward
1cdbd01748 go mod tidy 2026-01-26 16:44:32 -05:00
bbedward
08cc076a4c clipboard: skip application/vnd.portal.filetransfer mime in history 2026-01-26 16:40:56 -05:00
bbedward
2a02d5594c clipboard: add cl copy --download option for images/videos
- offers application/vnd.portal.filetransfer and text/uri-list
2026-01-26 16:34:47 -05:00
bbedward
2263338878 dankbar: account for outlineThickness in margins
settings: dont clear caches or apply on startup
2026-01-26 14:19:26 -05:00
bbedward
26bc5425d3 displays: fix vrr=0 setting on hyprland 2026-01-26 11:00:37 -05:00
Karan Singh
38b4d1dc95 Disable VRR in hyprland configuration (#1509)
VRR disabled by default.
2026-01-26 10:57:34 -05:00
bbedward
3aaca7ff39 theme: allow overriding color center theme 2026-01-26 09:18:14 -05:00
bbedward
83d9808536 workspaces: add icon size offset 2026-01-25 22:49:46 -05:00
bbedward
ad458dfece clock: fix no shifting logic 2026-01-25 15:53:01 -05:00
bbedward
8f6fe7ed2b i18n: sync 2026-01-25 14:31:28 -05:00
bbedward
419a692593 update template for feature request 2026-01-25 14:23:05 -05:00
bbedward
03fdf795e0 launcher v2: general styling fixes
- scrollbar
- footer alignment
- radii
- hover colors
2026-01-25 14:21:03 -05:00
bbedward
832807a217 desktop clock: general scaling and stylng fixes for digital variant 2026-01-25 13:30:11 -05:00
Yamada.Kazuyoshi
f7df3b2a68 Fixed an issue where the UI width was shifted due to the clock widget when using non-monospaced fonts. (#1491) 2026-01-24 23:09:20 -05:00
bbedward
0d03e73595 fix vesktop theme name 2026-01-24 22:53:37 -05:00
bbedward
c5ae1a77d3 settings: sidebar scaling improvements 2026-01-24 22:51:59 -05:00
bbedward
5f16624000 misc: fix some various scaling issues with fonts
fixes #1268
2026-01-24 22:27:23 -05:00
purian23
80025804ab theme: Tweaks to Auto Color Mode 2026-01-24 20:43:45 -05:00
bbedward
028d3b4e61 workspaces: fix index numbers with show apps on vBar + animation 2026-01-24 20:31:45 -05:00
purian23
9cce5ccfe6 autoThemeMode: Add transition time & layout update 2026-01-24 19:33:37 -05:00
purian23
a260b8060e Merge branch 'master' into auto-theme 2026-01-24 18:19:13 -05:00
purian23
f945307232 cleanup: Auto theme switcher 2026-01-24 17:48:34 -05:00
bbedward
8f44d52cb2 launcher v2: allow categories in plugins 2026-01-24 16:58:55 -05:00
purian23
3413cb7b89 feat: Create new Auto theme mode based on region / time of day 2026-01-24 16:38:45 -05:00
bbedward
4e3b24ffbb settings: migrate vpnLastConnected to session
fixes #1488
2026-01-24 16:08:15 -05:00
170 changed files with 19776 additions and 2744 deletions

View File

@@ -1,5 +1,5 @@
name: Feature Request name: Feature Request
description: Suggest a new feature or improvement for DMS description: Suggest a new feature or improvement for DMS. Keep features focused on a single topic with clear benefits, examples, etc. Avoid vague or broad requests, they will be closed.
labels: labels:
- enhancement - enhancement
body: body:

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ This file is more of a quick reference so I know what to account for before next
- launcher actions, customize env, args, name, icon - launcher actions, customize env, args, name, icon
- launcher v2 - omega stuff, GIF search, supa powerful - launcher v2 - omega stuff, GIF search, supa powerful
- dock on bar - dock on bar
- window rule manager, with IPC - #TODO verify RTL layout (niri only)
# 1.2.0 # 1.2.0

View File

@@ -58,10 +58,10 @@ install-completions:
install-systemd: install-systemd:
@echo "Installing systemd user service..." @echo "Installing systemd user service..."
@mkdir -p $(SYSTEMD_USER_DIR) @mkdir -p $(SYSTEMD_USER_DIR)
@if [ -n "$(SUDO_USER)" ]; then chown -R $(SUDO_USER):$(SUDO_USER) $(SYSTEMD_USER_DIR); fi @if [ -n "$(SUDO_USER)" ]; then chown -R $(SUDO_USER):"$(id -gn $SUDO_USER)" $(SYSTEMD_USER_DIR); fi
@sed 's|/usr/bin/dms|$(INSTALL_DIR)/dms|g' $(ASSETS_DIR)/systemd/dms.service > $(SYSTEMD_USER_DIR)/dms.service @sed 's|/usr/bin/dms|$(INSTALL_DIR)/dms|g' $(ASSETS_DIR)/systemd/dms.service > $(SYSTEMD_USER_DIR)/dms.service
@chmod 644 $(SYSTEMD_USER_DIR)/dms.service @chmod 644 $(SYSTEMD_USER_DIR)/dms.service
@if [ -n "$(SUDO_USER)" ]; then chown $(SUDO_USER):$(SUDO_USER) $(SYSTEMD_USER_DIR)/dms.service; fi @if [ -n "$(SUDO_USER)" ]; then chown $(SUDO_USER):"$(id -gn $SUDO_USER)" $(SYSTEMD_USER_DIR)/dms.service; fi
@echo "Systemd service installed to $(SYSTEMD_USER_DIR)/dms.service" @echo "Systemd service installed to $(SYSTEMD_USER_DIR)/dms.service"
install-icon: install-icon:

View File

@@ -12,17 +12,21 @@ import (
_ "image/jpeg" _ "image/jpeg"
_ "image/png" _ "image/png"
"io" "io"
"net/http"
"net/url"
"os" "os"
"os/exec" "os/exec"
"os/signal" "os/signal"
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings"
"syscall" "syscall"
"time" "time"
bolt "go.etcd.io/bbolt" bolt "go.etcd.io/bbolt"
_ "golang.org/x/image/bmp" _ "golang.org/x/image/bmp"
_ "golang.org/x/image/tiff" _ "golang.org/x/image/tiff"
_ "golang.org/x/image/webp"
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard" "github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
@@ -48,6 +52,7 @@ var (
clipCopyForeground bool clipCopyForeground bool
clipCopyPasteOnce bool clipCopyPasteOnce bool
clipCopyType string clipCopyType string
clipCopyDownload bool
clipJSONOutput bool clipJSONOutput bool
) )
@@ -184,11 +189,12 @@ func init() {
clipCopyCmd.Flags().BoolVarP(&clipCopyForeground, "foreground", "f", false, "Stay in foreground instead of forking") clipCopyCmd.Flags().BoolVarP(&clipCopyForeground, "foreground", "f", false, "Stay in foreground instead of forking")
clipCopyCmd.Flags().BoolVarP(&clipCopyPasteOnce, "paste-once", "o", false, "Exit after first paste") clipCopyCmd.Flags().BoolVarP(&clipCopyPasteOnce, "paste-once", "o", false, "Exit after first paste")
clipCopyCmd.Flags().StringVarP(&clipCopyType, "type", "t", "text/plain;charset=utf-8", "MIME type") clipCopyCmd.Flags().StringVarP(&clipCopyType, "type", "t", "text/plain;charset=utf-8", "MIME type")
clipCopyCmd.Flags().BoolVarP(&clipCopyDownload, "download", "d", false, "Download URL as image and copy as file")
clipWatchCmd.Flags().BoolVar(&clipJSONOutput, "json", false, "Output as JSON") clipWatchCmd.Flags().BoolVar(&clipJSONOutput, "json", false, "Output as JSON")
clipHistoryCmd.Flags().BoolVar(&clipJSONOutput, "json", false, "Output as JSON") clipHistoryCmd.Flags().BoolVar(&clipJSONOutput, "json", false, "Output as JSON")
clipGetCmd.Flags().BoolVar(&clipJSONOutput, "json", false, "Output as JSON") clipGetCmd.Flags().BoolVar(&clipJSONOutput, "json", false, "Output as JSON")
clipGetCmd.Flags().BoolVarP(&clipGetCopy, "copy", "c", false, "Copy entry to clipboard") clipGetCmd.Flags().BoolVarP(&clipGetCopy, "copy", "C", false, "Copy entry to clipboard")
clipSearchCmd.Flags().IntVarP(&clipSearchLimit, "limit", "l", 50, "Max results") clipSearchCmd.Flags().IntVarP(&clipSearchLimit, "limit", "l", 50, "Max results")
clipSearchCmd.Flags().IntVarP(&clipSearchOffset, "offset", "o", 0, "Result offset") clipSearchCmd.Flags().IntVarP(&clipSearchOffset, "offset", "o", 0, "Result offset")
@@ -215,9 +221,10 @@ func init() {
func runClipCopy(cmd *cobra.Command, args []string) { func runClipCopy(cmd *cobra.Command, args []string) {
var data []byte var data []byte
if len(args) > 0 { switch {
case len(args) > 0:
data = []byte(args[0]) data = []byte(args[0])
} else { default:
var err error var err error
data, err = io.ReadAll(os.Stdin) data, err = io.ReadAll(os.Stdin)
if err != nil { if err != nil {
@@ -225,11 +232,68 @@ func runClipCopy(cmd *cobra.Command, args []string) {
} }
} }
if clipCopyDownload {
filePath, err := downloadToTempFile(strings.TrimSpace(string(data)))
if err != nil {
log.Fatalf("download: %v", err)
}
if err := copyFileToClipboard(filePath); err != nil {
log.Fatalf("copy file: %v", err)
}
fmt.Printf("Downloaded and copied: %s\n", filePath)
return
}
if clipCopyType == "__multi__" {
offers, err := parseMultiOffers(data)
if err != nil {
log.Fatalf("parse multi offers: %v", err)
}
if err := clipboard.CopyMulti(offers, true, clipCopyPasteOnce); err != nil {
log.Fatalf("copy multi: %v", err)
}
return
}
if err := clipboard.CopyOpts(data, clipCopyType, clipCopyForeground, clipCopyPasteOnce); err != nil { if err := clipboard.CopyOpts(data, clipCopyType, clipCopyForeground, clipCopyPasteOnce); err != nil {
log.Fatalf("copy: %v", err) log.Fatalf("copy: %v", err)
} }
} }
func parseMultiOffers(data []byte) ([]clipboard.Offer, error) {
var offers []clipboard.Offer
pos := 0
for pos < len(data) {
mimeEnd := bytes.IndexByte(data[pos:], 0)
if mimeEnd == -1 {
break
}
mimeType := string(data[pos : pos+mimeEnd])
pos += mimeEnd + 1
lenEnd := bytes.IndexByte(data[pos:], 0)
if lenEnd == -1 {
break
}
dataLen, err := strconv.Atoi(string(data[pos : pos+lenEnd]))
if err != nil {
return nil, fmt.Errorf("parse length: %w", err)
}
pos += lenEnd + 1
if pos+dataLen > len(data) {
return nil, fmt.Errorf("data truncated")
}
offerData := data[pos : pos+dataLen]
pos += dataLen
offers = append(offers, clipboard.Offer{MimeType: mimeType, Data: offerData})
}
return offers, nil
}
func runClipPaste(cmd *cobra.Command, args []string) { func runClipPaste(cmd *cobra.Command, args []string) {
data, _, err := clipboard.Paste() data, _, err := clipboard.Paste()
if err != nil { if err != nil {
@@ -386,16 +450,13 @@ func runClipGet(cmd *cobra.Command, args []string) {
req := models.Request{ req := models.Request{
ID: 1, ID: 1,
Method: "clipboard.copyEntry", Method: "clipboard.copyEntry",
Params: map[string]any{ Params: map[string]any{"id": id},
"id": id,
},
} }
resp, err := sendServerRequest(req) resp, err := sendServerRequest(req)
if err != nil { if err != nil {
log.Fatalf("Failed to copy clipboard entry: %v", err) log.Fatalf("Failed to copy clipboard entry: %v", err)
} }
if resp.Error != "" { if resp.Error != "" {
log.Fatalf("Error: %s", resp.Error) log.Fatalf("Error: %s", resp.Error)
} }
@@ -672,7 +733,7 @@ func runClipExport(cmd *cobra.Command, args []string) {
return return
} }
if err := os.WriteFile(args[0], out, 0644); err != nil { if err := os.WriteFile(args[0], out, 0o644); err != nil {
log.Fatalf("Failed to write file: %v", err) log.Fatalf("Failed to write file: %v", err)
} }
fmt.Printf("Exported to %s\n", args[0]) fmt.Printf("Exported to %s\n", args[0])
@@ -727,7 +788,7 @@ func runClipMigrate(cmd *cobra.Command, args []string) {
log.Fatalf("Cliphist db not found: %s", dbPath) log.Fatalf("Cliphist db not found: %s", dbPath)
} }
db, err := bolt.Open(dbPath, 0644, &bolt.Options{ db, err := bolt.Open(dbPath, 0o644, &bolt.Options{
ReadOnly: true, ReadOnly: true,
Timeout: 1 * time.Second, Timeout: 1 * time.Second,
}) })
@@ -795,3 +856,113 @@ func detectMimeType(data []byte) string {
func btoi(v []byte) uint64 { func btoi(v []byte) uint64 {
return binary.BigEndian.Uint64(v) return binary.BigEndian.Uint64(v)
} }
func downloadToTempFile(rawURL string) (string, error) {
if !strings.HasPrefix(rawURL, "http://") && !strings.HasPrefix(rawURL, "https://") {
return "", fmt.Errorf("invalid URL: %s", rawURL)
}
parsedURL, err := url.Parse(rawURL)
if err != nil {
return "", fmt.Errorf("parse URL: %w", err)
}
ext := filepath.Ext(parsedURL.Path)
if ext == "" {
ext = ".png"
}
client := &http.Client{Timeout: 30 * time.Second}
var data []byte
var contentType string
var lastErr error
for attempt := 0; attempt < 3; attempt++ {
if attempt > 0 {
time.Sleep(time.Duration(attempt) * 500 * time.Millisecond)
}
req, err := http.NewRequest("GET", rawURL, nil)
if err != nil {
lastErr = fmt.Errorf("create request: %w", err)
continue
}
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
req.Header.Set("Accept", "image/*,video/*,*/*")
resp, err := client.Do(req)
if err != nil {
lastErr = fmt.Errorf("download (attempt %d): %w", attempt+1, err)
continue
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
lastErr = fmt.Errorf("download failed (attempt %d): status %d", attempt+1, resp.StatusCode)
continue
}
data, err = io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
lastErr = fmt.Errorf("read response (attempt %d): %w", attempt+1, err)
continue
}
contentType = resp.Header.Get("Content-Type")
if idx := strings.Index(contentType, ";"); idx != -1 {
contentType = strings.TrimSpace(contentType[:idx])
}
lastErr = nil
break
}
if lastErr != nil {
return "", lastErr
}
if len(data) == 0 {
return "", fmt.Errorf("downloaded empty file")
}
if !strings.HasPrefix(contentType, "image/") && !strings.HasPrefix(contentType, "video/") {
if _, _, err := image.DecodeConfig(bytes.NewReader(data)); err != nil {
return "", fmt.Errorf("not a valid media file (content-type: %s)", contentType)
}
}
cacheDir, err := os.UserCacheDir()
if err != nil {
cacheDir = "/tmp"
}
clipDir := filepath.Join(cacheDir, "dms", "clipboard")
if err := os.MkdirAll(clipDir, 0o755); err != nil {
return "", fmt.Errorf("create cache dir: %w", err)
}
filePath := filepath.Join(clipDir, fmt.Sprintf("%d%s", time.Now().UnixNano(), ext))
if err := os.WriteFile(filePath, data, 0o644); err != nil {
return "", fmt.Errorf("write file: %w", err)
}
return filePath, nil
}
func copyFileToClipboard(filePath string) error {
req := models.Request{
ID: 1,
Method: "clipboard.copyFile",
Params: map[string]any{"filePath": filePath},
}
resp, err := sendServerRequest(req)
if err != nil {
return fmt.Errorf("server request: %w", err)
}
if resp.Error != "" {
return fmt.Errorf("server error: %s", resp.Error)
}
return nil
}

View File

@@ -591,7 +591,7 @@ ShellRoot {
} }
` `
if err := os.WriteFile(testScript, []byte(qmlContent), 0644); err != nil { if err := os.WriteFile(testScript, []byte(qmlContent), 0o644); err != nil {
return nil, false return nil, false
} }
@@ -752,7 +752,7 @@ func checkConfigurationFiles() []checkResult {
status := statusOK status := statusOK
message := "Present" message := "Present"
if info.Mode().Perm()&0200 == 0 { if info.Mode().Perm()&0o200 == 0 {
status = statusWarn status = statusWarn
message += " (read-only)" message += " (read-only)"
} }

View File

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

View File

@@ -4,7 +4,7 @@ go 1.24.6
require ( require (
github.com/Wifx/gonetworkmanager/v2 v2.2.0 github.com/Wifx/gonetworkmanager/v2 v2.2.0
github.com/alecthomas/chroma/v2 v2.17.2 github.com/alecthomas/chroma/v2 v2.23.1
github.com/charmbracelet/bubbles v0.21.0 github.com/charmbracelet/bubbles v0.21.0
github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/lipgloss v1.1.0
@@ -13,7 +13,7 @@ require (
github.com/godbus/dbus/v5 v5.2.2 github.com/godbus/dbus/v5 v5.2.2
github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83 github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83
github.com/pilebones/go-udev v0.9.1 github.com/pilebones/go-udev v0.9.1
github.com/sblinch/kdl-go v0.0.0-20251203232544-981d4ecc17c3 github.com/sblinch/kdl-go v0.0.0-20260121213736-8b7053306ca6
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
github.com/yuin/goldmark v1.7.16 github.com/yuin/goldmark v1.7.16
@@ -26,10 +26,10 @@ require (
require ( require (
github.com/Microsoft/go-winio v0.6.2 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.3.0 // indirect github.com/ProtonMail/go-crypto v1.3.0 // indirect
github.com/clipperhouse/displaywidth v0.7.0 // indirect github.com/clipperhouse/displaywidth v0.8.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect github.com/clipperhouse/uax29/v2 v2.4.0 // indirect
github.com/cloudflare/circl v1.6.2 // indirect github.com/cloudflare/circl v1.6.3 // indirect
github.com/cyphar/filepath-securejoin v0.6.1 // indirect github.com/cyphar/filepath-securejoin v0.6.1 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/emirpasic/gods v1.18.1 // indirect github.com/emirpasic/gods v1.18.1 // indirect
@@ -56,7 +56,7 @@ require (
github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/go-git/go-git/v6 v6.0.0-20260114124804-a8db3a6585a6 github.com/go-git/go-git/v6 v6.0.0-20260123133532-f99a98e81ce9
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 github.com/lucasb-eyer/go-colorful v1.3.0
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect

View File

@@ -7,11 +7,11 @@ github.com/Wifx/gonetworkmanager/v2 v2.2.0/go.mod h1:fMDb//SHsKWxyDUAwXvCqurV3np
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs= github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs=
github.com/alecthomas/chroma/v2 v2.17.2 h1:Rm81SCZ2mPoH+Q8ZCc/9YvzPUN/E7HgPiPJD8SLV6GI= github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=
github.com/alecthomas/chroma/v2 v2.17.2/go.mod h1:RVX6AvYm4VfYe/zsk7mjHueLDZor3aWCNE14TFlepBk= github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8= github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
@@ -38,14 +38,14 @@ github.com/charmbracelet/x/cellbuf v0.0.14 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaL
github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA= github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.7.0 h1:QNv1GYsnLX9QBrcWUtMlogpTXuM5FVnBwKWp1O5NwmE= github.com/clipperhouse/displaywidth v0.8.0 h1:/z8v+H+4XLluJKS7rAc7uHZTalT5Z+1430ld3lePSRI=
github.com/clipperhouse/displaywidth v0.7.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o= github.com/clipperhouse/displaywidth v0.8.0/go.mod h1:UpOXiIKep+TohQYwvAAM/VDU8v3Z5rnWTxiwueR0XvQ=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= github.com/clipperhouse/uax29/v2 v2.4.0 h1:RXqE/l5EiAbA4u97giimKNlmpvkmz+GrBVTelsoXy9g=
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/clipperhouse/uax29/v2 v2.4.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/cloudflare/circl v1.6.2 h1:hL7VBpHHKzrV5WTfHCaBsgx/HGbBYlgrwvNXEVDYYsQ= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.2/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
@@ -68,10 +68,10 @@ github.com/go-git/gcfg/v2 v2.0.2 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo=
github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs= github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs=
github.com/go-git/go-billy/v6 v6.0.0-20260114122816-19306b749ecc h1:rhkjrnRkamkRC7woapp425E4CAH6RPcqsS9X8LA93IY= github.com/go-git/go-billy/v6 v6.0.0-20260114122816-19306b749ecc h1:rhkjrnRkamkRC7woapp425E4CAH6RPcqsS9X8LA93IY=
github.com/go-git/go-billy/v6 v6.0.0-20260114122816-19306b749ecc/go.mod h1:X1oe0Z2qMsa9hkar3AAPuL9hu4Mi3ztXEjdqRhr6fcc= github.com/go-git/go-billy/v6 v6.0.0-20260114122816-19306b749ecc/go.mod h1:X1oe0Z2qMsa9hkar3AAPuL9hu4Mi3ztXEjdqRhr6fcc=
github.com/go-git/go-git-fixtures/v5 v5.1.2-0.20251229094738-4b14af179146 h1:xYfxAopYyL44ot6dMBIb1Z1njFM0ZBQ99HdIB99KxLs= github.com/go-git/go-git-fixtures/v5 v5.1.2-0.20260122163445-0622d7459a67 h1:3hutPZF+/FBjR/9MdsLJ7e1mlt9pwHgwxMW7CrbmWII=
github.com/go-git/go-git-fixtures/v5 v5.1.2-0.20251229094738-4b14af179146/go.mod h1:QE/75B8tBSLNGyUUbA9tw3EGHoFtYOtypa2h8YJxsWI= github.com/go-git/go-git-fixtures/v5 v5.1.2-0.20260122163445-0622d7459a67/go.mod h1:xKt0pNHST9tYHvbiLxSY27CQWFwgIxBJuDrOE0JvbZw=
github.com/go-git/go-git/v6 v6.0.0-20260114124804-a8db3a6585a6 h1:Yo1MlE8LpvD0pr7mZ04b6hKZKQcPvLrQFgyY1jNMEyU= github.com/go-git/go-git/v6 v6.0.0-20260123133532-f99a98e81ce9 h1:VzdR70t+SMjYnBgnbtNpq4ElZAAovLPMG+GFX8OBRtM=
github.com/go-git/go-git/v6 v6.0.0-20260114124804-a8db3a6585a6/go.mod h1:enMzPHv+9hL4B7tH7OJGQKNzCkMzXovUoaiXfsLF7Xs= github.com/go-git/go-git/v6 v6.0.0-20260123133532-f99a98e81ce9/go.mod h1:EWlxLBkiFCzXNCadvt05fT9PCAE2sUedgDsvUUIo18s=
github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE= github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE=
github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk= github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
@@ -124,8 +124,8 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sblinch/kdl-go v0.0.0-20251203232544-981d4ecc17c3 h1:msKaIZrrNpvofLPDzNBW3152PJBsnPZsoNNosOCS+C0= github.com/sblinch/kdl-go v0.0.0-20260121213736-8b7053306ca6 h1:JsjzqC6ymELkN4XlTjZPSahSAem21GySugLbKz6uF5E=
github.com/sblinch/kdl-go v0.0.0-20251203232544-981d4ecc17c3/go.mod h1:b3oNGuAKOQzhsCKmuLc/urEOPzgHj6fB8vl8bwTBh28= github.com/sblinch/kdl-go v0.0.0-20260121213736-8b7053306ca6/go.mod h1:b3oNGuAKOQzhsCKmuLc/urEOPzgHj6fB8vl8bwTBh28=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=

View File

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

View File

@@ -60,7 +60,7 @@ func StoreWithConfig(data []byte, mimeType string, cfg StoreConfig) error {
return fmt.Errorf("get db path: %w", err) return fmt.Errorf("get db path: %w", err)
} }
db, err := bolt.Open(dbPath, 0644, &bolt.Options{Timeout: 1 * time.Second}) db, err := bolt.Open(dbPath, 0o644, &bolt.Options{Timeout: 1 * time.Second})
if err != nil { if err != nil {
return fmt.Errorf("open db: %w", err) return fmt.Errorf("open db: %w", err)
} }
@@ -132,7 +132,7 @@ func GetDBPath() (string, error) {
oldPath := filepath.Join(oldDir, "db") oldPath := filepath.Join(oldDir, "db")
if _, err := os.Stat(oldPath); err == nil { if _, err := os.Stat(oldPath); err == nil {
if err := os.MkdirAll(newDir, 0700); err != nil { if err := os.MkdirAll(newDir, 0o700); err != nil {
return "", err return "", err
} }
if err := os.Rename(oldPath, newPath); err != nil { if err := os.Rename(oldPath, newPath); err != nil {
@@ -142,7 +142,7 @@ func GetDBPath() (string, error) {
return newPath, nil return newPath, nil
} }
if err := os.MkdirAll(newDir, 0700); err != nil { if err := os.MkdirAll(newDir, 0o700); err != nil {
return "", err return "", err
} }
return newPath, nil return newPath, nil

View File

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

View File

@@ -126,13 +126,13 @@ func (cd *ConfigDeployer) deployNiriConfig(terminal deps.Terminal, useSystemd bo
} }
configDir := filepath.Dir(result.Path) configDir := filepath.Dir(result.Path)
if err := os.MkdirAll(configDir, 0755); err != nil { if err := os.MkdirAll(configDir, 0o755); err != nil {
result.Error = fmt.Errorf("failed to create config directory: %w", err) result.Error = fmt.Errorf("failed to create config directory: %w", err)
return result, result.Error return result, result.Error
} }
dmsDir := filepath.Join(configDir, "dms") dmsDir := filepath.Join(configDir, "dms")
if err := os.MkdirAll(dmsDir, 0755); err != nil { if err := os.MkdirAll(dmsDir, 0o755); err != nil {
result.Error = fmt.Errorf("failed to create dms directory: %w", err) result.Error = fmt.Errorf("failed to create dms directory: %w", err)
return result, result.Error return result, result.Error
} }
@@ -150,7 +150,7 @@ func (cd *ConfigDeployer) deployNiriConfig(terminal deps.Terminal, useSystemd bo
timestamp := time.Now().Format("2006-01-02_15-04-05") timestamp := time.Now().Format("2006-01-02_15-04-05")
result.BackupPath = result.Path + ".backup." + timestamp result.BackupPath = result.Path + ".backup." + timestamp
if err := os.WriteFile(result.BackupPath, existingData, 0644); err != nil { 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
} }
@@ -185,7 +185,7 @@ func (cd *ConfigDeployer) deployNiriConfig(terminal deps.Terminal, useSystemd bo
} }
} }
if err := os.WriteFile(result.Path, []byte(newConfig), 0644); err != nil { if err := os.WriteFile(result.Path, []byte(newConfig), 0o644); err != nil {
result.Error = fmt.Errorf("failed to write config: %w", err) result.Error = fmt.Errorf("failed to write config: %w", err)
return result, result.Error return result, result.Error
} }
@@ -211,6 +211,7 @@ func (cd *ConfigDeployer) deployNiriDmsConfigs(dmsDir, terminalCommand string) e
{"binds.kdl", strings.ReplaceAll(NiriBindsConfig, "{{TERMINAL_COMMAND}}", terminalCommand)}, {"binds.kdl", strings.ReplaceAll(NiriBindsConfig, "{{TERMINAL_COMMAND}}", terminalCommand)},
{"outputs.kdl", ""}, {"outputs.kdl", ""},
{"cursor.kdl", ""}, {"cursor.kdl", ""},
{"windowrules.kdl", ""},
} }
for _, cfg := range configs { for _, cfg := range configs {
@@ -220,7 +221,7 @@ func (cd *ConfigDeployer) deployNiriDmsConfigs(dmsDir, terminalCommand string) e
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), 0644); 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)
} }
cd.log(fmt.Sprintf("Deployed %s", cfg.name)) cd.log(fmt.Sprintf("Deployed %s", cfg.name))
@@ -238,7 +239,7 @@ func (cd *ConfigDeployer) deployGhosttyConfig() ([]DeploymentResult, error) {
} }
configDir := filepath.Dir(mainResult.Path) configDir := filepath.Dir(mainResult.Path)
if err := os.MkdirAll(configDir, 0755); err != nil { if err := os.MkdirAll(configDir, 0o755); err != nil {
mainResult.Error = fmt.Errorf("failed to create config directory: %w", err) mainResult.Error = fmt.Errorf("failed to create config directory: %w", err)
return []DeploymentResult{mainResult}, mainResult.Error return []DeploymentResult{mainResult}, mainResult.Error
} }
@@ -254,14 +255,14 @@ func (cd *ConfigDeployer) deployGhosttyConfig() ([]DeploymentResult, error) {
timestamp := time.Now().Format("2006-01-02_15-04-05") timestamp := time.Now().Format("2006-01-02_15-04-05")
mainResult.BackupPath = mainResult.Path + ".backup." + timestamp mainResult.BackupPath = mainResult.Path + ".backup." + timestamp
if err := os.WriteFile(mainResult.BackupPath, existingData, 0644); err != nil { if err := os.WriteFile(mainResult.BackupPath, existingData, 0o644); err != nil {
mainResult.Error = fmt.Errorf("failed to create backup: %w", err) mainResult.Error = fmt.Errorf("failed to create backup: %w", err)
return []DeploymentResult{mainResult}, mainResult.Error return []DeploymentResult{mainResult}, mainResult.Error
} }
cd.log(fmt.Sprintf("Backed up existing config to %s", mainResult.BackupPath)) cd.log(fmt.Sprintf("Backed up existing config to %s", mainResult.BackupPath))
} }
if err := os.WriteFile(mainResult.Path, []byte(GhosttyConfig), 0644); err != nil { if err := os.WriteFile(mainResult.Path, []byte(GhosttyConfig), 0o644); err != nil {
mainResult.Error = fmt.Errorf("failed to write config: %w", err) mainResult.Error = fmt.Errorf("failed to write config: %w", err)
return []DeploymentResult{mainResult}, mainResult.Error return []DeploymentResult{mainResult}, mainResult.Error
} }
@@ -276,12 +277,12 @@ func (cd *ConfigDeployer) deployGhosttyConfig() ([]DeploymentResult, error) {
} }
themesDir := filepath.Dir(colorResult.Path) themesDir := filepath.Dir(colorResult.Path)
if err := os.MkdirAll(themesDir, 0755); err != nil { if err := os.MkdirAll(themesDir, 0o755); err != nil {
mainResult.Error = fmt.Errorf("failed to create themes directory: %w", err) mainResult.Error = fmt.Errorf("failed to create themes directory: %w", err)
return []DeploymentResult{mainResult}, mainResult.Error return []DeploymentResult{mainResult}, mainResult.Error
} }
if err := os.WriteFile(colorResult.Path, []byte(GhosttyColorConfig), 0644); err != nil { if err := os.WriteFile(colorResult.Path, []byte(GhosttyColorConfig), 0o644); err != nil {
colorResult.Error = fmt.Errorf("failed to write color config: %w", err) colorResult.Error = fmt.Errorf("failed to write color config: %w", err)
return results, colorResult.Error return results, colorResult.Error
} }
@@ -302,7 +303,7 @@ func (cd *ConfigDeployer) deployKittyConfig() ([]DeploymentResult, error) {
} }
configDir := filepath.Dir(mainResult.Path) configDir := filepath.Dir(mainResult.Path)
if err := os.MkdirAll(configDir, 0755); err != nil { if err := os.MkdirAll(configDir, 0o755); err != nil {
mainResult.Error = fmt.Errorf("failed to create config directory: %w", err) mainResult.Error = fmt.Errorf("failed to create config directory: %w", err)
return []DeploymentResult{mainResult}, mainResult.Error return []DeploymentResult{mainResult}, mainResult.Error
} }
@@ -318,14 +319,14 @@ func (cd *ConfigDeployer) deployKittyConfig() ([]DeploymentResult, error) {
timestamp := time.Now().Format("2006-01-02_15-04-05") timestamp := time.Now().Format("2006-01-02_15-04-05")
mainResult.BackupPath = mainResult.Path + ".backup." + timestamp mainResult.BackupPath = mainResult.Path + ".backup." + timestamp
if err := os.WriteFile(mainResult.BackupPath, existingData, 0644); err != nil { if err := os.WriteFile(mainResult.BackupPath, existingData, 0o644); err != nil {
mainResult.Error = fmt.Errorf("failed to create backup: %w", err) mainResult.Error = fmt.Errorf("failed to create backup: %w", err)
return []DeploymentResult{mainResult}, mainResult.Error return []DeploymentResult{mainResult}, mainResult.Error
} }
cd.log(fmt.Sprintf("Backed up existing config to %s", mainResult.BackupPath)) cd.log(fmt.Sprintf("Backed up existing config to %s", mainResult.BackupPath))
} }
if err := os.WriteFile(mainResult.Path, []byte(KittyConfig), 0644); err != nil { if err := os.WriteFile(mainResult.Path, []byte(KittyConfig), 0o644); err != nil {
mainResult.Error = fmt.Errorf("failed to write config: %w", err) mainResult.Error = fmt.Errorf("failed to write config: %w", err)
return []DeploymentResult{mainResult}, mainResult.Error return []DeploymentResult{mainResult}, mainResult.Error
} }
@@ -339,7 +340,7 @@ func (cd *ConfigDeployer) deployKittyConfig() ([]DeploymentResult, error) {
Path: filepath.Join(os.Getenv("HOME"), ".config", "kitty", "dank-theme.conf"), Path: filepath.Join(os.Getenv("HOME"), ".config", "kitty", "dank-theme.conf"),
} }
if err := os.WriteFile(themeResult.Path, []byte(KittyThemeConfig), 0644); err != nil { if err := os.WriteFile(themeResult.Path, []byte(KittyThemeConfig), 0o644); err != nil {
themeResult.Error = fmt.Errorf("failed to write theme config: %w", err) themeResult.Error = fmt.Errorf("failed to write theme config: %w", err)
return results, themeResult.Error return results, themeResult.Error
} }
@@ -353,7 +354,7 @@ func (cd *ConfigDeployer) deployKittyConfig() ([]DeploymentResult, error) {
Path: filepath.Join(os.Getenv("HOME"), ".config", "kitty", "dank-tabs.conf"), Path: filepath.Join(os.Getenv("HOME"), ".config", "kitty", "dank-tabs.conf"),
} }
if err := os.WriteFile(tabsResult.Path, []byte(KittyTabsConfig), 0644); err != nil { if err := os.WriteFile(tabsResult.Path, []byte(KittyTabsConfig), 0o644); err != nil {
tabsResult.Error = fmt.Errorf("failed to write tabs config: %w", err) tabsResult.Error = fmt.Errorf("failed to write tabs config: %w", err)
return results, tabsResult.Error return results, tabsResult.Error
} }
@@ -374,7 +375,7 @@ func (cd *ConfigDeployer) deployAlacrittyConfig() ([]DeploymentResult, error) {
} }
configDir := filepath.Dir(mainResult.Path) configDir := filepath.Dir(mainResult.Path)
if err := os.MkdirAll(configDir, 0755); err != nil { if err := os.MkdirAll(configDir, 0o755); err != nil {
mainResult.Error = fmt.Errorf("failed to create config directory: %w", err) mainResult.Error = fmt.Errorf("failed to create config directory: %w", err)
return []DeploymentResult{mainResult}, mainResult.Error return []DeploymentResult{mainResult}, mainResult.Error
} }
@@ -390,14 +391,14 @@ func (cd *ConfigDeployer) deployAlacrittyConfig() ([]DeploymentResult, error) {
timestamp := time.Now().Format("2006-01-02_15-04-05") timestamp := time.Now().Format("2006-01-02_15-04-05")
mainResult.BackupPath = mainResult.Path + ".backup." + timestamp mainResult.BackupPath = mainResult.Path + ".backup." + timestamp
if err := os.WriteFile(mainResult.BackupPath, existingData, 0644); err != nil { if err := os.WriteFile(mainResult.BackupPath, existingData, 0o644); err != nil {
mainResult.Error = fmt.Errorf("failed to create backup: %w", err) mainResult.Error = fmt.Errorf("failed to create backup: %w", err)
return []DeploymentResult{mainResult}, mainResult.Error return []DeploymentResult{mainResult}, mainResult.Error
} }
cd.log(fmt.Sprintf("Backed up existing config to %s", mainResult.BackupPath)) cd.log(fmt.Sprintf("Backed up existing config to %s", mainResult.BackupPath))
} }
if err := os.WriteFile(mainResult.Path, []byte(AlacrittyConfig), 0644); err != nil { if err := os.WriteFile(mainResult.Path, []byte(AlacrittyConfig), 0o644); err != nil {
mainResult.Error = fmt.Errorf("failed to write config: %w", err) mainResult.Error = fmt.Errorf("failed to write config: %w", err)
return []DeploymentResult{mainResult}, mainResult.Error return []DeploymentResult{mainResult}, mainResult.Error
} }
@@ -411,7 +412,7 @@ func (cd *ConfigDeployer) deployAlacrittyConfig() ([]DeploymentResult, error) {
Path: filepath.Join(os.Getenv("HOME"), ".config", "alacritty", "dank-theme.toml"), Path: filepath.Join(os.Getenv("HOME"), ".config", "alacritty", "dank-theme.toml"),
} }
if err := os.WriteFile(themeResult.Path, []byte(AlacrittyThemeConfig), 0644); err != nil { if err := os.WriteFile(themeResult.Path, []byte(AlacrittyThemeConfig), 0o644); err != nil {
themeResult.Error = fmt.Errorf("failed to write theme config: %w", err) themeResult.Error = fmt.Errorf("failed to write theme config: %w", err)
return results, themeResult.Error return results, themeResult.Error
} }
@@ -438,7 +439,7 @@ func (cd *ConfigDeployer) mergeNiriOutputSections(newConfig, existingConfig, dms
outputsContent.WriteString(output) outputsContent.WriteString(output)
outputsContent.WriteString("\n\n") outputsContent.WriteString("\n\n")
} }
if err := os.WriteFile(outputsPath, []byte(outputsContent.String()), 0644); err != nil { if err := os.WriteFile(outputsPath, []byte(outputsContent.String()), 0o644); err != nil {
cd.log(fmt.Sprintf("Warning: Failed to migrate outputs to %s: %v", outputsPath, err)) cd.log(fmt.Sprintf("Warning: Failed to migrate outputs to %s: %v", outputsPath, err))
} else { } else {
cd.log("Migrated output sections to dms/outputs.kdl") cd.log("Migrated output sections to dms/outputs.kdl")
@@ -479,13 +480,13 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem
} }
configDir := filepath.Dir(result.Path) configDir := filepath.Dir(result.Path)
if err := os.MkdirAll(configDir, 0755); err != nil { if err := os.MkdirAll(configDir, 0o755); err != nil {
result.Error = fmt.Errorf("failed to create config directory: %w", err) result.Error = fmt.Errorf("failed to create config directory: %w", err)
return result, result.Error return result, result.Error
} }
dmsDir := filepath.Join(configDir, "dms") dmsDir := filepath.Join(configDir, "dms")
if err := os.MkdirAll(dmsDir, 0755); err != nil { if err := os.MkdirAll(dmsDir, 0o755); err != nil {
result.Error = fmt.Errorf("failed to create dms directory: %w", err) result.Error = fmt.Errorf("failed to create dms directory: %w", err)
return result, result.Error return result, result.Error
} }
@@ -503,7 +504,7 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem
timestamp := time.Now().Format("2006-01-02_15-04-05") timestamp := time.Now().Format("2006-01-02_15-04-05")
result.BackupPath = result.Path + ".backup." + timestamp result.BackupPath = result.Path + ".backup." + timestamp
if err := os.WriteFile(result.BackupPath, existingData, 0644); err != nil { 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
} }
@@ -538,7 +539,7 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem
} }
} }
if err := os.WriteFile(result.Path, []byte(newConfig), 0644); err != nil { if err := os.WriteFile(result.Path, []byte(newConfig), 0o644); err != nil {
result.Error = fmt.Errorf("failed to write config: %w", err) result.Error = fmt.Errorf("failed to write config: %w", err)
return result, result.Error return result, result.Error
} }
@@ -563,6 +564,7 @@ func (cd *ConfigDeployer) deployHyprlandDmsConfigs(dmsDir string, terminalComman
{"binds.conf", strings.ReplaceAll(HyprBindsConfig, "{{TERMINAL_COMMAND}}", terminalCommand)}, {"binds.conf", strings.ReplaceAll(HyprBindsConfig, "{{TERMINAL_COMMAND}}", terminalCommand)},
{"outputs.conf", ""}, {"outputs.conf", ""},
{"cursor.conf", ""}, {"cursor.conf", ""},
{"windowrules.conf", ""},
} }
for _, cfg := range configs { for _, cfg := range configs {
@@ -572,7 +574,7 @@ func (cd *ConfigDeployer) deployHyprlandDmsConfigs(dmsDir string, terminalComman
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), 0644); 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)
} }
cd.log(fmt.Sprintf("Deployed %s", cfg.name)) cd.log(fmt.Sprintf("Deployed %s", cfg.name))
@@ -596,7 +598,7 @@ func (cd *ConfigDeployer) mergeHyprlandMonitorSections(newConfig, existingConfig
outputsContent.WriteString(monitor) outputsContent.WriteString(monitor)
outputsContent.WriteString("\n") outputsContent.WriteString("\n")
} }
if err := os.WriteFile(outputsPath, []byte(outputsContent.String()), 0644); err != nil { 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)) cd.log(fmt.Sprintf("Warning: Failed to migrate monitors to %s: %v", outputsPath, err))
} else { } else {
cd.log("Migrated monitor sections to dms/outputs.conf") cd.log("Migrated monitor sections to dms/outputs.conf")

View File

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

View File

@@ -38,6 +38,7 @@ bind = SUPER, F, fullscreen, 1
bind = SUPER SHIFT, F, fullscreen, 0 bind = SUPER SHIFT, F, fullscreen, 0
bind = SUPER SHIFT, T, togglefloating bind = SUPER SHIFT, T, togglefloating
bind = SUPER, W, togglegroup bind = SUPER, W, togglegroup
bind = SUPER SHIFT, W, exec, dms ipc call window-rules toggle
# === Focus Navigation === # === Focus Navigation ===
bind = SUPER, left, movefocus, l bind = SUPER, left, movefocus, l

View File

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

View File

@@ -76,6 +76,7 @@ binds {
Mod+Shift+T { toggle-window-floating; } Mod+Shift+T { toggle-window-floating; }
Mod+Shift+V { switch-focus-between-floating-and-tiling; } Mod+Shift+V { switch-focus-between-floating-and-tiling; }
Mod+W { toggle-column-tabbed-display; } Mod+W { toggle-column-tabbed-display; }
Mod+Shift+W hotkey-overlay-title="Create window rule" { spawn "dms" "ipc" "call" "window-rules" "toggle"; }
// === Focus Navigation === // === Focus Navigation ===
Mod+Left { focus-column-left; } Mod+Left { focus-column-left; }

View File

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

View File

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

View File

@@ -540,12 +540,12 @@ func (o *OpenSUSEDistribution) installQuickshell(ctx context.Context, variant de
} }
cacheDir := filepath.Join(homeDir, ".cache", "dankinstall") cacheDir := filepath.Join(homeDir, ".cache", "dankinstall")
if err := os.MkdirAll(cacheDir, 0755); err != nil { if err := os.MkdirAll(cacheDir, 0o755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err) return fmt.Errorf("failed to create cache directory: %w", err)
} }
tmpDir := filepath.Join(cacheDir, "quickshell-build") tmpDir := filepath.Join(cacheDir, "quickshell-build")
if err := os.MkdirAll(tmpDir, 0755); err != nil { if err := os.MkdirAll(tmpDir, 0o755); err != nil {
return fmt.Errorf("failed to create temp directory: %w", err) return fmt.Errorf("failed to create temp directory: %w", err)
} }
defer os.RemoveAll(tmpDir) defer os.RemoveAll(tmpDir)
@@ -576,7 +576,7 @@ func (o *OpenSUSEDistribution) installQuickshell(ctx context.Context, variant de
} }
buildDir := tmpDir + "/build" buildDir := tmpDir + "/build"
if err := os.MkdirAll(buildDir, 0755); err != nil { if err := os.MkdirAll(buildDir, 0o755); err != nil {
return fmt.Errorf("failed to create build directory: %w", err) return fmt.Errorf("failed to create build directory: %w", err)
} }

View File

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

View File

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

View File

@@ -216,7 +216,7 @@ func (h *HyprlandProvider) SetBind(key, action, description string, options map[
overridePath := h.GetOverridePath() overridePath := h.GetOverridePath()
if err := os.MkdirAll(filepath.Dir(overridePath), 0755); err != nil { if err := os.MkdirAll(filepath.Dir(overridePath), 0o755); err != nil {
return fmt.Errorf("failed to create dms directory: %w", err) return fmt.Errorf("failed to create dms directory: %w", err)
} }
@@ -398,7 +398,7 @@ func (h *HyprlandProvider) getBindSortPriority(action string) int {
func (h *HyprlandProvider) writeOverrideBinds(binds map[string]*hyprlandOverrideBind) error { func (h *HyprlandProvider) writeOverrideBinds(binds map[string]*hyprlandOverrideBind) error {
overridePath := h.GetOverridePath() overridePath := h.GetOverridePath()
content := h.generateBindsContent(binds) content := h.generateBindsContent(binds)
return os.WriteFile(overridePath, []byte(content), 0644) return os.WriteFile(overridePath, []byte(content), 0o644)
} }
func (h *HyprlandProvider) generateBindsContent(binds map[string]*hyprlandOverrideBind) string { func (h *HyprlandProvider) generateBindsContent(binds map[string]*hyprlandOverrideBind) string {

View File

@@ -187,7 +187,7 @@ bind = SUPER, right, movefocus, r
bind = SUPER, T, exec, kitty # Terminal bind = SUPER, T, exec, kitty # Terminal
` `
if err := os.WriteFile(configFile, []byte(content), 0644); err != nil { if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil {
t.Fatalf("Failed to write test config: %v", err) t.Fatalf("Failed to write test config: %v", err)
} }
@@ -245,7 +245,7 @@ bind = SUPER, B, exec, app2
#/# = SUPER, C, exec, app3 # Custom comment #/# = SUPER, C, exec, app3 # Custom comment
` `
if err := os.WriteFile(configFile, []byte(content), 0644); err != nil { if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil {
t.Fatalf("Failed to write test config: %v", err) t.Fatalf("Failed to write test config: %v", err)
} }
@@ -278,10 +278,10 @@ func TestHyprlandReadContentMultipleFiles(t *testing.T) {
content1 := "bind = SUPER, Q, killactive\n" content1 := "bind = SUPER, Q, killactive\n"
content2 := "bind = SUPER, T, exec, kitty\n" content2 := "bind = SUPER, T, exec, kitty\n"
if err := os.WriteFile(file1, []byte(content1), 0644); err != nil { if err := os.WriteFile(file1, []byte(content1), 0o644); err != nil {
t.Fatalf("Failed to write file1: %v", err) t.Fatalf("Failed to write file1: %v", err)
} }
if err := os.WriteFile(file2, []byte(content2), 0644); err != nil { if err := os.WriteFile(file2, []byte(content2), 0o644); err != nil {
t.Fatalf("Failed to write file2: %v", err) t.Fatalf("Failed to write file2: %v", err)
} }
@@ -328,13 +328,13 @@ func TestHyprlandReadContentWithTildeExpansion(t *testing.T) {
} }
tmpSubdir := filepath.Join(homeDir, ".config", "test-hypr-"+t.Name()) tmpSubdir := filepath.Join(homeDir, ".config", "test-hypr-"+t.Name())
if err := os.MkdirAll(tmpSubdir, 0755); err != nil { if err := os.MkdirAll(tmpSubdir, 0o755); err != nil {
t.Skip("Cannot create test directory in home") t.Skip("Cannot create test directory in home")
} }
defer os.RemoveAll(tmpSubdir) defer os.RemoveAll(tmpSubdir)
configFile := filepath.Join(tmpSubdir, "test.conf") configFile := filepath.Join(tmpSubdir, "test.conf")
if err := os.WriteFile(configFile, []byte("bind = SUPER, Q, killactive\n"), 0644); err != nil { if err := os.WriteFile(configFile, []byte("bind = SUPER, Q, killactive\n"), 0o644); err != nil {
t.Fatalf("Failed to write test config: %v", err) t.Fatalf("Failed to write test config: %v", err)
} }
@@ -381,7 +381,7 @@ bind = SUPER, Q, killactive
bind = SUPER, T, exec, kitty bind = SUPER, T, exec, kitty
` `
if err := os.WriteFile(configFile, []byte(content), 0644); err != nil { if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil {
t.Fatalf("Failed to write test config: %v", err) t.Fatalf("Failed to write test config: %v", err)
} }

View File

@@ -57,7 +57,7 @@ bind = SUPER, T, exec, kitty # Terminal
bind = SUPER, 1, workspace, 1 bind = SUPER, 1, workspace, 1
` `
if err := os.WriteFile(configFile, []byte(content), 0644); err != nil { if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil {
t.Fatalf("Failed to write test config: %v", err) t.Fatalf("Failed to write test config: %v", err)
} }
@@ -134,7 +134,7 @@ func TestFormatKey(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
if err := os.WriteFile(configFile, []byte(tt.content), 0644); err != nil { if err := os.WriteFile(configFile, []byte(tt.content), 0o644); err != nil {
t.Fatalf("Failed to write test config: %v", err) t.Fatalf("Failed to write test config: %v", err)
} }
@@ -189,7 +189,7 @@ func TestDescriptionFallback(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
if err := os.WriteFile(configFile, []byte(tt.content), 0644); err != nil { if err := os.WriteFile(configFile, []byte(tt.content), 0o644); err != nil {
t.Fatalf("Failed to write test config: %v", err) t.Fatalf("Failed to write test config: %v", err)
} }

View File

@@ -218,7 +218,7 @@ func (m *MangoWCProvider) SetBind(key, action, description string, options map[s
overridePath := m.GetOverridePath() overridePath := m.GetOverridePath()
if err := os.MkdirAll(filepath.Dir(overridePath), 0755); err != nil { if err := os.MkdirAll(filepath.Dir(overridePath), 0o755); err != nil {
return fmt.Errorf("failed to create dms directory: %w", err) return fmt.Errorf("failed to create dms directory: %w", err)
} }
@@ -360,7 +360,7 @@ func (m *MangoWCProvider) getBindSortPriority(action string) int {
func (m *MangoWCProvider) writeOverrideBinds(binds map[string]*mangowcOverrideBind) error { func (m *MangoWCProvider) writeOverrideBinds(binds map[string]*mangowcOverrideBind) error {
overridePath := m.GetOverridePath() overridePath := m.GetOverridePath()
content := m.generateBindsContent(binds) content := m.generateBindsContent(binds)
return os.WriteFile(overridePath, []byte(content), 0644) return os.WriteFile(overridePath, []byte(content), 0o644)
} }
func (m *MangoWCProvider) generateBindsContent(binds map[string]*mangowcOverrideBind) string { func (m *MangoWCProvider) generateBindsContent(binds map[string]*mangowcOverrideBind) string {

View File

@@ -238,7 +238,7 @@ bind=Ctrl,1,view,1,0
bind=Ctrl,2,view,2,0 bind=Ctrl,2,view,2,0
` `
if err := os.WriteFile(configFile, []byte(content), 0644); err != nil { if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil {
t.Fatalf("Failed to write test config: %v", err) t.Fatalf("Failed to write test config: %v", err)
} }
@@ -276,10 +276,10 @@ func TestMangoWCReadContentMultipleFiles(t *testing.T) {
content1 := "bind=ALT,q,killclient,\n" content1 := "bind=ALT,q,killclient,\n"
content2 := "bind=Alt,t,spawn,kitty\n" content2 := "bind=Alt,t,spawn,kitty\n"
if err := os.WriteFile(file1, []byte(content1), 0644); err != nil { if err := os.WriteFile(file1, []byte(content1), 0o644); err != nil {
t.Fatalf("Failed to write file1: %v", err) t.Fatalf("Failed to write file1: %v", err)
} }
if err := os.WriteFile(file2, []byte(content2), 0644); err != nil { if err := os.WriteFile(file2, []byte(content2), 0o644); err != nil {
t.Fatalf("Failed to write file2: %v", err) t.Fatalf("Failed to write file2: %v", err)
} }
@@ -300,7 +300,7 @@ func TestMangoWCReadContentSingleFile(t *testing.T) {
content := "bind=ALT,q,killclient,\n" content := "bind=ALT,q,killclient,\n"
if err := os.WriteFile(configFile, []byte(content), 0644); err != nil { if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil {
t.Fatalf("Failed to write config: %v", err) t.Fatalf("Failed to write config: %v", err)
} }
@@ -347,13 +347,13 @@ func TestMangoWCReadContentWithTildeExpansion(t *testing.T) {
} }
tmpSubdir := filepath.Join(homeDir, ".config", "test-mango-"+t.Name()) tmpSubdir := filepath.Join(homeDir, ".config", "test-mango-"+t.Name())
if err := os.MkdirAll(tmpSubdir, 0755); err != nil { if err := os.MkdirAll(tmpSubdir, 0o755); err != nil {
t.Skip("Cannot create test directory in home") t.Skip("Cannot create test directory in home")
} }
defer os.RemoveAll(tmpSubdir) defer os.RemoveAll(tmpSubdir)
configFile := filepath.Join(tmpSubdir, "config.conf") configFile := filepath.Join(tmpSubdir, "config.conf")
if err := os.WriteFile(configFile, []byte("bind=ALT,q,killclient,\n"), 0644); err != nil { if err := os.WriteFile(configFile, []byte("bind=ALT,q,killclient,\n"), 0o644); err != nil {
t.Fatalf("Failed to write test config: %v", err) t.Fatalf("Failed to write test config: %v", err)
} }
@@ -384,7 +384,7 @@ bind=ALT,q,killclient,
bind=Alt,t,spawn,kitty bind=Alt,t,spawn,kitty
` `
if err := os.WriteFile(configFile, []byte(content), 0644); err != nil { if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil {
t.Fatalf("Failed to write test config: %v", err) t.Fatalf("Failed to write test config: %v", err)
} }
@@ -458,7 +458,7 @@ bind=Ctrl,2,view,2,0
bind=Ctrl,3,view,3,0 bind=Ctrl,3,view,3,0
` `
if err := os.WriteFile(configFile, []byte(content), 0644); err != nil { if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil {
t.Fatalf("Failed to write test config: %v", err) t.Fatalf("Failed to write test config: %v", err)
} }

View File

@@ -223,7 +223,7 @@ bind=SUPER,n,switch_layout
bind=ALT+SHIFT,X,incgaps,1 bind=ALT+SHIFT,X,incgaps,1
` `
if err := os.WriteFile(configFile, []byte(content), 0644); err != nil { if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil {
t.Fatalf("Failed to write test config: %v", err) t.Fatalf("Failed to write test config: %v", err)
} }
@@ -285,7 +285,7 @@ bind=ALT,Left,focusdir,left
bind=Ctrl,1,view,1,0 bind=Ctrl,1,view,1,0
` `
if err := os.WriteFile(configFile, []byte(content), 0644); err != nil { if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil {
t.Fatalf("Failed to write test config: %v", err) t.Fatalf("Failed to write test config: %v", err)
} }

View File

@@ -235,7 +235,7 @@ func (n *NiriProvider) SetBind(key, action, description string, options map[stri
overridePath := n.GetOverridePath() overridePath := n.GetOverridePath()
if err := os.MkdirAll(filepath.Dir(overridePath), 0755); err != nil { if err := os.MkdirAll(filepath.Dir(overridePath), 0o755); err != nil {
return fmt.Errorf("failed to create dms directory: %w", err) return fmt.Errorf("failed to create dms directory: %w", err)
} }
@@ -485,7 +485,7 @@ func (n *NiriProvider) writeOverrideBinds(binds map[string]*overrideBind) error
return err return err
} }
return os.WriteFile(overridePath, []byte(content), 0644) return os.WriteFile(overridePath, []byte(content), 0o644)
} }
func (n *NiriProvider) getBindSortPriority(action string) int { func (n *NiriProvider) getBindSortPriority(action string) int {

View File

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

View File

@@ -27,7 +27,7 @@ func TestNiriProviderGetCheatSheet(t *testing.T) {
Mod+Shift+E { quit; } Mod+Shift+E { quit; }
} }
` `
if err := os.WriteFile(configFile, []byte(content), 0644); err != nil { if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil {
t.Fatalf("Failed to write test config: %v", err) t.Fatalf("Failed to write test config: %v", err)
} }
@@ -312,7 +312,7 @@ func TestNiriGenerateBindsContentRoundTrip(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "config.kdl") configFile := filepath.Join(tmpDir, "config.kdl")
if err := os.WriteFile(configFile, []byte(content), 0644); err != nil { if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil {
t.Fatalf("Failed to write temp file: %v", err) t.Fatalf("Failed to write temp file: %v", err)
} }
@@ -351,12 +351,12 @@ func TestNiriEmptyArgsPreservation(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
dmsDir := filepath.Join(tmpDir, "dms") dmsDir := filepath.Join(tmpDir, "dms")
if err := os.MkdirAll(dmsDir, 0755); err != nil { if err := os.MkdirAll(dmsDir, 0o755); err != nil {
t.Fatalf("Failed to create dms directory: %v", err) t.Fatalf("Failed to create dms directory: %v", err)
} }
bindsFile := filepath.Join(dmsDir, "binds.kdl") bindsFile := filepath.Join(dmsDir, "binds.kdl")
if err := os.WriteFile(bindsFile, []byte(content), 0644); err != nil { if err := os.WriteFile(bindsFile, []byte(content), 0o644); err != nil {
t.Fatalf("Failed to write binds file: %v", err) t.Fatalf("Failed to write binds file: %v", err)
} }
@@ -428,7 +428,7 @@ recent-windows {
} }
} }
` `
if err := os.WriteFile(configFile, []byte(content), 0644); err != nil { if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil {
t.Fatalf("Failed to write test config: %v", err) t.Fatalf("Failed to write test config: %v", err)
} }
@@ -621,7 +621,7 @@ func TestNiriGenerateWorkspaceBindsRoundTrip(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "config.kdl") configFile := filepath.Join(tmpDir, "config.kdl")
if err := os.WriteFile(configFile, []byte(content), 0644); err != nil { if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil {
t.Fatalf("Failed to write temp file: %v", err) t.Fatalf("Failed to write temp file: %v", err)
} }

View File

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

View File

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

View File

@@ -148,7 +148,7 @@ func Run(opts Options) error {
opts.AppChecker = utils.DefaultAppChecker{} opts.AppChecker = utils.DefaultAppChecker{}
} }
if err := os.MkdirAll(opts.StateDir, 0755); err != nil { if err := os.MkdirAll(opts.StateDir, 0o755); err != nil {
return fmt.Errorf("failed to create state dir: %w", err) return fmt.Errorf("failed to create state dir: %w", err)
} }
@@ -414,7 +414,7 @@ func appendTerminalConfig(opts *Options, cfgFile *os.File, tmpDir string, checkC
modified := strings.ReplaceAll(string(origData), ".default.", ".dark.") modified := strings.ReplaceAll(string(origData), ".default.", ".dark.")
tmpPath := filepath.Join(tmpDir, templateName) tmpPath := filepath.Join(tmpDir, templateName)
if err := os.WriteFile(tmpPath, []byte(modified), 0644); err != nil { if err := os.WriteFile(tmpPath, []byte(modified), 0o644); err != nil {
continue continue
} }

View File

@@ -15,13 +15,13 @@ func TestAppendConfigBinaryExists(t *testing.T) {
shellDir := filepath.Join(tempDir, "shell") shellDir := filepath.Join(tempDir, "shell")
configsDir := filepath.Join(shellDir, "matugen", "configs") configsDir := filepath.Join(shellDir, "matugen", "configs")
if err := os.MkdirAll(configsDir, 0755); err != nil { if err := os.MkdirAll(configsDir, 0o755); err != nil {
t.Fatalf("failed to create configs dir: %v", err) t.Fatalf("failed to create configs dir: %v", err)
} }
testConfig := "test config content" testConfig := "test config content"
configPath := filepath.Join(configsDir, "test.toml") configPath := filepath.Join(configsDir, "test.toml")
if err := os.WriteFile(configPath, []byte(testConfig), 0644); err != nil { if err := os.WriteFile(configPath, []byte(testConfig), 0o644); err != nil {
t.Fatalf("failed to write config: %v", err) t.Fatalf("failed to write config: %v", err)
} }
@@ -58,13 +58,13 @@ func TestAppendConfigBinaryDoesNotExist(t *testing.T) {
shellDir := filepath.Join(tempDir, "shell") shellDir := filepath.Join(tempDir, "shell")
configsDir := filepath.Join(shellDir, "matugen", "configs") configsDir := filepath.Join(shellDir, "matugen", "configs")
if err := os.MkdirAll(configsDir, 0755); err != nil { if err := os.MkdirAll(configsDir, 0o755); err != nil {
t.Fatalf("failed to create configs dir: %v", err) t.Fatalf("failed to create configs dir: %v", err)
} }
testConfig := "test config content" testConfig := "test config content"
configPath := filepath.Join(configsDir, "test.toml") configPath := filepath.Join(configsDir, "test.toml")
if err := os.WriteFile(configPath, []byte(testConfig), 0644); err != nil { if err := os.WriteFile(configPath, []byte(testConfig), 0o644); err != nil {
t.Fatalf("failed to write config: %v", err) t.Fatalf("failed to write config: %v", err)
} }
@@ -99,13 +99,13 @@ func TestAppendConfigFlatpakExists(t *testing.T) {
shellDir := filepath.Join(tempDir, "shell") shellDir := filepath.Join(tempDir, "shell")
configsDir := filepath.Join(shellDir, "matugen", "configs") configsDir := filepath.Join(shellDir, "matugen", "configs")
if err := os.MkdirAll(configsDir, 0755); err != nil { if err := os.MkdirAll(configsDir, 0o755); err != nil {
t.Fatalf("failed to create configs dir: %v", err) t.Fatalf("failed to create configs dir: %v", err)
} }
testConfig := "zen config content" testConfig := "zen config content"
configPath := filepath.Join(configsDir, "test.toml") configPath := filepath.Join(configsDir, "test.toml")
if err := os.WriteFile(configPath, []byte(testConfig), 0644); err != nil { if err := os.WriteFile(configPath, []byte(testConfig), 0o644); err != nil {
t.Fatalf("failed to write config: %v", err) t.Fatalf("failed to write config: %v", err)
} }
@@ -139,13 +139,13 @@ func TestAppendConfigFlatpakDoesNotExist(t *testing.T) {
shellDir := filepath.Join(tempDir, "shell") shellDir := filepath.Join(tempDir, "shell")
configsDir := filepath.Join(shellDir, "matugen", "configs") configsDir := filepath.Join(shellDir, "matugen", "configs")
if err := os.MkdirAll(configsDir, 0755); err != nil { if err := os.MkdirAll(configsDir, 0o755); err != nil {
t.Fatalf("failed to create configs dir: %v", err) t.Fatalf("failed to create configs dir: %v", err)
} }
testConfig := "test config content" testConfig := "test config content"
configPath := filepath.Join(configsDir, "test.toml") configPath := filepath.Join(configsDir, "test.toml")
if err := os.WriteFile(configPath, []byte(testConfig), 0644); err != nil { if err := os.WriteFile(configPath, []byte(testConfig), 0o644); err != nil {
t.Fatalf("failed to write config: %v", err) t.Fatalf("failed to write config: %v", err)
} }
@@ -180,13 +180,13 @@ func TestAppendConfigBothExist(t *testing.T) {
shellDir := filepath.Join(tempDir, "shell") shellDir := filepath.Join(tempDir, "shell")
configsDir := filepath.Join(shellDir, "matugen", "configs") configsDir := filepath.Join(shellDir, "matugen", "configs")
if err := os.MkdirAll(configsDir, 0755); err != nil { if err := os.MkdirAll(configsDir, 0o755); err != nil {
t.Fatalf("failed to create configs dir: %v", err) t.Fatalf("failed to create configs dir: %v", err)
} }
testConfig := "zen config content" testConfig := "zen config content"
configPath := filepath.Join(configsDir, "test.toml") configPath := filepath.Join(configsDir, "test.toml")
if err := os.WriteFile(configPath, []byte(testConfig), 0644); err != nil { if err := os.WriteFile(configPath, []byte(testConfig), 0o644); err != nil {
t.Fatalf("failed to write config: %v", err) t.Fatalf("failed to write config: %v", err)
} }
@@ -220,13 +220,13 @@ func TestAppendConfigNeitherExists(t *testing.T) {
shellDir := filepath.Join(tempDir, "shell") shellDir := filepath.Join(tempDir, "shell")
configsDir := filepath.Join(shellDir, "matugen", "configs") configsDir := filepath.Join(shellDir, "matugen", "configs")
if err := os.MkdirAll(configsDir, 0755); err != nil { if err := os.MkdirAll(configsDir, 0o755); err != nil {
t.Fatalf("failed to create configs dir: %v", err) t.Fatalf("failed to create configs dir: %v", err)
} }
testConfig := "test config content" testConfig := "test config content"
configPath := filepath.Join(configsDir, "test.toml") configPath := filepath.Join(configsDir, "test.toml")
if err := os.WriteFile(configPath, []byte(testConfig), 0644); err != nil { if err := os.WriteFile(configPath, []byte(testConfig), 0o644); err != nil {
t.Fatalf("failed to write config: %v", err) t.Fatalf("failed to write config: %v", err)
} }
@@ -261,13 +261,13 @@ func TestAppendConfigNoChecks(t *testing.T) {
shellDir := filepath.Join(tempDir, "shell") shellDir := filepath.Join(tempDir, "shell")
configsDir := filepath.Join(shellDir, "matugen", "configs") configsDir := filepath.Join(shellDir, "matugen", "configs")
if err := os.MkdirAll(configsDir, 0755); err != nil { if err := os.MkdirAll(configsDir, 0o755); err != nil {
t.Fatalf("failed to create configs dir: %v", err) t.Fatalf("failed to create configs dir: %v", err)
} }
testConfig := "always include" testConfig := "always include"
configPath := filepath.Join(configsDir, "test.toml") configPath := filepath.Join(configsDir, "test.toml")
if err := os.WriteFile(configPath, []byte(testConfig), 0644); err != nil { if err := os.WriteFile(configPath, []byte(testConfig), 0o644); err != nil {
t.Fatalf("failed to write config: %v", err) t.Fatalf("failed to write config: %v", err)
} }
@@ -298,7 +298,7 @@ func TestAppendConfigFileDoesNotExist(t *testing.T) {
shellDir := filepath.Join(tempDir, "shell") shellDir := filepath.Join(tempDir, "shell")
configsDir := filepath.Join(shellDir, "matugen", "configs") configsDir := filepath.Join(shellDir, "matugen", "configs")
if err := os.MkdirAll(configsDir, 0755); err != nil { if err := os.MkdirAll(configsDir, 0o755); err != nil {
t.Fatalf("failed to create configs dir: %v", err) t.Fatalf("failed to create configs dir: %v", err)
} }

View File

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

View File

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

View File

@@ -147,7 +147,7 @@ func (r *Registry) Update() error {
} }
if !exists { if !exists {
if err := r.fs.MkdirAll(filepath.Dir(r.cacheDir), 0755); err != nil { if err := r.fs.MkdirAll(filepath.Dir(r.cacheDir), 0o755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err) return fmt.Errorf("failed to create cache directory: %w", err)
} }
@@ -162,7 +162,7 @@ func (r *Registry) Update() error {
return fmt.Errorf("failed to remove corrupted registry: %w", err) return fmt.Errorf("failed to remove corrupted registry: %w", err)
} }
if err := r.fs.MkdirAll(filepath.Dir(r.cacheDir), 0755); err != nil { if err := r.fs.MkdirAll(filepath.Dir(r.cacheDir), 0o755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err) return fmt.Errorf("failed to create cache directory: %w", err)
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -45,6 +45,8 @@ func HandleRequest(conn net.Conn, req models.Request, m *Manager) {
handleGetPinnedEntries(conn, req, m) handleGetPinnedEntries(conn, req, m)
case "clipboard.getPinnedCount": case "clipboard.getPinnedCount":
handleGetPinnedCount(conn, req, m) handleGetPinnedCount(conn, req, m)
case "clipboard.copyFile":
handleCopyFile(conn, req, m)
default: default:
models.RespondError(conn, req.ID, "unknown method: "+req.Method) models.RespondError(conn, req.ID, "unknown method: "+req.Method)
} }
@@ -126,11 +128,29 @@ func handleCopyEntry(conn net.Conn, req models.Request, m *Manager) {
return return
} }
filePath := m.EntryToFile(entry)
if filePath != "" {
if err := m.CopyFile(filePath); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, map[string]any{
"success": true,
"filePath": filePath,
})
return
}
if err := m.SetClipboard(entry.Data, entry.MimeType); err != nil { if err := m.SetClipboard(entry.Data, entry.MimeType); err != nil {
models.RespondError(conn, req.ID, err.Error()) models.RespondError(conn, req.ID, err.Error())
return return
} }
if err := m.TouchEntry(uint64(id)); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "copied to clipboard"}) models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "copied to clipboard"})
} }
@@ -281,3 +301,18 @@ func handleGetPinnedCount(conn net.Conn, req models.Request, m *Manager) {
count := m.GetPinnedCount() count := m.GetPinnedCount()
models.Respond(conn, req.ID, map[string]int{"count": count}) models.Respond(conn, req.ID, map[string]int{"count": count})
} }
func handleCopyFile(conn net.Conn, req models.Request, m *Manager) {
filePath, err := params.String(req.Params, "filePath")
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
if err := m.CopyFile(filePath); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "copied"})
}

View File

@@ -10,6 +10,7 @@ import (
_ "image/png" _ "image/png"
"io" "io"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"slices" "slices"
"strings" "strings"
@@ -19,8 +20,10 @@ import (
"hash/fnv" "hash/fnv"
"github.com/fsnotify/fsnotify" "github.com/fsnotify/fsnotify"
"github.com/godbus/dbus/v5"
_ "golang.org/x/image/bmp" _ "golang.org/x/image/bmp"
_ "golang.org/x/image/tiff" _ "golang.org/x/image/tiff"
_ "golang.org/x/image/webp"
bolt "go.etcd.io/bbolt" bolt "go.etcd.io/bbolt"
@@ -104,7 +107,7 @@ func NewManager(wlCtx wlcontext.WaylandContext, config Config) (*Manager, error)
} }
func openDB(path string) (*bolt.DB, error) { func openDB(path string) (*bolt.DB, error) {
db, err := bolt.Open(path, 0644, &bolt.Options{ db, err := bolt.Open(path, 0o644, &bolt.Options{
Timeout: 1 * time.Second, Timeout: 1 * time.Second,
}) })
if err != nil { if err != nil {
@@ -316,6 +319,13 @@ func (m *Manager) readAndStore(r *os.File, mimeType string) {
} }
func (m *Manager) storeClipboardEntry(data []byte, mimeType string) { func (m *Manager) storeClipboardEntry(data []byte, mimeType string) {
if mimeType == "text/uri-list" {
if imgData, imgMime, ok := m.tryReadImageFromURI(data); ok {
data = imgData
mimeType = imgMime
}
}
entry := Entry{ entry := Entry{
Data: data, Data: data,
MimeType: mimeType, MimeType: mimeType,
@@ -327,6 +337,8 @@ func (m *Manager) storeClipboardEntry(data []byte, mimeType string) {
switch { switch {
case entry.IsImage: case entry.IsImage:
entry.Preview = m.imagePreview(data, mimeType) entry.Preview = m.imagePreview(data, mimeType)
case mimeType == "text/uri-list":
entry.Preview, entry.IsImage = m.uriListPreview(data)
default: default:
entry.Preview = m.textPreview(data) entry.Preview = m.textPreview(data)
} }
@@ -493,10 +505,10 @@ func computeHash(data []byte) uint64 {
} }
func extractHash(data []byte) uint64 { func extractHash(data []byte) uint64 {
if len(data) < 8 { if len(data) < 9 {
return 0 return 0
} }
return binary.BigEndian.Uint64(data[len(data)-8:]) return binary.BigEndian.Uint64(data[len(data)-9 : len(data)-1])
} }
func (m *Manager) hasSensitiveMimeType(mimes []string) bool { func (m *Manager) hasSensitiveMimeType(mimes []string) bool {
@@ -507,6 +519,7 @@ func (m *Manager) hasSensitiveMimeType(mimes []string) bool {
func (m *Manager) selectMimeType(mimes []string) string { func (m *Manager) selectMimeType(mimes []string) string {
preferredTypes := []string{ preferredTypes := []string{
"text/uri-list",
"text/plain;charset=utf-8", "text/plain;charset=utf-8",
"text/plain", "text/plain",
"UTF8_STRING", "UTF8_STRING",
@@ -527,8 +540,14 @@ func (m *Manager) selectMimeType(mimes []string) string {
} }
} }
if len(mimes) > 0 { // Skip useless MIME types when falling back
return mimes[0] for _, mime := range mimes {
switch mime {
case "application/vnd.portal.filetransfer":
continue
default:
return mime
}
} }
return "" return ""
@@ -557,6 +576,62 @@ func (m *Manager) imagePreview(data []byte, format string) string {
return fmt.Sprintf("[[ image %s %s %dx%d ]]", sizeStr(len(data)), imgFmt, config.Width, config.Height) return fmt.Sprintf("[[ image %s %s %dx%d ]]", sizeStr(len(data)), imgFmt, config.Width, config.Height)
} }
func (m *Manager) uriListPreview(data []byte) (string, bool) {
text := strings.TrimSpace(string(data))
uris := strings.Split(text, "\r\n")
if len(uris) == 0 {
uris = strings.Split(text, "\n")
}
if len(uris) == 1 && strings.HasPrefix(uris[0], "file://") {
filePath := strings.TrimPrefix(uris[0], "file://")
if info, err := os.Stat(filePath); err == nil && !info.IsDir() {
if imgData, err := os.ReadFile(filePath); err == nil {
if config, imgFmt, err := image.DecodeConfig(bytes.NewReader(imgData)); err == nil {
return fmt.Sprintf("[[ file %s %s %dx%d ]]", filepath.Base(filePath), imgFmt, config.Width, config.Height), true
}
}
return fmt.Sprintf("[[ file %s ]]", filepath.Base(filePath)), false
}
}
if len(uris) > 1 {
return fmt.Sprintf("[[ %d files ]]", len(uris)), false
}
return m.textPreview(data), false
}
func (m *Manager) tryReadImageFromURI(data []byte) ([]byte, string, bool) {
text := strings.TrimSpace(string(data))
uris := strings.Split(text, "\r\n")
if len(uris) == 0 {
uris = strings.Split(text, "\n")
}
if len(uris) != 1 || !strings.HasPrefix(uris[0], "file://") {
return nil, "", false
}
filePath := strings.TrimPrefix(uris[0], "file://")
info, err := os.Stat(filePath)
if err != nil || info.IsDir() {
return nil, "", false
}
imgData, err := os.ReadFile(filePath)
if err != nil {
return nil, "", false
}
_, imgFmt, err := image.DecodeConfig(bytes.NewReader(imgData))
if err != nil {
return nil, "", false
}
return imgData, "image/" + imgFmt, true
}
func sizeStr(size int) string { func sizeStr(size int) string {
units := []string{"B", "KiB", "MiB"} units := []string{"B", "KiB", "MiB"}
var i int var i int
@@ -745,6 +820,28 @@ func (m *Manager) DeleteEntry(id uint64) error {
return err return err
} }
func (m *Manager) TouchEntry(id uint64) error {
if m.db == nil {
return fmt.Errorf("database not available")
}
entry, err := m.GetEntry(id)
if err != nil {
return err
}
entry.Timestamp = time.Now()
if err := m.storeEntry(*entry); err != nil {
return err
}
m.updateState()
m.notifySubscribers()
return nil
}
func (m *Manager) ClearHistory() { func (m *Manager) ClearHistory() {
if m.db == nil { if m.db == nil {
return return
@@ -810,23 +907,23 @@ func (m *Manager) compactDB() error {
tmpPath := m.dbPath + ".compact" tmpPath := m.dbPath + ".compact"
defer os.Remove(tmpPath) defer os.Remove(tmpPath)
srcDB, err := bolt.Open(m.dbPath, 0644, &bolt.Options{ReadOnly: true, Timeout: time.Second}) srcDB, err := bolt.Open(m.dbPath, 0o644, &bolt.Options{ReadOnly: true, Timeout: time.Second})
if err != nil { if err != nil {
m.db, _ = bolt.Open(m.dbPath, 0644, &bolt.Options{Timeout: time.Second}) m.db, _ = bolt.Open(m.dbPath, 0o644, &bolt.Options{Timeout: time.Second})
return fmt.Errorf("open source: %w", err) return fmt.Errorf("open source: %w", err)
} }
dstDB, err := bolt.Open(tmpPath, 0644, &bolt.Options{Timeout: time.Second}) dstDB, err := bolt.Open(tmpPath, 0o644, &bolt.Options{Timeout: time.Second})
if err != nil { if err != nil {
srcDB.Close() srcDB.Close()
m.db, _ = bolt.Open(m.dbPath, 0644, &bolt.Options{Timeout: time.Second}) m.db, _ = bolt.Open(m.dbPath, 0o644, &bolt.Options{Timeout: time.Second})
return fmt.Errorf("open destination: %w", err) return fmt.Errorf("open destination: %w", err)
} }
if err := bolt.Compact(dstDB, srcDB, 0); err != nil { if err := bolt.Compact(dstDB, srcDB, 0); err != nil {
srcDB.Close() srcDB.Close()
dstDB.Close() dstDB.Close()
m.db, _ = bolt.Open(m.dbPath, 0644, &bolt.Options{Timeout: time.Second}) m.db, _ = bolt.Open(m.dbPath, 0o644, &bolt.Options{Timeout: time.Second})
return fmt.Errorf("compact: %w", err) return fmt.Errorf("compact: %w", err)
} }
@@ -834,11 +931,11 @@ func (m *Manager) compactDB() error {
dstDB.Close() dstDB.Close()
if err := os.Rename(tmpPath, m.dbPath); err != nil { if err := os.Rename(tmpPath, m.dbPath); err != nil {
m.db, _ = bolt.Open(m.dbPath, 0644, &bolt.Options{Timeout: time.Second}) m.db, _ = bolt.Open(m.dbPath, 0o644, &bolt.Options{Timeout: time.Second})
return fmt.Errorf("rename: %w", err) return fmt.Errorf("rename: %w", err)
} }
m.db, err = bolt.Open(m.dbPath, 0644, &bolt.Options{Timeout: time.Second}) m.db, err = bolt.Open(m.dbPath, 0o644, &bolt.Options{Timeout: time.Second})
if err != nil { if err != nil {
return fmt.Errorf("reopen: %w", err) return fmt.Errorf("reopen: %w", err)
} }
@@ -885,11 +982,21 @@ func (m *Manager) SetClipboard(data []byte, mimeType string) error {
} }
}) })
source.SetCancelledHandler(func(e ext_data_control.ExtDataControlSourceV1CancelledEvent) {
m.ownerLock.Lock()
m.isOwner = false
m.ownerLock.Unlock()
})
m.currentSource = source m.currentSource = source
m.sourceMutex.Lock() m.sourceMutex.Lock()
m.sourceMimeTypes = []string{mimeType} m.sourceMimeTypes = []string{mimeType}
m.sourceMutex.Unlock() m.sourceMutex.Unlock()
m.ownerLock.Lock()
m.isOwner = true
m.ownerLock.Unlock()
device := m.dataDevice.(*ext_data_control.ExtDataControlDeviceV1) device := m.dataDevice.(*ext_data_control.ExtDataControlDeviceV1)
if err := device.SetSelection(source); err != nil { if err := device.SetSelection(source); err != nil {
log.Errorf("Failed to set selection: %v", err) log.Errorf("Failed to set selection: %v", err)
@@ -1291,6 +1398,8 @@ func (m *Manager) StoreData(data []byte, mimeType string) error {
switch { switch {
case entry.IsImage: case entry.IsImage:
entry.Preview = m.imagePreview(data, mimeType) entry.Preview = m.imagePreview(data, mimeType)
case mimeType == "text/uri-list":
entry.Preview, entry.IsImage = m.uriListPreview(data)
default: default:
entry.Preview = m.textPreview(data) entry.Preview = m.textPreview(data)
} }
@@ -1454,3 +1563,233 @@ func (m *Manager) GetPinnedCount() int {
return count return count
} }
func (m *Manager) CopyFile(filePath string) error {
fileInfo, err := os.Stat(filePath)
if err != nil {
return fmt.Errorf("file not found: %w", err)
}
cfg := m.getConfig()
if fileInfo.Size() > cfg.MaxEntrySize {
return fmt.Errorf("file too large: %d > %d", fileInfo.Size(), cfg.MaxEntrySize)
}
fileData, err := os.ReadFile(filePath)
if err != nil {
return fmt.Errorf("read file: %w", err)
}
exportedPath, err := m.ExportFileForFlatpak(filePath)
if err != nil {
exportedPath = filePath
}
fileURI := "file://" + exportedPath
if imgData, imgMime, ok := m.tryReadImageFromURI([]byte("file://" + filePath)); ok {
entry := Entry{
Data: imgData,
MimeType: imgMime,
Size: len(imgData),
Timestamp: time.Now(),
IsImage: true,
Preview: m.imagePreview(imgData, imgMime),
}
if err := m.storeEntry(entry); err != nil {
log.Errorf("Failed to store file entry: %v", err)
}
} else {
entry := Entry{
Data: fileData,
MimeType: "text/uri-list",
Size: len(fileData),
Timestamp: time.Now(),
IsImage: false,
Preview: fmt.Sprintf("[[ file %s ]]", filepath.Base(filePath)),
}
if err := m.storeEntry(entry); err != nil {
log.Errorf("Failed to store file entry: %v", err)
}
}
m.updateState()
m.notifySubscribers()
m.post(func() {
if m.dataControlMgr == nil || m.dataDevice == nil {
log.Error("Data control manager or device not initialized")
return
}
dataMgr := m.dataControlMgr.(*ext_data_control.ExtDataControlManagerV1)
source, err := dataMgr.CreateDataSource()
if err != nil {
log.Errorf("Failed to create data source: %v", err)
return
}
type offer struct {
mime string
data []byte
}
offers := []offer{
{"x-special/gnome-copied-files", []byte("copy\n" + fileURI)},
{"text/uri-list", []byte(fileURI + "\r\n")},
{"text/plain", []byte(filePath)},
}
offerData := make(map[string][]byte)
for _, o := range offers {
if err := source.Offer(o.mime); err != nil {
log.Errorf("Failed to offer %s: %v", o.mime, err)
return
}
offerData[o.mime] = o.data
}
source.SetSendHandler(func(e ext_data_control.ExtDataControlSourceV1SendEvent) {
fd := e.Fd
defer syscall.Close(fd)
file := os.NewFile(uintptr(fd), "clipboard-pipe")
defer file.Close()
if data, ok := offerData[e.MimeType]; ok {
file.Write(data)
}
})
source.SetCancelledHandler(func(e ext_data_control.ExtDataControlSourceV1CancelledEvent) {
m.ownerLock.Lock()
m.isOwner = false
m.ownerLock.Unlock()
})
m.currentSource = source
m.ownerLock.Lock()
m.isOwner = true
m.ownerLock.Unlock()
device := m.dataDevice.(*ext_data_control.ExtDataControlDeviceV1)
if err := device.SetSelection(source); err != nil {
log.Errorf("Failed to set selection: %v", err)
}
})
return nil
}
func (m *Manager) EntryToFile(entry *Entry) string {
switch {
case entry.MimeType == "text/uri-list":
data := strings.TrimSpace(string(entry.Data))
lines := strings.Split(data, "\n")
if len(lines) == 0 {
return ""
}
uri := strings.TrimSuffix(strings.TrimSpace(lines[0]), "\r")
if path, ok := strings.CutPrefix(uri, "file://"); ok {
return path
}
case entry.IsImage:
ext := ".png"
if suffix, ok := strings.CutPrefix(entry.MimeType, "image/"); ok {
ext = "." + suffix
}
cacheDir, err := os.UserCacheDir()
if err != nil {
return ""
}
clipDir := filepath.Join(cacheDir, "dms", "clipboard")
if err := os.MkdirAll(clipDir, 0o755); err != nil {
return ""
}
filePath := filepath.Join(clipDir, fmt.Sprintf("%d%s", time.Now().UnixNano(), ext))
if os.WriteFile(filePath, entry.Data, 0o644) != nil {
return ""
}
return filePath
}
return ""
}
func (m *Manager) ExportFileForFlatpak(filePath string) (string, error) {
if _, err := os.Stat(filePath); err != nil {
return "", fmt.Errorf("file not found: %w", err)
}
if m.dbusConn == nil {
conn, err := dbus.ConnectSessionBus()
if err != nil {
return "", fmt.Errorf("connect session bus: %w", err)
}
if !conn.SupportsUnixFDs() {
conn.Close()
return "", fmt.Errorf("D-Bus connection does not support Unix FD passing")
}
m.dbusConn = conn
}
file, err := os.Open(filePath)
if err != nil {
return "", fmt.Errorf("open file: %w", err)
}
fd := int(file.Fd())
portal := m.dbusConn.Object("org.freedesktop.portal.Documents", "/org/freedesktop/portal/documents")
var docIds []string
var extra map[string]dbus.Variant
flags := uint32(0)
err = portal.Call(
"org.freedesktop.portal.Documents.AddFull",
0,
[]dbus.UnixFD{dbus.UnixFD(fd)},
flags,
"",
[]string{},
).Store(&docIds, &extra)
file.Close()
if err != nil {
return "", fmt.Errorf("AddFull: %w", err)
}
if len(docIds) == 0 {
return "", fmt.Errorf("no doc IDs returned")
}
docId := docIds[0]
for _, app := range getInstalledFlatpaks() {
_ = portal.Call(
"org.freedesktop.portal.Documents.GrantPermissions",
0,
docId,
app,
[]string{"read"},
).Err
}
uid := os.Getuid()
basename := filepath.Base(filePath)
exportedPath := fmt.Sprintf("/run/user/%d/doc/%s/%s", uid, docId, basename)
return exportedPath, nil
}
func getInstalledFlatpaks() []string {
out, err := exec.Command("flatpak", "list", "--app", "--columns=application").Output()
if err != nil {
return nil
}
var apps []string
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if app := strings.TrimSpace(line); app != "" {
apps = append(apps, app)
}
}
return apps
}

View File

@@ -7,6 +7,7 @@ import (
"sync" "sync"
"time" "time"
"github.com/godbus/dbus/v5"
bolt "go.etcd.io/bbolt" bolt "go.etcd.io/bbolt"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext"
@@ -65,7 +66,7 @@ func SaveConfig(cfg Config) error {
return err return err
} }
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err return err
} }
@@ -74,7 +75,7 @@ func SaveConfig(cfg Config) error {
return err return err
} }
return os.WriteFile(path, data, 0644) return os.WriteFile(path, data, 0o644)
} }
type SearchParams struct { type SearchParams struct {
@@ -157,6 +158,8 @@ type Manager struct {
dirty chan struct{} dirty chan struct{}
notifierWg sync.WaitGroup notifierWg sync.WaitGroup
lastState *State lastState *State
dbusConn *dbus.Conn
} }
func (m *Manager) GetState() State { func (m *Manager) GetState() State {

View File

@@ -60,12 +60,44 @@ func (m *Manager) Call(bus, dest, path, iface, method string, args []any) (*Call
obj := conn.Object(dest, dbus.ObjectPath(path)) obj := conn.Object(dest, dbus.ObjectPath(path))
fullMethod := iface + "." + method fullMethod := iface + "." + method
call := obj.Call(fullMethod, 0, args...) convertedArgs := convertArgs(args)
call := obj.Call(fullMethod, 0, convertedArgs...)
if call.Err != nil { if call.Err != nil {
return nil, fmt.Errorf("dbus call failed: %w", call.Err) return nil, fmt.Errorf("dbus call failed: %w", call.Err)
} }
return &CallResult{Values: call.Body}, nil return &CallResult{Values: dbusutil.NormalizeSlice(call.Body)}, nil
}
func convertArgs(args []any) []any {
result := make([]any, len(args))
for i, arg := range args {
result[i] = convertArg(arg)
}
return result
}
func convertArg(arg any) any {
switch v := arg.(type) {
case float64:
if v == float64(uint32(v)) && v >= 0 && v <= float64(^uint32(0)) {
return uint32(v)
}
if v == float64(int32(v)) {
return int32(v)
}
return v
case []any:
return convertArgs(v)
case map[string]any:
result := make(map[string]any)
for k, val := range v {
result[k] = convertArg(val)
}
return result
default:
return arg
}
} }
func (m *Manager) GetProperty(bus, dest, path, iface, property string) (*PropertyResult, error) { func (m *Manager) GetProperty(bus, dest, path, iface, property string) (*PropertyResult, error) {

View File

@@ -19,6 +19,7 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/network" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
serverPlugins "github.com/AvengeMedia/DankMaterialShell/core/internal/server/plugins" serverPlugins "github.com/AvengeMedia/DankMaterialShell/core/internal/server/plugins"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode"
serverThemes "github.com/AvengeMedia/DankMaterialShell/core/internal/server/themes" serverThemes "github.com/AvengeMedia/DankMaterialShell/core/internal/server/themes"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlroutput" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlroutput"
@@ -44,6 +45,15 @@ func RouteRequest(conn net.Conn, req models.Request) {
return return
} }
if strings.HasPrefix(req.Method, "theme.auto.") {
if themeModeManager == nil {
models.RespondError(conn, req.ID, "theme mode manager not initialized")
return
}
thememode.HandleRequest(conn, req, themeModeManager)
return
}
if strings.HasPrefix(req.Method, "loginctl.") { if strings.HasPrefix(req.Method, "loginctl.") {
if loginctlManager == nil { if loginctlManager == nil {
models.RespondError(conn, req.ID, "loginctl manager not initialized") models.RespondError(conn, req.ID, "loginctl manager not initialized")

View File

@@ -28,6 +28,7 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/loginctl" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/loginctl"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/network" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlroutput" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlroutput"
@@ -68,6 +69,7 @@ var evdevManager *evdev.Manager
var clipboardManager *clipboard.Manager var clipboardManager *clipboard.Manager
var dbusManager *serverDbus.Manager var dbusManager *serverDbus.Manager
var wlContext *wlcontext.SharedContext var wlContext *wlcontext.SharedContext
var themeModeManager *thememode.Manager
const dbusClientID = "dms-dbus-client" const dbusClientID = "dms-dbus-client"
@@ -380,6 +382,14 @@ func InitializeDbusManager() error {
return nil return nil
} }
func InitializeThemeModeManager() error {
manager := thememode.NewManager()
themeModeManager = manager
log.Info("Theme mode automation manager initialized")
return nil
}
func handleConnection(conn net.Conn) { func handleConnection(conn net.Conn) {
defer conn.Close() defer conn.Close()
@@ -457,6 +467,10 @@ func getCapabilities() Capabilities {
caps = append(caps, "clipboard") caps = append(caps, "clipboard")
} }
if themeModeManager != nil {
caps = append(caps, "theme.auto")
}
if dbusManager != nil { if dbusManager != nil {
caps = append(caps, "dbus") caps = append(caps, "dbus")
} }
@@ -519,6 +533,10 @@ func getServerInfo() ServerInfo {
caps = append(caps, "clipboard") caps = append(caps, "clipboard")
} }
if themeModeManager != nil {
caps = append(caps, "theme.auto")
}
if dbusManager != nil { if dbusManager != nil {
caps = append(caps, "dbus") caps = append(caps, "dbus")
} }
@@ -791,6 +809,38 @@ func handleSubscribe(conn net.Conn, req models.Request) {
}() }()
} }
if shouldSubscribe("theme.auto") && themeModeManager != nil {
wg.Add(1)
themeAutoChan := themeModeManager.Subscribe(clientID + "-theme-auto")
go func() {
defer wg.Done()
defer themeModeManager.Unsubscribe(clientID + "-theme-auto")
initialState := themeModeManager.GetState()
select {
case eventChan <- ServiceEvent{Service: "theme.auto", Data: initialState}:
case <-stopChan:
return
}
for {
select {
case state, ok := <-themeAutoChan:
if !ok {
return
}
select {
case eventChan <- ServiceEvent{Service: "theme.auto", Data: state}:
case <-stopChan:
return
}
case <-stopChan:
return
}
}
}()
}
if shouldSubscribe("bluetooth") && bluezManager != nil { if shouldSubscribe("bluetooth") && bluezManager != nil {
wg.Add(1) wg.Add(1)
bluezChan := bluezManager.Subscribe(clientID + "-bluetooth") bluezChan := bluezManager.Subscribe(clientID + "-bluetooth")
@@ -1251,6 +1301,9 @@ func cleanupManagers() {
if dbusManager != nil { if dbusManager != nil {
dbusManager.Close() dbusManager.Close()
} }
if themeModeManager != nil {
themeModeManager.Close()
}
if wlContext != nil { if wlContext != nil {
wlContext.Close() wlContext.Close()
} }
@@ -1346,6 +1399,15 @@ func Start(printDocs bool) error {
log.Info(" wayland.gamma.setGamma - Set gamma value (params: gamma)") log.Info(" wayland.gamma.setGamma - Set gamma value (params: gamma)")
log.Info(" wayland.gamma.setEnabled - Enable/disable gamma control (params: enabled)") log.Info(" wayland.gamma.setEnabled - Enable/disable gamma control (params: enabled)")
log.Info(" wayland.gamma.subscribe - Subscribe to gamma state changes (streaming)") log.Info(" wayland.gamma.subscribe - Subscribe to gamma state changes (streaming)")
log.Info("Theme automation:")
log.Info(" theme.auto.getState - Get current theme automation state")
log.Info(" theme.auto.setEnabled - Enable/disable theme automation (params: enabled)")
log.Info(" theme.auto.setMode - Set automation mode (params: mode [time|location])")
log.Info(" theme.auto.setSchedule - Set time schedule (params: startHour, startMinute, endHour, endMinute)")
log.Info(" theme.auto.setLocation - Set location (params: latitude, longitude)")
log.Info(" theme.auto.setUseIPLocation - Use IP location (params: use)")
log.Info(" theme.auto.trigger - Trigger immediate re-evaluation")
log.Info(" theme.auto.subscribe - Subscribe to theme automation state changes (streaming)")
log.Info("Bluetooth:") log.Info("Bluetooth:")
log.Info(" bluetooth.getState - Get current bluetooth state") log.Info(" bluetooth.getState - Get current bluetooth state")
log.Info(" bluetooth.startDiscovery - Start device discovery") log.Info(" bluetooth.startDiscovery - Start device discovery")
@@ -1503,6 +1565,12 @@ func Start(printDocs bool) error {
log.Debugf("WlrOutput manager unavailable: %v", err) log.Debugf("WlrOutput manager unavailable: %v", err)
} }
if err := InitializeThemeModeManager(); err != nil {
log.Warnf("Theme mode manager unavailable: %v", err)
} else {
notifyCapabilityChange()
}
fatalErrChan := make(chan error, 1) fatalErrChan := make(chan error, 1)
if wlrOutputManager != nil { if wlrOutputManager != nil {
go func() { go func() {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -182,7 +182,7 @@ func (r *Registry) Update() error {
} }
if !exists { if !exists {
if err := r.fs.MkdirAll(filepath.Dir(r.cacheDir), 0755); err != nil { if err := r.fs.MkdirAll(filepath.Dir(r.cacheDir), 0o755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err) return fmt.Errorf("failed to create cache directory: %w", err)
} }
@@ -195,7 +195,7 @@ func (r *Registry) Update() error {
return fmt.Errorf("failed to remove corrupted registry: %w", err) return fmt.Errorf("failed to remove corrupted registry: %w", err)
} }
if err := r.fs.MkdirAll(filepath.Dir(r.cacheDir), 0755); err != nil { if err := r.fs.MkdirAll(filepath.Dir(r.cacheDir), 0o755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err) return fmt.Errorf("failed to create cache directory: %w", err)
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,873 @@
package providers
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"github.com/sblinch/kdl-go"
"github.com/sblinch/kdl-go/document"
"github.com/AvengeMedia/DankMaterialShell/core/internal/windowrules"
)
type NiriWindowRule struct {
MatchAppID string
MatchTitle string
MatchIsFloating *bool
MatchIsActive *bool
MatchIsFocused *bool
MatchIsActiveInColumn *bool
MatchIsWindowCastTarget *bool
MatchIsUrgent *bool
MatchAtStartup *bool
Opacity *float64
OpenFloating *bool
OpenMaximized *bool
OpenMaximizedToEdges *bool
OpenFullscreen *bool
OpenFocused *bool
OpenOnOutput string
OpenOnWorkspace string
DefaultColumnWidth string
DefaultWindowHeight string
VariableRefreshRate *bool
BlockOutFrom string
DefaultColumnDisplay string
ScrollFactor *float64
CornerRadius *int
ClipToGeometry *bool
TiledState *bool
MinWidth *int
MaxWidth *int
MinHeight *int
MaxHeight *int
BorderColor string
FocusRingColor string
FocusRingOff *bool
BorderOff *bool
DrawBorderWithBg *bool
Source string
}
type NiriRulesParser struct {
configDir string
processedFiles map[string]bool
rules []NiriWindowRule
currentSource string
dmsRulesIncluded bool
dmsRulesExists bool
includeCount int
dmsIncludePos int
rulesAfterDMS int
dmsProcessed bool
}
func NewNiriRulesParser(configDir string) *NiriRulesParser {
return &NiriRulesParser{
configDir: configDir,
processedFiles: make(map[string]bool),
rules: []NiriWindowRule{},
dmsIncludePos: -1,
}
}
func (p *NiriRulesParser) Parse() ([]NiriWindowRule, error) {
dmsRulesPath := filepath.Join(p.configDir, "dms", "windowrules.kdl")
if _, err := os.Stat(dmsRulesPath); err == nil {
p.dmsRulesExists = true
}
configPath := filepath.Join(p.configDir, "config.kdl")
if err := p.parseFile(configPath); err != nil {
return nil, err
}
if p.dmsRulesExists && !p.dmsProcessed {
p.parseDMSRulesDirectly(dmsRulesPath)
}
return p.rules, nil
}
func (p *NiriRulesParser) parseDMSRulesDirectly(dmsRulesPath string) {
data, err := os.ReadFile(dmsRulesPath)
if err != nil {
return
}
doc, err := kdl.Parse(strings.NewReader(string(data)))
if err != nil {
return
}
prevSource := p.currentSource
p.currentSource = dmsRulesPath
p.processNodes(doc.Nodes, filepath.Dir(dmsRulesPath))
p.currentSource = prevSource
p.dmsProcessed = true
}
func (p *NiriRulesParser) parseFile(filePath string) error {
absPath, err := filepath.Abs(filePath)
if err != nil {
return err
}
if p.processedFiles[absPath] {
return nil
}
p.processedFiles[absPath] = true
data, err := os.ReadFile(absPath)
if err != nil {
return nil
}
doc, err := kdl.Parse(strings.NewReader(string(data)))
if err != nil {
return err
}
prevSource := p.currentSource
p.currentSource = absPath
baseDir := filepath.Dir(absPath)
p.processNodes(doc.Nodes, baseDir)
p.currentSource = prevSource
return nil
}
func (p *NiriRulesParser) processNodes(nodes []*document.Node, baseDir string) {
for _, node := range nodes {
name := node.Name.String()
switch name {
case "include":
p.handleInclude(node, baseDir)
case "window-rule":
p.parseWindowRuleNode(node)
}
}
}
func (p *NiriRulesParser) handleInclude(node *document.Node, baseDir string) {
if len(node.Arguments) == 0 {
return
}
includePath := strings.Trim(node.Arguments[0].String(), "\"")
isDMSInclude := includePath == "dms/windowrules.kdl" || strings.HasSuffix(includePath, "/dms/windowrules.kdl")
p.includeCount++
if isDMSInclude {
p.dmsRulesIncluded = true
p.dmsIncludePos = p.includeCount
p.dmsProcessed = true
}
fullPath := filepath.Join(baseDir, includePath)
if filepath.IsAbs(includePath) {
fullPath = includePath
}
_ = p.parseFile(fullPath)
}
func (p *NiriRulesParser) parseWindowRuleNode(node *document.Node) {
if node.Children == nil {
return
}
rule := NiriWindowRule{
Source: p.currentSource,
}
for _, child := range node.Children {
childName := child.Name.String()
switch childName {
case "match":
p.parseMatchNode(child, &rule)
case "opacity":
if len(child.Arguments) > 0 {
val := child.Arguments[0].ResolvedValue()
if f, ok := val.(float64); ok {
rule.Opacity = &f
}
}
case "open-floating":
b := p.parseBoolArg(child)
rule.OpenFloating = &b
case "open-maximized":
b := p.parseBoolArg(child)
rule.OpenMaximized = &b
case "open-maximized-to-edges":
b := p.parseBoolArg(child)
rule.OpenMaximizedToEdges = &b
case "open-fullscreen":
b := p.parseBoolArg(child)
rule.OpenFullscreen = &b
case "open-focused":
b := p.parseBoolArg(child)
rule.OpenFocused = &b
case "open-on-output":
if len(child.Arguments) > 0 {
rule.OpenOnOutput = child.Arguments[0].ValueString()
}
case "open-on-workspace":
if len(child.Arguments) > 0 {
rule.OpenOnWorkspace = child.Arguments[0].ValueString()
}
case "default-column-width":
rule.DefaultColumnWidth = p.parseSizeNode(child)
case "default-window-height":
rule.DefaultWindowHeight = p.parseSizeNode(child)
case "variable-refresh-rate":
b := p.parseBoolArg(child)
rule.VariableRefreshRate = &b
case "block-out-from":
if len(child.Arguments) > 0 {
rule.BlockOutFrom = child.Arguments[0].ValueString()
}
case "default-column-display":
if len(child.Arguments) > 0 {
rule.DefaultColumnDisplay = child.Arguments[0].ValueString()
}
case "scroll-factor":
if len(child.Arguments) > 0 {
val := child.Arguments[0].ResolvedValue()
if f, ok := val.(float64); ok {
rule.ScrollFactor = &f
}
}
case "geometry-corner-radius":
if len(child.Arguments) > 0 {
val := child.Arguments[0].ResolvedValue()
if i, ok := val.(int64); ok {
intVal := int(i)
rule.CornerRadius = &intVal
}
}
case "clip-to-geometry":
b := p.parseBoolArg(child)
rule.ClipToGeometry = &b
case "tiled-state":
b := p.parseBoolArg(child)
rule.TiledState = &b
case "min-width":
if len(child.Arguments) > 0 {
val := child.Arguments[0].ResolvedValue()
if i, ok := val.(int64); ok {
intVal := int(i)
rule.MinWidth = &intVal
}
}
case "max-width":
if len(child.Arguments) > 0 {
val := child.Arguments[0].ResolvedValue()
if i, ok := val.(int64); ok {
intVal := int(i)
rule.MaxWidth = &intVal
}
}
case "min-height":
if len(child.Arguments) > 0 {
val := child.Arguments[0].ResolvedValue()
if i, ok := val.(int64); ok {
intVal := int(i)
rule.MinHeight = &intVal
}
}
case "max-height":
if len(child.Arguments) > 0 {
val := child.Arguments[0].ResolvedValue()
if i, ok := val.(int64); ok {
intVal := int(i)
rule.MaxHeight = &intVal
}
}
case "border":
p.parseBorderNode(child, &rule)
case "focus-ring":
p.parseFocusRingNode(child, &rule)
case "draw-border-with-background":
b := p.parseBoolArg(child)
rule.DrawBorderWithBg = &b
}
}
p.rules = append(p.rules, rule)
}
func (p *NiriRulesParser) parseSizeNode(node *document.Node) string {
if node.Children == nil {
return ""
}
for _, child := range node.Children {
name := child.Name.String()
if len(child.Arguments) > 0 {
val := child.Arguments[0].ResolvedValue()
switch name {
case "fixed":
if i, ok := val.(int64); ok {
return "fixed " + strconv.FormatInt(i, 10)
}
case "proportion":
if f, ok := val.(float64); ok {
return "proportion " + strconv.FormatFloat(f, 'f', -1, 64)
}
}
}
}
return ""
}
func (p *NiriRulesParser) parseMatchNode(node *document.Node, rule *NiriWindowRule) {
if node.Properties == nil {
return
}
if val, ok := node.Properties.Get("app-id"); ok {
rule.MatchAppID = val.ValueString()
}
if val, ok := node.Properties.Get("title"); ok {
rule.MatchTitle = val.ValueString()
}
if val, ok := node.Properties.Get("is-floating"); ok {
b := val.ValueString() == "true"
rule.MatchIsFloating = &b
}
if val, ok := node.Properties.Get("is-active"); ok {
b := val.ValueString() == "true"
rule.MatchIsActive = &b
}
if val, ok := node.Properties.Get("is-focused"); ok {
b := val.ValueString() == "true"
rule.MatchIsFocused = &b
}
if val, ok := node.Properties.Get("is-active-in-column"); ok {
b := val.ValueString() == "true"
rule.MatchIsActiveInColumn = &b
}
if val, ok := node.Properties.Get("is-window-cast-target"); ok {
b := val.ValueString() == "true"
rule.MatchIsWindowCastTarget = &b
}
if val, ok := node.Properties.Get("is-urgent"); ok {
b := val.ValueString() == "true"
rule.MatchIsUrgent = &b
}
if val, ok := node.Properties.Get("at-startup"); ok {
b := val.ValueString() == "true"
rule.MatchAtStartup = &b
}
}
func (p *NiriRulesParser) parseBorderNode(node *document.Node, rule *NiriWindowRule) {
if node.Children == nil {
return
}
for _, child := range node.Children {
switch child.Name.String() {
case "off":
b := true
rule.BorderOff = &b
case "active-color":
if len(child.Arguments) > 0 {
rule.BorderColor = child.Arguments[0].ValueString()
}
}
}
}
func (p *NiriRulesParser) parseFocusRingNode(node *document.Node, rule *NiriWindowRule) {
if node.Children == nil {
return
}
for _, child := range node.Children {
switch child.Name.String() {
case "off":
b := true
rule.FocusRingOff = &b
case "active-color":
if len(child.Arguments) > 0 {
rule.FocusRingColor = child.Arguments[0].ValueString()
}
}
}
}
func (p *NiriRulesParser) parseBoolArg(node *document.Node) bool {
if len(node.Arguments) == 0 {
return true
}
return node.Arguments[0].ValueString() != "false"
}
func (p *NiriRulesParser) HasDMSRulesIncluded() bool {
return p.dmsRulesIncluded
}
func (p *NiriRulesParser) buildDMSStatus() *windowrules.DMSRulesStatus {
status := &windowrules.DMSRulesStatus{
Exists: p.dmsRulesExists,
Included: p.dmsRulesIncluded,
IncludePosition: p.dmsIncludePos,
TotalIncludes: p.includeCount,
RulesAfterDMS: p.rulesAfterDMS,
}
switch {
case !p.dmsRulesExists:
status.Effective = false
status.StatusMessage = "dms/windowrules.kdl does not exist"
case !p.dmsRulesIncluded:
status.Effective = false
status.StatusMessage = "dms/windowrules.kdl is not included in config.kdl"
case p.rulesAfterDMS > 0:
status.Effective = true
status.OverriddenBy = p.rulesAfterDMS
status.StatusMessage = "Some DMS rules may be overridden by config rules"
default:
status.Effective = true
status.StatusMessage = "DMS window rules are active"
}
return status
}
type NiriRulesParseResult struct {
Rules []NiriWindowRule
DMSRulesIncluded bool
DMSStatus *windowrules.DMSRulesStatus
}
func ParseNiriWindowRules(configDir string) (*NiriRulesParseResult, error) {
parser := NewNiriRulesParser(configDir)
rules, err := parser.Parse()
if err != nil {
return nil, err
}
return &NiriRulesParseResult{
Rules: rules,
DMSRulesIncluded: parser.HasDMSRulesIncluded(),
DMSStatus: parser.buildDMSStatus(),
}, nil
}
func ConvertNiriRulesToWindowRules(niriRules []NiriWindowRule) []windowrules.WindowRule {
result := make([]windowrules.WindowRule, 0, len(niriRules))
for i, nr := range niriRules {
wr := windowrules.WindowRule{
ID: fmt.Sprintf("rule_%d", i),
Enabled: true,
Source: nr.Source,
MatchCriteria: windowrules.MatchCriteria{
AppID: nr.MatchAppID,
Title: nr.MatchTitle,
IsFloating: nr.MatchIsFloating,
IsActive: nr.MatchIsActive,
IsFocused: nr.MatchIsFocused,
IsActiveInColumn: nr.MatchIsActiveInColumn,
IsWindowCastTarget: nr.MatchIsWindowCastTarget,
IsUrgent: nr.MatchIsUrgent,
AtStartup: nr.MatchAtStartup,
},
Actions: windowrules.Actions{
Opacity: nr.Opacity,
OpenFloating: nr.OpenFloating,
OpenMaximized: nr.OpenMaximized,
OpenMaximizedToEdges: nr.OpenMaximizedToEdges,
OpenFullscreen: nr.OpenFullscreen,
OpenFocused: nr.OpenFocused,
OpenOnOutput: nr.OpenOnOutput,
OpenOnWorkspace: nr.OpenOnWorkspace,
DefaultColumnWidth: nr.DefaultColumnWidth,
DefaultWindowHeight: nr.DefaultWindowHeight,
VariableRefreshRate: nr.VariableRefreshRate,
BlockOutFrom: nr.BlockOutFrom,
DefaultColumnDisplay: nr.DefaultColumnDisplay,
ScrollFactor: nr.ScrollFactor,
CornerRadius: nr.CornerRadius,
ClipToGeometry: nr.ClipToGeometry,
TiledState: nr.TiledState,
MinWidth: nr.MinWidth,
MaxWidth: nr.MaxWidth,
MinHeight: nr.MinHeight,
MaxHeight: nr.MaxHeight,
BorderColor: nr.BorderColor,
FocusRingColor: nr.FocusRingColor,
FocusRingOff: nr.FocusRingOff,
BorderOff: nr.BorderOff,
DrawBorderWithBg: nr.DrawBorderWithBg,
},
}
result = append(result, wr)
}
return result
}
type NiriWritableProvider struct {
configDir string
}
func NewNiriWritableProvider(configDir string) *NiriWritableProvider {
return &NiriWritableProvider{configDir: configDir}
}
func (p *NiriWritableProvider) Name() string {
return "niri"
}
func (p *NiriWritableProvider) GetOverridePath() string {
return filepath.Join(p.configDir, "dms", "windowrules.kdl")
}
func (p *NiriWritableProvider) GetRuleSet() (*windowrules.RuleSet, error) {
result, err := ParseNiriWindowRules(p.configDir)
if err != nil {
return nil, err
}
return &windowrules.RuleSet{
Title: "Niri Window Rules",
Provider: "niri",
Rules: ConvertNiriRulesToWindowRules(result.Rules),
DMSRulesIncluded: result.DMSRulesIncluded,
DMSStatus: result.DMSStatus,
}, nil
}
func (p *NiriWritableProvider) 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 *NiriWritableProvider) 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 *NiriWritableProvider) ReorderRules(ids []string) error {
rules, err := p.LoadDMSRules()
if err != nil {
return err
}
ruleMap := make(map[string]windowrules.WindowRule)
for _, r := range rules {
ruleMap[r.ID] = r
}
newRules := make([]windowrules.WindowRule, 0, len(ids))
for _, id := range ids {
if r, ok := ruleMap[id]; ok {
newRules = append(newRules, r)
delete(ruleMap, id)
}
}
for _, r := range ruleMap {
newRules = append(newRules, r)
}
return p.writeDMSRules(newRules)
}
var niriMetaCommentRegex = regexp.MustCompile(`^//\s*@id=(\S*)\s*@name=(.*)$`)
func (p *NiriWritableProvider) LoadDMSRules() ([]windowrules.WindowRule, error) {
rulesPath := p.GetOverridePath()
data, err := os.ReadFile(rulesPath)
if err != nil {
if os.IsNotExist(err) {
return []windowrules.WindowRule{}, nil
}
return nil, err
}
content := string(data)
lines := strings.Split(content, "\n")
type ruleMeta struct {
id string
name string
}
var metas []ruleMeta
var currentID, currentName string
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if matches := niriMetaCommentRegex.FindStringSubmatch(trimmed); matches != nil {
currentID = matches[1]
currentName = strings.TrimSpace(matches[2])
continue
}
if strings.HasPrefix(trimmed, "window-rule") {
metas = append(metas, ruleMeta{id: currentID, name: currentName})
currentID = ""
currentName = ""
}
}
doc, err := kdl.Parse(strings.NewReader(content))
if err != nil {
return nil, err
}
parser := NewNiriRulesParser(p.configDir)
parser.currentSource = rulesPath
for _, node := range doc.Nodes {
if node.Name.String() == "window-rule" {
parser.parseWindowRuleNode(node)
}
}
var rules []windowrules.WindowRule
for i, nr := range parser.rules {
id := ""
name := ""
if i < len(metas) {
id = metas[i].id
name = metas[i].name
}
if id == "" {
id = fmt.Sprintf("dms_rule_%d", i)
}
wr := windowrules.WindowRule{
ID: id,
Name: name,
Enabled: true,
Source: rulesPath,
MatchCriteria: windowrules.MatchCriteria{
AppID: nr.MatchAppID,
Title: nr.MatchTitle,
IsFloating: nr.MatchIsFloating,
IsActive: nr.MatchIsActive,
IsFocused: nr.MatchIsFocused,
IsActiveInColumn: nr.MatchIsActiveInColumn,
IsWindowCastTarget: nr.MatchIsWindowCastTarget,
IsUrgent: nr.MatchIsUrgent,
AtStartup: nr.MatchAtStartup,
},
Actions: windowrules.Actions{
Opacity: nr.Opacity,
OpenFloating: nr.OpenFloating,
OpenMaximized: nr.OpenMaximized,
OpenMaximizedToEdges: nr.OpenMaximizedToEdges,
OpenFullscreen: nr.OpenFullscreen,
OpenFocused: nr.OpenFocused,
OpenOnOutput: nr.OpenOnOutput,
OpenOnWorkspace: nr.OpenOnWorkspace,
DefaultColumnWidth: nr.DefaultColumnWidth,
DefaultWindowHeight: nr.DefaultWindowHeight,
VariableRefreshRate: nr.VariableRefreshRate,
BlockOutFrom: nr.BlockOutFrom,
DefaultColumnDisplay: nr.DefaultColumnDisplay,
ScrollFactor: nr.ScrollFactor,
CornerRadius: nr.CornerRadius,
ClipToGeometry: nr.ClipToGeometry,
TiledState: nr.TiledState,
MinWidth: nr.MinWidth,
MaxWidth: nr.MaxWidth,
MinHeight: nr.MinHeight,
MaxHeight: nr.MaxHeight,
BorderColor: nr.BorderColor,
FocusRingColor: nr.FocusRingColor,
FocusRingOff: nr.FocusRingOff,
BorderOff: nr.BorderOff,
DrawBorderWithBg: nr.DrawBorderWithBg,
},
}
rules = append(rules, wr)
}
return rules, nil
}
func (p *NiriWritableProvider) writeDMSRules(rules []windowrules.WindowRule) error {
rulesPath := p.GetOverridePath()
if err := os.MkdirAll(filepath.Dir(rulesPath), 0755); err != nil {
return err
}
var lines []string
lines = append(lines, "// DMS Window Rules - Managed by DankMaterialShell")
lines = append(lines, "// Do not edit manually - changes may be overwritten")
lines = append(lines, "")
for _, rule := range rules {
lines = append(lines, p.formatRule(rule))
lines = append(lines, "")
}
return os.WriteFile(rulesPath, []byte(strings.Join(lines, "\n")), 0644)
}
func (p *NiriWritableProvider) formatRule(rule windowrules.WindowRule) string {
var lines []string
lines = append(lines, fmt.Sprintf("// @id=%s @name=%s", rule.ID, rule.Name))
lines = append(lines, "window-rule {")
m := rule.MatchCriteria
if m.AppID != "" || m.Title != "" || m.IsFloating != nil || m.IsActive != nil ||
m.IsFocused != nil || m.IsActiveInColumn != nil || m.IsWindowCastTarget != nil ||
m.IsUrgent != nil || m.AtStartup != nil {
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))
}
lines = append(lines, " match "+strings.Join(matchProps, " "))
}
a := rule.Actions
if a.Opacity != nil {
lines = append(lines, fmt.Sprintf(" opacity %.2f", *a.Opacity))
}
if a.OpenFloating != nil && *a.OpenFloating {
lines = append(lines, " open-floating true")
}
if a.OpenMaximized != nil && *a.OpenMaximized {
lines = append(lines, " open-maximized true")
}
if a.OpenMaximizedToEdges != nil && *a.OpenMaximizedToEdges {
lines = append(lines, " open-maximized-to-edges true")
}
if a.OpenFullscreen != nil && *a.OpenFullscreen {
lines = append(lines, " open-fullscreen true")
}
if a.OpenFocused != nil {
lines = append(lines, fmt.Sprintf(" open-focused %t", *a.OpenFocused))
}
if a.OpenOnOutput != "" {
lines = append(lines, fmt.Sprintf(" open-on-output %q", a.OpenOnOutput))
}
if a.OpenOnWorkspace != "" {
lines = append(lines, fmt.Sprintf(" open-on-workspace %q", a.OpenOnWorkspace))
}
if a.DefaultColumnWidth != "" {
lines = append(lines, formatSizeProperty("default-column-width", a.DefaultColumnWidth))
}
if a.DefaultWindowHeight != "" {
lines = append(lines, formatSizeProperty("default-window-height", a.DefaultWindowHeight))
}
if a.VariableRefreshRate != nil && *a.VariableRefreshRate {
lines = append(lines, " variable-refresh-rate true")
}
if a.BlockOutFrom != "" {
lines = append(lines, fmt.Sprintf(" block-out-from %q", a.BlockOutFrom))
}
if a.DefaultColumnDisplay != "" {
lines = append(lines, fmt.Sprintf(" default-column-display %q", a.DefaultColumnDisplay))
}
if a.ScrollFactor != nil {
lines = append(lines, fmt.Sprintf(" scroll-factor %.2f", *a.ScrollFactor))
}
if a.CornerRadius != nil {
lines = append(lines, fmt.Sprintf(" geometry-corner-radius %d", *a.CornerRadius))
}
if a.ClipToGeometry != nil && *a.ClipToGeometry {
lines = append(lines, " clip-to-geometry true")
}
if a.TiledState != nil && *a.TiledState {
lines = append(lines, " tiled-state true")
}
if a.MinWidth != nil {
lines = append(lines, fmt.Sprintf(" min-width %d", *a.MinWidth))
}
if a.MaxWidth != nil {
lines = append(lines, fmt.Sprintf(" max-width %d", *a.MaxWidth))
}
if a.MinHeight != nil {
lines = append(lines, fmt.Sprintf(" min-height %d", *a.MinHeight))
}
if a.MaxHeight != nil {
lines = append(lines, fmt.Sprintf(" max-height %d", *a.MaxHeight))
}
if a.BorderOff != nil && *a.BorderOff {
lines = append(lines, " border { off; }")
} else if a.BorderColor != "" {
lines = append(lines, fmt.Sprintf(" border { active-color %q; }", a.BorderColor))
}
if a.FocusRingOff != nil && *a.FocusRingOff {
lines = append(lines, " focus-ring { off; }")
} else if a.FocusRingColor != "" {
lines = append(lines, fmt.Sprintf(" focus-ring { active-color %q; }", a.FocusRingColor))
}
if a.DrawBorderWithBg != nil {
lines = append(lines, fmt.Sprintf(" draw-border-with-background %t", *a.DrawBorderWithBg))
}
lines = append(lines, "}")
return strings.Join(lines, "\n")
}
func formatSizeProperty(name, value string) string {
parts := strings.SplitN(value, " ", 2)
if len(parts) != 2 {
return fmt.Sprintf(" %s { }", name)
}
sizeType := parts[0]
sizeValue := parts[1]
return fmt.Sprintf(" %s { %s %s; }", name, sizeType, sizeValue)
}

View File

@@ -0,0 +1,335 @@
package providers
import (
"os"
"path/filepath"
"testing"
)
func TestNiriParseBasicWindowRule(t *testing.T) {
tmpDir := t.TempDir()
config := `
window-rule {
match app-id="^firefox$"
opacity 0.9
open-floating true
}
`
if err := os.WriteFile(filepath.Join(tmpDir, "config.kdl"), []byte(config), 0644); err != nil {
t.Fatal(err)
}
parser := NewNiriRulesParser(tmpDir)
rules, err := parser.Parse()
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
if len(rules) != 1 {
t.Fatalf("expected 1 rule, got %d", len(rules))
}
rule := rules[0]
if rule.MatchAppID != "^firefox$" {
t.Errorf("MatchAppID = %q, want ^firefox$", rule.MatchAppID)
}
if rule.Opacity == nil || *rule.Opacity != 0.9 {
t.Errorf("Opacity = %v, want 0.9", rule.Opacity)
}
if rule.OpenFloating == nil || !*rule.OpenFloating {
t.Error("OpenFloating should be true")
}
}
func TestNiriParseMultipleRules(t *testing.T) {
tmpDir := t.TempDir()
config := `
window-rule {
match app-id="app1"
open-maximized true
}
window-rule {
match app-id="app2"
open-fullscreen true
}
`
if err := os.WriteFile(filepath.Join(tmpDir, "config.kdl"), []byte(config), 0644); err != nil {
t.Fatal(err)
}
parser := NewNiriRulesParser(tmpDir)
rules, err := parser.Parse()
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
if len(rules) != 2 {
t.Fatalf("expected 2 rules, got %d", len(rules))
}
if rules[0].MatchAppID != "app1" {
t.Errorf("rule 0 MatchAppID = %q, want app1", rules[0].MatchAppID)
}
if rules[1].MatchAppID != "app2" {
t.Errorf("rule 1 MatchAppID = %q, want app2", rules[1].MatchAppID)
}
}
func TestConvertNiriRulesToWindowRules(t *testing.T) {
niriRules := []NiriWindowRule{
{MatchAppID: "^firefox$", Opacity: floatPtr(0.8)},
{MatchAppID: "^code$", OpenFloating: boolPtr(true)},
}
result := ConvertNiriRulesToWindowRules(niriRules)
if len(result) != 2 {
t.Errorf("expected 2 rules, got %d", len(result))
}
if result[0].MatchCriteria.AppID != "^firefox$" {
t.Errorf("rule 0 AppID = %q, want ^firefox$", result[0].MatchCriteria.AppID)
}
if result[0].Actions.Opacity == nil || *result[0].Actions.Opacity != 0.8 {
t.Errorf("rule 0 Opacity = %v, want 0.8", result[0].Actions.Opacity)
}
if result[1].Actions.OpenFloating == nil || !*result[1].Actions.OpenFloating {
t.Error("rule 1 should have OpenFloating = true")
}
}
func TestNiriWritableProvider(t *testing.T) {
tmpDir := t.TempDir()
provider := NewNiriWritableProvider(tmpDir)
if provider.Name() != "niri" {
t.Errorf("Name() = %q, want niri", provider.Name())
}
expectedPath := filepath.Join(tmpDir, "dms", "windowrules.kdl")
if provider.GetOverridePath() != expectedPath {
t.Errorf("GetOverridePath() = %q, want %q", provider.GetOverridePath(), expectedPath)
}
}
func TestNiriSetAndLoadDMSRules(t *testing.T) {
tmpDir := t.TempDir()
provider := NewNiriWritableProvider(tmpDir)
rule := newTestWindowRule("test_id", "Test Rule", "^firefox$")
rule.Actions.OpenFloating = boolPtr(true)
rule.Actions.Opacity = floatPtr(0.85)
if err := provider.SetRule(rule); err != nil {
t.Fatalf("SetRule failed: %v", err)
}
rules, err := provider.LoadDMSRules()
if err != nil {
t.Fatalf("LoadDMSRules failed: %v", err)
}
if len(rules) != 1 {
t.Fatalf("expected 1 rule, got %d", len(rules))
}
if rules[0].ID != "test_id" {
t.Errorf("ID = %q, want test_id", rules[0].ID)
}
if rules[0].MatchCriteria.AppID != "^firefox$" {
t.Errorf("AppID = %q, want ^firefox$", rules[0].MatchCriteria.AppID)
}
}
func TestNiriRemoveRule(t *testing.T) {
tmpDir := t.TempDir()
provider := NewNiriWritableProvider(tmpDir)
rule1 := newTestWindowRule("rule1", "Rule 1", "app1")
rule1.Actions.OpenFloating = boolPtr(true)
rule2 := newTestWindowRule("rule2", "Rule 2", "app2")
rule2.Actions.OpenFloating = boolPtr(true)
_ = provider.SetRule(rule1)
_ = provider.SetRule(rule2)
if err := provider.RemoveRule("rule1"); err != nil {
t.Fatalf("RemoveRule failed: %v", err)
}
rules, _ := provider.LoadDMSRules()
if len(rules) != 1 {
t.Fatalf("expected 1 rule after removal, got %d", len(rules))
}
if rules[0].ID != "rule2" {
t.Errorf("remaining rule ID = %q, want rule2", rules[0].ID)
}
}
func TestNiriReorderRules(t *testing.T) {
tmpDir := t.TempDir()
provider := NewNiriWritableProvider(tmpDir)
rule1 := newTestWindowRule("rule1", "Rule 1", "app1")
rule1.Actions.OpenFloating = boolPtr(true)
rule2 := newTestWindowRule("rule2", "Rule 2", "app2")
rule2.Actions.OpenFloating = boolPtr(true)
rule3 := newTestWindowRule("rule3", "Rule 3", "app3")
rule3.Actions.OpenFloating = boolPtr(true)
_ = provider.SetRule(rule1)
_ = provider.SetRule(rule2)
_ = provider.SetRule(rule3)
if err := provider.ReorderRules([]string{"rule3", "rule1", "rule2"}); err != nil {
t.Fatalf("ReorderRules failed: %v", err)
}
rules, _ := provider.LoadDMSRules()
if len(rules) != 3 {
t.Fatalf("expected 3 rules, got %d", len(rules))
}
expectedOrder := []string{"rule3", "rule1", "rule2"}
for i, expectedID := range expectedOrder {
if rules[i].ID != expectedID {
t.Errorf("rule %d ID = %q, want %q", i, rules[i].ID, expectedID)
}
}
}
func TestNiriParseConfigWithInclude(t *testing.T) {
tmpDir := t.TempDir()
mainConfig := `
window-rule {
match app-id="mainapp"
opacity 1.0
}
include "extra.kdl"
`
extraConfig := `
window-rule {
match app-id="extraapp"
open-maximized true
}
`
if err := os.WriteFile(filepath.Join(tmpDir, "config.kdl"), []byte(mainConfig), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(tmpDir, "extra.kdl"), []byte(extraConfig), 0644); err != nil {
t.Fatal(err)
}
parser := NewNiriRulesParser(tmpDir)
rules, err := parser.Parse()
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
if len(rules) != 2 {
t.Errorf("expected 2 rules, got %d", len(rules))
}
}
func TestNiriParseSizeNode(t *testing.T) {
tmpDir := t.TempDir()
config := `
window-rule {
match app-id="testapp"
default-column-width { fixed 800; }
default-window-height { proportion 0.5; }
}
`
if err := os.WriteFile(filepath.Join(tmpDir, "config.kdl"), []byte(config), 0644); err != nil {
t.Fatal(err)
}
parser := NewNiriRulesParser(tmpDir)
rules, err := parser.Parse()
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
if len(rules) != 1 {
t.Fatalf("expected 1 rule, got %d", len(rules))
}
if rules[0].DefaultColumnWidth != "fixed 800" {
t.Errorf("DefaultColumnWidth = %q, want 'fixed 800'", rules[0].DefaultColumnWidth)
}
if rules[0].DefaultWindowHeight != "proportion 0.5" {
t.Errorf("DefaultWindowHeight = %q, want 'proportion 0.5'", rules[0].DefaultWindowHeight)
}
}
func TestFormatSizeProperty(t *testing.T) {
tests := []struct {
name string
propName string
value string
want string
}{
{
name: "fixed size",
propName: "default-column-width",
value: "fixed 800",
want: " default-column-width { fixed 800; }",
},
{
name: "proportion",
propName: "default-window-height",
value: "proportion 0.5",
want: " default-window-height { proportion 0.5; }",
},
{
name: "invalid format",
propName: "default-column-width",
value: "invalid",
want: " default-column-width { }",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := formatSizeProperty(tt.propName, tt.value)
if result != tt.want {
t.Errorf("formatSizeProperty(%q, %q) = %q, want %q",
tt.propName, tt.value, result, tt.want)
}
})
}
}
func TestNiriDMSRulesStatus(t *testing.T) {
tmpDir := t.TempDir()
config := `
window-rule {
match app-id="testapp"
opacity 0.9
}
`
if err := os.WriteFile(filepath.Join(tmpDir, "config.kdl"), []byte(config), 0644); err != nil {
t.Fatal(err)
}
result, err := ParseNiriWindowRules(tmpDir)
if err != nil {
t.Fatalf("ParseNiriWindowRules failed: %v", err)
}
if result.DMSStatus == nil {
t.Fatal("DMSStatus should not be nil")
}
if result.DMSStatus.Exists {
t.Error("DMSStatus.Exists should be false when dms rules file doesn't exist")
}
}

View File

@@ -0,0 +1,22 @@
package providers
import "github.com/AvengeMedia/DankMaterialShell/core/internal/windowrules"
func newTestWindowRule(id, name, appID string) windowrules.WindowRule {
return windowrules.WindowRule{
ID: id,
Name: name,
Enabled: true,
MatchCriteria: windowrules.MatchCriteria{
AppID: appID,
},
}
}
func boolPtr(b bool) *bool {
return &b
}
func floatPtr(f float64) *float64 {
return &f
}

View File

@@ -0,0 +1,103 @@
package windowrules
type MatchCriteria struct {
AppID string `json:"appId,omitempty"`
Title string `json:"title,omitempty"`
IsFloating *bool `json:"isFloating,omitempty"`
IsActive *bool `json:"isActive,omitempty"`
IsFocused *bool `json:"isFocused,omitempty"`
IsActiveInColumn *bool `json:"isActiveInColumn,omitempty"`
IsWindowCastTarget *bool `json:"isWindowCastTarget,omitempty"`
IsUrgent *bool `json:"isUrgent,omitempty"`
AtStartup *bool `json:"atStartup,omitempty"`
XWayland *bool `json:"xwayland,omitempty"`
Fullscreen *bool `json:"fullscreen,omitempty"`
Pinned *bool `json:"pinned,omitempty"`
Initialised *bool `json:"initialised,omitempty"`
}
type Actions struct {
Opacity *float64 `json:"opacity,omitempty"`
OpenFloating *bool `json:"openFloating,omitempty"`
OpenMaximized *bool `json:"openMaximized,omitempty"`
OpenMaximizedToEdges *bool `json:"openMaximizedToEdges,omitempty"`
OpenFullscreen *bool `json:"openFullscreen,omitempty"`
OpenFocused *bool `json:"openFocused,omitempty"`
OpenOnOutput string `json:"openOnOutput,omitempty"`
OpenOnWorkspace string `json:"openOnWorkspace,omitempty"`
DefaultColumnWidth string `json:"defaultColumnWidth,omitempty"`
DefaultWindowHeight string `json:"defaultWindowHeight,omitempty"`
VariableRefreshRate *bool `json:"variableRefreshRate,omitempty"`
BlockOutFrom string `json:"blockOutFrom,omitempty"`
DefaultColumnDisplay string `json:"defaultColumnDisplay,omitempty"`
ScrollFactor *float64 `json:"scrollFactor,omitempty"`
CornerRadius *int `json:"cornerRadius,omitempty"`
ClipToGeometry *bool `json:"clipToGeometry,omitempty"`
TiledState *bool `json:"tiledState,omitempty"`
MinWidth *int `json:"minWidth,omitempty"`
MaxWidth *int `json:"maxWidth,omitempty"`
MinHeight *int `json:"minHeight,omitempty"`
MaxHeight *int `json:"maxHeight,omitempty"`
BorderColor string `json:"borderColor,omitempty"`
FocusRingColor string `json:"focusRingColor,omitempty"`
FocusRingOff *bool `json:"focusRingOff,omitempty"`
BorderOff *bool `json:"borderOff,omitempty"`
DrawBorderWithBg *bool `json:"drawBorderWithBackground,omitempty"`
Size string `json:"size,omitempty"`
Move string `json:"move,omitempty"`
Monitor string `json:"monitor,omitempty"`
Workspace string `json:"workspace,omitempty"`
Tile *bool `json:"tile,omitempty"`
NoFocus *bool `json:"nofocus,omitempty"`
NoBorder *bool `json:"noborder,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 {
ID string `json:"id"`
Name string `json:"name,omitempty"`
Enabled bool `json:"enabled"`
MatchCriteria MatchCriteria `json:"matchCriteria"`
Actions Actions `json:"actions"`
Source string `json:"source,omitempty"`
}
type DMSRulesStatus struct {
Exists bool `json:"exists"`
Included bool `json:"included"`
IncludePosition int `json:"includePosition"`
TotalIncludes int `json:"totalIncludes"`
RulesAfterDMS int `json:"rulesAfterDms"`
Effective bool `json:"effective"`
OverriddenBy int `json:"overriddenBy"`
StatusMessage string `json:"statusMessage"`
}
type RuleSet struct {
Title string `json:"title"`
Provider string `json:"provider"`
Rules []WindowRule `json:"rules"`
DMSRulesIncluded bool `json:"dmsRulesIncluded"`
DMSStatus *DMSRulesStatus `json:"dmsStatus,omitempty"`
}
type Provider interface {
Name() string
GetRuleSet() (*RuleSet, error)
}
type WritableProvider interface {
Provider
SetRule(rule WindowRule) error
RemoveRule(id string) error
ReorderRules(ids []string) error
GetOverridePath() string
}

View File

@@ -49,12 +49,38 @@ func Normalize(v any) any {
result[k] = Normalize(vv.Value()) result[k] = Normalize(vv.Value())
} }
return result return result
case map[string]any:
result := make(map[string]any)
for k, vv := range val {
result[k] = Normalize(vv)
}
return result
case map[dbus.ObjectPath]map[string]map[string]dbus.Variant:
result := make(map[string]any)
for path, ifaces := range val {
ifaceMap := make(map[string]any)
for ifaceName, props := range ifaces {
propMap := make(map[string]any)
for propName, propVal := range props {
propMap[propName] = Normalize(propVal.Value())
}
ifaceMap[ifaceName] = propMap
}
result[string(path)] = ifaceMap
}
return result
case []any: case []any:
result := make([]any, len(val)) result := make([]any, len(val))
for i, item := range val { for i, item := range val {
result[i] = Normalize(item) result[i] = Normalize(item)
} }
return result return result
case []dbus.Variant:
result := make([]any, len(val))
for i, item := range val {
result[i] = Normalize(item.Value())
}
return result
default: default:
return v return v
} }

View File

@@ -109,8 +109,6 @@ rm -f %{buildroot}%{_datadir}/quickshell/dms/.gitignore
rm -rf %{buildroot}%{_datadir}/quickshell/dms/.github rm -rf %{buildroot}%{_datadir}/quickshell/dms/.github
rm -rf %{buildroot}%{_datadir}/quickshell/dms/distro rm -rf %{buildroot}%{_datadir}/quickshell/dms/distro
echo "%{version}" > %{buildroot}%{_datadir}/quickshell/dms/VERSION
%posttrans %posttrans
# Signal running DMS instances to reload # Signal running DMS instances to reload
pkill -USR1 -x dms >/dev/null 2>&1 || : pkill -USR1 -x dms >/dev/null 2>&1 || :

View File

@@ -100,8 +100,6 @@ rm -rf %{buildroot}%{_datadir}/quickshell/dms/.github
rm -rf %{buildroot}%{_datadir}/quickshell/dms/distro rm -rf %{buildroot}%{_datadir}/quickshell/dms/distro
rm -rf %{buildroot}%{_datadir}/quickshell/dms/core rm -rf %{buildroot}%{_datadir}/quickshell/dms/core
echo "%{version}" > %{buildroot}%{_datadir}/quickshell/dms/VERSION
%posttrans %posttrans
if [ -d "%{_sysconfdir}/xdg/quickshell/dms" ]; then if [ -d "%{_sysconfdir}/xdg/quickshell/dms" ]; then
rmdir "%{_sysconfdir}/xdg/quickshell/dms" 2>/dev/null || true rmdir "%{_sysconfdir}/xdg/quickshell/dms" 2>/dev/null || true

View File

@@ -79,7 +79,7 @@
inherit version; inherit version;
pname = "dms-shell"; pname = "dms-shell";
src = ./core; src = ./core;
vendorHash = "sha256-kWHB/FN6Z2Ydh+VvNrDnbg18RuJSDAle4DHDAP4NpNk="; vendorHash = "sha256-vsfCgpilOHzJbTaJjJfMK/cSvtyFYJsPDjY4m3iuoFg=";
subPackages = [ "cmd/dms" ]; subPackages = [ "cmd/dms" ];

View File

@@ -13,7 +13,7 @@ import "settings/SessionStore.js" as Store
Singleton { Singleton {
id: root id: root
readonly property int sessionConfigVersion: 2 readonly property int sessionConfigVersion: 3
readonly property bool isGreeterMode: Quickshell.env("DMS_RUN_GREETER") === "1" || Quickshell.env("DMS_RUN_GREETER") === "true" readonly property bool isGreeterMode: Quickshell.env("DMS_RUN_GREETER") === "1" || Quickshell.env("DMS_RUN_GREETER") === "true"
property bool _parseError: false property bool _parseError: false
@@ -82,6 +82,15 @@ Singleton {
property bool nightModeUseIPLocation: false property bool nightModeUseIPLocation: false
property string nightModeLocationProvider: "" property string nightModeLocationProvider: ""
property bool themeModeAutoEnabled: false
property string themeModeAutoMode: "time"
property int themeModeStartHour: 18
property int themeModeStartMinute: 0
property int themeModeEndHour: 6
property int themeModeEndMinute: 0
property bool themeModeShareGammaSettings: true
property string themeModeNextTransition: ""
property var pinnedApps: [] property var pinnedApps: []
property var barPinnedApps: [] property var barPinnedApps: []
property int dockLauncherPosition: 0 property int dockLauncherPosition: 0
@@ -109,6 +118,8 @@ Singleton {
property var appOverrides: ({}) property var appOverrides: ({})
property bool searchAppActions: true property bool searchAppActions: true
property string vpnLastConnected: ""
Component.onCompleted: { Component.onCompleted: {
if (!isGreeterMode) { if (!isGreeterMode) {
loadSettings(); loadSettings();
@@ -172,7 +183,7 @@ Singleton {
} catch (e) { } catch (e) {
_parseError = true; _parseError = true;
const msg = e.message; const msg = e.message;
console.error("SessionData: Failed to parse session.json - file will not be overwritten. Error:", msg); console.error("SessionData: Failed to parse session.json - file will not be overwritten.");
Qt.callLater(() => ToastService.showError(I18n.tr("Failed to parse session.json"), msg)); Qt.callLater(() => ToastService.showError(I18n.tr("Failed to parse session.json"), msg));
} }
} }
@@ -186,14 +197,10 @@ Singleton {
_isReadOnly = !writable; _isReadOnly = !writable;
if (_isReadOnly) { if (_isReadOnly) {
_hasUnsavedChanges = _checkForUnsavedChanges(); _hasUnsavedChanges = _checkForUnsavedChanges();
if (!wasReadOnly)
console.info("SessionData: session.json is now read-only");
} else { } else {
_loadedSessionSnapshot = getCurrentSessionJson(); _loadedSessionSnapshot = getCurrentSessionJson();
_hasUnsavedChanges = false; _hasUnsavedChanges = false;
if (wasReadOnly) if (wasReadOnly && _pendingMigration)
console.info("SessionData: session.json is now writable");
if (_pendingMigration)
settingsFile.setText(JSON.stringify(_pendingMigration, null, 2)); settingsFile.setText(JSON.stringify(_pendingMigration, null, 2));
} }
_pendingMigration = null; _pendingMigration = null;
@@ -255,7 +262,7 @@ Singleton {
} catch (e) { } catch (e) {
_parseError = true; _parseError = true;
const msg = e.message; const msg = e.message;
console.error("SessionData: Failed to parse session.json - file will not be overwritten. Error:", msg); console.error("SessionData: Failed to parse session.json - file will not be overwritten.");
Qt.callLater(() => ToastService.showError(I18n.tr("Failed to parse session.json"), msg)); Qt.callLater(() => ToastService.showError(I18n.tr("Failed to parse session.json"), msg));
} }
} }
@@ -273,7 +280,6 @@ Singleton {
} }
function migrateFromUndefinedToV1(settings) { function migrateFromUndefinedToV1(settings) {
console.info("SessionData: Migrating configuration from undefined to version 1");
if (typeof SettingsData !== "undefined") { if (typeof SettingsData !== "undefined") {
if (settings.acMonitorTimeout !== undefined) { if (settings.acMonitorTimeout !== undefined) {
SettingsData.set("acMonitorTimeout", settings.acMonitorTimeout); SettingsData.set("acMonitorTimeout", settings.acMonitorTimeout);
@@ -448,7 +454,7 @@ Singleton {
} }
if (!screen) { if (!screen) {
console.warn("SessionData: Screen not found:", screenName); console.warn("SessionData: Screen not found");
return; return;
} }
@@ -545,7 +551,7 @@ Singleton {
} }
if (!screen) { if (!screen) {
console.warn("SessionData: Screen not found:", screenName); console.warn("SessionData: Screen not found");
return; return;
} }
@@ -583,7 +589,7 @@ Singleton {
} }
if (!screen) { if (!screen) {
console.warn("SessionData: Screen not found:", screenName); console.warn("SessionData: Screen not found");
return; return;
} }
@@ -621,7 +627,7 @@ Singleton {
} }
if (!screen) { if (!screen) {
console.warn("SessionData: Screen not found:", screenName); console.warn("SessionData: Screen not found");
return; return;
} }
@@ -659,7 +665,7 @@ Singleton {
} }
if (!screen) { if (!screen) {
console.warn("SessionData: Screen not found:", screenName); console.warn("SessionData: Screen not found");
return; return;
} }
@@ -702,7 +708,6 @@ Singleton {
} }
function setNightModeAutoEnabled(enabled) { function setNightModeAutoEnabled(enabled) {
console.log("SessionData: Setting nightModeAutoEnabled to", enabled);
nightModeAutoEnabled = enabled; nightModeAutoEnabled = enabled;
saveSettings(); saveSettings();
} }
@@ -738,13 +743,11 @@ Singleton {
} }
function setLatitude(lat) { function setLatitude(lat) {
console.log("SessionData: Setting latitude to", lat);
latitude = lat; latitude = lat;
saveSettings(); saveSettings();
} }
function setLongitude(lng) { function setLongitude(lng) {
console.log("SessionData: Setting longitude to", lng);
longitude = lng; longitude = lng;
saveSettings(); saveSettings();
} }
@@ -754,6 +757,41 @@ Singleton {
saveSettings(); saveSettings();
} }
function setThemeModeAutoEnabled(enabled) {
themeModeAutoEnabled = enabled;
saveSettings();
}
function setThemeModeAutoMode(mode) {
themeModeAutoMode = mode;
saveSettings();
}
function setThemeModeStartHour(hour) {
themeModeStartHour = hour;
saveSettings();
}
function setThemeModeStartMinute(minute) {
themeModeStartMinute = minute;
saveSettings();
}
function setThemeModeEndHour(hour) {
themeModeEndHour = hour;
saveSettings();
}
function setThemeModeEndMinute(minute) {
themeModeEndMinute = minute;
saveSettings();
}
function setThemeModeShareGammaSettings(share) {
themeModeShareGammaSettings = share;
saveSettings();
}
function setPinnedApps(apps) { function setPinnedApps(apps) {
pinnedApps = apps; pinnedApps = apps;
saveSettings(); saveSettings();
@@ -1003,6 +1041,11 @@ Singleton {
saveSettings(); saveSettings();
} }
function setVpnLastConnected(uuid) {
vpnLastConnected = uuid || "";
saveSettings();
}
function syncWallpaperForCurrentMode() { function syncWallpaperForCurrentMode() {
if (!perModeWallpaper) if (!perModeWallpaper)
return; return;

View File

@@ -133,6 +133,7 @@ Singleton {
property real dockTransparency: 1 property real dockTransparency: 1
property string widgetBackgroundColor: "sch" property string widgetBackgroundColor: "sch"
property string widgetColorMode: "default" property string widgetColorMode: "default"
property string controlCenterTileColorMode: "primary"
property real cornerRadius: 12 property real cornerRadius: 12
property int niriLayoutGapsOverride: -1 property int niriLayoutGapsOverride: -1
property int niriLayoutRadiusOverride: -1 property int niriLayoutRadiusOverride: -1
@@ -242,6 +243,7 @@ Singleton {
property bool showWorkspaceApps: false property bool showWorkspaceApps: false
property bool groupWorkspaceApps: true property bool groupWorkspaceApps: true
property int maxWorkspaceIcons: 3 property int maxWorkspaceIcons: 3
property int workspaceAppIconSizeOffset: 0
property bool workspaceFollowFocus: false property bool workspaceFollowFocus: false
property bool showOccupiedWorkspacesOnly: false property bool showOccupiedWorkspacesOnly: false
property bool reverseScrolling: false property bool reverseScrolling: false
@@ -261,6 +263,9 @@ Singleton {
property bool clockCompactMode: false property bool clockCompactMode: false
property bool focusedWindowCompactMode: false property bool focusedWindowCompactMode: false
property bool runningAppsCompactMode: true property bool runningAppsCompactMode: true
property int barMaxVisibleApps: 0
property int barMaxVisibleRunningApps: 0
property bool barShowOverflowBadge: true
property bool keyboardLayoutNameCompactMode: false property bool keyboardLayoutNameCompactMode: false
property bool runningAppsCurrentWorkspace: false property bool runningAppsCurrentWorkspace: false
property bool runningAppsGroupByApp: false property bool runningAppsGroupByApp: false
@@ -292,13 +297,13 @@ Singleton {
property string _legacyWeatherLocation: "New York, NY" property string _legacyWeatherLocation: "New York, NY"
property string _legacyWeatherCoordinates: "40.7128,-74.0060" property string _legacyWeatherCoordinates: "40.7128,-74.0060"
property string _legacyVpnLastConnected: ""
readonly property string weatherLocation: SessionData.weatherLocation readonly property string weatherLocation: SessionData.weatherLocation
readonly property string weatherCoordinates: SessionData.weatherCoordinates readonly property string weatherCoordinates: SessionData.weatherCoordinates
property bool useAutoLocation: false property bool useAutoLocation: false
property bool weatherEnabled: true property bool weatherEnabled: true
property string networkPreference: "auto" property string networkPreference: "auto"
property string vpnLastConnected: ""
property string iconTheme: "System Default" property string iconTheme: "System Default"
property var availableIconThemes: ["System Default"] property var availableIconThemes: ["System Default"]
@@ -441,6 +446,9 @@ Singleton {
property int dockLauncherLogoSizeOffset: 0 property int dockLauncherLogoSizeOffset: 0
property real dockLauncherLogoBrightness: 0.5 property real dockLauncherLogoBrightness: 0.5
property real dockLauncherLogoContrast: 1 property real dockLauncherLogoContrast: 1
property int dockMaxVisibleApps: 0
property int dockMaxVisibleRunningApps: 0
property bool dockShowOverflowBadge: true
property bool notificationOverlayEnabled: false property bool notificationOverlayEnabled: false
property int overviewRows: 2 property int overviewRows: 2
@@ -511,6 +519,11 @@ Singleton {
property var showOnLastDisplay: ({}) property var showOnLastDisplay: ({})
property var niriOutputSettings: ({}) property var niriOutputSettings: ({})
property var hyprlandOutputSettings: ({}) property var hyprlandOutputSettings: ({})
property var displayProfiles: ({})
property var activeDisplayProfile: ({})
property bool displayProfileAutoSelect: false
property bool displayShowDisconnected: false
property bool displaySnapToEdge: true
property var barConfigs: [ property var barConfigs: [
{ {
@@ -1002,7 +1015,6 @@ Singleton {
fi fi
done done
rm -rf ~/.cache/icon-cache ~/.cache/thumbnails 2>/dev/null || true
pkill -HUP -f 'gtk' 2>/dev/null || true`; pkill -HUP -f 'gtk' 2>/dev/null || true`;
Quickshell.execDetached(["sh", "-lc", configScript]); Quickshell.execDetached(["sh", "-lc", configScript]);
@@ -1034,8 +1046,7 @@ Singleton {
fi fi
} }
update_qt_icon_theme ${_configDir}/qt5ct/qt5ct.conf '${qtThemeNameEscaped}' update_qt_icon_theme ${_configDir}/qt5ct/qt5ct.conf '${qtThemeNameEscaped}'
update_qt_icon_theme ${_configDir}/qt6ct/qt6ct.conf '${qtThemeNameEscaped}' update_qt_icon_theme ${_configDir}/qt6ct/qt6ct.conf '${qtThemeNameEscaped}'`;
rm -rf '${home}'/.cache/icon-cache '${home}'/.cache/thumbnails 2>/dev/null || true`;
Quickshell.execDetached(["sh", "-lc", script]); Quickshell.execDetached(["sh", "-lc", script]);
} }
@@ -1078,11 +1089,15 @@ Singleton {
_legacyWeatherLocation = obj.weatherLocation; _legacyWeatherLocation = obj.weatherLocation;
if (obj?.weatherCoordinates !== undefined) if (obj?.weatherCoordinates !== undefined)
_legacyWeatherCoordinates = obj.weatherCoordinates; _legacyWeatherCoordinates = obj.weatherCoordinates;
if (obj?.vpnLastConnected !== undefined && obj.vpnLastConnected !== "") {
_legacyVpnLastConnected = obj.vpnLastConnected;
SessionData.vpnLastConnected = _legacyVpnLastConnected;
SessionData.saveSettings();
}
_loadedSettingsSnapshot = JSON.stringify(Store.toJson(root)); _loadedSettingsSnapshot = JSON.stringify(Store.toJson(root));
_hasLoaded = true; _hasLoaded = true;
applyStoredTheme(); applyStoredTheme();
applyStoredIconTheme();
updateCompositorCursor(); updateCompositorCursor();
Processes.detectQtTools(); Processes.detectQtTools();
@@ -1093,7 +1108,6 @@ Singleton {
console.error("SettingsData: Failed to parse settings.json - file will not be overwritten. Error:", msg); console.error("SettingsData: Failed to parse settings.json - file will not be overwritten. Error:", msg);
Qt.callLater(() => ToastService.showError(I18n.tr("Failed to parse settings.json"), msg)); Qt.callLater(() => ToastService.showError(I18n.tr("Failed to parse settings.json"), msg));
applyStoredTheme(); applyStoredTheme();
applyStoredIconTheme();
} finally { } finally {
_loading = false; _loading = false;
} }
@@ -2247,6 +2261,39 @@ Singleton {
saveSettings(); saveSettings();
} }
function getDisplayProfiles(compositor) {
return displayProfiles[compositor] || {};
}
function setDisplayProfile(compositor, profileId, data) {
const updated = JSON.parse(JSON.stringify(displayProfiles));
if (!updated[compositor])
updated[compositor] = {};
updated[compositor][profileId] = data;
displayProfiles = updated;
saveSettings();
}
function removeDisplayProfile(compositor, profileId) {
if (!displayProfiles[compositor] || !displayProfiles[compositor][profileId])
return;
const updated = JSON.parse(JSON.stringify(displayProfiles));
delete updated[compositor][profileId];
displayProfiles = updated;
saveSettings();
}
function getActiveDisplayProfile(compositor) {
return activeDisplayProfile[compositor] || "";
}
function setActiveDisplayProfile(compositor, profileId) {
const updated = JSON.parse(JSON.stringify(activeDisplayProfile));
updated[compositor] = profileId;
activeDisplayProfile = updated;
saveSettings();
}
ListModel { ListModel {
id: leftWidgetsModel id: leftWidgetsModel
} }
@@ -2311,11 +2358,15 @@ Singleton {
_legacyWeatherLocation = obj.weatherLocation; _legacyWeatherLocation = obj.weatherLocation;
if (obj.weatherCoordinates !== undefined) if (obj.weatherCoordinates !== undefined)
_legacyWeatherCoordinates = obj.weatherCoordinates; _legacyWeatherCoordinates = obj.weatherCoordinates;
if (obj.vpnLastConnected !== undefined && obj.vpnLastConnected !== "") {
_legacyVpnLastConnected = obj.vpnLastConnected;
SessionData.vpnLastConnected = _legacyVpnLastConnected;
SessionData.saveSettings();
}
_loadedSettingsSnapshot = JSON.stringify(Store.toJson(root)); _loadedSettingsSnapshot = JSON.stringify(Store.toJson(root));
_hasLoaded = true; _hasLoaded = true;
applyStoredTheme(); applyStoredTheme();
applyStoredIconTheme();
updateCompositorCursor(); updateCompositorCursor();
} catch (e) { } catch (e) {
_parseError = true; _parseError = true;

View File

@@ -94,6 +94,9 @@ Singleton {
property var matugenColors: ({}) property var matugenColors: ({})
property var _pendingGenerateParams: null property var _pendingGenerateParams: null
property bool themeModeAutomationActive: false
property bool dmsServiceWasDisconnected: true
readonly property var dank16: { readonly property var dank16: {
const raw = matugenColors?.dank16; const raw = matugenColors?.dank16;
if (!raw) if (!raw)
@@ -176,6 +179,227 @@ Singleton {
if (typeof SettingsData !== "undefined" && SettingsData.currentThemeName) { if (typeof SettingsData !== "undefined" && SettingsData.currentThemeName) {
switchTheme(SettingsData.currentThemeName, false, false); switchTheme(SettingsData.currentThemeName, false, false);
} }
if (typeof SessionData !== "undefined" && SessionData.themeModeAutoEnabled) {
startThemeModeAutomation();
}
}
Connections {
target: SessionData
enabled: typeof SessionData !== "undefined"
function onThemeModeAutoEnabledChanged() {
if (SessionData.themeModeAutoEnabled) {
root.startThemeModeAutomation();
} else {
root.stopThemeModeAutomation();
}
}
function onThemeModeAutoModeChanged() {
if (root.themeModeAutomationActive) {
root.evaluateThemeMode();
root.syncTimeThemeSchedule();
root.syncLocationThemeSchedule();
}
}
function onThemeModeStartHourChanged() {
if (root.themeModeAutomationActive && !SessionData.themeModeShareGammaSettings) {
root.evaluateThemeMode();
root.syncTimeThemeSchedule();
}
}
function onThemeModeStartMinuteChanged() {
if (root.themeModeAutomationActive && !SessionData.themeModeShareGammaSettings) {
root.evaluateThemeMode();
root.syncTimeThemeSchedule();
}
}
function onThemeModeEndHourChanged() {
if (root.themeModeAutomationActive && !SessionData.themeModeShareGammaSettings) {
root.evaluateThemeMode();
root.syncTimeThemeSchedule();
}
}
function onThemeModeEndMinuteChanged() {
if (root.themeModeAutomationActive && !SessionData.themeModeShareGammaSettings) {
root.evaluateThemeMode();
root.syncTimeThemeSchedule();
}
}
function onThemeModeShareGammaSettingsChanged() {
if (root.themeModeAutomationActive) {
root.evaluateThemeMode();
root.syncTimeThemeSchedule();
root.syncLocationThemeSchedule();
}
}
function onNightModeStartHourChanged() {
if (root.themeModeAutomationActive && SessionData.themeModeShareGammaSettings) {
root.evaluateThemeMode();
root.syncTimeThemeSchedule();
}
}
function onNightModeStartMinuteChanged() {
if (root.themeModeAutomationActive && SessionData.themeModeShareGammaSettings) {
root.evaluateThemeMode();
root.syncTimeThemeSchedule();
}
}
function onNightModeEndHourChanged() {
if (root.themeModeAutomationActive && SessionData.themeModeShareGammaSettings) {
root.evaluateThemeMode();
root.syncTimeThemeSchedule();
}
}
function onNightModeEndMinuteChanged() {
if (root.themeModeAutomationActive && SessionData.themeModeShareGammaSettings) {
root.evaluateThemeMode();
root.syncTimeThemeSchedule();
}
}
function onLatitudeChanged() {
if (root.themeModeAutomationActive && SessionData.themeModeAutoMode === "location") {
if (!SessionData.nightModeUseIPLocation && SessionData.latitude !== 0.0 && SessionData.longitude !== 0.0 && typeof DMSService !== "undefined") {
DMSService.sendRequest("wayland.gamma.setLocation", {
"latitude": SessionData.latitude,
"longitude": SessionData.longitude
});
}
root.evaluateThemeMode();
root.syncLocationThemeSchedule();
}
}
function onLongitudeChanged() {
if (root.themeModeAutomationActive && SessionData.themeModeAutoMode === "location") {
if (!SessionData.nightModeUseIPLocation && SessionData.latitude !== 0.0 && SessionData.longitude !== 0.0 && typeof DMSService !== "undefined") {
DMSService.sendRequest("wayland.gamma.setLocation", {
"latitude": SessionData.latitude,
"longitude": SessionData.longitude
});
}
root.evaluateThemeMode();
root.syncLocationThemeSchedule();
}
}
function onNightModeUseIPLocationChanged() {
if (root.themeModeAutomationActive && SessionData.themeModeAutoMode === "location") {
if (typeof DMSService !== "undefined") {
DMSService.sendRequest("wayland.gamma.setUseIPLocation", {
"use": SessionData.nightModeUseIPLocation
}, response => {
if (!response.error && !SessionData.nightModeUseIPLocation && SessionData.latitude !== 0.0 && SessionData.longitude !== 0.0) {
DMSService.sendRequest("wayland.gamma.setLocation", {
"latitude": SessionData.latitude,
"longitude": SessionData.longitude
});
}
});
}
root.evaluateThemeMode();
root.syncLocationThemeSchedule();
}
}
}
// React to gamma backend's isDay state changes for location-based mode
Connections {
target: DisplayService
enabled: typeof DisplayService !== "undefined" && typeof SessionData !== "undefined" && SessionData.themeModeAutoEnabled && SessionData.themeModeAutoMode === "location" && !themeAutoBackendAvailable()
function onGammaIsDayChanged() {
if (root.isLightMode !== DisplayService.gammaIsDay) {
root.setLightMode(DisplayService.gammaIsDay, true, true);
}
}
}
Connections {
target: DMSService
enabled: typeof DMSService !== "undefined" && typeof SessionData !== "undefined"
function onLoginctlEvent(event) {
if (!SessionData.themeModeAutoEnabled)
return;
if (event.event === "unlock" || event.event === "resume") {
if (!themeAutoBackendAvailable()) {
root.evaluateThemeMode();
return;
}
DMSService.sendRequest("theme.auto.trigger", {});
}
}
function onThemeAutoStateUpdate(data) {
if (!SessionData.themeModeAutoEnabled) {
return;
}
applyThemeAutoState(data);
}
function onConnectionStateChanged() {
if (DMSService.isConnected && SessionData.themeModeAutoMode === "time") {
root.syncTimeThemeSchedule();
}
if (DMSService.isConnected && SessionData.themeModeAutoMode === "location") {
root.syncLocationThemeSchedule();
}
if (themeAutoBackendAvailable() && SessionData.themeModeAutoEnabled) {
DMSService.sendRequest("theme.auto.getState", null, response => {
if (response && response.result) {
applyThemeAutoState(response.result);
}
});
}
if (!SessionData.themeModeAutoEnabled) {
return;
}
if (DMSService.isConnected && SessionData.themeModeAutoMode === "location") {
if (SessionData.nightModeUseIPLocation) {
DMSService.sendRequest("wayland.gamma.setUseIPLocation", {
"use": true
}, response => {
if (!response.error) {
console.info("Theme automation: IP location enabled after connection");
}
});
} else if (SessionData.latitude !== 0.0 && SessionData.longitude !== 0.0) {
DMSService.sendRequest("wayland.gamma.setUseIPLocation", {
"use": false
}, response => {
if (!response.error) {
DMSService.sendRequest("wayland.gamma.setLocation", {
"latitude": SessionData.latitude,
"longitude": SessionData.longitude
}, locationResponse => {
if (locationResponse?.error) {
console.warn("Theme automation: Failed to set location", locationResponse.error);
}
});
}
});
} else {
console.warn("Theme automation: No location configured");
}
}
}
} }
function applyGreeterTheme(themeName) { function applyGreeterTheme(themeName) {
@@ -228,39 +452,39 @@ Singleton {
readonly property var availableMatugenSchemes: [({ readonly property var availableMatugenSchemes: [({
"value": "scheme-tonal-spot", "value": "scheme-tonal-spot",
"label": "Tonal Spot", "label": I18n.tr("Tonal Spot", "matugen color scheme option"),
"description": I18n.tr("Balanced palette with focused accents (default).") "description": I18n.tr("Balanced palette with focused accents (default).")
}), ({ }), ({
"value": "scheme-vibrant", "value": "scheme-vibrant",
"label": "Vibrant", "label": I18n.tr("Vibrant", "matugen color scheme option"),
"description": I18n.tr("Lively palette with saturated accents.") "description": I18n.tr("Lively palette with saturated accents.")
}), ({ }), ({
"value": "scheme-content", "value": "scheme-content",
"label": "Content", "label": I18n.tr("Content", "matugen color scheme option"),
"description": I18n.tr("Derives colors that closely match the underlying image.") "description": I18n.tr("Derives colors that closely match the underlying image.")
}), ({ }), ({
"value": "scheme-expressive", "value": "scheme-expressive",
"label": "Expressive", "label": I18n.tr("Expressive", "matugen color scheme option"),
"description": I18n.tr("Vibrant palette with playful saturation.") "description": I18n.tr("Vibrant palette with playful saturation.")
}), ({ }), ({
"value": "scheme-fidelity", "value": "scheme-fidelity",
"label": "Fidelity", "label": I18n.tr("Fidelity", "matugen color scheme option"),
"description": I18n.tr("High-fidelity palette that preserves source hues.") "description": I18n.tr("High-fidelity palette that preserves source hues.")
}), ({ }), ({
"value": "scheme-fruit-salad", "value": "scheme-fruit-salad",
"label": "Fruit Salad", "label": I18n.tr("Fruit Salad", "matugen color scheme option"),
"description": I18n.tr("Colorful mix of bright contrasting accents.") "description": I18n.tr("Colorful mix of bright contrasting accents.")
}), ({ }), ({
"value": "scheme-monochrome", "value": "scheme-monochrome",
"label": "Monochrome", "label": I18n.tr("Monochrome", "matugen color scheme option"),
"description": I18n.tr("Minimal palette built around a single hue.") "description": I18n.tr("Minimal palette built around a single hue.")
}), ({ }), ({
"value": "scheme-neutral", "value": "scheme-neutral",
"label": "Neutral", "label": I18n.tr("Neutral", "matugen color scheme option"),
"description": I18n.tr("Muted palette with subdued, calming tones.") "description": I18n.tr("Muted palette with subdued, calming tones.")
}), ({ }), ({
"value": "scheme-rainbow", "value": "scheme-rainbow",
"label": "Rainbow", "label": I18n.tr("Rainbow", "matugen color scheme option"),
"description": I18n.tr("Diverse palette spanning the full spectrum.") "description": I18n.tr("Diverse palette spanning the full spectrum.")
})] })]
@@ -330,6 +554,58 @@ Singleton {
property color errorHover: Qt.rgba(error.r, error.g, error.b, 0.12) property color errorHover: Qt.rgba(error.r, error.g, error.b, 0.12)
property color errorPressed: Qt.rgba(error.r, error.g, error.b, 0.16) property color errorPressed: Qt.rgba(error.r, error.g, error.b, 0.16)
readonly property color ccTileActiveBg: {
switch (SettingsData.controlCenterTileColorMode) {
case "primaryContainer":
return primaryContainer;
case "secondary":
return secondary;
case "surfaceVariant":
return surfaceVariant;
default:
return primary;
}
}
readonly property color ccTileActiveText: {
switch (SettingsData.controlCenterTileColorMode) {
case "primaryContainer":
return primary;
case "secondary":
return surfaceText;
case "surfaceVariant":
return surfaceText;
default:
return primaryText;
}
}
readonly property color ccTileInactiveIcon: {
switch (SettingsData.controlCenterTileColorMode) {
case "primaryContainer":
return primary;
case "secondary":
return secondary;
case "surfaceVariant":
return surfaceText;
default:
return primary;
}
}
readonly property color ccTileRing: {
switch (SettingsData.controlCenterTileColorMode) {
case "primaryContainer":
return Qt.rgba(primary.r, primary.g, primary.b, 0.22);
case "secondary":
return Qt.rgba(surfaceText.r, surfaceText.g, surfaceText.b, 0.22);
case "surfaceVariant":
return Qt.rgba(surfaceText.r, surfaceText.g, surfaceText.b, 0.22);
default:
return Qt.rgba(primaryText.r, primaryText.g, primaryText.b, 0.22);
}
}
property color shadowMedium: Qt.rgba(0, 0, 0, 0.08) property color shadowMedium: Qt.rgba(0, 0, 0, 0.08)
property color shadowStrong: Qt.rgba(0, 0, 0, 0.3) property color shadowStrong: Qt.rgba(0, 0, 0, 0.3)
@@ -491,7 +767,9 @@ Singleton {
property real popupTransparency: typeof SettingsData !== "undefined" && SettingsData.popupTransparency !== undefined ? SettingsData.popupTransparency : 1.0 property real popupTransparency: typeof SettingsData !== "undefined" && SettingsData.popupTransparency !== undefined ? SettingsData.popupTransparency : 1.0
function screenTransition() { function screenTransition() {
CompositorService.isNiri && NiriService.doScreenTransition(); if (CompositorService.isNiri) {
NiriService.doScreenTransition();
}
} }
function switchTheme(themeName, savePrefs = true, enableTransition = true) { function switchTheme(themeName, savePrefs = true, enableTransition = true) {
@@ -543,8 +821,10 @@ Singleton {
} }
const isGreeterMode = (typeof SessionData !== "undefined" && SessionData.isGreeterMode); const isGreeterMode = (typeof SessionData !== "undefined" && SessionData.isGreeterMode);
if (savePrefs && typeof SessionData !== "undefined" && !isGreeterMode) if (savePrefs && typeof SessionData !== "undefined" && !isGreeterMode) {
SessionData.setLightMode(light); SessionData.setLightMode(light);
}
if (!isGreeterMode) { if (!isGreeterMode) {
// Skip with matugen because, our script runner will do it. // Skip with matugen because, our script runner will do it.
if (!matugenAvailable) { if (!matugenAvailable) {
@@ -822,26 +1102,26 @@ Singleton {
function getPowerProfileLabel(profile) { function getPowerProfileLabel(profile) {
switch (profile) { switch (profile) {
case 0: case 0:
return "Power Saver"; return I18n.tr("Power Saver", "power profile option");
case 1: case 1:
return "Balanced"; return I18n.tr("Balanced", "power profile option");
case 2: case 2:
return "Performance"; return I18n.tr("Performance", "power profile option");
default: default:
return "Unknown"; return I18n.tr("Unknown", "power profile option");
} }
} }
function getPowerProfileDescription(profile) { function getPowerProfileDescription(profile) {
switch (profile) { switch (profile) {
case 0: case 0:
return "Extend battery life"; return I18n.tr("Extend battery life", "power profile description");
case 1: case 1:
return "Balance power and performance"; return I18n.tr("Balance power and performance", "power profile description");
case 2: case 2:
return "Prioritize performance"; return I18n.tr("Prioritize performance", "power profile description");
default: default:
return "Custom power profile"; return I18n.tr("Custom power profile", "power profile description");
} }
} }
@@ -948,7 +1228,7 @@ Singleton {
skipTemplates.push("kcolorscheme"); skipTemplates.push("kcolorscheme");
if (!SettingsData.matugenTemplateVscode) if (!SettingsData.matugenTemplateVscode)
skipTemplates.push("vscode"); skipTemplates.push("vscode");
if (!SettingsData.matugenTemplateEmacs) if (!SettingsData.matugenTemplateEmacs)
skipTemplates.push("emacs"); skipTemplates.push("emacs");
} }
if (skipTemplates.length > 0) { if (skipTemplates.length > 0) {
@@ -1233,7 +1513,7 @@ Singleton {
return `#${invR}${invG}${invB}`; return `#${invR}${invG}${invB}`;
} }
property string baseLogoColor: { property var baseLogoColor: {
if (typeof SettingsData === "undefined") if (typeof SettingsData === "undefined")
return ""; return "";
const colorOverride = SettingsData.launcherLogoColorOverride; const colorOverride = SettingsData.launcherLogoColorOverride;
@@ -1246,7 +1526,7 @@ Singleton {
return colorOverride; return colorOverride;
} }
property string effectiveLogoColor: { property var effectiveLogoColor: {
if (typeof SettingsData === "undefined") if (typeof SettingsData === "undefined")
return ""; return "";
@@ -1453,4 +1733,303 @@ Singleton {
root.switchTheme(defaultTheme, true, false); root.switchTheme(defaultTheme, true, false);
} }
} }
// Theme mode automation functions
function themeAutoBackendAvailable() {
return typeof DMSService !== "undefined" && DMSService.isConnected && Array.isArray(DMSService.capabilities) && DMSService.capabilities.includes("theme.auto");
}
function applyThemeAutoState(state) {
if (!state) {
return;
}
if (state.config && state.config.mode && state.config.mode !== SessionData.themeModeAutoMode) {
return;
}
if (typeof SessionData !== "undefined" && state.nextTransition !== undefined) {
SessionData.themeModeNextTransition = state.nextTransition || "";
}
if (state.isLight !== undefined && root.isLightMode !== state.isLight) {
root.setLightMode(state.isLight, true, true);
}
}
function syncTimeThemeSchedule() {
if (typeof SessionData === "undefined" || typeof DMSService === "undefined") {
return;
}
if (!DMSService.isConnected) {
return;
}
const timeModeActive = SessionData.themeModeAutoEnabled && SessionData.themeModeAutoMode === "time";
if (!timeModeActive) {
return;
}
DMSService.sendRequest("theme.auto.setMode", {
"mode": "time"
});
const shareSettings = SessionData.themeModeShareGammaSettings;
const startHour = shareSettings ? SessionData.nightModeStartHour : SessionData.themeModeStartHour;
const startMinute = shareSettings ? SessionData.nightModeStartMinute : SessionData.themeModeStartMinute;
const endHour = shareSettings ? SessionData.nightModeEndHour : SessionData.themeModeEndHour;
const endMinute = shareSettings ? SessionData.nightModeEndMinute : SessionData.themeModeEndMinute;
DMSService.sendRequest("theme.auto.setSchedule", {
"startHour": startHour,
"startMinute": startMinute,
"endHour": endHour,
"endMinute": endMinute
}, response => {
if (response && response.error) {
console.error("Theme automation: Failed to sync time schedule:", response.error);
}
});
DMSService.sendRequest("theme.auto.setEnabled", {
"enabled": true
});
DMSService.sendRequest("theme.auto.trigger", {});
}
function syncLocationThemeSchedule() {
if (typeof SessionData === "undefined" || typeof DMSService === "undefined") {
return;
}
if (!DMSService.isConnected) {
return;
}
const locationModeActive = SessionData.themeModeAutoEnabled && SessionData.themeModeAutoMode === "location";
if (!locationModeActive) {
return;
}
DMSService.sendRequest("theme.auto.setMode", {
"mode": "location"
});
if (SessionData.nightModeUseIPLocation) {
DMSService.sendRequest("theme.auto.setUseIPLocation", {
"use": true
});
} else {
DMSService.sendRequest("theme.auto.setUseIPLocation", {
"use": false
});
if (SessionData.latitude !== 0.0 && SessionData.longitude !== 0.0) {
DMSService.sendRequest("theme.auto.setLocation", {
"latitude": SessionData.latitude,
"longitude": SessionData.longitude
});
}
}
DMSService.sendRequest("theme.auto.setEnabled", {
"enabled": true
});
DMSService.sendRequest("theme.auto.trigger", {});
}
function evaluateThemeMode() {
if (typeof SessionData === "undefined" || !SessionData.themeModeAutoEnabled) {
return;
}
if (themeAutoBackendAvailable()) {
DMSService.sendRequest("theme.auto.getState", null, response => {
if (response && response.result) {
applyThemeAutoState(response.result);
}
});
return;
}
const mode = SessionData.themeModeAutoMode;
if (mode === "location") {
evaluateLocationBasedThemeMode();
} else {
evaluateTimeBasedThemeMode();
}
}
function evaluateLocationBasedThemeMode() {
if (typeof DisplayService !== "undefined") {
const shouldBeLight = DisplayService.gammaIsDay;
if (root.isLightMode !== shouldBeLight) {
root.setLightMode(shouldBeLight, true, true);
}
return;
}
if (!SessionData.nightModeUseIPLocation && SessionData.latitude !== 0.0 && SessionData.longitude !== 0.0) {
const shouldBeLight = calculateIsDaytime(SessionData.latitude, SessionData.longitude);
if (root.isLightMode !== shouldBeLight) {
root.setLightMode(shouldBeLight, true, true);
}
return;
}
if (root.themeModeAutomationActive) {
if (SessionData.nightModeUseIPLocation) {
console.warn("Theme automation: Waiting for IP location from backend");
} else {
console.warn("Theme automation: Location mode requires coordinates");
}
}
}
function evaluateTimeBasedThemeMode() {
const shareSettings = SessionData.themeModeShareGammaSettings;
const startHour = shareSettings ? SessionData.nightModeStartHour : SessionData.themeModeStartHour;
const startMinute = shareSettings ? SessionData.nightModeStartMinute : SessionData.themeModeStartMinute;
const endHour = shareSettings ? SessionData.nightModeEndHour : SessionData.themeModeEndHour;
const endMinute = shareSettings ? SessionData.nightModeEndMinute : SessionData.themeModeEndMinute;
const now = new Date();
const currentMinutes = now.getHours() * 60 + now.getMinutes();
const startMinutes = startHour * 60 + startMinute;
const endMinutes = endHour * 60 + endMinute;
let shouldBeLight;
if (startMinutes < endMinutes) {
shouldBeLight = currentMinutes < startMinutes || currentMinutes >= endMinutes;
} else {
shouldBeLight = currentMinutes >= endMinutes && currentMinutes < startMinutes;
}
if (root.isLightMode !== shouldBeLight) {
root.setLightMode(shouldBeLight, true, true);
}
}
function calculateIsDaytime(lat, lng) {
const now = new Date();
const start = new Date(now.getFullYear(), 0, 0);
const diff = now - start;
const dayOfYear = Math.floor(diff / 86400000);
const latRad = lat * Math.PI / 180;
const declination = 23.45 * Math.sin((360 / 365) * (dayOfYear - 81) * Math.PI / 180);
const declinationRad = declination * Math.PI / 180;
const cosHourAngle = -Math.tan(latRad) * Math.tan(declinationRad);
if (cosHourAngle > 1) {
return false; // Polar night
}
if (cosHourAngle < -1) {
return true; // Midnight sun
}
const hourAngle = Math.acos(cosHourAngle);
const hourAngleDeg = hourAngle * 180 / Math.PI;
const sunriseHour = 12 - hourAngleDeg / 15;
const sunsetHour = 12 + hourAngleDeg / 15;
const timeZoneOffset = now.getTimezoneOffset() / 60;
const localSunrise = sunriseHour - lng / 15 - timeZoneOffset;
const localSunset = sunsetHour - lng / 15 - timeZoneOffset;
const currentHour = now.getHours() + now.getMinutes() / 60;
const normalizeSunrise = ((localSunrise % 24) + 24) % 24;
const normalizeSunset = ((localSunset % 24) + 24) % 24;
return currentHour >= normalizeSunrise && currentHour < normalizeSunset;
}
// Helper function to send location to backend
function sendLocationToBackend() {
if (typeof SessionData === "undefined" || typeof DMSService === "undefined") {
return false;
}
if (!DMSService.isConnected) {
return false;
}
if (SessionData.nightModeUseIPLocation) {
DMSService.sendRequest("wayland.gamma.setUseIPLocation", {
"use": true
}, response => {
if (response?.error) {
console.warn("Theme automation: Failed to enable IP location", response.error);
}
});
return true;
} else if (SessionData.latitude !== 0.0 && SessionData.longitude !== 0.0) {
DMSService.sendRequest("wayland.gamma.setUseIPLocation", {
"use": false
}, response => {
if (!response.error) {
DMSService.sendRequest("wayland.gamma.setLocation", {
"latitude": SessionData.latitude,
"longitude": SessionData.longitude
}, locResp => {
if (locResp?.error) {
console.warn("Theme automation: Failed to set location", locResp.error);
}
});
}
});
return true;
}
return false;
}
Timer {
id: locationRetryTimer
interval: 1000
repeat: true
running: false
property int retryCount: 0
onTriggered: {
if (root.sendLocationToBackend()) {
stop();
retryCount = 0;
root.evaluateThemeMode();
} else {
retryCount++;
if (retryCount >= 10) {
stop();
retryCount = 0;
}
}
}
}
function startThemeModeAutomation() {
root.themeModeAutomationActive = true;
root.syncTimeThemeSchedule();
root.syncLocationThemeSchedule();
const sent = root.sendLocationToBackend();
if (!sent && typeof SessionData !== "undefined" && SessionData.themeModeAutoMode === "location") {
locationRetryTimer.start();
} else {
root.evaluateThemeMode();
}
}
function stopThemeModeAutomation() {
root.themeModeAutomationActive = false;
if (typeof DMSService !== "undefined" && DMSService.isConnected) {
DMSService.sendRequest("theme.auto.setEnabled", {
"enabled": false
});
}
}
} }

View File

@@ -35,6 +35,14 @@ var SPEC = {
nightModeUseIPLocation: { def: false }, nightModeUseIPLocation: { def: false },
nightModeLocationProvider: { def: "" }, nightModeLocationProvider: { def: "" },
themeModeAutoEnabled: { def: false },
themeModeAutoMode: { def: "time" },
themeModeStartHour: { def: 18 },
themeModeStartMinute: { def: 0 },
themeModeEndHour: { def: 6 },
themeModeEndMinute: { def: 0 },
themeModeShareGammaSettings: { def: true },
weatherLocation: { def: "New York, NY" }, weatherLocation: { def: "New York, NY" },
weatherCoordinates: { def: "40.7128,-74.0060" }, weatherCoordinates: { def: "40.7128,-74.0060" },
@@ -61,7 +69,9 @@ var SPEC = {
hiddenApps: { def: [] }, hiddenApps: { def: [] },
appOverrides: { def: {} }, appOverrides: { def: {} },
searchAppActions: { def: true } searchAppActions: { def: true },
vpnLastConnected: { def: "" }
}; };
function getValidKeys() { function getValidKeys() {

View File

@@ -1,6 +1,6 @@
.pragma library .pragma library
.import "./SessionSpec.js" as SpecModule .import "./SessionSpec.js" as SpecModule
function parse(root, jsonObj) { function parse(root, jsonObj) {
var SPEC = SpecModule.SPEC; var SPEC = SpecModule.SPEC;
@@ -68,6 +68,11 @@ function migrateToVersion(obj, targetVersion, settingsData) {
session.configVersion = 2; session.configVersion = 2;
} }
if (currentVersion < 3) {
console.info("SessionData: Migrating session to version 3");
session.configVersion = 3;
}
return session; return session;
} }

View File

@@ -19,6 +19,7 @@ var SPEC = {
widgetBackgroundColor: { def: "sch" }, widgetBackgroundColor: { def: "sch" },
widgetColorMode: { def: "default" }, widgetColorMode: { def: "default" },
controlCenterTileColorMode: { def: "primary" },
cornerRadius: { def: 12, onChange: "updateCompositorLayout" }, cornerRadius: { def: 12, onChange: "updateCompositorLayout" },
niriLayoutGapsOverride: { def: -1, onChange: "updateCompositorLayout" }, niriLayoutGapsOverride: { def: -1, onChange: "updateCompositorLayout" },
niriLayoutRadiusOverride: { def: -1, onChange: "updateCompositorLayout" }, niriLayoutRadiusOverride: { def: -1, onChange: "updateCompositorLayout" },
@@ -79,16 +80,18 @@ var SPEC = {
privacyShowCameraIcon: { def: false }, privacyShowCameraIcon: { def: false },
privacyShowScreenShareIcon: { def: false }, privacyShowScreenShareIcon: { def: false },
controlCenterWidgets: { def: [ controlCenterWidgets: {
{ id: "volumeSlider", enabled: true, width: 50 }, def: [
{ id: "brightnessSlider", enabled: true, width: 50 }, { id: "volumeSlider", enabled: true, width: 50 },
{ id: "wifi", enabled: true, width: 50 }, { id: "brightnessSlider", enabled: true, width: 50 },
{ id: "bluetooth", enabled: true, width: 50 }, { id: "wifi", enabled: true, width: 50 },
{ id: "audioOutput", enabled: true, width: 50 }, { id: "bluetooth", enabled: true, width: 50 },
{ id: "audioInput", enabled: true, width: 50 }, { id: "audioOutput", enabled: true, width: 50 },
{ id: "nightMode", enabled: true, width: 50 }, { id: "audioInput", enabled: true, width: 50 },
{ id: "darkMode", enabled: true, width: 50 } { id: "nightMode", enabled: true, width: 50 },
]}, { id: "darkMode", enabled: true, width: 50 }
]
},
showWorkspaceIndex: { def: false }, showWorkspaceIndex: { def: false },
showWorkspaceName: { def: false }, showWorkspaceName: { def: false },
@@ -96,6 +99,7 @@ var SPEC = {
workspaceScrolling: { def: false }, workspaceScrolling: { def: false },
showWorkspaceApps: { def: false }, showWorkspaceApps: { def: false },
maxWorkspaceIcons: { def: 3 }, maxWorkspaceIcons: { def: 3 },
workspaceAppIconSizeOffset: { def: 0 },
groupWorkspaceApps: { def: true }, groupWorkspaceApps: { def: true },
workspaceFollowFocus: { def: false }, workspaceFollowFocus: { def: false },
showOccupiedWorkspacesOnly: { def: false }, showOccupiedWorkspacesOnly: { def: false },
@@ -116,16 +120,21 @@ var SPEC = {
clockCompactMode: { def: false }, clockCompactMode: { def: false },
focusedWindowCompactMode: { def: false }, focusedWindowCompactMode: { def: false },
runningAppsCompactMode: { def: true }, runningAppsCompactMode: { def: true },
barMaxVisibleApps: { def: 0 },
barMaxVisibleRunningApps: { def: 0 },
barShowOverflowBadge: { def: true },
keyboardLayoutNameCompactMode: { def: false }, keyboardLayoutNameCompactMode: { def: false },
runningAppsCurrentWorkspace: { def: false }, runningAppsCurrentWorkspace: { def: false },
runningAppsGroupByApp: { def: false }, runningAppsGroupByApp: { def: false },
appIdSubstitutions: { def: [ appIdSubstitutions: {
{ pattern: "Spotify", replacement: "spotify", type: "exact" }, def: [
{ pattern: "beepertexts", replacement: "beeper", type: "exact" }, { pattern: "Spotify", replacement: "spotify", type: "exact" },
{ pattern: "home assistant desktop", replacement: "homeassistant-desktop", type: "exact" }, { pattern: "beepertexts", replacement: "beeper", type: "exact" },
{ pattern: "com.transmissionbt.transmission", replacement: "transmission-gtk", type: "contains" }, { pattern: "home assistant desktop", replacement: "homeassistant-desktop", type: "exact" },
{ pattern: "^steam_app_(\\d+)$", replacement: "steam_icon_$1", type: "regex" } { pattern: "com.transmissionbt.transmission", replacement: "transmission-gtk", type: "contains" },
]}, { pattern: "^steam_app_(\\d+)$", replacement: "steam_icon_$1", type: "regex" }
]
},
centeringMode: { def: "index" }, centeringMode: { def: "index" },
clockDateFormat: { def: "" }, clockDateFormat: { def: "" },
lockDateFormat: { def: "" }, lockDateFormat: { def: "" },
@@ -153,7 +162,6 @@ var SPEC = {
weatherEnabled: { def: true }, weatherEnabled: { def: true },
networkPreference: { def: "auto" }, networkPreference: { def: "auto" },
vpnLastConnected: { def: "" },
iconTheme: { def: "System Default", onChange: "applyStoredIconTheme" }, iconTheme: { def: "System Default", onChange: "applyStoredIconTheme" },
availableIconThemes: { def: ["System Default"], persist: false }, availableIconThemes: { def: ["System Default"], persist: false },
@@ -267,6 +275,9 @@ var SPEC = {
dockLauncherLogoSizeOffset: { def: 0 }, dockLauncherLogoSizeOffset: { def: 0 },
dockLauncherLogoBrightness: { def: 0.5, coerce: percentToUnit }, dockLauncherLogoBrightness: { def: 0.5, coerce: percentToUnit },
dockLauncherLogoContrast: { def: 1, coerce: percentToUnit }, dockLauncherLogoContrast: { def: 1, coerce: percentToUnit },
dockMaxVisibleApps: { def: 0 },
dockMaxVisibleRunningApps: { def: 0 },
dockShowOverflowBadge: { def: true },
notificationOverlayEnabled: { def: false }, notificationOverlayEnabled: { def: false },
overviewRows: { def: 2, persist: false }, overviewRows: { def: 2, persist: false },
@@ -306,7 +317,7 @@ var SPEC = {
osdAlwaysShowValue: { def: false }, osdAlwaysShowValue: { def: false },
osdPosition: { def: 5 }, osdPosition: { def: 5 },
osdVolumeEnabled: { def: true }, osdVolumeEnabled: { def: true },
osdMediaVolumeEnabled : { def: true }, osdMediaVolumeEnabled: { def: true },
osdBrightnessEnabled: { def: true }, osdBrightnessEnabled: { def: true },
osdIdleInhibitorEnabled: { def: true }, osdIdleInhibitorEnabled: { def: true },
osdMicMuteEnabled: { def: true }, osdMicMuteEnabled: { def: true },
@@ -336,53 +347,60 @@ var SPEC = {
showOnLastDisplay: { def: {} }, showOnLastDisplay: { def: {} },
niriOutputSettings: { def: {} }, niriOutputSettings: { def: {} },
hyprlandOutputSettings: { def: {} }, hyprlandOutputSettings: { def: {} },
displayProfiles: { def: {} },
activeDisplayProfile: { def: {} },
displayProfileAutoSelect: { def: false },
displayShowDisconnected: { def: false },
displaySnapToEdge: { def: true },
barConfigs: { def: [{ barConfigs: {
id: "default", def: [{
name: "Main Bar", id: "default",
enabled: true, name: "Main Bar",
position: 0, enabled: true,
screenPreferences: ["all"], position: 0,
showOnLastDisplay: true, screenPreferences: ["all"],
leftWidgets: ["launcherButton", "workspaceSwitcher", "focusedWindow"], showOnLastDisplay: true,
centerWidgets: ["music", "clock", "weather"], leftWidgets: ["launcherButton", "workspaceSwitcher", "focusedWindow"],
rightWidgets: ["systemTray", "clipboard", "cpuUsage", "memUsage", "notificationButton", "battery", "controlCenterButton"], centerWidgets: ["music", "clock", "weather"],
spacing: 4, rightWidgets: ["systemTray", "clipboard", "cpuUsage", "memUsage", "notificationButton", "battery", "controlCenterButton"],
innerPadding: 4, spacing: 4,
bottomGap: 0, innerPadding: 4,
transparency: 1.0, bottomGap: 0,
widgetTransparency: 1.0, transparency: 1.0,
squareCorners: false, widgetTransparency: 1.0,
noBackground: false, squareCorners: false,
gothCornersEnabled: false, noBackground: false,
gothCornerRadiusOverride: false, gothCornersEnabled: false,
gothCornerRadiusValue: 12, gothCornerRadiusOverride: false,
borderEnabled: false, gothCornerRadiusValue: 12,
borderColor: "surfaceText", borderEnabled: false,
borderOpacity: 1.0, borderColor: "surfaceText",
borderThickness: 1, borderOpacity: 1.0,
widgetOutlineEnabled: false, borderThickness: 1,
widgetOutlineColor: "primary", widgetOutlineEnabled: false,
widgetOutlineOpacity: 1.0, widgetOutlineColor: "primary",
widgetOutlineThickness: 1, widgetOutlineOpacity: 1.0,
fontScale: 1.0, widgetOutlineThickness: 1,
autoHide: false, fontScale: 1.0,
autoHideDelay: 250, autoHide: false,
showOnWindowsOpen: false, autoHideDelay: 250,
openOnOverview: false, showOnWindowsOpen: false,
visible: true, openOnOverview: false,
popupGapsAuto: true, visible: true,
popupGapsManual: 4, popupGapsAuto: true,
maximizeDetection: true, popupGapsManual: 4,
scrollEnabled: true, maximizeDetection: true,
scrollXBehavior: "column", scrollEnabled: true,
scrollYBehavior: "workspace", scrollXBehavior: "column",
shadowIntensity: 0, scrollYBehavior: "workspace",
shadowOpacity: 60, shadowIntensity: 0,
shadowColorMode: "text", shadowOpacity: 60,
shadowCustomColor: "#000000", shadowColorMode: "text",
clickThrough: false shadowCustomColor: "#000000",
}], onChange: "updateBarConfigs" }, clickThrough: false
}], onChange: "updateBarConfigs"
},
desktopClockEnabled: { def: false }, desktopClockEnabled: { def: false },
desktopClockStyle: { def: "analog" }, desktopClockStyle: { def: "analog" },
@@ -437,7 +455,7 @@ var SPEC = {
}; };
function getValidKeys() { function getValidKeys() {
return Object.keys(SPEC).filter(function(k) { return SPEC[k].persist !== false; }).concat(["configVersion"]); return Object.keys(SPEC).filter(function (k) { return SPEC[k].persist !== false; }).concat(["configVersion"]);
} }
function set(root, key, value, saveFn, hooks) { function set(root, key, value, saveFn, hooks) {

View File

@@ -1,6 +1,6 @@
.pragma library .pragma library
.import "./SettingsSpec.js" as SpecModule .import "./SettingsSpec.js" as SpecModule
function parse(root, jsonObj) { function parse(root, jsonObj) {
var SPEC = SpecModule.SPEC; var SPEC = SpecModule.SPEC;

View File

@@ -661,6 +661,18 @@ Item {
} }
} }
LazyLoader {
id: windowRuleModalLoader
active: false
Component.onCompleted: PopoutService.windowRuleModalLoader = windowRuleModalLoader
WindowRuleModal {
id: windowRuleModal
}
}
LazyLoader { LazyLoader {
id: processListModalLoader id: processListModalLoader
@@ -787,6 +799,7 @@ Item {
dankBarRepeater: dankBarRepeater dankBarRepeater: dankBarRepeater
hyprlandOverviewLoader: hyprlandOverviewLoader hyprlandOverviewLoader: hyprlandOverviewLoader
workspaceRenameModalLoader: workspaceRenameModalLoader workspaceRenameModalLoader: workspaceRenameModalLoader
windowRuleModalLoader: windowRuleModalLoader
} }
Variants { Variants {

View File

@@ -1,8 +1,10 @@
import QtQuick import QtQuick
import Quickshell.Io import Quickshell.Io
import Quickshell.Hyprland import Quickshell.Hyprland
import Quickshell.Wayland
import qs.Common import qs.Common
import qs.Services import qs.Services
import qs.Modules.Settings.DisplayConfig
Item { Item {
id: root id: root
@@ -16,6 +18,7 @@ Item {
required property var dankBarRepeater required property var dankBarRepeater
required property var hyprlandOverviewLoader required property var hyprlandOverviewLoader
required property var workspaceRenameModalLoader required property var workspaceRenameModalLoader
required property var windowRuleModalLoader
function getFirstBar() { function getFirstBar() {
if (!root.dankBarRepeater || root.dankBarRepeater.count === 0) if (!root.dankBarRepeater || root.dankBarRepeater.count === 0)
@@ -1402,4 +1405,129 @@ Item {
target: "workspace-rename" target: "workspace-rename"
} }
IpcHandler {
function getFocusedWindow() {
const active = ToplevelManager.activeToplevel;
if (!active)
return null;
return {
appId: active.appId || "",
title: active.title || ""
};
}
function open(): string {
if (!CompositorService.isNiri)
return "WINDOW_RULES_NIRI_ONLY";
root.windowRuleModalLoader.active = true;
if (root.windowRuleModalLoader.item) {
root.windowRuleModalLoader.item.show(getFocusedWindow());
return "WINDOW_RULE_MODAL_OPENED";
}
return "WINDOW_RULE_MODAL_NOT_FOUND";
}
function close(): string {
if (root.windowRuleModalLoader.item) {
root.windowRuleModalLoader.item.hide();
return "WINDOW_RULE_MODAL_CLOSED";
}
return "WINDOW_RULE_MODAL_NOT_FOUND";
}
function toggle(): string {
if (!CompositorService.isNiri)
return "WINDOW_RULES_NIRI_ONLY";
root.windowRuleModalLoader.active = true;
if (root.windowRuleModalLoader.item) {
if (root.windowRuleModalLoader.item.visible) {
root.windowRuleModalLoader.item.hide();
return "WINDOW_RULE_MODAL_CLOSED";
}
root.windowRuleModalLoader.item.show(getFocusedWindow());
return "WINDOW_RULE_MODAL_OPENED";
}
return "WINDOW_RULE_MODAL_NOT_FOUND";
}
target: "window-rules"
}
IpcHandler {
function listProfiles(): string {
const profiles = DisplayConfigState.validatedProfiles;
const activeId = SettingsData.getActiveDisplayProfile(CompositorService.compositor);
const matchedId = DisplayConfigState.matchedProfile;
const lines = [];
for (const id in profiles) {
const p = profiles[id];
const flags = [];
if (id === activeId)
flags.push("active");
if (id === matchedId)
flags.push("matched");
const flagStr = flags.length > 0 ? " [" + flags.join(",") + "]" : "";
lines.push(p.name + flagStr + " -> " + JSON.stringify(p.outputSet));
}
if (lines.length === 0)
return "No profiles configured";
return lines.join("\n");
}
function setProfile(profileName: string): string {
if (!profileName)
return "ERROR: No profile name specified";
if (SettingsData.displayProfileAutoSelect)
return "ERROR: Auto profile selection is enabled. Use toggleAuto first";
const profiles = DisplayConfigState.validatedProfiles;
let profileId = null;
for (const id in profiles) {
if (profiles[id].name === profileName) {
profileId = id;
break;
}
}
if (!profileId)
return `ERROR: Profile not found: ${profileName}`;
DisplayConfigState.activateProfile(profileId);
return `PROFILE_SET_SUCCESS: ${profileName}`;
}
// ! TODO - auto profile switching is buggy on niri and other compositors
function toggleAuto(): string {
return "ERROR: Auto profile selection is temporarily disabled due to compositor bugs";
}
function status(): string {
const auto = "off"; // disabled for now
const activeId = SettingsData.getActiveDisplayProfile(CompositorService.compositor);
const matchedId = DisplayConfigState.matchedProfile;
const profiles = DisplayConfigState.validatedProfiles;
const activeName = profiles[activeId]?.name || "none";
const matchedName = profiles[matchedId]?.name || "none";
const currentOutputs = JSON.stringify(DisplayConfigState.currentOutputSet);
return `auto: ${auto}\nactive: ${activeName}\nmatched: ${matchedName}\noutputs: ${currentOutputs}`;
}
function current(): string {
return JSON.stringify(DisplayConfigState.currentOutputSet);
}
function refresh(): string {
DisplayConfigState.currentOutputSet = DisplayConfigState.buildCurrentOutputSet();
DisplayConfigState.validateProfiles();
return "Refreshed output state";
}
target: "outputs"
}
} }

View File

@@ -30,7 +30,9 @@ Item {
onKeyboardHintsToggled: modal.showKeyboardHints = !modal.showKeyboardHints onKeyboardHintsToggled: modal.showKeyboardHints = !modal.showKeyboardHints
onTabChanged: tabName => modal.activeTab = tabName onTabChanged: tabName => modal.activeTab = tabName
onClearAllClicked: { onClearAllClicked: {
clearConfirmDialog.show(I18n.tr("Clear All History?"), I18n.tr("This will permanently delete all clipboard history."), function () { const hasPinned = modal.pinnedCount > 0;
const message = hasPinned ? I18n.tr("This will delete all unpinned entries. %1 pinned entries will be kept.").arg(modal.pinnedCount) : I18n.tr("This will permanently delete all clipboard history.");
clearConfirmDialog.show(I18n.tr("Clear History?"), message, function () {
modal.clearAll(); modal.clearAll();
modal.hide(); modal.hide();
}, function () {}); }, function () {});

View File

@@ -239,20 +239,16 @@ DankModal {
function clearAll() { function clearAll() {
const hasPinned = pinnedCount > 0; const hasPinned = pinnedCount > 0;
const message = hasPinned ? I18n.tr("This will delete all unpinned entries. %1 pinned entries will be kept.").arg(pinnedCount) : I18n.tr("This will permanently delete all clipboard history."); DMSService.sendRequest("clipboard.clearHistory", null, function (response) {
if (response.error) {
clearConfirmDialog.show(I18n.tr("Clear History?"), message, function () { console.warn("ClipboardHistoryModal: Failed to clear history:", response.error);
DMSService.sendRequest("clipboard.clearHistory", null, function (response) { return;
if (response.error) { }
console.warn("ClipboardHistoryModal: Failed to clear history:", response.error); refreshClipboard();
return; if (hasPinned) {
} ToastService.showInfo(I18n.tr("History cleared. %1 pinned entries kept.").arg(pinnedCount));
refreshClipboard(); }
if (hasPinned) { });
ToastService.showInfo(I18n.tr("History cleared. %1 pinned entries kept.").arg(pinnedCount));
}
});
}, function () {});
} }
function getEntryPreview(entry) { function getEntryPreview(entry) {
@@ -288,6 +284,20 @@ DankModal {
modal: clipboardHistoryModal modal: clipboardHistoryModal
} }
Connections {
target: DMSService
function onClipboardStateUpdate(data) {
if (!clipboardHistoryModal.shouldBeVisible) {
return;
}
const newHistory = data.history || [];
internalEntries = newHistory;
pinnedEntries = newHistory.filter(e => e.pinned);
pinnedCount = pinnedEntries.length;
updateFilteredModel();
}
}
ConfirmModal { ConfirmModal {
id: clearConfirmDialog id: clearConfirmDialog
confirmButtonText: I18n.tr("Clear All") confirmButtonText: I18n.tr("Clear All")
@@ -295,13 +305,18 @@ DankModal {
onVisibleChanged: { onVisibleChanged: {
if (visible) { if (visible) {
clipboardHistoryModal.shouldHaveFocus = false; clipboardHistoryModal.shouldHaveFocus = false;
} else if (clipboardHistoryModal.shouldBeVisible) { return;
}
Qt.callLater(function () {
if (!clipboardHistoryModal.shouldBeVisible) {
return;
}
clipboardHistoryModal.shouldHaveFocus = true; clipboardHistoryModal.shouldHaveFocus = true;
clipboardHistoryModal.modalFocusScope.forceActiveFocus(); clipboardHistoryModal.modalFocusScope.forceActiveFocus();
if (clipboardHistoryModal.contentLoader.item?.searchField) { if (clipboardHistoryModal.contentLoader.item?.searchField) {
clipboardHistoryModal.contentLoader.item.searchField.forceActiveFocus(); clipboardHistoryModal.contentLoader.item.searchField.forceActiveFocus();
} }
} });
} }
} }

View File

@@ -79,7 +79,7 @@ Item {
} }
Component.onCompleted: { Component.onCompleted: {
if (entryType !== "image") { if (entryType !== "image" || listView.height <= 0) {
return; return;
} }
@@ -93,22 +93,41 @@ Item {
} }
} }
Timer {
id: visibilityTimer
interval: 100
onTriggered: thumbnailImage.checkVisibility()
}
function checkVisibility() {
if (entryType !== "image" || listView.height <= 0 || isVisible) {
return;
}
const itemY = itemIndex * (ClipboardConstants.itemHeight + listView.spacing);
const viewTop = listView.contentY - ClipboardConstants.viewportBuffer;
const viewBottom = viewTop + listView.height + ClipboardConstants.extendedBuffer;
const nowVisible = (itemY + ClipboardConstants.itemHeight >= viewTop && itemY <= viewBottom);
if (nowVisible) {
isVisible = true;
tryLoadImage();
}
}
Connections { Connections {
target: listView target: listView
function onContentYChanged() { function onContentYChanged() {
if (entryType !== "image") { if (thumbnailImage.isVisible || entryType !== "image") {
return; return;
} }
visibilityTimer.restart();
}
const itemY = itemIndex * (ClipboardConstants.itemHeight + listView.spacing); function onHeightChanged() {
const viewTop = listView.contentY - ClipboardConstants.viewportBuffer; if (thumbnailImage.isVisible || entryType !== "image") {
const viewBottom = viewTop + listView.height + ClipboardConstants.extendedBuffer; return;
const nowVisible = (itemY + ClipboardConstants.itemHeight >= viewTop && itemY <= viewBottom);
if (nowVisible && !thumbnailImage.isVisible) {
thumbnailImage.isVisible = true;
thumbnailImage.tryLoadImage();
} }
visibilityTimer.restart();
} }
} }
} }

View File

@@ -48,9 +48,14 @@ Item {
Connections { Connections {
target: PluginService target: PluginService
function onRequestLauncherUpdate(pluginId) { function onRequestLauncherUpdate(pluginId) {
if (activePluginId === pluginId || searchQuery) { if (activePluginId === pluginId) {
if (activePluginCategories.length <= 1)
loadPluginCategories(pluginId);
performSearch(); performSearch();
return;
} }
if (searchQuery)
performSearch();
} }
} }
@@ -60,6 +65,12 @@ Item {
running: false running: false
} }
Process {
id: copyProcess
running: false
onExited: pasteTimer.start()
}
Timer { Timer {
id: pasteTimer id: pasteTimer
interval: 200 interval: 200
@@ -78,12 +89,12 @@ Item {
const pluginId = selectedItem.pluginId; const pluginId = selectedItem.pluginId;
if (!pluginId) if (!pluginId)
return; return;
const pasteText = AppSearchService.getPluginPasteText(pluginId, selectedItem.data); const pasteArgs = AppSearchService.getPluginPasteArgs(pluginId, selectedItem.data);
if (!pasteText) if (!pasteArgs)
return; return;
Quickshell.execDetached(["dms", "cl", "copy", pasteText]); copyProcess.command = pasteArgs;
copyProcess.running = true;
itemExecuted(); itemExecuted();
pasteTimer.start();
} }
readonly property var sectionDefinitions: [ readonly property var sectionDefinitions: [
@@ -133,6 +144,8 @@ Item {
property string pluginFilter: "" property string pluginFilter: ""
property string activePluginName: "" property string activePluginName: ""
property var activePluginCategories: []
property string activePluginCategory: ""
function getSectionViewMode(sectionId) { function getSectionViewMode(sectionId) {
if (sectionId === "browse_plugins") if (sectionId === "browse_plugins")
@@ -307,10 +320,46 @@ Item {
isSearching = false; isSearching = false;
activePluginId = ""; activePluginId = "";
activePluginName = ""; activePluginName = "";
activePluginCategories = [];
activePluginCategory = "";
pluginFilter = ""; pluginFilter = "";
collapsedSections = {}; collapsedSections = {};
} }
function loadPluginCategories(pluginId) {
if (!pluginId) {
if (activePluginCategories.length > 0) {
activePluginCategories = [];
activePluginCategory = "";
}
return;
}
const categories = AppSearchService.getPluginLauncherCategories(pluginId);
if (categories.length === activePluginCategories.length) {
let same = true;
for (let i = 0; i < categories.length; i++) {
if (categories[i].id !== activePluginCategories[i]?.id) {
same = false;
break;
}
}
if (same)
return;
}
activePluginCategories = categories;
activePluginCategory = "";
AppSearchService.setPluginLauncherCategory(pluginId, "");
}
function setActivePluginCategory(categoryId) {
if (activePluginCategory === categoryId)
return;
activePluginCategory = categoryId;
AppSearchService.setPluginLauncherCategory(activePluginId, categoryId);
performSearch();
}
function clearPluginFilter() { function clearPluginFilter() {
if (pluginFilter) { if (pluginFilter) {
pluginFilter = ""; pluginFilter = "";
@@ -342,6 +391,8 @@ Item {
if (cachedSections && !searchQuery && searchMode === "all" && !pluginFilter) { if (cachedSections && !searchQuery && searchMode === "all" && !pluginFilter) {
activePluginId = ""; activePluginId = "";
activePluginName = ""; activePluginName = "";
activePluginCategories = [];
activePluginCategory = "";
clearActivePluginViewPreference(); clearActivePluginViewPreference();
sections = cachedSections.map(function (s) { sections = cachedSections.map(function (s) {
var copy = Object.assign({}, s, { var copy = Object.assign({}, s, {
@@ -363,10 +414,14 @@ Item {
var triggerMatch = detectTrigger(searchQuery); var triggerMatch = detectTrigger(searchQuery);
if (triggerMatch.pluginId) { if (triggerMatch.pluginId) {
var pluginChanged = activePluginId !== triggerMatch.pluginId;
activePluginId = triggerMatch.pluginId; activePluginId = triggerMatch.pluginId;
activePluginName = getPluginName(triggerMatch.pluginId, triggerMatch.isBuiltIn); activePluginName = getPluginName(triggerMatch.pluginId, triggerMatch.isBuiltIn);
applyActivePluginViewPreference(triggerMatch.pluginId, triggerMatch.isBuiltIn); applyActivePluginViewPreference(triggerMatch.pluginId, triggerMatch.isBuiltIn);
if (pluginChanged && !triggerMatch.isBuiltIn)
loadPluginCategories(triggerMatch.pluginId);
var pluginItems = getPluginItems(triggerMatch.pluginId, triggerMatch.query); var pluginItems = getPluginItems(triggerMatch.pluginId, triggerMatch.query);
allItems = allItems.concat(pluginItems); allItems = allItems.concat(pluginItems);
@@ -401,6 +456,8 @@ Item {
activePluginId = ""; activePluginId = "";
activePluginName = ""; activePluginName = "";
activePluginCategories = [];
activePluginCategory = "";
clearActivePluginViewPreference(); clearActivePluginViewPreference();
if (searchMode === "files") { if (searchMode === "files") {

View File

@@ -36,7 +36,7 @@ Rectangle {
readonly property int computedIconSize: Math.min(48, Math.max(32, width * 0.45)) readonly property int computedIconSize: Math.min(48, Math.max(32, width * 0.45))
radius: Theme.cornerRadius radius: Theme.cornerRadius
color: isSelected ? Theme.primaryPressed : isHovered ? Theme.primaryPressed : "transparent" color: isSelected ? Theme.primaryPressed : isHovered ? Theme.primaryHoverLight : "transparent"
Column { Column {
anchors.centerIn: parent anchors.centerIn: parent

View File

@@ -271,21 +271,32 @@ FocusScope {
anchors.fill: parent anchors.fill: parent
visible: !editMode visible: !editMode
Rectangle { Item {
id: footerBar id: footerBar
anchors.left: parent.left anchors.left: parent.left
anchors.right: parent.right anchors.right: parent.right
anchors.bottom: parent.bottom anchors.bottom: parent.bottom
anchors.leftMargin: root.parentModal?.borderWidth ?? 1
anchors.rightMargin: root.parentModal?.borderWidth ?? 1
anchors.bottomMargin: root.parentModal?.borderWidth ?? 1
readonly property bool showFooter: SettingsData.dankLauncherV2Size !== "micro" && SettingsData.dankLauncherV2ShowFooter readonly property bool showFooter: SettingsData.dankLauncherV2Size !== "micro" && SettingsData.dankLauncherV2ShowFooter
height: showFooter ? 32 : 0 height: showFooter ? 36 : 0
visible: showFooter visible: showFooter
color: Theme.surfaceContainerHigh clip: true
radius: Theme.cornerRadius
Rectangle {
anchors.fill: parent
anchors.topMargin: -Theme.cornerRadius
color: Theme.surfaceContainerHigh
radius: Theme.cornerRadius
}
Row { Row {
id: modeButtonsRow id: modeButtonsRow
x: I18n.isRtl ? parent.width - width - Theme.spacingS : Theme.spacingS anchors.left: parent.left
y: (parent.height - height) / 2 anchors.leftMargin: Theme.spacingXS
anchors.verticalCenter: parent.verticalCenter
layoutDirection: I18n.isRtl ? Qt.RightToLeft : Qt.LeftToRight
spacing: 2 spacing: 2
Repeater { Repeater {
@@ -316,28 +327,25 @@ FocusScope {
required property var modelData required property var modelData
required property int index required property int index
width: modeButtonMetrics.width + 14 + Theme.spacingXS + Theme.spacingM * 2 + Theme.spacingS width: buttonContent.width + Theme.spacingM * 2
height: footerBar.height - 4 height: 28
radius: Theme.cornerRadius - 2 radius: Theme.cornerRadius
color: controller.searchMode === modelData.id || modeArea.containsMouse ? Theme.primaryContainer : "transparent" color: controller.searchMode === modelData.id || modeArea.containsMouse ? Theme.primaryContainer : "transparent"
TextMetrics {
id: modeButtonMetrics
font.pixelSize: Theme.fontSizeSmall
text: modelData.label
}
Row { Row {
id: buttonContent
anchors.centerIn: parent anchors.centerIn: parent
spacing: Theme.spacingXS spacing: Theme.spacingXS
DankIcon { DankIcon {
anchors.verticalCenter: parent.verticalCenter
name: modelData.icon name: modelData.icon
size: 14 size: 14
color: controller.searchMode === modelData.id ? Theme.primary : Theme.surfaceVariantText color: controller.searchMode === modelData.id ? Theme.primary : Theme.surfaceVariantText
} }
StyledText { StyledText {
anchors.verticalCenter: parent.verticalCenter
text: modelData.label text: modelData.label
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
color: controller.searchMode === modelData.id ? Theme.primary : Theme.surfaceText color: controller.searchMode === modelData.id ? Theme.primary : Theme.surfaceText
@@ -357,23 +365,28 @@ FocusScope {
Row { Row {
id: hintsRow id: hintsRow
x: I18n.isRtl ? Theme.spacingS : parent.width - width - Theme.spacingS anchors.right: parent.right
y: (parent.height - height) / 2 anchors.rightMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
layoutDirection: I18n.isRtl ? Qt.RightToLeft : Qt.LeftToRight
spacing: Theme.spacingM spacing: Theme.spacingM
StyledText { StyledText {
anchors.verticalCenter: parent.verticalCenter
text: "↑↓ " + I18n.tr("nav") text: "↑↓ " + I18n.tr("nav")
font.pixelSize: Theme.fontSizeSmall - 1 font.pixelSize: Theme.fontSizeSmall - 1
color: Theme.surfaceVariantText color: Theme.surfaceVariantText
} }
StyledText { StyledText {
anchors.verticalCenter: parent.verticalCenter
text: "↵ " + I18n.tr("open") text: "↵ " + I18n.tr("open")
font.pixelSize: Theme.fontSizeSmall - 1 font.pixelSize: Theme.fontSizeSmall - 1
color: Theme.surfaceVariantText color: Theme.surfaceVariantText
} }
StyledText { StyledText {
anchors.verticalCenter: parent.verticalCenter
text: "Tab " + I18n.tr("actions") text: "Tab " + I18n.tr("actions")
font.pixelSize: Theme.fontSizeSmall - 1 font.pixelSize: Theme.fontSizeSmall - 1
color: Theme.surfaceVariantText color: Theme.surfaceVariantText
@@ -390,7 +403,6 @@ FocusScope {
anchors.leftMargin: Theme.spacingM anchors.leftMargin: Theme.spacingM
anchors.rightMargin: Theme.spacingM anchors.rightMargin: Theme.spacingM
anchors.topMargin: Theme.spacingM anchors.topMargin: Theme.spacingM
anchors.bottomMargin: Theme.spacingXS
spacing: Theme.spacingXS spacing: Theme.spacingXS
clip: false clip: false
@@ -483,9 +495,64 @@ FocusScope {
} }
} }
Row {
id: categoryRow
width: parent.width
height: controller.activePluginCategories.length > 0 ? 36 : 0
visible: controller.activePluginCategories.length > 0
spacing: Theme.spacingS
clip: true
Behavior on height {
NumberAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
DankDropdown {
id: categoryDropdown
width: Math.min(200, parent.width)
compactMode: true
dropdownWidth: 200
popupWidth: 240
maxPopupHeight: 300
enableFuzzySearch: controller.activePluginCategories.length > 8
currentValue: {
const cats = controller.activePluginCategories;
const current = controller.activePluginCategory;
if (!current)
return cats.length > 0 ? cats[0].name : "";
for (let i = 0; i < cats.length; i++) {
if (cats[i].id === current)
return cats[i].name;
}
return cats.length > 0 ? cats[0].name : "";
}
options: {
const cats = controller.activePluginCategories;
const names = [];
for (let i = 0; i < cats.length; i++)
names.push(cats[i].name);
return names;
}
onValueChanged: value => {
const cats = controller.activePluginCategories;
for (let i = 0; i < cats.length; i++) {
if (cats[i].name === value) {
controller.setActivePluginCategory(cats[i].id);
return;
}
}
}
}
}
Item { Item {
width: parent.width width: parent.width
height: parent.height - searchField.height - actionPanel.height - Theme.spacingXS * 2 height: parent.height - searchField.height - categoryRow.height - actionPanel.height - Theme.spacingXS * (categoryRow.visible ? 3 : 2)
opacity: root.parentModal?.isClosing ? 0 : 1 opacity: root.parentModal?.isClosing ? 0 : 1
ResultsList { ResultsList {

View File

@@ -64,11 +64,18 @@ Popup {
return actions; return actions;
} }
function executePluginAction(actionFunc) { function executePluginAction(actionOrObj) {
if (typeof actionFunc === "function") { var actionFunc = typeof actionOrObj === "function" ? actionOrObj : actionOrObj?.action;
var closeLauncher = typeof actionOrObj === "object" && actionOrObj?.closeLauncher;
if (typeof actionFunc === "function")
actionFunc(); actionFunc();
if (closeLauncher) {
controller?.itemExecuted();
} else {
controller?.performSearch();
} }
controller?.performSearch();
hide(); hide();
} }
@@ -83,7 +90,7 @@ Popup {
type: "item", type: "item",
icon: act.icon || "play_arrow", icon: act.icon || "play_arrow",
text: act.text || act.name || "", text: act.text || act.name || "",
pluginAction: act.action pluginAction: act
}); });
} }
return items; return items;

View File

@@ -35,7 +35,7 @@ Rectangle {
width: parent?.width ?? 200 width: parent?.width ?? 200
height: 52 height: 52
color: isSelected ? Theme.primaryPressed : isHovered ? Theme.primaryPressed : "transparent" color: isSelected ? Theme.primaryPressed : isHovered ? Theme.primaryHoverLight : "transparent"
radius: Theme.cornerRadius radius: Theme.cornerRadius
MouseArea { MouseArea {

View File

@@ -159,6 +159,15 @@ Item {
contentHeight: sectionsColumn.height contentHeight: sectionsColumn.height
clip: true clip: true
Component.onCompleted: {
verticalScrollBar.targetFlickable = mainFlickable;
verticalScrollBar.parent = root;
verticalScrollBar.z = 102;
verticalScrollBar.anchors.right = root.right;
verticalScrollBar.anchors.top = root.top;
verticalScrollBar.anchors.bottom = root.bottom;
}
Column { Column {
id: sectionsColumn id: sectionsColumn
width: parent.width width: parent.width

View File

@@ -19,7 +19,7 @@ Rectangle {
width: parent?.width ?? 200 width: parent?.width ?? 200
height: 32 height: 32
color: isSticky ? "transparent" : (hoverArea.containsMouse ? Theme.surfaceHover : "transparent") color: isSticky ? "transparent" : (hoverArea.containsMouse ? Theme.surfaceHover : "transparent")
radius: Theme.cornerRadius / 2 radius: Theme.cornerRadius
MouseArea { MouseArea {
id: hoverArea id: hoverArea

View File

@@ -441,5 +441,22 @@ FocusScope {
Qt.callLater(() => item.forceActiveFocus()); Qt.callLater(() => item.forceActiveFocus());
} }
} }
Loader {
id: windowRulesLoader
anchors.fill: parent
active: root.currentIndex === 28
visible: active
focus: active
sourceComponent: WindowRulesTab {
parentModal: root.parentModal
}
onActiveChanged: {
if (active && item)
Qt.callLater(() => item.forceActiveFocus());
}
}
} }
} }

View File

@@ -343,7 +343,7 @@ FloatingWindow {
id: sidebar id: sidebar
anchors.left: parent.left anchors.left: parent.left
width: settingsModal.isCompactMode ? parent.width : 270 width: settingsModal.isCompactMode ? parent.width : sidebar.implicitWidth
visible: settingsModal.isCompactMode ? settingsModal.menuVisible : true visible: settingsModal.isCompactMode ? settingsModal.menuVisible : true
parentModal: settingsModal parentModal: settingsModal
currentIndex: settingsModal.currentTabIndex currentIndex: settingsModal.currentTabIndex

View File

@@ -252,6 +252,13 @@ Rectangle {
"icon": "content_paste", "icon": "content_paste",
"tabIndex": 23, "tabIndex": 23,
"clipboardOnly": true "clipboardOnly": true
},
{
"id": "window_rules",
"text": I18n.tr("Window Rules"),
"icon": "select_window",
"tabIndex": 28,
"niriOnly": true
} }
] ]
}, },
@@ -304,6 +311,8 @@ Rectangle {
return false; return false;
if (item.hyprlandNiriOnly && !CompositorService.isNiri && !CompositorService.isHyprland) if (item.hyprlandNiriOnly && !CompositorService.isNiri && !CompositorService.isHyprland)
return false; return false;
if (item.niriOnly && !CompositorService.isNiri)
return false;
if (item.clipboardOnly && (!DMSService.isConnected || DMSService.apiVersion < 23)) if (item.clipboardOnly && (!DMSService.isConnected || DMSService.apiVersion < 23))
return false; return false;
return true; return true;
@@ -483,11 +492,52 @@ Rectangle {
return -1; return -1;
} }
width: 270 property real __maxTextWidth: Math.max(__m1.advanceWidth, __m2.advanceWidth, __m3.advanceWidth, __m4.advanceWidth, __m5.advanceWidth, __m6.advanceWidth)
property real __calculatedWidth: Math.max(270, __maxTextWidth + Theme.iconSize * 2 + Theme.spacingM * 4 + Theme.spacingS * 2)
implicitWidth: __calculatedWidth
width: __calculatedWidth
height: parent.height height: parent.height
color: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency) color: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency)
radius: Theme.cornerRadius radius: Theme.cornerRadius
StyledTextMetrics {
id: __m1
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
text: I18n.tr("Workspaces & Widgets")
}
StyledTextMetrics {
id: __m2
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
text: I18n.tr("Typography & Motion")
}
StyledTextMetrics {
id: __m3
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
text: I18n.tr("Keyboard Shortcuts")
}
StyledTextMetrics {
id: __m4
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
text: I18n.tr("Power & Security")
}
StyledTextMetrics {
id: __m5
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
text: I18n.tr("Dock & Launcher")
}
StyledTextMetrics {
id: __m6
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
text: I18n.tr("Personalization")
}
function selectSearchResult(result) { function selectSearchResult(result) {
if (!result) if (!result)
return; return;
@@ -750,7 +800,7 @@ Rectangle {
Rectangle { Rectangle {
id: categoryRow id: categoryRow
width: parent.width width: parent.width
height: 40 height: Math.max(Theme.iconSize, Theme.fontSizeMedium) + Theme.spacingS * 2
radius: Theme.cornerRadius radius: Theme.cornerRadius
visible: categoryDelegate.modelData.separator !== true visible: categoryDelegate.modelData.separator !== true
@@ -769,10 +819,9 @@ Rectangle {
} }
Row { Row {
id: categoryRowContent
anchors.left: parent.left anchors.left: parent.left
anchors.leftMargin: Theme.spacingM anchors.leftMargin: Theme.spacingM
anchors.right: parent.right
anchors.rightMargin: Theme.spacingM
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingM spacing: Theme.spacingM
@@ -789,19 +838,18 @@ Rectangle {
font.weight: (categoryRow.isActive || root.isChildActive(categoryDelegate.modelData)) ? Font.Medium : Font.Normal font.weight: (categoryRow.isActive || root.isChildActive(categoryDelegate.modelData)) ? Font.Medium : Font.Normal
color: categoryRow.isActive ? Theme.primaryText : Theme.surfaceText color: categoryRow.isActive ? Theme.primaryText : Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
width: parent.width - Theme.iconSize - Theme.spacingM - (categoryDelegate.modelData.children ? expandIcon.width + Theme.spacingS : 0)
elide: Text.ElideRight
horizontalAlignment: Text.AlignLeft
} }
}
DankIcon { DankIcon {
id: expandIcon id: expandIcon
name: root.isCategoryExpanded(categoryDelegate.modelData.id) ? "expand_less" : "expand_more" name: root.isCategoryExpanded(categoryDelegate.modelData.id) ? "expand_less" : "expand_more"
size: Theme.iconSize - 4 size: Theme.iconSize - 4
color: Theme.surfaceVariantText color: Theme.surfaceVariantText
anchors.verticalCenter: parent.verticalCenter anchors.right: parent.right
visible: categoryDelegate.modelData.children !== undefined && categoryDelegate.modelData.children.length > 0 anchors.rightMargin: Theme.spacingM
} anchors.verticalCenter: parent.verticalCenter
visible: categoryDelegate.modelData.children !== undefined && categoryDelegate.modelData.children.length > 0
} }
MouseArea { MouseArea {
@@ -847,7 +895,7 @@ Rectangle {
readonly property bool isHighlighted: root.keyboardHighlightIndex === modelData.tabIndex readonly property bool isHighlighted: root.keyboardHighlightIndex === modelData.tabIndex
width: childrenColumn.width width: childrenColumn.width
height: 36 height: Math.max(Theme.iconSize - 4, Theme.fontSizeSmall + 1) + Theme.spacingS * 2
radius: Theme.cornerRadius radius: Theme.cornerRadius
visible: root.isItemVisible(modelData) visible: root.isItemVisible(modelData)
color: { color: {
@@ -861,6 +909,7 @@ Rectangle {
} }
Row { Row {
id: childRowContent
anchors.left: parent.left anchors.left: parent.left
anchors.leftMargin: Theme.spacingL + Theme.spacingM anchors.leftMargin: Theme.spacingL + Theme.spacingM
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter

File diff suppressed because it is too large Load Diff

View File

@@ -22,9 +22,9 @@ Item {
case "analog": case "analog":
return 200; return 200;
case "stacked": case "stacked":
return 160; return 100;
default: default:
return 280; return 160;
} }
} }
property real defaultHeight: { property real defaultHeight: {
@@ -32,9 +32,9 @@ Item {
case "analog": case "analog":
return 200; return 200;
case "stacked": case "stacked":
return 220;
default:
return 160; return 160;
default:
return 70;
} }
} }
property real minWidth: { property real minWidth: {
@@ -42,9 +42,9 @@ Item {
case "analog": case "analog":
return 120; return 120;
case "stacked": case "stacked":
return 100; return 70;
default: default:
return 140; return 100;
} }
} }
property real minHeight: { property real minHeight: {
@@ -52,9 +52,9 @@ Item {
case "analog": case "analog":
return 120; return 120;
case "stacked": case "stacked":
return 140;
default:
return 100; return 100;
default:
return 45;
} }
} }
@@ -84,7 +84,8 @@ Item {
readonly property color backgroundColor: Theme.withAlpha(Theme.surface, root.transparency) readonly property color backgroundColor: Theme.withAlpha(Theme.surface, root.transparency)
readonly property bool showAnalogSeconds: isInstance ? (cfg.showAnalogSeconds ?? true) : SettingsData.desktopClockShowAnalogSeconds readonly property bool showAnalogSeconds: isInstance ? (cfg.showAnalogSeconds ?? true) : SettingsData.desktopClockShowAnalogSeconds
readonly property bool needsSeconds: clockStyle === "analog" ? showAnalogSeconds : SettingsData.showSeconds readonly property bool showDigitalSeconds: isInstance ? (cfg.showDigitalSeconds ?? false) : false
readonly property bool needsSeconds: clockStyle === "analog" ? showAnalogSeconds : showDigitalSeconds
SystemClock { SystemClock {
id: systemClock id: systemClock
@@ -298,12 +299,25 @@ Item {
Item { Item {
id: digitalRoot id: digitalRoot
property real baseSize: Math.max(28, height * 0.38) property bool hasDate: root.showDate
property real smallSize: Math.max(12, baseSize * 0.32) property bool hasAmPm: !SettingsData.use24HourClock
property real verticalScale: hasDate && hasAmPm ? 0.55 : (hasDate || hasAmPm ? 0.65 : 0.8)
property real baseSize: Math.min(height * verticalScale, width * 0.22)
property real digitWidth: baseSize * 0.62
property real smallSize: baseSize * 0.35
property string hoursStr: {
const hours = SettingsData.use24HourClock ? systemClock.date?.getHours() ?? 0 : ((systemClock.date?.getHours() ?? 0) % 12 || 12);
if (SettingsData.use24HourClock || SettingsData.padHours12Hour)
return String(hours).padStart(2, '0');
return String(hours);
}
property string minutesStr: String(systemClock.date?.getMinutes() ?? 0).padStart(2, '0')
property string secondsStr: String(systemClock.date?.getSeconds() ?? 0).padStart(2, '0')
Column { Column {
anchors.centerIn: parent anchors.centerIn: parent
spacing: 4 spacing: 0
StyledText { StyledText {
visible: root.showDate visible: root.showDate
@@ -314,51 +328,86 @@ Item {
return systemClock.date?.toLocaleDateString(Qt.locale(), "ddd, MMM d") ?? ""; return systemClock.date?.toLocaleDateString(Qt.locale(), "ddd, MMM d") ?? "";
} }
font.pixelSize: digitalRoot.smallSize font.pixelSize: digitalRoot.smallSize
color: root.accentColor color: Theme.withAlpha(root.accentColor, 0.7)
}
StyledText {
anchors.horizontalCenter: parent.horizontalCenter
text: {
const hours = SettingsData.use24HourClock ? systemClock.date?.getHours() ?? 0 : ((systemClock.date?.getHours() ?? 0) % 12 || 12);
const minutes = String(systemClock.date?.getMinutes() ?? 0).padStart(2, '0');
return hours + ":" + minutes;
}
font.pixelSize: digitalRoot.baseSize
font.weight: Font.Normal
color: root.accentColor
} }
Row { Row {
visible: !SettingsData.use24HourClock || SettingsData.showSeconds
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter
spacing: Theme.spacingS spacing: 0
Row {
visible: SettingsData.showSeconds
spacing: Theme.spacingXS
DankIcon {
name: "timer"
size: Math.max(10, digitalRoot.baseSize * 0.25)
color: root.subtleTextColor
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: String(systemClock.date?.getSeconds() ?? 0).padStart(2, '0')
font.pixelSize: digitalRoot.smallSize
color: root.subtleTextColor
}
}
StyledText { StyledText {
visible: !SettingsData.use24HourClock visible: digitalRoot.hoursStr.length > 1
text: (systemClock.date?.getHours() ?? 0) >= 12 ? "PM" : "AM" width: digitalRoot.digitWidth
font.pixelSize: digitalRoot.smallSize text: digitalRoot.hoursStr.charAt(0)
font.pixelSize: digitalRoot.baseSize
font.weight: Font.Medium
color: root.accentColor
horizontalAlignment: Text.AlignHCenter
}
StyledText {
width: digitalRoot.digitWidth
text: digitalRoot.hoursStr.length > 1 ? digitalRoot.hoursStr.charAt(1) : digitalRoot.hoursStr.charAt(0)
font.pixelSize: digitalRoot.baseSize
font.weight: Font.Medium
color: root.accentColor
horizontalAlignment: Text.AlignHCenter
}
StyledText {
text: ":"
font.pixelSize: digitalRoot.baseSize
font.weight: Font.Medium font.weight: Font.Medium
color: root.accentColor color: root.accentColor
} }
StyledText {
width: digitalRoot.digitWidth
text: digitalRoot.minutesStr.charAt(0)
font.pixelSize: digitalRoot.baseSize
font.weight: Font.Medium
color: root.accentColor
horizontalAlignment: Text.AlignHCenter
}
StyledText {
width: digitalRoot.digitWidth
text: digitalRoot.minutesStr.charAt(1)
font.pixelSize: digitalRoot.baseSize
font.weight: Font.Medium
color: root.accentColor
horizontalAlignment: Text.AlignHCenter
}
StyledText {
visible: root.showDigitalSeconds
text: ":"
font.pixelSize: digitalRoot.baseSize
font.weight: Font.Medium
color: Theme.withAlpha(root.accentColor, 0.7)
}
StyledText {
visible: root.showDigitalSeconds
width: digitalRoot.digitWidth
text: digitalRoot.secondsStr.charAt(0)
font.pixelSize: digitalRoot.baseSize
font.weight: Font.Medium
color: Theme.withAlpha(root.accentColor, 0.7)
horizontalAlignment: Text.AlignHCenter
}
StyledText {
visible: root.showDigitalSeconds
width: digitalRoot.digitWidth
text: digitalRoot.secondsStr.charAt(1)
font.pixelSize: digitalRoot.baseSize
font.weight: Font.Medium
color: Theme.withAlpha(root.accentColor, 0.7)
horizontalAlignment: Text.AlignHCenter
}
}
StyledText {
visible: !SettingsData.use24HourClock
anchors.horizontalCenter: parent.horizontalCenter
text: (systemClock.date?.getHours() ?? 0) >= 12 ? "PM" : "AM"
font.pixelSize: digitalRoot.smallSize
font.weight: Font.Medium
color: Theme.withAlpha(root.accentColor, 0.7)
} }
} }
} }
@@ -370,78 +419,127 @@ Item {
Item { Item {
id: stackedRoot id: stackedRoot
property real baseSize: Math.max(32, height * 0.32) property bool hasSeconds: root.showDigitalSeconds
property real smallSize: Math.max(12, baseSize * 0.28) property bool hasDate: root.showDate
property bool hasAmPm: !SettingsData.use24HourClock
property real extraContent: (hasSeconds ? 0.12 : 0) + (hasDate ? 0.08 : 0) + (hasAmPm ? 0.08 : 0)
property real baseSize: height * (0.42 - extraContent * 0.5)
property real digitWidth: baseSize * 0.58
property real smallSize: baseSize * 0.5
property real rowSpacing: -baseSize * 0.17
Column { Column {
anchors.centerIn: parent anchors.centerIn: parent
spacing: -baseSize * 0.1 spacing: 0
StyledText { Column {
visible: root.showDate spacing: stackedRoot.rowSpacing
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter
bottomPadding: Theme.spacingS
text: {
if (SettingsData.clockDateFormat && SettingsData.clockDateFormat.length > 0)
return systemClock.date?.toLocaleDateString(Qt.locale(), SettingsData.clockDateFormat) ?? "";
return systemClock.date?.toLocaleDateString(Qt.locale(), "ddd, MMM d") ?? "";
}
font.pixelSize: stackedRoot.smallSize
color: root.accentColor
}
StyledText {
anchors.horizontalCenter: parent.horizontalCenter
text: {
const hours = SettingsData.use24HourClock ? systemClock.date?.getHours() ?? 0 : ((systemClock.date?.getHours() ?? 0) % 12 || 12);
return String(hours).padStart(2, '0');
}
font.pixelSize: stackedRoot.baseSize
font.weight: Font.Normal
color: root.accentColor
lineHeight: 0.85
}
StyledText {
anchors.horizontalCenter: parent.horizontalCenter
text: String(systemClock.date?.getMinutes() ?? 0).padStart(2, '0')
font.pixelSize: stackedRoot.baseSize
font.weight: Font.Normal
color: root.accentColor
lineHeight: 0.85
}
Row {
visible: SettingsData.showSeconds || !SettingsData.use24HourClock
anchors.horizontalCenter: parent.horizontalCenter
topPadding: Theme.spacingXS
spacing: Theme.spacingS
Row { Row {
visible: SettingsData.showSeconds spacing: 0
spacing: Theme.spacingXS anchors.horizontalCenter: parent.horizontalCenter
DankIcon { StyledText {
name: "timer" text: {
size: Math.max(10, stackedRoot.baseSize * 0.28) if (SettingsData.use24HourClock)
color: root.subtleTextColor return String(systemClock.date?.getHours() ?? 0).padStart(2, '0').charAt(0);
anchors.verticalCenter: parent.verticalCenter const hours = systemClock.date?.getHours() ?? 0;
const display = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours;
return String(display).padStart(2, '0').charAt(0);
}
font.pixelSize: stackedRoot.baseSize
font.weight: Font.Medium
color: root.accentColor
width: stackedRoot.digitWidth
horizontalAlignment: Text.AlignHCenter
} }
StyledText { StyledText {
text: String(systemClock.date?.getSeconds() ?? 0).padStart(2, '0') text: {
font.pixelSize: stackedRoot.smallSize if (SettingsData.use24HourClock)
color: root.subtleTextColor return String(systemClock.date?.getHours() ?? 0).padStart(2, '0').charAt(1);
const hours = systemClock.date?.getHours() ?? 0;
const display = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours;
return String(display).padStart(2, '0').charAt(1);
}
font.pixelSize: stackedRoot.baseSize
font.weight: Font.Medium
color: root.accentColor
width: stackedRoot.digitWidth
horizontalAlignment: Text.AlignHCenter
} }
} }
Row {
spacing: 0
anchors.horizontalCenter: parent.horizontalCenter
StyledText {
text: String(systemClock.date?.getMinutes() ?? 0).padStart(2, '0').charAt(0)
font.pixelSize: stackedRoot.baseSize
font.weight: Font.Medium
color: root.accentColor
width: stackedRoot.digitWidth
horizontalAlignment: Text.AlignHCenter
}
StyledText {
text: String(systemClock.date?.getMinutes() ?? 0).padStart(2, '0').charAt(1)
font.pixelSize: stackedRoot.baseSize
font.weight: Font.Medium
color: root.accentColor
width: stackedRoot.digitWidth
horizontalAlignment: Text.AlignHCenter
}
}
}
Row {
visible: stackedRoot.hasSeconds
spacing: 0
anchors.horizontalCenter: parent.horizontalCenter
StyledText { StyledText {
visible: !SettingsData.use24HourClock text: String(systemClock.date?.getSeconds() ?? 0).padStart(2, '0').charAt(0)
text: (systemClock.date?.getHours() ?? 0) >= 12 ? "PM" : "AM"
font.pixelSize: stackedRoot.smallSize font.pixelSize: stackedRoot.smallSize
font.weight: Font.Medium font.weight: Font.Medium
color: root.accentColor color: Theme.withAlpha(root.accentColor, 0.7)
width: stackedRoot.smallSize * 0.58
horizontalAlignment: Text.AlignHCenter
} }
StyledText {
text: String(systemClock.date?.getSeconds() ?? 0).padStart(2, '0').charAt(1)
font.pixelSize: stackedRoot.smallSize
font.weight: Font.Medium
color: Theme.withAlpha(root.accentColor, 0.7)
width: stackedRoot.smallSize * 0.58
horizontalAlignment: Text.AlignHCenter
}
}
Item {
width: 1
height: stackedRoot.baseSize * 0.1
visible: stackedRoot.hasDate
}
StyledText {
visible: stackedRoot.hasDate
anchors.horizontalCenter: parent.horizontalCenter
text: systemClock.date?.toLocaleDateString(Qt.locale(), "MMM dd") ?? ""
font.pixelSize: stackedRoot.smallSize * 0.7
color: Theme.withAlpha(root.accentColor, 0.7)
}
StyledText {
visible: stackedRoot.hasAmPm
anchors.horizontalCenter: parent.horizontalCenter
text: (systemClock.date?.getHours() ?? 0) >= 12 ? "PM" : "AM"
font.pixelSize: stackedRoot.smallSize * 0.7
font.weight: Font.Medium
color: Theme.withAlpha(root.accentColor, 0.7)
} }
} }
} }

View File

@@ -17,20 +17,19 @@ Rectangle {
property var widgetData: null property var widgetData: null
property bool editMode: false property bool editMode: false
signal clicked() signal clicked
width: parent ? parent.width : 200 width: parent ? parent.width : 200
height: 60 height: 60
radius: { radius: {
if (Theme.cornerRadius === 0) return 0 if (Theme.cornerRadius === 0)
return isActive ? Theme.cornerRadius : Theme.cornerRadius + 4 return 0;
return isActive ? Theme.cornerRadius : Theme.cornerRadius + 4;
} }
readonly property color _tileBgActive: Theme.primary readonly property color _tileBgActive: Theme.ccTileActiveBg
readonly property color _tileBgInactive: readonly property color _tileBgInactive: Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency)
Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency) readonly property color _tileRingActive: Theme.ccTileRing
readonly property color _tileRingActive:
Qt.rgba(Theme.primaryText.r, Theme.primaryText.g, Theme.primaryText.b, 0.22)
color: isActive ? _tileBgActive : _tileBgInactive color: isActive ? _tileBgActive : _tileBgInactive
border.color: isActive ? _tileRingActive : Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08) border.color: isActive ? _tileRingActive : Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08)
@@ -38,8 +37,8 @@ Rectangle {
opacity: enabled ? 1.0 : 0.6 opacity: enabled ? 1.0 : 0.6
function hoverTint(base) { function hoverTint(base) {
const factor = 1.2 const factor = 1.2;
return Theme.isLightMode ? Qt.darker(base, factor) : Qt.lighter(base, factor) return Theme.isLightMode ? Qt.darker(base, factor) : Qt.lighter(base, factor);
} }
Rectangle { Rectangle {
@@ -49,7 +48,9 @@ Rectangle {
opacity: mouseArea.containsMouse ? 0.08 : 0.0 opacity: mouseArea.containsMouse ? 0.08 : 0.0
Behavior on opacity { Behavior on opacity {
NumberAnimation { duration: Theme.shortDuration } NumberAnimation {
duration: Theme.shortDuration
}
} }
} }
@@ -62,7 +63,7 @@ Rectangle {
DankIcon { DankIcon {
name: root.iconName name: root.iconName
size: Theme.iconSize size: Theme.iconSize
color: isActive ? Theme.primaryText : Theme.primary color: isActive ? Theme.ccTileActiveText : Theme.ccTileInactiveIcon
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
} }
@@ -80,7 +81,7 @@ Rectangle {
width: parent.width width: parent.width
text: root.text text: root.text
style: Typography.Style.Body style: Typography.Style.Body
color: isActive ? Theme.primaryText : Theme.surfaceText color: isActive ? Theme.ccTileActiveText : Theme.surfaceText
elide: Text.ElideRight elide: Text.ElideRight
wrapMode: Text.NoWrap wrapMode: Text.NoWrap
horizontalAlignment: Text.AlignLeft horizontalAlignment: Text.AlignLeft
@@ -90,7 +91,7 @@ Rectangle {
width: parent.width width: parent.width
text: root.secondaryText text: root.secondaryText
style: Typography.Style.Caption style: Typography.Style.Caption
color: isActive ? Theme.primaryText : Theme.surfaceVariantText color: isActive ? Theme.ccTileActiveText : Theme.surfaceVariantText
visible: text.length > 0 visible: text.length > 0
elide: Text.ElideRight elide: Text.ElideRight
wrapMode: Text.NoWrap wrapMode: Text.NoWrap

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