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

Compare commits

...

67 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
bbedward
03cfa55e0b ipc: ass toast IPCs
fixes #964
2026-01-24 12:53:51 -05:00
bbedward
a887e60f40 keybinds: fix MangoWC config traversal in provider
fixes #1464
2026-01-24 12:23:59 -05:00
bbedward
816819bf9f dankinstall: fix xero color typo 2026-01-23 23:10:24 -05:00
bbedward
78f3bb3812 dankinstall: support XeroLinux
fixes #1474
2026-01-23 22:39:14 -05:00
bbedward
01d7ed5dd8 launcher v2: ability to toggle visibility in modal 2026-01-23 22:17:35 -05:00
Lucas
50311db280 nix: add qt-imageformats to DMS qml dependencies (#1479)
* nix: add qt-imageformats to DMS qml dependencies

* nix: update flake.lock
2026-01-23 21:53:35 -05:00
bbedward
01b1a276c5 launcher v2: support ScreenCopy in tiles 2026-01-23 21:29:48 -05:00
IChengHo
6d4c31492c fix: pass query string to launcher v2 during IPC toggle (#1477)
Ensure toggleQuery forwards the query parameter to the launcher v2
2026-01-23 19:43:42 -05:00
Jon Rogers
f8c5f07e9f Fix: Add view mode persistence for xdg-open picker modals (#1465)
* fix: Add browserPickerViewMode persistence to settings spec

The BrowserPickerModal (used by xdg-open feature) was not persisting
view mode selection between sessions. While the modal had code to save
the view mode preference, the browserPickerViewMode property was not
registered in SettingsSpec.js, preventing it from being saved to disk.

Added browserPickerViewMode and browserUsageHistory to SettingsSpec.js
to ensure user's view preference (list/grid) is properly persisted.

Fixes view mode reverting to grid after restarting DMS/QuickShell.

* fix: Add view mode persistence for both browser and file pickers

Extended the fix to include both picker modals used by xdg-open:

BrowserPickerModal (URLs):
- Added browserPickerViewMode and browserUsageHistory to SettingsSpec.js
- Already had save logic in BrowserPickerModal.qml

AppPickerModal/filePickerModal (files):
- Added appPickerViewMode and filePickerUsageHistory to SettingsSpec.js
- Added appPickerViewMode and filePickerUsageHistory properties to SettingsData.qml
- Added viewMode binding and onViewModeChanged handler to filePickerModal

Both modals now properly persist user's view preference (list/grid) and
usage history between sessions.

Fixes view mode reverting to default grid after restarting DMS/QuickShell
for both 'dms open https://...' and 'dms open file.pdf' workflows.
2026-01-23 19:39:13 -05:00
Ethan Todd
11e23feb0e lockscreen/greetd: add 0 in front of single digit hours for 12 hour format. greetd: add option to hide profile image (#1247)
* greetd: add lockScreenShowProfileImage option

* lockscreen/greetd: for non 24 hour formats, add 0 in front of single digit hours to ensure that everything is always centered properly - previously, it would only appear centered if on a double digit hour. also add getEffectiveTimeFormat function to GreetdSettings.

* clock: made pad 12 hour formats optional

---------

Co-authored-by: bbedward <bbedward@gmail.com>
2026-01-23 14:47:59 -05:00
bbedward
b4ba2dac37 launcher v2: fix nvidia dgpu race condition 2026-01-23 14:15:46 -05:00
bbedward
d013c3b718 workspace: fix rename modal 2026-01-23 14:03:02 -05:00
Kamil Chmielewski
b3ea28c5c4 feat: add workspace rename dialog (#1429)
* feat: add workspace rename dialog

- Adds a modal dialog to rename the current workspace
- Supports both Niri (via IPC socket) and Hyprland (via hyprctl dispatch)
- Default keybinding: Ctrl+Shift+R to open the dialog
- Pre-fills with current workspace name
- Allows setting empty name to reset to default

* refactor: wrap WorkspaceRenameModal in LazyLoader

Reduces memory footprint when the modal is not in use.
2026-01-23 13:46:34 -05:00
bbedward
775b381987 lock: add disable media player option
fixes #1470
2026-01-23 13:34:25 -05:00
bbedward
3a41f2f1ed greeter+lock: remove random facts
fixes #1475
2026-01-23 13:25:42 -05:00
188 changed files with 21138 additions and 3560 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

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

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
@@ -91,6 +92,9 @@ bind = SUPER CTRL, up, movetoworkspace, e-1
bind = SUPER CTRL, U, movetoworkspace, e+1 bind = SUPER CTRL, U, movetoworkspace, e+1
bind = SUPER CTRL, I, movetoworkspace, e-1 bind = SUPER CTRL, I, movetoworkspace, e-1
# === Workspace Management ===
bind = CTRL SHIFT, R, exec, dms ipc call workspace-rename open
# === Move Workspaces === # === Move Workspaces ===
bind = SUPER SHIFT, Page_Down, movetoworkspace, e+1 bind = SUPER SHIFT, Page_Down, movetoworkspace, e+1
bind = SUPER SHIFT, Page_Up, movetoworkspace, e-1 bind = SUPER SHIFT, Page_Up, movetoworkspace, e-1

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; }
@@ -133,6 +134,11 @@ binds {
Mod+Ctrl+U { move-column-to-workspace-down; } Mod+Ctrl+U { move-column-to-workspace-down; }
Mod+Ctrl+I { move-column-to-workspace-up; } Mod+Ctrl+I { move-column-to-workspace-up; }
// === Workspace Management ===
Ctrl+Shift+R hotkey-overlay-title="Rename Workspace" {
spawn "dms" "ipc" "call" "workspace-rename" "open";
}
// === Move Workspaces === // === Move Workspaces ===
Mod+Shift+Page_Down { move-workspace-down; } Mod+Shift+Page_Down { move-workspace-down; }
Mod+Shift+Page_Up { move-workspace-up; } Mod+Shift+Page_Up { move-workspace-up; }

View File

@@ -41,6 +41,9 @@ func init() {
Register("artix", "#1793D1", FamilyArch, func(config DistroConfig, logChan chan<- string) Distribution { Register("artix", "#1793D1", FamilyArch, func(config DistroConfig, logChan chan<- string) Distribution {
return NewArchDistribution(config, logChan) return NewArchDistribution(config, logChan)
}) })
Register("XeroLinux", "#888fe2", FamilyArch, func(config DistroConfig, logChan chan<- string) Distribution {
return NewArchDistribution(config, logChan)
})
} }
type ArchDistribution struct { type ArchDistribution struct {

View File

@@ -534,7 +534,7 @@ func (b *BaseDistribution) WriteEnvironmentConfig(terminal deps.Terminal) error
} }
envDir := filepath.Join(homeDir, ".config", "environment.d") envDir := filepath.Join(homeDir, ".config", "environment.d")
if err := os.MkdirAll(envDir, 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

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

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

6
flake.lock generated
View File

@@ -2,11 +2,11 @@
"nodes": { "nodes": {
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1766651565, "lastModified": 1769018530,
"narHash": "sha256-QEhk0eXgyIqTpJ/ehZKg9IKS7EtlWxF3N7DXy42zPfU=", "narHash": "sha256-MJ27Cy2NtBEV5tsK+YraYr2g851f3Fl1LpNHDzDX15c=",
"owner": "nixos", "owner": "nixos",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "3e2499d5539c16d0d173ba53552a4ff8547f4539", "rev": "88d3861acdd3d2f0e361767018218e51810df8a1",
"type": "github" "type": "github"
}, },
"original": { "original": {

View File

@@ -47,6 +47,7 @@
kirigami.unwrapped kirigami.unwrapped
sonnet sonnet
qtmultimedia qtmultimedia
qtimageformats
]; ];
in in
{ {
@@ -78,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

@@ -1,53 +0,0 @@
pragma Singleton
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
Singleton {
id: root
readonly property var facts: [
"A photon takes 100,000 to 200,000 years bouncing through the Sun's dense core, then races to Earth in just 8 minutes 20 seconds.",
"A teaspoon of neutron star matter would weigh a billion metric tons here on Earth.",
"Right now, 100 trillion solar neutrinos are passing through your body every second.",
"The Sun converts 4 million metric tons of matter into pure energy every second—enough to power Earth for 500,000 years.",
"The universe still glows with leftover heat from the Big Bang—just 2.7 degrees above absolute zero.",
"There's a nebula out there that's actually colder than empty space itself.",
"We've detected black holes crashing together by measuring spacetime stretch by less than 1/10,000th the width of a proton.",
"Fast radio bursts can release more energy in 5 milliseconds than our Sun produces in 3 days.",
"Our galaxy might be crawling with billions of rogue planets drifting alone in the dark.",
"Distant galaxies can move away from us faster than light because space itself is stretching.",
"The edge of what we can see is 46.5 billion light-years away, even though the universe is only 13.8 billion years old.",
"The universe is mostly invisible: 5% regular matter, 27% dark matter, 68% dark energy.",
"A day on Venus lasts longer than its entire year around the Sun.",
"On Mercury, the time between sunrises is 176 Earth days long.",
"In about 4.5 billion years, our galaxy will smash into Andromeda.",
"Most of the gold in your jewelry was forged when neutron stars collided somewhere in space.",
"PSR J1748-2446ad, the fastest spinning star, rotates 716 times per second—its equator moves at 24% the speed of light.",
"Cosmic rays create particles that shouldn't make it to Earth's surface, but time dilation lets them sneak through.",
"Jupiter's magnetic field is so huge that if we could see it, it would look bigger than the Moon in our sky.",
"Interstellar space is so empty it's like a cube 32 kilometers wide containing just a single grain of sand.",
"Voyager 1 is 24 billion kilometers away but won't leave the Sun's gravitational influence for another 30,000 years.",
"Counting to a billion at one number per second would take over 31 years.",
"Space is so vast, even speeding at light-speed, you'd never return past the cosmic horizon.",
"Astronauts on the ISS age about 0.01 seconds less each year than people on Earth.",
"Sagittarius B2, a dust cloud near our galaxy's center, contains ethyl formate—the compound that gives raspberries their flavor and rum its smell.",
"Beyond 16 billion light-years, the cosmic event horizon marks where space expands too fast for light to ever reach us again.",
"Even at light-speed, you'd never catch up to most galaxies—space expands faster.",
"Only around 5% of galaxies are ever reachable—even at light-speed.",
"If the Sun vanished, we'd still orbit it for 8 minutes before drifting away.",
"If a planet 65 million light-years away looked at Earth now, it'd see dinosaurs.",
"Our oldest radio signals will reach the Milky Way's center in 26,000 years.",
"Every atom in your body heavier than hydrogen was forged in the nuclear furnace of a dying star.",
"The Moon moves 3.8 centimeters farther from Earth every year.",
"The universe creates 275 million new stars every single day.",
"Jupiter's Great Red Spot is a storm twice the size of Earth that has been raging for at least 350 years.",
"If you watched someone fall into a black hole, they'd appear frozen at the event horizon forever—time effectively stops from your perspective.",
"The Boötes Supervoid is a cosmic desert 1.8 billion light-years across with 60% fewer galaxies than it should have."
]
function getRandomFact() {
return facts[Math.floor(Math.random() * facts.length)]
}
}

View File

@@ -100,7 +100,8 @@ const DMS_ACTIONS = [
{ id: "spawn dms ipc call hypr openOverview", label: "Hyprland: Open Overview", compositor: "hyprland" }, { id: "spawn dms ipc call hypr openOverview", label: "Hyprland: Open Overview", compositor: "hyprland" },
{ id: "spawn dms ipc call hypr closeOverview", label: "Hyprland: Close Overview", compositor: "hyprland" }, { id: "spawn dms ipc call hypr closeOverview", label: "Hyprland: Close Overview", compositor: "hyprland" },
{ id: "spawn dms ipc call wallpaper next", label: "Wallpaper: Next" }, { id: "spawn dms ipc call wallpaper next", label: "Wallpaper: Next" },
{ id: "spawn dms ipc call wallpaper prev", label: "Wallpaper: Previous" } { id: "spawn dms ipc call wallpaper prev", label: "Wallpaper: Previous" },
{ id: "spawn dms ipc call workspace-rename open", label: "Workspace: Rename" }
]; ];
const NIRI_ACTIONS = { const NIRI_ACTIONS = {

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
@@ -146,6 +147,7 @@ Singleton {
property bool use24HourClock: true property bool use24HourClock: true
property bool showSeconds: false property bool showSeconds: false
property bool padHours12Hour: false
property bool useFahrenheit: false property bool useFahrenheit: false
property string windSpeedUnit: "kmh" property string windSpeedUnit: "kmh"
property bool nightModeEnabled: false property bool nightModeEnabled: false
@@ -241,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
@@ -260,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
@@ -273,6 +279,8 @@ Singleton {
property string spotlightModalViewMode: "list" property string spotlightModalViewMode: "list"
property string browserPickerViewMode: "grid" property string browserPickerViewMode: "grid"
property var browserUsageHistory: ({}) property var browserUsageHistory: ({})
property string appPickerViewMode: "grid"
property var filePickerUsageHistory: ({})
property bool sortAppsAlphabetically: false property bool sortAppsAlphabetically: false
property int appLauncherGridColumns: 4 property int appLauncherGridColumns: 4
property bool spotlightCloseNiriOverview: true property bool spotlightCloseNiriOverview: true
@@ -289,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"]
@@ -438,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
@@ -452,6 +463,7 @@ Singleton {
property bool lockScreenShowDate: true property bool lockScreenShowDate: true
property bool lockScreenShowProfileImage: true property bool lockScreenShowProfileImage: true
property bool lockScreenShowPasswordField: true property bool lockScreenShowPasswordField: true
property bool lockScreenShowMediaPlayer: true
property bool lockScreenPowerOffMonitorsOnLock: false property bool lockScreenPowerOffMonitorsOnLock: false
property bool enableFprint: false property bool enableFprint: false
@@ -507,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: [
{ {
@@ -998,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]);
@@ -1030,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]);
} }
@@ -1074,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();
@@ -1089,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;
} }
@@ -1251,11 +1269,11 @@ Singleton {
} }
function getEffectiveTimeFormat() { function getEffectiveTimeFormat() {
if (use24HourClock) { if (use24HourClock)
return showSeconds ? "hh:mm:ss" : "hh:mm"; return showSeconds ? "hh:mm:ss" : "hh:mm";
} else { if (padHours12Hour)
return showSeconds ? "h:mm:ss AP" : "h:mm AP"; return showSeconds ? "hh:mm:ss AP" : "hh:mm AP";
} return showSeconds ? "h:mm:ss AP" : "h:mm AP";
} }
function getEffectiveClockDateFormat() { function getEffectiveClockDateFormat() {
@@ -2243,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
} }
@@ -2307,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" },
@@ -32,6 +33,7 @@ var SPEC = {
use24HourClock: { def: true }, use24HourClock: { def: true },
showSeconds: { def: false }, showSeconds: { def: false },
padHours12Hour: { def: false },
useFahrenheit: { def: false }, useFahrenheit: { def: false },
windSpeedUnit: { def: "kmh" }, windSpeedUnit: { def: "kmh" },
nightModeEnabled: { def: false }, nightModeEnabled: { def: false },
@@ -78,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 },
@@ -95,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 },
@@ -115,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: "" },
@@ -132,6 +142,10 @@ var SPEC = {
appLauncherViewMode: { def: "list" }, appLauncherViewMode: { def: "list" },
spotlightModalViewMode: { def: "list" }, spotlightModalViewMode: { def: "list" },
browserPickerViewMode: { def: "grid" },
browserUsageHistory: { def: {} },
appPickerViewMode: { def: "grid" },
filePickerUsageHistory: { def: {} },
sortAppsAlphabetically: { def: false }, sortAppsAlphabetically: { def: false },
appLauncherGridColumns: { def: 4 }, appLauncherGridColumns: { def: 4 },
spotlightCloseNiriOverview: { def: true }, spotlightCloseNiriOverview: { def: true },
@@ -148,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 },
@@ -262,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 },
@@ -276,6 +292,7 @@ var SPEC = {
lockScreenShowDate: { def: true }, lockScreenShowDate: { def: true },
lockScreenShowProfileImage: { def: true }, lockScreenShowProfileImage: { def: true },
lockScreenShowPasswordField: { def: true }, lockScreenShowPasswordField: { def: true },
lockScreenShowMediaPlayer: { def: true },
lockScreenPowerOffMonitorsOnLock: { def: false }, lockScreenPowerOffMonitorsOnLock: { def: false },
enableFprint: { def: false }, enableFprint: { def: false },
maxFprintTries: { def: 15 }, maxFprintTries: { def: 15 },
@@ -300,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 },
@@ -330,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" },
@@ -431,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

@@ -550,6 +550,11 @@ Item {
AppPickerModal { AppPickerModal {
id: filePickerModal id: filePickerModal
title: I18n.tr("Open with...") title: I18n.tr("Open with...")
viewMode: SettingsData.appPickerViewMode || "grid"
onViewModeChanged: {
SettingsData.set("appPickerViewMode", viewMode)
}
function shellEscape(str) { function shellEscape(str) {
return "'" + str.replace(/'/g, "'\\''") + "'"; return "'" + str.replace(/'/g, "'\\''") + "'";
@@ -644,6 +649,30 @@ Item {
} }
} }
LazyLoader {
id: workspaceRenameModalLoader
active: false
Component.onCompleted: PopoutService.workspaceRenameModalLoader = workspaceRenameModalLoader
WorkspaceRenameModal {
id: workspaceRenameModal
}
}
LazyLoader {
id: windowRuleModalLoader
active: false
Component.onCompleted: PopoutService.windowRuleModalLoader = windowRuleModalLoader
WindowRuleModal {
id: windowRuleModal
}
}
LazyLoader { LazyLoader {
id: processListModalLoader id: processListModalLoader
@@ -769,6 +798,8 @@ Item {
hyprKeybindsModalLoader: hyprKeybindsModalLoader hyprKeybindsModalLoader: hyprKeybindsModalLoader
dankBarRepeater: dankBarRepeater dankBarRepeater: dankBarRepeater
hyprlandOverviewLoader: hyprlandOverviewLoader hyprlandOverviewLoader: hyprlandOverviewLoader
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
@@ -15,6 +17,8 @@ Item {
required property var hyprKeybindsModalLoader required property var hyprKeybindsModalLoader
required property var dankBarRepeater required property var dankBarRepeater
required property var hyprlandOverviewLoader required property var hyprlandOverviewLoader
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)
@@ -1062,7 +1066,7 @@ Item {
} }
function toggleQuery(query: string): string { function toggleQuery(query: string): string {
PopoutService.toggleDankLauncherV2(); PopoutService.toggleDankLauncherV2WithQuery(query);
return "LAUNCHER_TOGGLE_QUERY_SUCCESS"; return "LAUNCHER_TOGGLE_QUERY_SUCCESS";
} }
@@ -1106,13 +1110,86 @@ Item {
} }
function toggleQuery(query: string): string { function toggleQuery(query: string): string {
PopoutService.toggleDankLauncherV2(); PopoutService.toggleDankLauncherV2WithQuery(query);
return "SPOTLIGHT_TOGGLE_QUERY_SUCCESS"; return "SPOTLIGHT_TOGGLE_QUERY_SUCCESS";
} }
target: "spotlight" target: "spotlight"
} }
IpcHandler {
function info(message: string): string {
if (!message)
return "ERROR: No message specified";
ToastService.showInfo(message);
return "TOAST_INFO_SUCCESS";
}
function infoWith(message: string, details: string, command: string, category: string): string {
if (!message)
return "ERROR: No message specified";
ToastService.showInfo(message, details, command, category);
return "TOAST_INFO_SUCCESS";
}
function warn(message: string): string {
if (!message)
return "ERROR: No message specified";
ToastService.showWarning(message);
return "TOAST_WARN_SUCCESS";
}
function warnWith(message: string, details: string, command: string, category: string): string {
if (!message)
return "ERROR: No message specified";
ToastService.showWarning(message, details, command, category);
return "TOAST_WARN_SUCCESS";
}
function error(message: string): string {
if (!message)
return "ERROR: No message specified";
ToastService.showError(message);
return "TOAST_ERROR_SUCCESS";
}
function errorWith(message: string, details: string, command: string, category: string): string {
if (!message)
return "ERROR: No message specified";
ToastService.showError(message, details, command, category);
return "TOAST_ERROR_SUCCESS";
}
function hide(): string {
ToastService.hideToast();
return "TOAST_HIDE_SUCCESS";
}
function dismiss(category: string): string {
if (!category)
return "ERROR: No category specified";
ToastService.dismissCategory(category);
return "TOAST_DISMISS_SUCCESS";
}
function status(): string {
if (!ToastService.toastVisible)
return "hidden";
const levels = ["info", "warn", "error"];
return `visible:${levels[ToastService.currentLevel]}:${ToastService.currentMessage}`;
}
target: "toast"
}
IpcHandler { IpcHandler {
function open(): string { function open(): string {
FirstLaunchService.showWelcome(); FirstLaunchService.showWelcome();
@@ -1292,4 +1369,165 @@ Item {
target: "desktopWidget" target: "desktopWidget"
} }
IpcHandler {
function open(): string {
root.workspaceRenameModalLoader.active = true;
if (root.workspaceRenameModalLoader.item) {
const ws = NiriService.workspaces[NiriService.focusedWorkspaceId];
root.workspaceRenameModalLoader.item.show(ws?.name || "");
return "WORKSPACE_RENAME_MODAL_OPENED";
}
return "WORKSPACE_RENAME_MODAL_NOT_FOUND";
}
function close(): string {
if (root.workspaceRenameModalLoader.item) {
root.workspaceRenameModalLoader.item.hide();
return "WORKSPACE_RENAME_MODAL_CLOSED";
}
return "WORKSPACE_RENAME_MODAL_NOT_FOUND";
}
function toggle(): string {
root.workspaceRenameModalLoader.active = true;
if (root.workspaceRenameModalLoader.item) {
if (root.workspaceRenameModalLoader.item.visible) {
root.workspaceRenameModalLoader.item.hide();
return "WORKSPACE_RENAME_MODAL_CLOSED";
}
const ws = NiriService.workspaces[NiriService.focusedWorkspaceId];
root.workspaceRenameModalLoader.item.show(ws?.name || "");
return "WORKSPACE_RENAME_MODAL_OPENED";
}
return "WORKSPACE_RENAME_MODAL_NOT_FOUND";
}
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

@@ -33,7 +33,8 @@ Rectangle {
result.push(selectedItem.primaryAction); result.push(selectedItem.primaryAction);
} }
if (selectedItem?.type === "plugin") { switch (selectedItem?.type) {
case "plugin":
var pluginActions = getPluginContextMenuActions(); var pluginActions = getPluginContextMenuActions();
for (var i = 0; i < pluginActions.length; i++) { for (var i = 0; i < pluginActions.length; i++) {
var act = pluginActions[i]; var act = pluginActions[i];
@@ -44,24 +45,45 @@ Rectangle {
pluginAction: act.action pluginAction: act.action
}); });
} }
} else if (selectedItem?.type === "app" && !selectedItem?.isCore) { break;
case "plugin_browse":
if (selectedItem?.actions) { if (selectedItem?.actions) {
for (var i = 0; i < selectedItem.actions.length; i++) { for (var i = 0; i < selectedItem.actions.length; i++) {
result.push(selectedItem.actions[i]); result.push(selectedItem.actions[i]);
} }
} }
break;
case "app":
if (selectedItem?.isCore)
break;
if (selectedItem?.actions) {
for (var i = 0; i < selectedItem.actions.length; i++) {
result.push(selectedItem.actions[i]);
}
}
if (SessionService.nvidiaCommand) {
result.push({
name: I18n.tr("Launch on dGPU"),
icon: "memory",
action: "launch_dgpu"
});
}
break;
} }
return result; return result;
} }
readonly property bool hasActions: { readonly property bool hasActions: {
if (selectedItem?.type === "app" && !selectedItem?.isCore) switch (selectedItem?.type) {
return true; case "app":
if (selectedItem?.type === "plugin") { return !selectedItem?.isCore;
var pluginActions = getPluginContextMenuActions(); case "plugin":
return pluginActions.length > 0; return getPluginContextMenuActions().length > 0;
case "plugin_browse":
return selectedItem?.actions?.length > 0;
default:
return actions.length > 1;
} }
return actions.length > 1;
} }
width: parent?.width ?? 200 width: parent?.width ?? 200

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,157 @@
.pragma library
function getFileIcon(filename) {
var ext = filename.lastIndexOf(".") > 0 ? filename.substring(filename.lastIndexOf(".") + 1).toLowerCase() : "";
switch (ext) {
case "pdf":
return "picture_as_pdf";
case "doc":
case "docx":
case "odt":
return "description";
case "xls":
case "xlsx":
case "ods":
return "table_chart";
case "ppt":
case "pptx":
case "odp":
return "slideshow";
case "txt":
case "md":
case "rst":
return "article";
case "jpg":
case "jpeg":
case "png":
case "gif":
case "svg":
case "webp":
return "image";
case "mp3":
case "wav":
case "flac":
case "ogg":
return "audio_file";
case "mp4":
case "mkv":
case "avi":
case "webm":
return "video_file";
case "zip":
case "tar":
case "gz":
case "7z":
case "rar":
return "folder_zip";
case "js":
case "ts":
case "py":
case "rs":
case "go":
case "java":
case "c":
case "cpp":
case "h":
return "code";
case "html":
case "css":
case "htm":
return "web";
case "json":
case "xml":
case "yaml":
case "yml":
return "data_object";
case "sh":
case "bash":
case "zsh":
return "terminal";
default:
return "insert_drive_file";
}
}
function stripIconPrefix(iconName) {
if (!iconName)
return "extension";
if (iconName.startsWith("unicode:"))
return iconName.substring(8);
if (iconName.startsWith("material:"))
return iconName.substring(9);
if (iconName.startsWith("image:"))
return iconName.substring(6);
return iconName;
}
function detectIconType(iconName) {
if (!iconName)
return "material";
if (iconName.startsWith("unicode:"))
return "unicode";
if (iconName.startsWith("material:"))
return "material";
if (iconName.startsWith("image:"))
return "image";
if (iconName.indexOf("/") >= 0 || iconName.indexOf(".") >= 0)
return "image";
if (/^[a-z]+-[a-z]/.test(iconName.toLowerCase()))
return "image";
return "material";
}
function evaluateCalculator(query) {
if (!query || query.length === 0)
return null;
var mathExpr = query.replace(/[^0-9+\-*/().%\s^]/g, "");
if (mathExpr.length < 2)
return null;
var hasMath = /[+\-*/^%]/.test(query) && /\d/.test(query);
if (!hasMath)
return null;
try {
var sanitized = mathExpr.replace(/\^/g, "**");
var result = Function('"use strict"; return (' + sanitized + ')')();
if (typeof result === "number" && isFinite(result)) {
var displayResult = Number.isInteger(result) ? result.toString() : result.toFixed(6).replace(/\.?0+$/, "");
return {
expression: query,
result: result,
displayResult: displayResult
};
}
} catch (e) { }
return null;
}
function sortPluginIdsByOrder(pluginIds, order) {
if (!order || order.length === 0)
return pluginIds;
var orderMap = {};
for (var i = 0; i < order.length; i++)
orderMap[order[i]] = i;
return pluginIds.slice().sort(function (a, b) {
var aOrder = orderMap[a] !== undefined ? orderMap[a] : 9999;
var bOrder = orderMap[b] !== undefined ? orderMap[b] : 9999;
return aOrder - bOrder;
});
}
function sortPluginsOrdered(plugins, order) {
if (!order || order.length === 0)
return plugins;
var orderMap = {};
for (var i = 0; i < order.length; i++)
orderMap[order[i]] = i;
return plugins.sort(function (a, b) {
var aOrder = orderMap[a.id] !== undefined ? orderMap[a.id] : 9999;
var bOrder = orderMap[b.id] !== undefined ? orderMap[b.id] : 9999;
return aOrder - bOrder;
});
}

View File

@@ -186,6 +186,14 @@ Item {
} }
} }
function toggleWithQuery(query) {
if (spotlightOpen) {
hide();
} else {
showWithQuery(query);
}
}
Timer { Timer {
id: closeCleanupTimer id: closeCleanupTimer
interval: Theme.expressiveDurations.expressiveFastSpatial + 50 interval: Theme.expressiveDurations.expressiveFastSpatial + 50

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

@@ -0,0 +1,223 @@
.pragma library
.import "ControllerUtils.js" as Utils
function transformApp(app, override, defaultActions, primaryActionLabel) {
var appId = app.id || app.execString || app.exec || "";
var actions = [];
if (app.actions && app.actions.length > 0) {
for (var i = 0; i < app.actions.length; i++) {
actions.push({
name: app.actions[i].name,
icon: "play_arrow",
actionData: app.actions[i]
});
}
}
return {
id: appId,
type: "app",
name: override?.name || app.name || "",
subtitle: override?.comment || app.comment || "",
icon: override?.icon || app.icon || "application-x-executable",
iconType: "image",
section: "apps",
data: app,
keywords: app.keywords || [],
actions: actions,
primaryAction: {
name: primaryActionLabel,
icon: "open_in_new",
action: "launch"
}
};
}
function transformCoreApp(app, openLabel) {
var iconName = "apps";
var iconType = "material";
if (app.icon) {
if (app.icon.startsWith("svg+corner:")) {
iconType = "composite";
} else if (app.icon.startsWith("material:")) {
iconName = app.icon.substring(9);
} else {
iconName = app.icon;
iconType = "image";
}
}
return {
id: app.builtInPluginId || app.action || "",
type: "app",
name: app.name || "",
subtitle: app.comment || "",
icon: iconName,
iconType: iconType,
iconFull: app.icon,
section: "apps",
data: app,
isCore: true,
actions: [],
primaryAction: {
name: openLabel,
icon: "open_in_new",
action: "launch"
}
};
}
function transformBuiltInLauncherItem(item, pluginId, openLabel) {
var rawIcon = item.icon || "extension";
var icon = Utils.stripIconPrefix(rawIcon);
var iconType = item.iconType;
if (!iconType) {
if (rawIcon.startsWith("material:"))
iconType = "material";
else if (rawIcon.startsWith("unicode:"))
iconType = "unicode";
else
iconType = "image";
}
return {
id: item.action || "",
type: "plugin",
name: item.name || "",
subtitle: item.comment || "",
icon: icon,
iconType: iconType,
section: "plugin_" + pluginId,
data: item,
pluginId: pluginId,
isBuiltInLauncher: true,
keywords: item.keywords || [],
actions: [],
primaryAction: {
name: openLabel,
icon: "open_in_new",
action: "execute"
}
};
}
function transformFileResult(file, openLabel, openFolderLabel, copyPathLabel) {
var filename = file.path ? file.path.split("/").pop() : "";
var dirname = file.path ? file.path.substring(0, file.path.lastIndexOf("/")) : "";
return {
id: file.path || "",
type: "file",
name: filename,
subtitle: dirname,
icon: Utils.getFileIcon(filename),
iconType: "material",
section: "files",
data: file,
actions: [
{
name: openFolderLabel,
icon: "folder_open",
action: "open_folder"
},
{
name: copyPathLabel,
icon: "content_copy",
action: "copy_path"
}
],
primaryAction: {
name: openLabel,
icon: "open_in_new",
action: "open"
}
};
}
function transformPluginItem(item, pluginId, selectLabel) {
var rawIcon = item.icon || "extension";
var icon = Utils.stripIconPrefix(rawIcon);
var iconType = item.iconType;
if (!iconType) {
if (rawIcon.startsWith("material:"))
iconType = "material";
else if (rawIcon.startsWith("unicode:"))
iconType = "unicode";
else
iconType = "image";
}
return {
id: item.id || item.name || "",
type: "plugin",
name: item.name || "",
subtitle: item.comment || item.description || "",
icon: icon,
iconType: iconType,
section: "plugin_" + pluginId,
data: item,
pluginId: pluginId,
keywords: item.keywords || [],
actions: item.actions || [],
primaryAction: item.primaryAction || {
name: selectLabel,
icon: "check",
action: "execute"
}
};
}
function createCalculatorItem(calc, query, copyLabel) {
return {
id: "calculator_result",
type: "calculator",
name: calc.displayResult,
subtitle: query + " =",
icon: "calculate",
iconType: "material",
section: "calculator",
data: {
expression: calc.expression,
result: calc.result
},
actions: [],
primaryAction: {
name: copyLabel,
icon: "content_copy",
action: "copy"
}
};
}
function createPluginBrowseItem(pluginId, plugin, trigger, isBuiltIn, isAllowed, browseLabel, triggerLabel, noTriggerLabel) {
var rawIcon = isBuiltIn ? (plugin.cornerIcon || "extension") : (plugin.icon || "extension");
return {
id: "browse_" + pluginId,
type: "plugin_browse",
name: plugin.name || pluginId,
subtitle: trigger ? triggerLabel.replace("%1", trigger) : noTriggerLabel,
icon: isBuiltIn ? rawIcon : Utils.stripIconPrefix(rawIcon),
iconType: isBuiltIn ? "material" : Utils.detectIconType(rawIcon),
section: "browse_plugins",
data: {
pluginId: pluginId,
plugin: plugin,
isBuiltIn: isBuiltIn
},
actions: [
{
name: "All",
icon: isAllowed ? "visibility" : "visibility_off",
action: "toggle_all_visibility"
}
],
primaryAction: {
name: browseLabel,
icon: "arrow_forward",
action: "browse_plugin"
}
};
}

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;
@@ -131,6 +138,16 @@ Popup {
items.push({ items.push({
type: "separator" type: "separator"
}); });
if (isRegularApp && SessionService.nvidiaCommand) {
items.push({
type: "item",
icon: "memory",
text: I18n.tr("Launch on dGPU"),
action: launchWithNvidia
});
}
items.push({ items.push({
type: "item", type: "item",
icon: "launch", icon: "launch",

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