1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-01-24 21:42:51 -05:00

Compare commits

...

73 Commits

Author SHA1 Message Date
LuckShiba
25322970ed doctor: use network backend detector 2026-01-03 20:14:51 -03:00
LuckShiba
6679a5f6f9 doctor: refactor to use DoctorStatus struct and 'enum' 2026-01-03 20:07:27 -03:00
LuckShiba
13c352fd58 doctor: use builtin config/cache dir functions 2026-01-03 18:53:15 -03:00
LuckShiba
002039bd3f doctor: fix icon theme env variable 2026-01-03 18:50:38 -03:00
LuckShiba
85a7961e74 doctor: cleanup code 2026-01-03 18:14:27 -03:00
LuckShiba
822e1c4404 doctor: add power-profiles-daemon and i2c 2026-01-03 18:14:27 -03:00
LuckShiba
cd5bb35be5 doctor: indicate if config files are readonly 2026-01-03 18:14:27 -03:00
LuckShiba
40b103d6d4 doctor: show useful env variables 2026-01-03 18:14:27 -03:00
LuckShiba
170082751b doctor: show compositor, quickshell and cli path in verbose 2026-01-03 18:14:27 -03:00
LuckShiba
05c7293b34 doctor: use console.warn for quickshell feature logs 2026-01-03 18:14:27 -03:00
LuckShiba
4f52e22535 feat: doctor command 2026-01-03 18:14:25 -03:00
Ryan Bateman
02166a4ca5 feat: matugen detects flatpak installations of zenbrowser and vesktop (#1251)
* feat: matugen detects flatpak installations of zenbrowser and vesktop

* fix: add flatpak deps on precommit runner

* fix: address short circuit conditions
2026-01-03 15:28:39 -05:00
bbedward
f0f2e6ef72 i18n: update terms 2026-01-03 15:20:34 -05:00
bbedward
8d8d5de5fd matugen: update vscode template
- yaml/toml highlighting colors
- fix scrollbar contrast
- fix command-search marker
2026-01-03 15:10:38 -05:00
bbedward
6d76f0b476 power: add fade to monitor off option
fixes #558
2026-01-03 15:00:12 -05:00
bbedward
f3f720bb37 settings: fix network refresh button animation behavior
fixes #1258
2026-01-03 14:37:27 -05:00
bbedward
2bf85bc4dd motifications: add support for configurable persistent history
fixes #929
2026-01-03 13:08:48 -05:00
bbedward
faddc46185 core: respect QT_LOGGING_RULES var 2026-01-03 11:05:47 -05:00
bbedward
2991aac82e printers: fix input field height
fixes #1254
2026-01-03 10:54:53 -05:00
bbedward
e1817027b1 settings: add existence check in addition to RO check 2026-01-02 22:36:37 -05:00
bbedward
ba2d51bcbb core: initialize fd pipes in tests and increase queue size in test 2026-01-02 22:30:42 -05:00
Sparsh Mishra
7f10d6a9b8 Add media control bindings for audio playback (#1240)
* Add media control bindings for audio playback

* Update niri-binds.kdl for audio controls

Added play pause prev next controls for niri too
2026-01-02 22:25:21 -05:00
bbedward
405749aa98 theme: unconditionally load dms-colors.json 2026-01-02 22:01:04 -05:00
bbedward
77681fd387 launcher: allow terminal apps 2026-01-02 21:56:56 -05:00
bbedward
8253ec4496 theme: add dank16 to dms matugen template 2026-01-02 21:37:48 -05:00
bbedward
a1e001e640 i18n: update terms 2026-01-02 19:35:02 -05:00
bbedward
3a65ea21ba plugins: fix first plugin install reactivity 2026-01-02 19:22:04 -05:00
NikSne
7d761c4c9a feat(distro/nix/niri): add a hack for config includes with niri flake (#1239)
It works fine but needs all dms-generated config files to be present
2026-01-03 00:43:39 +01:00
Phil Jackson
4cb90c5367 Bar (mediaplayer): Mouse wheel options for media player widget (#1248)
* Add different options for scroll on media widget.

* Nicer lookup code.

* Remove some checks I didn't need.

* Update the search tags.

* EOF.
2026-01-02 17:08:42 -05:00
Ryan Bateman
1c7d15db0b util: add flatpak introspection utilities (#1234)
ci: run apt as sudo

ci: fix flatpak remote in runner

ci: flatpak install steps in runner

ci: specific version of freedesktop

ci: freedesktop install perms
2026-01-02 16:07:32 -05:00
vha
7268a3fe7f feat: Add group workspace apps toggle (#1238)
* Add group workspace apps toggle

* wording

* fix pre-commit

---------

Co-authored-by: bbedward <bbedward@gmail.com>
2026-01-02 15:55:51 -05:00
pcortellezzi
d2c4391514 feat: Persistent Plugins & Async Updates (#1231)
- PluginService: maintain persistent instances for Launcher plugins
- AppSearchService: reuse persistent instances for queries
- Added requestLauncherUpdate signal for async UI refreshes
2026-01-02 15:49:04 -05:00
sweenu
69b1d0c2da bar(ws): add option to show name (#1223) 2026-01-02 15:47:33 -05:00
sweenu
ba28767492 bar(clock): respect compact mode on vertical bar (#1222) 2026-01-02 15:46:33 -05:00
bbedward
6cff5f1146 settings: prevent overwrites if parse called with null object 2026-01-02 15:45:31 -05:00
bbedward
3e1c6534bd matugen: add GTKTheme method on type alias 2026-01-01 23:22:13 -05:00
bbedward
c1d57946d9 matugen: fix adw-gtk3 setting in light mode
- and add models.Get/GetOr helpers
2026-01-01 23:13:12 -05:00
bbedward
5e111d89a5 gamma: recreate controls on resume 2026-01-01 22:50:25 -05:00
Phil Jackson
1a98da22b2 Larger option for the media player widget. (#1236) 2026-01-01 22:12:37 -05:00
johngalt
618ccbcb2f zen-userchrome.css - fixing workspaces container color (#1194) 2026-01-01 22:03:59 -05:00
Body
d3a79a055e tweak background and popout colors to be brighter and more similar to adwaita (#1237) 2026-01-01 21:44:50 -05:00
bbedward
bae32e51ff core: skip display filtering in IPC 2026-01-01 15:24:55 -05:00
bbedward
edfda965e9 core: prevent stale path file 2026-01-01 14:04:58 -05:00
bbedward
a547966b23 vpn: wrap secrets in secrets key, cache pkcs11 pin input 2026-01-01 13:43:22 -05:00
bbedward
f6279b1b2e greeter: simplify start-hyprland check 2026-01-01 13:17:01 -05:00
bbedward
957c89a85d settings: refactor for read-only handling
- Remove default-* copying logic
- Allow in-memory changes of settings/session datas
- Convert SessionData to newer spec pattern
- Migrate weather coords to Session data
- Bricks home manager (temporarily)
2026-01-01 13:13:35 -05:00
bbedward
571a9dabcd dock: fix tooltip positioning with adjacent bars 2026-01-01 12:04:49 -05:00
bbedward
51ca9a7686 cachingimage: dont depend on sha256sum 2026-01-01 11:47:26 -05:00
bbedward
c141ad1e34 settings: guard saving before load completed 2026-01-01 11:30:09 -05:00
bbedward
37f972d075 vpn: update pksc11 handling 2025-12-31 15:42:41 -05:00
Oscar R.
7d8de6e6f0 Improving the logic for start-hyprland wrapper use (#1220)
* Adding a way to use the start-hyprland wrapper when it's needed from Hyprland 0.53 it's recommended because offers more security if happens a fail

* Deleting unnecessary things and doing verifications

* fix pre-commit

* Changing to not depend on hyprctl to obtain version and avoid posible problems

---------

Co-authored-by: bbedward <bbedward@gmail.com>
2025-12-31 13:27:05 -05:00
bbedward
7ff751f8a2 vpn: attempt to support pkcs11 prompts 2025-12-31 10:03:49 -05:00
bbedward
651672afe2 gamma: allow steps of 100 with slider
fixes #1216
2025-12-31 09:31:16 -05:00
bbedward
2dbadfe1b5 clipboard: single disable + read-only history option 2025-12-31 09:14:35 -05:00
purian23
621710bd86 Update & Replace all issue templates 2025-12-30 23:03:50 -05:00
bbedward
1edecb05bb widgets: dynamic DankToggle height 2025-12-30 22:24:48 -05:00
bbedward
f1a876301b dankbar: fix reveal on overview/niri when auto-hide on 2025-12-30 22:19:25 -05:00
bbedward
97a07c399a greeter: use folderlistmodel for session iteration, add launch timeout 2025-12-30 11:49:00 -05:00
Oscar R.
18f095cb23 feat: implement smart compositor entry point (start-hyprland vs Hyprland) (#1211)
* Adding a way to use the start-hyprland wrapper when it's needed from Hyprland 0.53 it's recommended because offers more security if happens a fail

* Deleting unnecessary things and doing verifications

* fix pre-commit

---------

Co-authored-by: bbedward <bbedward@gmail.com>
2025-12-30 11:20:33 -05:00
bbedward
d95d516d64 settings: fix desktop widget accordion row height
fixes #1214
2025-12-30 10:56:20 -05:00
purian23
45ba64ab02 About versioning 2025-12-29 22:47:33 -05:00
bbedward
9501d66af6 matugen: fix skip 2025-12-29 17:46:32 -05:00
bbedward
2127fc339a core: update hypr config test 2025-12-29 14:59:02 -05:00
bbedward
7962fee0bd dankinstall: update hyprland reference config for 0.53
fixes #913
2025-12-29 14:55:12 -05:00
bbedward
d5c7b5c0cc workspace: update scroll accumulator logic 2025-12-29 12:11:37 -05:00
vha
5f77d69dd8 feat: accept numpad's enter key to finish screenshot selection (#1210)
* added reverse scrolling to settings and widget

* added support for dankbar scrolling

* Better settings description

* removed isNiri conditional from search index

* Added numpad enter key to finish screenshot selection
2025-12-29 11:13:57 -05:00
bbedward
60034be06a dankbar: copy high-dpi scrolling logic from DankListView 2025-12-29 10:52:33 -05:00
bbedward
518a5d38aa settings: show parse error message 2025-12-29 10:46:03 -05:00
Eduardo Ribeiro
2eeaf8ff62 feat: allow adjusting notification volume (#1199) 2025-12-29 10:41:12 -05:00
bbedward
cffee0fae6 matugen: make check codition an array 2025-12-29 10:36:24 -05:00
bbedward
f08e2ef5b8 hypr: add disable output option 2025-12-28 23:15:43 -05:00
Joaquim S.
2b0070c31a matugen/template: Soothing neovim theme (#1201) 2025-12-28 21:49:44 -05:00
Marcus Ramberg
ae82716afa core: apply gopls automatic modernizers (#1198) 2025-12-28 21:48:56 -05:00
153 changed files with 9160 additions and 3437 deletions

View File

@@ -1,65 +0,0 @@
---
name: Bug Report
about: Crashes or unexpected behaviors
title: ""
labels: "bug"
assignees: ""
---
<!-- If your issue is related to ICONS
- Purple and black checkerboards are QT's way of signalling an icon doesn't exist
- FIX: Configure a QT6 or Icon Pack in DMS Settings that has the icon you want
- Follow the [THEMING](https://danklinux.com/docs/dankmaterialshell/icon-theming) section to ensure your QT environment variable is configured correctly for themes.
- Once done, configure an icon theme - either however you normally do with gtk3 or qt6ct, or through the built-in settings modal. -->
## Compositor
- [ ] niri
- [ ] Hyprland
- [ ] dwl (MangoWC)
- [ ] sway
- [ ] Other (specify)
## Distribution
<!-- Arch, Fedora, Debian, etc. -->
## dms version
<!-- Output of dms version command -->
## Description
<!-- Brief description of the issue -->
## Expected Behavior
<!-- Describe what you expected to happen -->
## Steps to Reproduce
<!-- Please provide detailed steps to reproduce the issue -->
1.
2.
3.
## Error Messages/Logs
<!-- Please include any error messages, stack traces, or relevant logs -->
<!-- you can get a log file with the following steps:
dms kill
mkdir ~/dms_logs
nohup dms run > ~/dms_logs/dms-$(date +%s).txt 2>&1 &
Then trigger your issue, and share the contents of ~/dms_logs/dms-<timestamp>.txt
-->
```
Paste error messages or logs here
```
## Screenshots/Recordings
<!-- If applicable, add screenshots or screen recordings -->

96
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@@ -0,0 +1,96 @@
name: Bug Report
description: Crashes or unexpected behaviors
labels:
- bug
body:
- type: markdown
attributes:
value: |
## DankMaterialShell Bug Report
Limit your report to one issue per submission unless closely related
- type: checkboxes
id: compositor
attributes:
label: Compositor
options:
- label: Niri
- label: Hyprland
- label: MangoWC (dwl)
- label: Sway
validations:
required: true
- type: checkboxes
id: distribution
attributes:
label: Distribution
options:
- label: Arch Linux
- label: CachyOS
- label: Fedora
- label: NixOS
- label: Debian
- label: Ubuntu
- label: Gentoo
- label: OpenSUSE
- label: Other (specify below)
validations:
required: true
- type: input
id: distribution_other
attributes:
label: If Other, please specify
placeholder: e.g., PikaOS, Void Linux, etc.
validations:
required: false
- type: input
id: dms_version
attributes:
label: dms version
description: Output of dms version command
placeholder: e.g., 1.2.3
validations:
required: true
- type: textarea
id: description
attributes:
label: Description
description: Brief description of the issue
placeholder: What happened?
validations:
required: true
- type: textarea
id: expected_behavior
attributes:
label: Expected Behavior
description: What did you expect to happen?
placeholder: Describe the expected behavior
validations:
required: false
- type: textarea
id: steps_to_reproduce
attributes:
label: Steps to Reproduce & Installation Method
description: Please provide detailed steps to reproduce the issue
placeholder: |
1. ...
2. ...
3. ...
validations:
required: true
- type: textarea
id: logs
attributes:
label: Error Messages/Logs
description: Please include any error messages, stack traces, or relevant logs
placeholder: |
Paste error messages or logs here
validations:
required: false
- type: textarea
id: screenshots
attributes:
label: Screenshots/Recordings
description: If applicable, add screenshots or screen recordings
placeholder: Attach images or videos here
validations:
required: false

View File

@@ -1,33 +0,0 @@
---
name: Request a Feature
about: New widgets, new widget behavior, etc.
title: ""
labels: "enhancement"
assignees: ""
---
## Feature Description
<!-- Brief description of the feature requested -->
## Use Case
<!-- Explain the purpose of this feature/why it'd be useful to you -->
## Compositor
Is this feature specific to one compositor?
- [ ] All compositors
- [ ] niri
- [ ] Hyprland
- [ ] dwl (MangoWC)
- [ ] sway
## Proposed Solution
<!-- If you have any ideas for how to implement this, please share! -->
## Alternatives/Existing Solutions
<!-- Include any similar/pre-existing products that solve this problem -->

View File

@@ -0,0 +1,55 @@
name: Feature Request
description: Suggest a new feature or improvement for DMS
labels:
- enhancement
body:
- type: markdown
attributes:
value: |
## DankMaterialShell Feature Request
- type: textarea
id: feature_description
attributes:
label: Feature Description
description: Brief description of the feature requested
placeholder: What feature would you like to see?
validations:
required: true
- type: textarea
id: use_case
attributes:
label: Use Case
description: Explain the purpose of this feature/why it'd be useful to you
placeholder: Why is this feature important?
validations:
required: false
- type: checkboxes
id: compositor
attributes:
label: Compositor(s)
description: Is this feature specific to one or more compositors?
options:
- label: All compositors
- label: Niri
- label: Hyprland
- label: MangoWC (dwl)
- label: Sway
- label: Other (specify below)
validations:
required: false
- type: textarea
id: proposed_solution
attributes:
label: Proposed Solution
description: If you have any ideas for how to implement this, please share!
placeholder: Suggest a solution or approach
validations:
required: false
- type: textarea
id: alternatives
attributes:
label: Alternatives/Existing Solutions
description: Include any similar/pre-existing products that solve this problem
placeholder: List alternatives or existing solutions
validations:
required: false

View File

@@ -1,40 +0,0 @@
---
name: Request Assistance or Support
about: Help with installation, usage, or general questions.
title: ""
labels: "support"
assignees: ""
---
## Compositor
- [ ] niri
- [ ] Hyprland
- [ ] dwl (MangoWC)
- [ ] sway
- [ ] other
## Distribution
<!-- Arch, Fedora, Debian, etc. -->
## dms version
<!-- Output of dms version command -->
## Description
<!-- Brief description of the support needed -->
## Solutions Tried
<!-- Describe what you've tried so far -->
<!-- Outlining what you've tried so far helps us make improvements to the user experience and documentation to avoid recurrent issues -->
## Configuration Details
<!-- Include any configuration if relevant -->
## Screenshots/Recordings
<!-- If applicable, add screenshots or screen recordings -->

View File

@@ -0,0 +1,69 @@
name: Support Request
description: Help with installation, usage, or general questions about DankMaterialShell
labels:
- support
body:
- type: markdown
attributes:
value: |
## DankMaterialShell Support Request
- type: checkboxes
id: compositor
attributes:
label: Compositor
options:
- label: Niri
- label: Hyprland
- label: MangoWC (dwl)
- label: Sway
- label: Other (specify below)
validations:
required: false
- type: input
id: distribution
attributes:
label: Distribution
description: Which Linux distribution are you using? (e.g., Arch, Fedora, Debian, etc.)
placeholder: Your Linux distribution
validations:
required: false
- type: input
id: dms_version
attributes:
label: dms version
description: Output of dms version command
placeholder: e.g., 1.2.3
validations:
required: false
- type: textarea
id: description
attributes:
label: Description
description: Brief description of the support needed
placeholder: What do you need help with?
validations:
required: true
- type: textarea
id: solutions_tried
attributes:
label: Solutions Tried
description: Describe what you've tried so far (commands, documentation, etc.)
placeholder: List steps or resources you've already tried
validations:
required: false
- type: textarea
id: configuration
attributes:
label: Configuration Details
description: Include any relevant configuration if relevant
placeholder: Add configuration or environment info
validations:
required: false
- type: textarea
id: screenshots
attributes:
label: Screenshots/Recordings
description: If applicable, add screenshots or screen recordings
placeholder: Attach images or videos here
validations:
required: false

View File

@@ -28,6 +28,15 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Install flatpak
run: sudo apt update && sudo apt install -y flatpak
- name: Add flathub
run: sudo flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
- name: Add a flatpak that mutagen could support
run: sudo flatpak install -y org.freedesktop.Platform/x86_64/24.08 app.zen_browser.zen
- name: Set up Go
uses: actions/setup-go@v5
with:

View File

@@ -11,5 +11,14 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Install flatpak
run: sudo apt update && sudo apt install -y flatpak
- name: Add flathub
run: sudo flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
- name: Add a flatpak that mutagen could support
run: sudo flatpak install -y org.freedesktop.Platform/x86_64/24.08 app.zen_browser.zen
- name: run pre-commit hooks
uses: j178/prek-action@v1

View File

@@ -15,3 +15,4 @@ This file is more of a quick reference so I know what to account for before next
- new IPC targets
- Initial RTL support/i18n
- Theme registry
- Notification persistence & history

View File

@@ -179,7 +179,7 @@ func runBrightnessList(cmd *cobra.Command, args []string) {
fmt.Printf("%-*s %-12s %-*s %s\n", idPad, "Device", "Class", namePad, "Name", "Brightness")
sepLen := idPad + 2 + 12 + 2 + namePad + 2 + 15
for i := 0; i < sepLen; i++ {
for range sepLen {
fmt.Print("─")
}
fmt.Println()

View File

@@ -142,8 +142,6 @@ var (
clipConfigNoClearStartup bool
clipConfigDisabled bool
clipConfigEnabled bool
clipConfigDisableHistory bool
clipConfigEnableHistory bool
)
func init() {
@@ -167,10 +165,8 @@ func init() {
clipConfigSetCmd.Flags().IntVar(&clipConfigAutoClearDays, "auto-clear-days", -1, "Auto-clear entries older than N days (0 to disable)")
clipConfigSetCmd.Flags().BoolVar(&clipConfigClearAtStartup, "clear-at-startup", false, "Clear history on startup")
clipConfigSetCmd.Flags().BoolVar(&clipConfigNoClearStartup, "no-clear-at-startup", false, "Don't clear history on startup")
clipConfigSetCmd.Flags().BoolVar(&clipConfigDisabled, "disable", false, "Disable clipboard manager entirely")
clipConfigSetCmd.Flags().BoolVar(&clipConfigEnabled, "enable", false, "Enable clipboard manager")
clipConfigSetCmd.Flags().BoolVar(&clipConfigDisableHistory, "disable-history", false, "Disable clipboard history persistence")
clipConfigSetCmd.Flags().BoolVar(&clipConfigEnableHistory, "enable-history", false, "Enable clipboard history persistence")
clipConfigSetCmd.Flags().BoolVar(&clipConfigDisabled, "disable", false, "Disable clipboard tracking")
clipConfigSetCmd.Flags().BoolVar(&clipConfigEnabled, "enable", false, "Enable clipboard tracking")
clipWatchCmd.Flags().BoolVarP(&clipWatchStore, "store", "s", false, "Store clipboard changes to history (no server required)")
@@ -587,12 +583,6 @@ func runClipConfigSet(cmd *cobra.Command, args []string) {
if clipConfigEnabled {
params["disabled"] = false
}
if clipConfigDisableHistory {
params["disableHistory"] = true
}
if clipConfigEnableHistory {
params["disableHistory"] = false
}
if len(params) == 0 {
fmt.Println("No config options specified")

View File

@@ -513,5 +513,6 @@ func getCommonCommands() []*cobra.Command {
notifyActionCmd,
matugenCmd,
clipboardCmd,
doctorCmd,
}
}

View File

@@ -0,0 +1,853 @@
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"slices"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/brightness"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
"github.com/AvengeMedia/DankMaterialShell/core/internal/tui"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
"github.com/AvengeMedia/DankMaterialShell/core/internal/version"
"github.com/charmbracelet/lipgloss"
"github.com/spf13/cobra"
)
type status string
const (
statusOK status = "ok"
statusWarn status = "warn"
statusError status = "error"
statusInfo status = "info"
)
func (s status) IconStyle(styles tui.Styles) (string, lipgloss.Style) {
switch s {
case statusOK:
return "●", styles.Success
case statusWarn:
return "●", styles.Warning
case statusError:
return "●", styles.Error
default:
return "○", styles.Subtle
}
}
type DoctorStatus struct {
Errors []checkResult
Warnings []checkResult
OK []checkResult
Info []checkResult
}
func (ds *DoctorStatus) Add(r checkResult) {
switch r.status {
case statusError:
ds.Errors = append(ds.Errors, r)
case statusWarn:
ds.Warnings = append(ds.Warnings, r)
case statusOK:
ds.OK = append(ds.OK, r)
case statusInfo:
ds.Info = append(ds.Info, r)
}
}
func (ds *DoctorStatus) HasIssues() bool {
return len(ds.Errors) > 0 || len(ds.Warnings) > 0
}
func (ds *DoctorStatus) ErrorCount() int {
return len(ds.Errors)
}
func (ds *DoctorStatus) WarningCount() int {
return len(ds.Warnings)
}
func (ds *DoctorStatus) OKCount() int {
return len(ds.OK)
}
var (
quickshellVersionRegex = regexp.MustCompile(`quickshell (\d+\.\d+\.\d+)`)
hyprlandVersionRegex = regexp.MustCompile(`v?(\d+\.\d+\.\d+)`)
niriVersionRegex = regexp.MustCompile(`niri (\d+\.\d+)`)
swayVersionRegex = regexp.MustCompile(`sway version (\d+\.\d+)`)
riverVersionRegex = regexp.MustCompile(`river (\d+\.\d+)`)
wayfireVersionRegex = regexp.MustCompile(`wayfire (\d+\.\d+)`)
)
var doctorCmd = &cobra.Command{
Use: "doctor",
Short: "Diagnose DMS installation and dependencies",
Long: "Check system health, verify dependencies, and diagnose configuration issues for DMS",
Run: runDoctor,
}
var doctorVerbose bool
func init() {
doctorCmd.Flags().BoolVarP(&doctorVerbose, "verbose", "v", false, "Show detailed output including paths and versions")
}
type category int
const (
catSystem category = iota
catVersions
catInstallation
catCompositor
catQuickshellFeatures
catOptionalFeatures
catConfigFiles
catServices
catEnvironment
)
func (c category) String() string {
switch c {
case catSystem:
return "System"
case catVersions:
return "Versions"
case catInstallation:
return "Installation"
case catCompositor:
return "Compositor"
case catQuickshellFeatures:
return "Quickshell Features"
case catOptionalFeatures:
return "Optional Features"
case catConfigFiles:
return "Config Files"
case catServices:
return "Services"
case catEnvironment:
return "Environment"
default:
return "Unknown"
}
}
const (
checkNameMaxLength = 21
)
type checkResult struct {
category category
name string
status status
message string
details string
}
func runDoctor(cmd *cobra.Command, args []string) {
printDoctorHeader()
qsFeatures, qsMissingFeatures := checkQuickshellFeatures()
results := slices.Concat(
checkSystemInfo(),
checkVersions(qsMissingFeatures),
checkDMSInstallation(),
checkWindowManagers(),
qsFeatures,
checkOptionalDependencies(),
checkConfigurationFiles(),
checkSystemdServices(),
checkEnvironmentVars(),
)
printResults(results)
printSummary(results, qsMissingFeatures)
}
func printDoctorHeader() {
theme := tui.TerminalTheme()
styles := tui.NewStyles(theme)
fmt.Println(getThemedASCII())
fmt.Println(styles.Title.Render("System Health Check"))
fmt.Println(styles.Subtle.Render("──────────────────────────────────────"))
fmt.Println()
}
func checkSystemInfo() []checkResult {
var results []checkResult
osInfo, err := distros.GetOSInfo()
if err != nil {
status, message, details := statusWarn, fmt.Sprintf("Unknown (%v)", err), ""
if strings.Contains(err.Error(), "Unsupported distribution") {
osRelease := readOSRelease()
if osRelease["ID"] == "nixos" {
status = statusOK
message = osRelease["PRETTY_NAME"]
if message == "" {
message = fmt.Sprintf("NixOS %s", osRelease["VERSION_ID"])
}
details = "Supported for runtime (install via NixOS module or Flake)"
} else if osRelease["PRETTY_NAME"] != "" {
message = fmt.Sprintf("%s (not supported by dms setup)", osRelease["PRETTY_NAME"])
details = "DMS may work but automatic installation is not available"
}
}
results = append(results, checkResult{catSystem, "Operating System", status, message, details})
} else {
status := statusOK
message := osInfo.PrettyName
if message == "" {
message = fmt.Sprintf("%s %s", osInfo.Distribution.ID, osInfo.VersionID)
}
if distros.IsUnsupportedDistro(osInfo.Distribution.ID, osInfo.VersionID) {
status = statusWarn
message += " (version may not be fully supported)"
}
results = append(results, checkResult{
catSystem, "Operating System", status, message,
fmt.Sprintf("ID: %s, Version: %s, Arch: %s", osInfo.Distribution.ID, osInfo.VersionID, osInfo.Architecture),
})
}
arch := runtime.GOARCH
archStatus := statusOK
if arch != "amd64" && arch != "arm64" {
archStatus = statusError
}
results = append(results, checkResult{catSystem, "Architecture", archStatus, arch, ""})
waylandDisplay := os.Getenv("WAYLAND_DISPLAY")
xdgSessionType := os.Getenv("XDG_SESSION_TYPE")
switch {
case waylandDisplay != "" || xdgSessionType == "wayland":
results = append(results, checkResult{
catSystem, "Display Server", statusOK, "Wayland",
fmt.Sprintf("WAYLAND_DISPLAY=%s", waylandDisplay),
})
case xdgSessionType == "x11":
results = append(results, checkResult{catSystem, "Display Server", statusError, "X11 (DMS requires Wayland)", ""})
default:
results = append(results, checkResult{
catSystem, "Display Server", statusWarn, "Unknown (ensure you're running Wayland)",
fmt.Sprintf("XDG_SESSION_TYPE=%s", xdgSessionType),
})
}
return results
}
func checkEnvironmentVars() []checkResult {
var results []checkResult
results = append(results, checkEnvVar("QT_QPA_PLATFORMTHEME")...)
results = append(results, checkEnvVar("QS_ICON_THEME")...)
return results
}
func checkEnvVar(name string) []checkResult {
value := os.Getenv(name)
if value != "" {
return []checkResult{{catEnvironment, name, statusInfo, value, ""}}
} else if doctorVerbose {
return []checkResult{{catEnvironment, name, statusInfo, "Not set", ""}}
}
return nil
}
func readOSRelease() map[string]string {
result := make(map[string]string)
data, err := os.ReadFile("/etc/os-release")
if err != nil {
return result
}
for line := range strings.SplitSeq(string(data), "\n") {
if parts := strings.SplitN(line, "=", 2); len(parts) == 2 {
result[parts[0]] = strings.Trim(parts[1], "\"")
}
}
return result
}
func checkVersions(qsMissingFeatures bool) []checkResult {
dmsCliPath, _ := os.Executable()
dmsCliDetails := ""
if doctorVerbose {
dmsCliDetails = dmsCliPath
}
results := []checkResult{
{catVersions, "DMS CLI", statusOK, formatVersion(Version), dmsCliDetails},
}
qsVersion, qsStatus, qsPath := getQuickshellVersionInfo(qsMissingFeatures)
qsDetails := ""
if doctorVerbose && qsPath != "" {
qsDetails = qsPath
}
results = append(results, checkResult{catVersions, "Quickshell", qsStatus, qsVersion, qsDetails})
dmsVersion, dmsPath := getDMSShellVersion()
if dmsVersion != "" {
results = append(results, checkResult{catVersions, "DMS Shell", statusOK, dmsVersion, dmsPath})
} else {
results = append(results, checkResult{catVersions, "DMS Shell", statusError, "Not installed or not detected", "Run 'dms setup' to install"})
}
return results
}
func getDMSShellVersion() (version, path string) {
if err := findConfig(nil, nil); err == nil && configPath != "" {
versionFile := filepath.Join(configPath, "VERSION")
if data, err := os.ReadFile(versionFile); err == nil {
return strings.TrimSpace(string(data)), configPath
}
return "installed", configPath
}
if dmsPath, err := config.LocateDMSConfig(); err == nil {
versionFile := filepath.Join(dmsPath, "VERSION")
if data, err := os.ReadFile(versionFile); err == nil {
return strings.TrimSpace(string(data)), dmsPath
}
return "installed", dmsPath
}
return "", ""
}
func getQuickshellVersionInfo(missingFeatures bool) (string, status, string) {
if !utils.CommandExists("qs") {
return "Not installed", statusError, ""
}
qsPath, _ := exec.LookPath("qs")
output, err := exec.Command("qs", "--version").Output()
if err != nil {
return "Installed (version check failed)", statusWarn, qsPath
}
fullVersion := strings.TrimSpace(string(output))
if matches := quickshellVersionRegex.FindStringSubmatch(fullVersion); len(matches) >= 2 {
if version.CompareVersions(matches[1], "0.2.0") < 0 {
return fmt.Sprintf("%s (needs >= 0.2.0)", fullVersion), statusError, qsPath
}
if missingFeatures {
return fullVersion, statusWarn, qsPath
}
return fullVersion, statusOK, qsPath
}
return fullVersion, statusWarn, qsPath
}
func checkDMSInstallation() []checkResult {
var results []checkResult
dmsPath := ""
if err := findConfig(nil, nil); err == nil && configPath != "" {
dmsPath = configPath
} else if path, err := config.LocateDMSConfig(); err == nil {
dmsPath = path
}
if dmsPath == "" {
return []checkResult{{catInstallation, "DMS Configuration", statusError, "Not found", "shell.qml not found in any config path"}}
}
results = append(results, checkResult{catInstallation, "DMS Configuration", statusOK, "Found", dmsPath})
shellQml := filepath.Join(dmsPath, "shell.qml")
if _, err := os.Stat(shellQml); err != nil {
results = append(results, checkResult{catInstallation, "shell.qml", statusError, "Missing", shellQml})
} else {
results = append(results, checkResult{catInstallation, "shell.qml", statusOK, "Present", shellQml})
}
if doctorVerbose {
installType := "Unknown"
switch {
case strings.Contains(dmsPath, "/nix/store"):
installType = "Nix store"
case strings.Contains(dmsPath, ".local/share") || strings.Contains(dmsPath, "/usr/share"):
installType = "System package"
case strings.Contains(dmsPath, ".config"):
installType = "User config"
}
results = append(results, checkResult{catInstallation, "Install Type", statusInfo, installType, dmsPath})
}
return results
}
func checkWindowManagers() []checkResult {
compositors := []struct {
name, versionCmd, versionArg string
versionRegex *regexp.Regexp
commands []string
}{
{"Hyprland", "hyprctl", "version", hyprlandVersionRegex, []string{"hyprland", "Hyprland"}},
{"niri", "niri", "--version", niriVersionRegex, []string{"niri"}},
{"Sway", "sway", "--version", swayVersionRegex, []string{"sway"}},
{"River", "river", "-version", riverVersionRegex, []string{"river"}},
{"Wayfire", "wayfire", "--version", wayfireVersionRegex, []string{"wayfire"}},
}
var results []checkResult
foundAny := false
for _, c := range compositors {
if slices.ContainsFunc(c.commands, utils.CommandExists) {
foundAny = true
var compositorPath string
for _, cmd := range c.commands {
if path, err := exec.LookPath(cmd); err == nil {
compositorPath = path
break
}
}
details := ""
if doctorVerbose && compositorPath != "" {
details = compositorPath
}
results = append(results, checkResult{
catCompositor, c.name, statusOK,
getVersionFromCommand(c.versionCmd, c.versionArg, c.versionRegex), details,
})
}
}
if !foundAny {
results = append(results, checkResult{
catCompositor, "Compositor", statusError,
"No supported Wayland compositor found",
"Install Hyprland, niri, Sway, River, or Wayfire",
})
}
if wm := detectRunningWM(); wm != "" {
results = append(results, checkResult{catCompositor, "Active", statusInfo, wm, ""})
}
return results
}
func getVersionFromCommand(cmd, arg string, regex *regexp.Regexp) string {
output, err := exec.Command(cmd, arg).Output()
if err != nil {
return "installed"
}
outStr := string(output)
if matches := regex.FindStringSubmatch(outStr); len(matches) > 1 {
ver := matches[1]
if strings.Contains(outStr, "git") || strings.Contains(outStr, "dirty") {
return ver + " (git)"
}
return ver
}
return strings.TrimSpace(outStr)
}
func detectRunningWM() string {
switch {
case os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") != "":
return "Hyprland"
case os.Getenv("NIRI_SOCKET") != "":
return "niri"
case os.Getenv("XDG_CURRENT_DESKTOP") != "":
return os.Getenv("XDG_CURRENT_DESKTOP")
}
return ""
}
func checkQuickshellFeatures() ([]checkResult, bool) {
if !utils.CommandExists("qs") {
return nil, false
}
tmpDir := os.TempDir()
testScript := filepath.Join(tmpDir, "qs-feature-test.qml")
defer os.Remove(testScript)
qmlContent := `
import QtQuick
import Quickshell
ShellRoot {
id: root
property bool polkitAvailable: false
property bool idleMonitorAvailable: false
property bool idleInhibitorAvailable: false
property bool shortcutInhibitorAvailable: false
Timer {
interval: 50
running: true
repeat: false
onTriggered: {
try {
var polkitTest = Qt.createQmlObject(
'import Quickshell.Services.Polkit; import QtQuick; Item {}',
root
)
root.polkitAvailable = true
polkitTest.destroy()
} catch (e) {}
try {
var testItem = Qt.createQmlObject(
'import Quickshell.Wayland; import QtQuick; QtObject { ' +
'readonly property bool hasIdleMonitor: typeof IdleMonitor !== "undefined"; ' +
'readonly property bool hasIdleInhibitor: typeof IdleInhibitor !== "undefined"; ' +
'readonly property bool hasShortcutInhibitor: typeof ShortcutInhibitor !== "undefined" ' +
'}',
root
)
root.idleMonitorAvailable = testItem.hasIdleMonitor
root.idleInhibitorAvailable = testItem.hasIdleInhibitor
root.shortcutInhibitorAvailable = testItem.hasShortcutInhibitor
testItem.destroy()
} catch (e) {}
console.warn(root.polkitAvailable ? "FEATURE:Polkit:OK" : "FEATURE:Polkit:UNAVAILABLE")
console.warn(root.idleMonitorAvailable ? "FEATURE:IdleMonitor:OK" : "FEATURE:IdleMonitor:UNAVAILABLE")
console.warn(root.idleInhibitorAvailable ? "FEATURE:IdleInhibitor:OK" : "FEATURE:IdleInhibitor:UNAVAILABLE")
console.warn(root.shortcutInhibitorAvailable ? "FEATURE:ShortcutInhibitor:OK" : "FEATURE:ShortcutInhibitor:UNAVAILABLE")
Quickshell.execDetached(["kill", "-TERM", String(Quickshell.processId)])
}
}
}
`
if err := os.WriteFile(testScript, []byte(qmlContent), 0644); err != nil {
return nil, false
}
cmd := exec.Command("qs", "-p", testScript)
cmd.Env = append(os.Environ(), "NO_COLOR=1")
output, _ := cmd.CombinedOutput()
outputStr := string(output)
features := []struct{ name, desc string }{
{"Polkit", "Authentication prompts"},
{"IdleMonitor", "Idle detection"},
{"IdleInhibitor", "Prevent idle/sleep"},
{"ShortcutInhibitor", "Allow shortcut management (niri)"},
}
var results []checkResult
missingFeatures := false
for _, f := range features {
available := strings.Contains(outputStr, fmt.Sprintf("FEATURE:%s:OK", f.name))
status, message := statusOK, "Available"
if !available {
status, message = statusInfo, "Not available"
missingFeatures = true
}
results = append(results, checkResult{catQuickshellFeatures, f.name, status, message, f.desc})
}
return results, missingFeatures
}
func checkI2CAvailability() checkResult {
ddc, err := brightness.NewDDCBackend()
if err != nil {
return checkResult{catOptionalFeatures, "I2C/DDC", statusInfo, "Not available", "External monitor brightness control"}
}
defer ddc.Close()
devices, err := ddc.GetDevices()
if err != nil || len(devices) == 0 {
return checkResult{catOptionalFeatures, "I2C/DDC", statusInfo, "No monitors detected", "External monitor brightness control"}
}
return checkResult{catOptionalFeatures, "I2C/DDC", statusOK, fmt.Sprintf("%d monitor(s) detected", len(devices)), "External monitor brightness control"}
}
func detectNetworkBackend() string {
result, err := network.DetectNetworkStack()
if err != nil {
return ""
}
switch result.Backend {
case network.BackendNetworkManager:
return "NetworkManager"
case network.BackendIwd:
return "iwd"
case network.BackendNetworkd:
if result.HasIwd {
return "iwd + systemd-networkd"
}
return "systemd-networkd"
case network.BackendConnMan:
return "ConnMan"
default:
return ""
}
}
func checkOptionalDependencies() []checkResult {
var results []checkResult
if utils.IsServiceActive("accounts-daemon", false) {
results = append(results, checkResult{catOptionalFeatures, "accountsservice", statusOK, "Running", "User accounts"})
} else {
results = append(results, checkResult{catOptionalFeatures, "accountsservice", statusWarn, "Not running", "User accounts"})
}
if utils.IsServiceActive("power-profiles-daemon", false) {
results = append(results, checkResult{catOptionalFeatures, "power-profiles-daemon", statusOK, "Running", "Power profile management"})
} else {
results = append(results, checkResult{catOptionalFeatures, "power-profiles-daemon", statusInfo, "Not running", "Power profile management"})
}
i2cStatus := checkI2CAvailability()
results = append(results, i2cStatus)
terminals := []string{"ghostty", "kitty", "alacritty", "foot", "wezterm"}
if idx := slices.IndexFunc(terminals, utils.CommandExists); idx >= 0 {
results = append(results, checkResult{catOptionalFeatures, "Terminal", statusOK, terminals[idx], ""})
} else {
results = append(results, checkResult{catOptionalFeatures, "Terminal", statusWarn, "None found", "Install ghostty, kitty, or alacritty"})
}
deps := []struct {
name, cmd, altCmd, desc string
important bool
}{
{"matugen", "matugen", "", "Dynamic theming", true},
{"dgop", "dgop", "", "System monitoring", true},
{"cava", "cava", "", "Audio waveform", false},
{"khal", "khal", "", "Calendar events", false},
{"Network", "nmcli", "iwctl", "Network management", false},
{"danksearch", "dsearch", "", "File search", false},
{"loginctl", "loginctl", "", "Session management", false},
{"fprintd", "fprintd-list", "", "Fingerprint auth", false},
}
for _, d := range deps {
found, foundCmd := utils.CommandExists(d.cmd), d.cmd
if !found && d.altCmd != "" {
if utils.CommandExists(d.altCmd) {
found, foundCmd = true, d.altCmd
}
}
if found {
message := "Installed"
details := d.desc
if d.name == "Network" {
result, err := network.DetectNetworkStack()
if err == nil && result.Backend != network.BackendNone {
message = detectNetworkBackend() + " (active)"
if doctorVerbose {
details = result.ChosenReason
}
} else {
switch foundCmd {
case "nmcli":
message = "NetworkManager (installed)"
case "iwctl":
message = "iwd (installed)"
}
}
}
results = append(results, checkResult{catOptionalFeatures, d.name, statusOK, message, details})
} else if d.important {
results = append(results, checkResult{catOptionalFeatures, d.name, statusWarn, "Missing", d.desc})
} else {
results = append(results, checkResult{catOptionalFeatures, d.name, statusInfo, "Not installed", d.desc})
}
}
return results
}
func checkConfigurationFiles() []checkResult {
configDir, _ := os.UserConfigDir()
cacheDir, _ := os.UserCacheDir()
dmsDir := "DankMaterialShell"
configFiles := []struct{ name, path string }{
{"settings.json", filepath.Join(configDir, dmsDir, "settings.json")},
{"clsettings.json", filepath.Join(configDir, dmsDir, "clsettings.json")},
{"plugin_settings.json", filepath.Join(configDir, dmsDir, "plugin_settings.json")},
{"session.json", filepath.Join(utils.XDGStateHome(), dmsDir, "session.json")},
{"dms-colors.json", filepath.Join(cacheDir, dmsDir, "dms-colors.json")},
}
var results []checkResult
for _, cf := range configFiles {
info, err := os.Stat(cf.path)
if err == nil {
status := statusOK
message := "Present"
if info.Mode().Perm()&0200 == 0 {
status = statusWarn
message += " (read-only)"
}
results = append(results, checkResult{catConfigFiles, cf.name, status, message, cf.path})
} else {
results = append(results, checkResult{catConfigFiles, cf.name, statusInfo, "Not yet created", cf.path})
}
}
return results
}
func checkSystemdServices() []checkResult {
if !utils.CommandExists("systemctl") {
return nil
}
var results []checkResult
dmsState := getServiceState("dms", true)
if !dmsState.exists {
results = append(results, checkResult{catServices, "dms.service", statusInfo, "Not installed", "Optional user service"})
} else {
status, message := statusOK, dmsState.enabled
if dmsState.active != "" {
message = fmt.Sprintf("%s, %s", dmsState.enabled, dmsState.active)
}
if dmsState.enabled == "disabled" {
status, message = statusWarn, "Disabled"
}
results = append(results, checkResult{catServices, "dms.service", status, message, ""})
}
greetdState := getServiceState("greetd", false)
if greetdState.exists {
status := statusOK
if greetdState.enabled == "disabled" {
status = statusInfo
}
results = append(results, checkResult{catServices, "greetd", status, greetdState.enabled, ""})
} else if doctorVerbose {
results = append(results, checkResult{catServices, "greetd", statusInfo, "Not installed", "Optional greeter service"})
}
return results
}
type serviceState struct {
exists bool
enabled string
active string
}
func getServiceState(name string, userService bool) serviceState {
args := []string{"is-enabled", name}
if userService {
args = []string{"--user", "is-enabled", name}
}
output, _ := exec.Command("systemctl", args...).Output()
enabled := strings.TrimSpace(string(output))
if enabled == "" || enabled == "not-found" {
return serviceState{}
}
state := serviceState{exists: true, enabled: enabled}
if userService {
output, _ = exec.Command("systemctl", "--user", "is-active", name).Output()
if active := strings.TrimSpace(string(output)); active != "" && active != "unknown" {
state.active = active
}
}
return state
}
func printResults(results []checkResult) {
theme := tui.TerminalTheme()
styles := tui.NewStyles(theme)
currentCategory := category(-1)
for _, r := range results {
if r.category != currentCategory {
if currentCategory != -1 {
fmt.Println()
}
fmt.Printf(" %s\n", styles.Bold.Render(r.category.String()))
currentCategory = r.category
}
printResultLine(r, styles)
}
}
func printResultLine(r checkResult, styles tui.Styles) {
icon, style := r.status.IconStyle(styles)
name := r.name
nameLen := len(name)
if nameLen > checkNameMaxLength {
name = name[:checkNameMaxLength-1] + "…"
nameLen = checkNameMaxLength
}
dots := strings.Repeat("·", checkNameMaxLength-nameLen)
fmt.Printf(" %s %s %s %s\n", style.Render(icon), name, styles.Subtle.Render(dots), r.message)
if doctorVerbose && r.details != "" {
fmt.Printf(" %s\n", styles.Subtle.Render("└─ "+r.details))
}
}
func printSummary(results []checkResult, qsMissingFeatures bool) {
theme := tui.TerminalTheme()
styles := tui.NewStyles(theme)
var ds DoctorStatus
for _, r := range results {
ds.Add(r)
}
fmt.Println()
fmt.Printf(" %s\n", styles.Subtle.Render("──────────────────────────────────────"))
if !ds.HasIssues() {
fmt.Printf(" %s\n", styles.Success.Render("✓ All checks passed!"))
} else {
var parts []string
if ds.ErrorCount() > 0 {
parts = append(parts, styles.Error.Render(fmt.Sprintf("%d error(s)", ds.ErrorCount())))
}
if ds.WarningCount() > 0 {
parts = append(parts, styles.Warning.Render(fmt.Sprintf("%d warning(s)", ds.WarningCount())))
}
parts = append(parts, styles.Success.Render(fmt.Sprintf("%d ok", ds.OKCount())))
fmt.Printf(" %s\n", strings.Join(parts, ", "))
if qsMissingFeatures {
fmt.Println()
fmt.Printf(" %s\n", styles.Subtle.Render("→ Consider using quickshell-git for full feature support"))
}
}
fmt.Println()
}

View File

@@ -377,7 +377,7 @@ func updateDMSBinary() error {
}
version := ""
for _, line := range strings.Split(string(output), "\n") {
for line := range strings.SplitSeq(string(output), "\n") {
if strings.Contains(line, "\"tag_name\"") {
parts := strings.Split(line, "\"")
if len(parts) >= 4 {
@@ -443,7 +443,7 @@ func updateDMSBinary() error {
decompressedPath := filepath.Join(tempDir, "dms")
if err := os.Chmod(decompressedPath, 0755); err != nil {
if err := os.Chmod(decompressedPath, 0o755); err != nil {
return fmt.Errorf("failed to make binary executable: %w", err)
}

View File

@@ -211,8 +211,8 @@ func checkGroupExists(groupName string) bool {
return false
}
lines := strings.Split(string(data), "\n")
for _, line := range lines {
lines := strings.SplitSeq(string(data), "\n")
for line := range lines {
if strings.HasPrefix(line, groupName+":") {
return true
}
@@ -521,7 +521,7 @@ func enableGreeter() error {
newConfig := strings.Join(finalLines, "\n")
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)
}
@@ -592,8 +592,8 @@ func checkGreeterStatus() error {
if data, err := os.ReadFile(configPath); err == nil {
configContent := string(data)
if strings.Contains(configContent, "dms-greeter") {
lines := strings.Split(configContent, "\n")
for _, line := range lines {
lines := strings.SplitSeq(configContent, "\n")
for line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "command =") || strings.HasPrefix(trimmed, "command=") {
parts := strings.SplitN(trimmed, "=", 2)

View File

@@ -74,7 +74,7 @@ func buildMatugenOptions(cmd *cobra.Command) matugen.Options {
ConfigDir: configDir,
Kind: kind,
Value: value,
Mode: mode,
Mode: matugen.ColorMode(mode),
IconTheme: iconTheme,
MatugenType: matugenType,
RunUserTemplates: runUserTemplates,

View File

@@ -50,15 +50,18 @@ func findConfig(cmd *cobra.Command, args []string) error {
configStateFile := filepath.Join(getRuntimeDir(), "danklinux.path")
if data, readErr := os.ReadFile(configStateFile); readErr == nil {
statePath := strings.TrimSpace(string(data))
shellPath := filepath.Join(statePath, "shell.qml")
if info, statErr := os.Stat(shellPath); statErr == nil && !info.IsDir() {
log.Debug("Using config from active session state file: %s", statePath)
configPath = statePath
log.Debug("Using config from: %s", configPath)
return nil // <-- Guard statement
if len(getAllDMSPIDs()) == 0 {
os.Remove(configStateFile)
} else {
statePath := strings.TrimSpace(string(data))
shellPath := filepath.Join(statePath, "shell.qml")
if info, statErr := os.Stat(shellPath); statErr == nil && !info.IsDir() {
log.Debug("Using config from active session state file: %s", statePath)
configPath = statePath
log.Debug("Using config from: %s", configPath)
return nil
}
os.Remove(configStateFile)
}
}

View File

@@ -87,20 +87,14 @@ func newDPMSClient() (*dpmsClient, error) {
switch e.Interface {
case wlr_output_power.ZwlrOutputPowerManagerV1InterfaceName:
powerMgr := wlr_output_power.NewZwlrOutputPowerManagerV1(c.ctx)
version := e.Version
if version > 1 {
version = 1
}
version := min(e.Version, 1)
if err := registry.Bind(e.Name, e.Interface, version, powerMgr); err == nil {
c.powerMgr = powerMgr
}
case "wl_output":
output := wlclient.NewOutput(c.ctx)
version := e.Version
if version > 4 {
version = 4
}
version := min(e.Version, 4)
if err := registry.Bind(e.Name, e.Interface, version, output); err == nil {
outputID := fmt.Sprintf("output-%d", output.ID())
state := &outputState{

View File

@@ -7,8 +7,10 @@ import (
"os/exec"
"os/signal"
"path/filepath"
"slices"
"strconv"
"strings"
"sync"
"syscall"
"time"
@@ -184,8 +186,10 @@ func runShellInteractive(session bool) {
cmd := exec.CommandContext(ctx, "qs", "-p", configPath)
cmd.Env = append(os.Environ(), "DMS_SOCKET="+socketPath)
if qtRules := log.GetQtLoggingRules(); qtRules != "" {
cmd.Env = append(cmd.Env, "QT_LOGGING_RULES="+qtRules)
if os.Getenv("QT_LOGGING_RULES") == "" {
if qtRules := log.GetQtLoggingRules(); qtRules != "" {
cmd.Env = append(cmd.Env, "QT_LOGGING_RULES="+qtRules)
}
}
if isSessionManaged && hasSystemdRun() {
@@ -371,13 +375,7 @@ func killShell() {
func runShellDaemon(session bool) {
isSessionManaged = session
isDaemonChild := false
for _, arg := range os.Args {
if arg == "--daemon-child" {
isDaemonChild = true
break
}
}
isDaemonChild := slices.Contains(os.Args, "--daemon-child")
if !isDaemonChild {
fmt.Fprintf(os.Stderr, "dms %s\n", Version)
@@ -428,8 +426,10 @@ func runShellDaemon(session bool) {
cmd := exec.CommandContext(ctx, "qs", "-p", configPath)
cmd.Env = append(os.Environ(), "DMS_SOCKET="+socketPath)
if qtRules := log.GetQtLoggingRules(); qtRules != "" {
cmd.Env = append(cmd.Env, "QT_LOGGING_RULES="+qtRules)
if os.Getenv("QT_LOGGING_RULES") == "" {
if qtRules := log.GetQtLoggingRules(); qtRules != "" {
cmd.Env = append(cmd.Env, "QT_LOGGING_RULES="+qtRules)
}
}
if isSessionManaged && hasSystemdRun() {
@@ -531,12 +531,20 @@ func runShellDaemon(session bool) {
}
}
var qsHasAnyDisplay = sync.OnceValue(func() bool {
out, err := exec.Command("qs", "ipc", "--help").Output()
if err != nil {
return false
}
return strings.Contains(string(out), "--any-display")
})
func parseTargetsFromIPCShowOutput(output string) ipcTargets {
targets := make(ipcTargets)
var currentTarget string
for _, line := range strings.Split(output, "\n") {
if strings.HasPrefix(line, "target ") {
currentTarget = strings.TrimSpace(strings.TrimPrefix(line, "target "))
for line := range strings.SplitSeq(output, "\n") {
if after, ok := strings.CutPrefix(line, "target "); ok {
currentTarget = strings.TrimSpace(after)
targets[currentTarget] = make(map[string][]string)
}
if strings.HasPrefix(line, " function") && currentTarget != "" {
@@ -561,7 +569,11 @@ func parseTargetsFromIPCShowOutput(output string) ipcTargets {
}
func getShellIPCCompletions(args []string, _ string) []string {
cmdArgs := []string{"-p", configPath, "ipc", "show"}
cmdArgs := []string{"ipc"}
if qsHasAnyDisplay() {
cmdArgs = append(cmdArgs, "--any-display")
}
cmdArgs = append(cmdArgs, "-p", configPath, "show")
cmd := exec.Command("qs", cmdArgs...)
var targets ipcTargets
@@ -615,7 +627,12 @@ func runShellIPCCommand(args []string) {
args = append([]string{"call"}, args...)
}
cmdArgs := append([]string{"-p", configPath, "ipc"}, args...)
cmdArgs := []string{"ipc"}
if qsHasAnyDisplay() {
cmdArgs = append(cmdArgs, "--any-display")
}
cmdArgs = append(cmdArgs, "-p", configPath)
cmdArgs = append(cmdArgs, args...)
cmd := exec.Command("qs", cmdArgs...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout

View File

@@ -3,6 +3,7 @@ package main
import (
"fmt"
"os/exec"
"slices"
"strings"
)
@@ -36,13 +37,7 @@ func checkSystemdServiceEnabled(serviceName string) (string, bool, error) {
if err != nil {
knownStates := []string{"disabled", "masked", "masked-runtime", "not-found", "enabled", "enabled-runtime", "static", "indirect", "alias"}
isKnownState := false
for _, known := range knownStates {
if stateStr == known {
isKnownState = true
break
}
}
isKnownState := slices.Contains(knownStates, stateStr)
if !isKnownState {
return stateStr, false, fmt.Errorf("systemctl is-enabled failed: %w (output: %s)", err, stateStr)

View File

@@ -221,10 +221,7 @@ func (p *Picker) handleGlobal(e client.RegistryGlobalEvent) {
case client.OutputInterfaceName:
output := client.NewOutput(p.ctx)
version := e.Version
if version > 4 {
version = 4
}
version := min(e.Version, 4)
if err := p.registry.Bind(e.Name, e.Interface, version, output); err == nil {
p.outputsMu.Lock()
p.outputs[e.Name] = &Output{
@@ -239,20 +236,14 @@ func (p *Picker) handleGlobal(e client.RegistryGlobalEvent) {
case wlr_layer_shell.ZwlrLayerShellV1InterfaceName:
layerShell := wlr_layer_shell.NewZwlrLayerShellV1(p.ctx)
version := e.Version
if version > 4 {
version = 4
}
version := min(e.Version, 4)
if err := p.registry.Bind(e.Name, e.Interface, version, layerShell); err == nil {
p.layerShell = layerShell
}
case wlr_screencopy.ZwlrScreencopyManagerV1InterfaceName:
screencopy := wlr_screencopy.NewZwlrScreencopyManagerV1(p.ctx)
version := e.Version
if version > 3 {
version = 3
}
version := min(e.Version, 3)
if err := p.registry.Bind(e.Name, e.Interface, version, screencopy); err == nil {
p.screencopy = screencopy
}

View File

@@ -1157,7 +1157,7 @@ func drawGlyph(data []byte, stride, width, height, x, y int, r rune, col Color,
rOff, bOff = 2, 0
}
for row := 0; row < fontH; row++ {
for row := range fontH {
yy := y + row
if yy < 0 || yy >= height {
continue
@@ -1165,7 +1165,7 @@ func drawGlyph(data []byte, stride, width, height, x, y int, r rune, col Color,
rowPattern := g[row]
dstRowOff := yy * stride
for colIdx := 0; colIdx < fontW; colIdx++ {
for colIdx := range fontW {
if (rowPattern & (1 << (fontW - 1 - colIdx))) == 0 {
continue
}

View File

@@ -14,11 +14,11 @@ func TestSurfaceState_ConcurrentPointerMotion(t *testing.T) {
const goroutines = 50
const iterations = 100
for i := 0; i < goroutines; i++ {
for i := range goroutines {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < iterations; j++ {
for j := range iterations {
s.OnPointerMotion(float64(id*10+j), float64(id*10+j))
}
}(i)
@@ -34,21 +34,21 @@ func TestSurfaceState_ConcurrentScaleAccess(t *testing.T) {
const goroutines = 30
const iterations = 100
for i := 0; i < goroutines/2; i++ {
for i := range goroutines / 2 {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < iterations; j++ {
for range iterations {
s.SetScale(int32(id%3 + 1))
}
}(i)
}
for i := 0; i < goroutines/2; i++ {
for range goroutines / 2 {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < iterations; j++ {
for range iterations {
scale := s.Scale()
assert.GreaterOrEqual(t, scale, int32(1))
}
@@ -65,21 +65,21 @@ func TestSurfaceState_ConcurrentLogicalSize(t *testing.T) {
const goroutines = 20
const iterations = 100
for i := 0; i < goroutines/2; i++ {
for i := range goroutines / 2 {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < iterations; j++ {
for j := range iterations {
_ = s.OnLayerConfigure(1920+id, 1080+j)
}
}(i)
}
for i := 0; i < goroutines/2; i++ {
for range goroutines / 2 {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < iterations; j++ {
for range iterations {
w, h := s.LogicalSize()
_ = w
_ = h
@@ -97,31 +97,31 @@ func TestSurfaceState_ConcurrentIsDone(t *testing.T) {
const goroutines = 30
const iterations = 100
for i := 0; i < goroutines/3; i++ {
for range goroutines / 3 {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < iterations; j++ {
for range iterations {
s.OnPointerButton(0x110, 1)
}
}()
}
for i := 0; i < goroutines/3; i++ {
for range goroutines / 3 {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < iterations; j++ {
for range iterations {
s.OnKey(1, 1)
}
}()
}
for i := 0; i < goroutines/3; i++ {
for range goroutines / 3 {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < iterations; j++ {
for range iterations {
picked, cancelled := s.IsDone()
_ = picked
_ = cancelled
@@ -139,11 +139,11 @@ func TestSurfaceState_ConcurrentIsReady(t *testing.T) {
const goroutines = 20
const iterations = 100
for i := 0; i < goroutines; i++ {
for range goroutines {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < iterations; j++ {
for range iterations {
_ = s.IsReady()
}
}()
@@ -159,11 +159,11 @@ func TestSurfaceState_ConcurrentSwapBuffers(t *testing.T) {
const goroutines = 20
const iterations = 100
for i := 0; i < goroutines; i++ {
for range goroutines {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < iterations; j++ {
for range iterations {
s.SwapBuffers()
}
}()

View File

@@ -462,7 +462,7 @@ func TestHyprlandConfigStructure(t *testing.T) {
assert.Contains(t, HyprlandConfig, "# KEYBINDINGS")
assert.Contains(t, HyprlandConfig, "bind = $mod, T, exec, {{TERMINAL_COMMAND}}")
assert.Contains(t, HyprlandConfig, "bind = $mod, space, exec, dms ipc call spotlight toggle")
assert.Contains(t, HyprlandConfig, "windowrulev2 = noborder, class:^(com\\.mitchellh\\.ghostty)$")
assert.Contains(t, HyprlandConfig, "windowrule = border_size 0, match:class ^(com\\.mitchellh\\.ghostty)$")
}
func TestGhosttyConfigStructure(t *testing.T) {

View File

@@ -21,7 +21,7 @@ func LocateDMSConfig() (string, error) {
dataDirs = "/usr/local/share:/usr/share"
}
for _, dir := range strings.Split(dataDirs, ":") {
for dir := range strings.SplitSeq(dataDirs, ":") {
if dir != "" {
primaryPaths = append(primaryPaths, filepath.Join(dir, "quickshell", "dms"))
}
@@ -33,7 +33,7 @@ func LocateDMSConfig() (string, error) {
configDirs = "/etc/xdg"
}
for _, dir := range strings.Split(configDirs, ":") {
for dir := range strings.SplitSeq(configDirs, ":") {
if dir != "" {
primaryPaths = append(primaryPaths, filepath.Join(dir, "quickshell", "dms"))
}

View File

@@ -90,36 +90,36 @@ misc {
# ==================
# WINDOW RULES
# ==================
windowrulev2 = tile, class:^(org\.wezfurlong\.wezterm)$
windowrule = tile on, match:class ^(org\.wezfurlong\.wezterm)$
windowrulev2 = rounding 12, class:^(org\.gnome\.)
windowrulev2 = noborder, class:^(org\.gnome\.)
windowrule = rounding 12, match:class ^(org\.gnome\.)
windowrule = border_size 0, match:class ^(org\.gnome\.)
windowrulev2 = tile, class:^(gnome-control-center)$
windowrulev2 = tile, class:^(pavucontrol)$
windowrulev2 = tile, class:^(nm-connection-editor)$
windowrule = tile on, match:class ^(gnome-control-center)$
windowrule = tile on, match:class ^(pavucontrol)$
windowrule = tile on, match:class ^(nm-connection-editor)$
windowrulev2 = float, class:^(gnome-calculator)$
windowrulev2 = float, class:^(galculator)$
windowrulev2 = float, class:^(blueman-manager)$
windowrulev2 = float, class:^(org\.gnome\.Nautilus)$
windowrulev2 = float, class:^(steam)$
windowrulev2 = float, class:^(xdg-desktop-portal)$
windowrule = float on, match:class ^(gnome-calculator)$
windowrule = float on, match:class ^(galculator)$
windowrule = float on, match:class ^(blueman-manager)$
windowrule = float on, match:class ^(org\.gnome\.Nautilus)$
windowrule = float on, match:class ^(steam)$
windowrule = float on, match:class ^(xdg-desktop-portal)$
windowrulev2 = noborder, class:^(org\.wezfurlong\.wezterm)$
windowrulev2 = noborder, class:^(Alacritty)$
windowrulev2 = noborder, class:^(zen)$
windowrulev2 = noborder, class:^(com\.mitchellh\.ghostty)$
windowrulev2 = noborder, class:^(kitty)$
windowrule = border_size 0, match:class ^(org\.wezfurlong\.wezterm)$
windowrule = border_size 0, match:class ^(Alacritty)$
windowrule = border_size 0, match:class ^(zen)$
windowrule = border_size 0, match:class ^(com\.mitchellh\.ghostty)$
windowrule = border_size 0, match:class ^(kitty)$
windowrulev2 = float, class:^(firefox)$, title:^(Picture-in-Picture)$
windowrulev2 = float, class:^(zoom)$
windowrule = float on, match:class ^(firefox)$, match:title ^(Picture-in-Picture)$
windowrule = float on, match:class ^(zoom)$
# DMS windows floating by default
windowrulev2 = float, class:^(org.quickshell)$
windowrulev2 = opacity 0.9 0.9, floating:0, focus:0
windowrule = float on, match:class ^(org.quickshell)$
windowrule = opacity 0.9 0.9, match:float false, match:focus false
layerrule = noanim, ^(quickshell)$
layerrule = no_anim on, match:namespace ^(quickshell)$
# ==================
# KEYBINDINGS
@@ -150,6 +150,10 @@ bindel = , XF86AudioRaiseVolume, exec, dms ipc call audio increment 3
bindel = , XF86AudioLowerVolume, exec, dms ipc call audio decrement 3
bindl = , XF86AudioMute, exec, dms ipc call audio mute
bindl = , XF86AudioMicMute, exec, dms ipc call audio micmute
bindl = , XF86AudioPause, exec, dms ipc call mpris playPause
bindl = , XF86AudioPlay, exec, dms ipc call mpris playPause
bindl = , XF86AudioPrev, exec, dms ipc call mpris previous
bindl = , XF86AudioNext, exec, dms ipc call mpris next
# === Brightness Controls ===
bindel = , XF86MonBrightnessUp, exec, dms ipc call brightness increment 5 ""

View File

@@ -51,6 +51,18 @@ binds {
XF86AudioMicMute allow-when-locked=true {
spawn "dms" "ipc" "call" "audio" "micmute";
}
XF86AudioPause allow-when-locked=true {
spawn "dms" "ipc" "call" "mpris" "playPause";
}
XF86AudioPlay allow-when-locked=true {
spawn "dms" "ipc" "call" "mpris" "playPause";
}
XF86AudioPrev allow-when-locked=true {
spawn "dms" "ipc" "call" "mpris" "previous";
}
XF86AudioNext allow-when-locked=true {
spawn "dms" "ipc" "call" "mpris" "next";
}
// === Brightness Controls ===
XF86MonBrightnessUp allow-when-locked=true {

View File

@@ -345,7 +345,7 @@ func EnsureContrastDPSLstar(hexColor, hexBg string, minLc float64, isLightMode b
}
step := 0.5
for i := 0; i < 120; i++ {
for range 120 {
Lf = math.Max(0, math.Min(100, Lf+dir*step))
cand := labToHex(Lf, af, bf)
if DeltaPhiStarContrast(cand, hexBg, isLightMode) >= minLc {

View File

@@ -658,7 +658,7 @@ func TestContrastAlgorithmComparison(t *testing.T) {
}
differentCount := 0
for i := 0; i < 16; i++ {
for i := range 16 {
if wcagColors[i].Hex != dpsColors[i].Hex {
differentCount++
}

View File

@@ -7,6 +7,7 @@ import (
"os/exec"
"path/filepath"
"runtime"
"slices"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
@@ -514,12 +515,9 @@ func (a *ArchDistribution) reorderAURPackages(packages []string) []string {
dmsShell = append(dmsShell, pkg)
} else {
isDep := false
for _, dep := range dmsDepencies {
if pkg == dep {
deps = append(deps, pkg)
isDep = true
break
}
if slices.Contains(dmsDepencies, pkg) {
deps = append(deps, pkg)
isDep = true
}
if !isDep {
others = append(others, pkg)
@@ -545,7 +543,7 @@ func (a *ArchDistribution) installSingleAURPackage(ctx context.Context, pkg, sud
a.log(fmt.Sprintf("Warning: failed to clean existing cache for %s: %v", pkg, err))
}
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)
}
defer func() {

View File

@@ -18,8 +18,8 @@ type ManualPackageInstaller struct {
// parseLatestTagFromGitOutput parses git ls-remote output and returns the latest tag
func (m *ManualPackageInstaller) parseLatestTagFromGitOutput(output string) string {
lines := strings.Split(output, "\n")
for _, line := range lines {
lines := strings.SplitSeq(output, "\n")
for line := range lines {
if strings.Contains(line, "refs/tags/") && !strings.Contains(line, "^{}") {
parts := strings.Split(line, "refs/tags/")
if len(parts) > 1 {
@@ -103,12 +103,12 @@ func (m *ManualPackageInstaller) installDgop(ctx context.Context, sudoPassword s
}
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)
}
tmpDir := filepath.Join(cacheDir, "dgop-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)
}
defer os.RemoveAll(tmpDir)
@@ -160,10 +160,10 @@ func (m *ManualPackageInstaller) installNiri(ctx context.Context, sudoPassword s
homeDir, _ := os.UserHomeDir()
buildDir := filepath.Join(homeDir, ".cache", "dankinstall", "niri-build")
tmpDir := filepath.Join(homeDir, ".cache", "dankinstall", "tmp")
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)
}
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)
}
defer func() {
@@ -237,12 +237,12 @@ func (m *ManualPackageInstaller) installQuickshell(ctx context.Context, variant
}
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)
}
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)
}
defer os.RemoveAll(tmpDir)
@@ -273,7 +273,7 @@ func (m *ManualPackageInstaller) installQuickshell(ctx context.Context, variant
}
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)
}
@@ -343,12 +343,12 @@ func (m *ManualPackageInstaller) installHyprland(ctx context.Context, sudoPasswo
}
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)
}
tmpDir := filepath.Join(cacheDir, "hyprland-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)
}
defer os.RemoveAll(tmpDir)
@@ -406,12 +406,12 @@ func (m *ManualPackageInstaller) installGhostty(ctx context.Context, sudoPasswor
}
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)
}
tmpDir := filepath.Join(cacheDir, "ghostty-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)
}
defer os.RemoveAll(tmpDir)
@@ -528,7 +528,7 @@ func (m *ManualPackageInstaller) installDankMaterialShell(ctx context.Context, v
}
configDir := filepath.Dir(dmsPath)
if err := os.MkdirAll(configDir, 0755); err != nil {
if err := os.MkdirAll(configDir, 0o755); err != nil {
return fmt.Errorf("failed to create quickshell config directory: %w", err)
}

View File

@@ -23,7 +23,7 @@ func DefaultDiscoveryConfig() *DiscoveryConfig {
configDirs := os.Getenv("XDG_CONFIG_DIRS")
if configDirs != "" {
for _, dir := range strings.Split(configDirs, ":") {
for dir := range strings.SplitSeq(configDirs, ":") {
if dir != "" {
searchPaths = append(searchPaths, filepath.Join(dir, "DankMaterialShell", "cheatsheets"))
}

View File

@@ -12,7 +12,7 @@ func TestNewJSONFileProvider(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "test.json")
if err := os.WriteFile(testFile, []byte("{}"), 0644); err != nil {
if err := os.WriteFile(testFile, []byte("{}"), 0o644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
@@ -81,7 +81,7 @@ func TestJSONFileProviderGetCheatSheet(t *testing.T) {
}
}`
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
if err := os.WriteFile(testFile, []byte(content), 0o644); err != nil {
t.Fatalf("Failed to write test file: %v", err)
}
@@ -135,7 +135,7 @@ func TestJSONFileProviderGetCheatSheetNoProvider(t *testing.T) {
"binds": {}
}`
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
if err := os.WriteFile(testFile, []byte(content), 0o644); err != nil {
t.Fatalf("Failed to write test file: %v", err)
}
@@ -181,7 +181,7 @@ func TestJSONFileProviderFlatArrayBackwardsCompat(t *testing.T) {
]
}`
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
if err := os.WriteFile(testFile, []byte(content), 0o644); err != nil {
t.Fatalf("Failed to write test file: %v", err)
}
@@ -216,7 +216,7 @@ func TestJSONFileProviderInvalidJSON(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "invalid.json")
if err := os.WriteFile(testFile, []byte("not valid json"), 0644); err != nil {
if err := os.WriteFile(testFile, []byte("not valid json"), 0o644); err != nil {
t.Fatalf("Failed to write test file: %v", err)
}

View File

@@ -16,6 +16,22 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
)
type ColorMode string
const (
ColorModeDark ColorMode = "dark"
ColorModeLight ColorMode = "light"
)
func (c *ColorMode) GTKTheme() string {
switch *c {
case ColorModeDark:
return "adw-gtk3-dark"
default:
return "adw-gtk3"
}
}
var (
matugenVersionOnce sync.Once
matugenSupportsCOE bool
@@ -27,7 +43,7 @@ type Options struct {
ConfigDir string
Kind string
Value string
Mode string
Mode ColorMode
IconTheme string
MatugenType string
RunUserTemplates bool
@@ -77,7 +93,7 @@ func Run(opts Options) error {
return fmt.Errorf("value is required")
}
if opts.Mode == "" {
opts.Mode = "dark"
opts.Mode = ColorModeDark
}
if opts.MatugenType == "" {
opts.MatugenType = "scheme-tonal-spot"
@@ -145,7 +161,7 @@ func buildOnce(opts *Options) error {
importArgs = []string{"--import-json-string", importData}
log.Info("Running matugen color hex with stock color overrides")
args := []string{"color", "hex", primaryDark, "-m", opts.Mode, "-t", opts.MatugenType, "-c", cfgFile.Name()}
args := []string{"color", "hex", primaryDark, "-m", string(opts.Mode), "-t", opts.MatugenType, "-c", cfgFile.Name()}
args = append(args, importArgs...)
if err := runMatugen(args); err != nil {
return err
@@ -181,7 +197,7 @@ func buildOnce(opts *Options) error {
default:
args = []string{opts.Kind, opts.Value}
}
args = append(args, "-m", opts.Mode, "-t", opts.MatugenType, "-c", cfgFile.Name())
args = append(args, "-m", string(opts.Mode), "-t", opts.MatugenType, "-c", cfgFile.Name())
args = append(args, importArgs...)
if err := runMatugen(args); err != nil {
return err
@@ -234,61 +250,61 @@ output_path = '%s'
if !opts.ShouldSkipTemplate("gtk") {
switch opts.Mode {
case "light":
appendConfig(opts, cfgFile, "skip", "gtk3-light.toml")
appendConfig(opts, cfgFile, nil, nil, "gtk3-light.toml")
default:
appendConfig(opts, cfgFile, "skip", "gtk3-dark.toml")
appendConfig(opts, cfgFile, nil, nil, "gtk3-dark.toml")
}
}
if !opts.ShouldSkipTemplate("niri") {
appendConfig(opts, cfgFile, "niri", "niri.toml")
appendConfig(opts, cfgFile, []string{"niri"}, nil, "niri.toml")
}
if !opts.ShouldSkipTemplate("qt5ct") {
appendConfig(opts, cfgFile, "qt5ct", "qt5ct.toml")
appendConfig(opts, cfgFile, []string{"qt5ct"}, nil, "qt5ct.toml")
}
if !opts.ShouldSkipTemplate("qt6ct") {
appendConfig(opts, cfgFile, "qt6ct", "qt6ct.toml")
appendConfig(opts, cfgFile, []string{"qt6ct"}, nil, "qt6ct.toml")
}
if !opts.ShouldSkipTemplate("firefox") {
appendConfig(opts, cfgFile, "firefox", "firefox.toml")
appendConfig(opts, cfgFile, []string{"firefox"}, nil, "firefox.toml")
}
if !opts.ShouldSkipTemplate("pywalfox") {
appendConfig(opts, cfgFile, "pywalfox", "pywalfox.toml")
appendConfig(opts, cfgFile, []string{"pywalfox"}, nil, "pywalfox.toml")
}
if !opts.ShouldSkipTemplate("zenbrowser") {
appendConfig(opts, cfgFile, "zen", "zenbrowser.toml")
appendConfig(opts, cfgFile, []string{"zen", "zen-browser"}, []string{"app.zen_browser.zen"}, "zenbrowser.toml")
}
if !opts.ShouldSkipTemplate("vesktop") {
appendConfig(opts, cfgFile, "vesktop", "vesktop.toml")
appendConfig(opts, cfgFile, []string{"vesktop"}, []string{"dev.vencord.Vesktop"}, "vesktop.toml")
}
if !opts.ShouldSkipTemplate("equibop") {
appendConfig(opts, cfgFile, "equibop", "equibop.toml")
appendConfig(opts, cfgFile, []string{"equibop"}, nil, "equibop.toml")
}
if !opts.ShouldSkipTemplate("ghostty") {
appendTerminalConfig(opts, cfgFile, tmpDir, "ghostty", "ghostty.toml")
appendTerminalConfig(opts, cfgFile, tmpDir, []string{"ghostty"}, nil, "ghostty.toml")
}
if !opts.ShouldSkipTemplate("kitty") {
appendTerminalConfig(opts, cfgFile, tmpDir, "kitty", "kitty.toml")
appendTerminalConfig(opts, cfgFile, tmpDir, []string{"kitty"}, nil, "kitty.toml")
}
if !opts.ShouldSkipTemplate("foot") {
appendTerminalConfig(opts, cfgFile, tmpDir, "foot", "foot.toml")
appendTerminalConfig(opts, cfgFile, tmpDir, []string{"foot"}, nil, "foot.toml")
}
if !opts.ShouldSkipTemplate("alacritty") {
appendTerminalConfig(opts, cfgFile, tmpDir, "alacritty", "alacritty.toml")
appendTerminalConfig(opts, cfgFile, tmpDir, []string{"alacritty"}, nil, "alacritty.toml")
}
if !opts.ShouldSkipTemplate("wezterm") {
appendTerminalConfig(opts, cfgFile, tmpDir, "wezterm", "wezterm.toml")
appendTerminalConfig(opts, cfgFile, tmpDir, []string{"wezterm"}, nil, "wezterm.toml")
}
if !opts.ShouldSkipTemplate("nvim") {
appendTerminalConfig(opts, cfgFile, tmpDir, "nvim", "neovim.toml")
appendTerminalConfig(opts, cfgFile, tmpDir, []string{"nvim"}, nil, "neovim.toml")
}
if !opts.ShouldSkipTemplate("dgop") {
appendConfig(opts, cfgFile, "dgop", "dgop.toml")
appendConfig(opts, cfgFile, []string{"dgop"}, nil, "dgop.toml")
}
if !opts.ShouldSkipTemplate("kcolorscheme") {
appendConfig(opts, cfgFile, "skip", "kcolorscheme.toml")
appendConfig(opts, cfgFile, nil, nil, "kcolorscheme.toml")
}
if !opts.ShouldSkipTemplate("vscode") {
@@ -326,12 +342,21 @@ output_path = '%s'
return nil
}
func appendConfig(opts *Options, cfgFile *os.File, checkCmd, fileName string) {
func appendConfig(
opts *Options,
cfgFile *os.File,
checkCmd []string,
checkFlatpaks []string,
fileName string,
) {
configPath := filepath.Join(opts.ShellDir, "matugen", "configs", fileName)
if _, err := os.Stat(configPath); err != nil {
return
}
if checkCmd != "skip" && !utils.CommandExists(checkCmd) {
cmdExists := checkCmd == nil || utils.AnyCommandExists(checkCmd...)
flatpakExists := checkFlatpaks == nil || utils.AnyFlatpakExists(checkFlatpaks...)
if !cmdExists && !flatpakExists {
return
}
data, err := os.ReadFile(configPath)
@@ -342,12 +367,15 @@ func appendConfig(opts *Options, cfgFile *os.File, checkCmd, fileName string) {
cfgFile.WriteString("\n")
}
func appendTerminalConfig(opts *Options, cfgFile *os.File, tmpDir, checkCmd, fileName string) {
func appendTerminalConfig(opts *Options, cfgFile *os.File, tmpDir string, checkCmd []string, checkFlatpaks []string, fileName string) {
configPath := filepath.Join(opts.ShellDir, "matugen", "configs", fileName)
if _, err := os.Stat(configPath); err != nil {
return
}
if checkCmd != "skip" && !utils.CommandExists(checkCmd) {
cmdExists := checkCmd == nil || utils.AnyCommandExists(checkCmd...)
flatpakExists := checkFlatpaks == nil || utils.AnyFlatpakExists(checkFlatpaks...)
if !cmdExists && !flatpakExists {
return
}
data, err := os.ReadFile(configPath)
@@ -556,19 +584,19 @@ func extractNestedColor(jsonStr, colorName, variant string) string {
return color
}
func generateDank16Variants(primaryDark, primaryLight, surface, mode string) string {
func generateDank16Variants(primaryDark, primaryLight, surface string, mode ColorMode) string {
variantOpts := dank16.VariantOptions{
PrimaryDark: primaryDark,
PrimaryLight: primaryLight,
Background: surface,
UseDPS: true,
IsLightMode: mode == "light",
IsLightMode: mode == ColorModeLight,
}
variantColors := dank16.GenerateVariantPalette(variantOpts)
return dank16.GenerateVariantJSON(variantColors)
}
func refreshGTK(configDir, mode string) {
func refreshGTK(configDir string, mode ColorMode) {
gtkCSS := filepath.Join(configDir, "gtk-3.0", "gtk.css")
info, err := os.Lstat(gtkCSS)
@@ -594,7 +622,7 @@ func refreshGTK(configDir, mode string) {
}
exec.Command("gsettings", "set", "org.gnome.desktop.interface", "gtk-theme", "").Run()
exec.Command("gsettings", "set", "org.gnome.desktop.interface", "gtk-theme", "adw-gtk3-"+mode).Run()
exec.Command("gsettings", "set", "org.gnome.desktop.interface", "gtk-theme", mode.GTKTheme()).Run()
}
func signalTerminals() {
@@ -624,9 +652,9 @@ func signalByName(name string, sig syscall.Signal) {
}
}
func syncColorScheme(mode string) {
func syncColorScheme(mode ColorMode) {
scheme := "prefer-dark"
if mode == "light" {
if mode == ColorModeLight {
scheme = "default"
}

View File

@@ -0,0 +1,300 @@
package matugen
import (
"os"
"path/filepath"
"testing"
)
func TestAppendConfigBinaryExists(t *testing.T) {
tempDir := t.TempDir()
shellDir := filepath.Join(tempDir, "shell")
configsDir := filepath.Join(shellDir, "matugen", "configs")
if err := os.MkdirAll(configsDir, 0755); err != nil {
t.Fatalf("failed to create configs dir: %v", err)
}
testConfig := "test config content"
configPath := filepath.Join(configsDir, "test.toml")
if err := os.WriteFile(configPath, []byte(testConfig), 0644); err != nil {
t.Fatalf("failed to write config: %v", err)
}
outFile := filepath.Join(tempDir, "output.toml")
cfgFile, err := os.Create(outFile)
if err != nil {
t.Fatalf("failed to create output file: %v", err)
}
defer cfgFile.Close()
opts := &Options{ShellDir: shellDir}
appendConfig(opts, cfgFile, []string{"sh"}, nil, "test.toml")
cfgFile.Close()
output, err := os.ReadFile(outFile)
if err != nil {
t.Fatalf("failed to read output: %v", err)
}
if len(output) == 0 {
t.Errorf("expected config to be written when binary exists")
}
if string(output) != testConfig+"\n" {
t.Errorf("expected %q, got %q", testConfig+"\n", string(output))
}
}
func TestAppendConfigBinaryDoesNotExist(t *testing.T) {
tempDir := t.TempDir()
shellDir := filepath.Join(tempDir, "shell")
configsDir := filepath.Join(shellDir, "matugen", "configs")
if err := os.MkdirAll(configsDir, 0755); err != nil {
t.Fatalf("failed to create configs dir: %v", err)
}
testConfig := "test config content"
configPath := filepath.Join(configsDir, "test.toml")
if err := os.WriteFile(configPath, []byte(testConfig), 0644); err != nil {
t.Fatalf("failed to write config: %v", err)
}
outFile := filepath.Join(tempDir, "output.toml")
cfgFile, err := os.Create(outFile)
if err != nil {
t.Fatalf("failed to create output file: %v", err)
}
defer cfgFile.Close()
opts := &Options{ShellDir: shellDir}
appendConfig(opts, cfgFile, []string{"nonexistent-binary-12345"}, []string{}, "test.toml")
cfgFile.Close()
output, err := os.ReadFile(outFile)
if err != nil {
t.Fatalf("failed to read output: %v", err)
}
if len(output) != 0 {
t.Errorf("expected no config when binary doesn't exist, got: %q", string(output))
}
}
func TestAppendConfigFlatpakExists(t *testing.T) {
tempDir := t.TempDir()
shellDir := filepath.Join(tempDir, "shell")
configsDir := filepath.Join(shellDir, "matugen", "configs")
if err := os.MkdirAll(configsDir, 0755); err != nil {
t.Fatalf("failed to create configs dir: %v", err)
}
testConfig := "zen config content"
configPath := filepath.Join(configsDir, "test.toml")
if err := os.WriteFile(configPath, []byte(testConfig), 0644); err != nil {
t.Fatalf("failed to write config: %v", err)
}
outFile := filepath.Join(tempDir, "output.toml")
cfgFile, err := os.Create(outFile)
if err != nil {
t.Fatalf("failed to create output file: %v", err)
}
defer cfgFile.Close()
opts := &Options{ShellDir: shellDir}
appendConfig(opts, cfgFile, nil, []string{"app.zen_browser.zen"}, "test.toml")
cfgFile.Close()
output, err := os.ReadFile(outFile)
if err != nil {
t.Fatalf("failed to read output: %v", err)
}
if len(output) == 0 {
t.Errorf("expected config to be written when flatpak exists")
}
}
func TestAppendConfigFlatpakDoesNotExist(t *testing.T) {
tempDir := t.TempDir()
shellDir := filepath.Join(tempDir, "shell")
configsDir := filepath.Join(shellDir, "matugen", "configs")
if err := os.MkdirAll(configsDir, 0755); err != nil {
t.Fatalf("failed to create configs dir: %v", err)
}
testConfig := "test config content"
configPath := filepath.Join(configsDir, "test.toml")
if err := os.WriteFile(configPath, []byte(testConfig), 0644); err != nil {
t.Fatalf("failed to write config: %v", err)
}
outFile := filepath.Join(tempDir, "output.toml")
cfgFile, err := os.Create(outFile)
if err != nil {
t.Fatalf("failed to create output file: %v", err)
}
defer cfgFile.Close()
opts := &Options{ShellDir: shellDir}
appendConfig(opts, cfgFile, []string{}, []string{"com.nonexistent.flatpak"}, "test.toml")
cfgFile.Close()
output, err := os.ReadFile(outFile)
if err != nil {
t.Fatalf("failed to read output: %v", err)
}
if len(output) != 0 {
t.Errorf("expected no config when flatpak doesn't exist, got: %q", string(output))
}
}
func TestAppendConfigBothExist(t *testing.T) {
tempDir := t.TempDir()
shellDir := filepath.Join(tempDir, "shell")
configsDir := filepath.Join(shellDir, "matugen", "configs")
if err := os.MkdirAll(configsDir, 0755); err != nil {
t.Fatalf("failed to create configs dir: %v", err)
}
testConfig := "zen config content"
configPath := filepath.Join(configsDir, "test.toml")
if err := os.WriteFile(configPath, []byte(testConfig), 0644); err != nil {
t.Fatalf("failed to write config: %v", err)
}
outFile := filepath.Join(tempDir, "output.toml")
cfgFile, err := os.Create(outFile)
if err != nil {
t.Fatalf("failed to create output file: %v", err)
}
defer cfgFile.Close()
opts := &Options{ShellDir: shellDir}
appendConfig(opts, cfgFile, []string{"sh"}, []string{"app.zen_browser.zen"}, "test.toml")
cfgFile.Close()
output, err := os.ReadFile(outFile)
if err != nil {
t.Fatalf("failed to read output: %v", err)
}
if len(output) == 0 {
t.Errorf("expected config to be written when both binary and flatpak exist")
}
}
func TestAppendConfigNeitherExists(t *testing.T) {
tempDir := t.TempDir()
shellDir := filepath.Join(tempDir, "shell")
configsDir := filepath.Join(shellDir, "matugen", "configs")
if err := os.MkdirAll(configsDir, 0755); err != nil {
t.Fatalf("failed to create configs dir: %v", err)
}
testConfig := "test config content"
configPath := filepath.Join(configsDir, "test.toml")
if err := os.WriteFile(configPath, []byte(testConfig), 0644); err != nil {
t.Fatalf("failed to write config: %v", err)
}
outFile := filepath.Join(tempDir, "output.toml")
cfgFile, err := os.Create(outFile)
if err != nil {
t.Fatalf("failed to create output file: %v", err)
}
defer cfgFile.Close()
opts := &Options{ShellDir: shellDir}
appendConfig(opts, cfgFile, []string{"nonexistent-binary-12345"}, []string{"com.nonexistent.flatpak"}, "test.toml")
cfgFile.Close()
output, err := os.ReadFile(outFile)
if err != nil {
t.Fatalf("failed to read output: %v", err)
}
if len(output) != 0 {
t.Errorf("expected no config when neither exists, got: %q", string(output))
}
}
func TestAppendConfigNoChecks(t *testing.T) {
tempDir := t.TempDir()
shellDir := filepath.Join(tempDir, "shell")
configsDir := filepath.Join(shellDir, "matugen", "configs")
if err := os.MkdirAll(configsDir, 0755); err != nil {
t.Fatalf("failed to create configs dir: %v", err)
}
testConfig := "always include"
configPath := filepath.Join(configsDir, "test.toml")
if err := os.WriteFile(configPath, []byte(testConfig), 0644); err != nil {
t.Fatalf("failed to write config: %v", err)
}
outFile := filepath.Join(tempDir, "output.toml")
cfgFile, err := os.Create(outFile)
if err != nil {
t.Fatalf("failed to create output file: %v", err)
}
defer cfgFile.Close()
opts := &Options{ShellDir: shellDir}
appendConfig(opts, cfgFile, nil, nil, "test.toml")
cfgFile.Close()
output, err := os.ReadFile(outFile)
if err != nil {
t.Fatalf("failed to read output: %v", err)
}
if len(output) == 0 {
t.Errorf("expected config to be written when no checks specified")
}
}
func TestAppendConfigFileDoesNotExist(t *testing.T) {
tempDir := t.TempDir()
shellDir := filepath.Join(tempDir, "shell")
configsDir := filepath.Join(shellDir, "matugen", "configs")
if err := os.MkdirAll(configsDir, 0755); err != nil {
t.Fatalf("failed to create configs dir: %v", err)
}
outFile := filepath.Join(tempDir, "output.toml")
cfgFile, err := os.Create(outFile)
if err != nil {
t.Fatalf("failed to create output file: %v", err)
}
defer cfgFile.Close()
opts := &Options{ShellDir: shellDir}
appendConfig(opts, cfgFile, nil, nil, "nonexistent.toml")
cfgFile.Close()
output, err := os.ReadFile(outFile)
if err != nil {
t.Fatalf("failed to read output: %v", err)
}
if len(output) != 0 {
t.Errorf("expected no config when file doesn't exist, got: %q", string(output))
}
}

View File

@@ -141,7 +141,7 @@ func (r *RegionSelector) setupKeyboardHandlers() {
for _, os := range r.surfaces {
r.redrawSurface(os)
}
case 28, 57:
case 28, 57, 96:
if r.selection.hasSelection {
r.finishSelection()
}

View File

@@ -19,9 +19,9 @@ func HandleRequest(conn net.Conn, req models.Request, manager *Manager) {
func handleOpen(conn net.Conn, req models.Request, manager *Manager) {
log.Infof("AppPicker: Received %s request with params: %+v", req.Method, req.Params)
target, ok := req.Params["target"].(string)
target, ok := models.Get[string](req, "target")
if !ok {
target, ok = req.Params["url"].(string)
target, ok = models.Get[string](req, "url")
if !ok {
log.Warnf("AppPicker: Invalid target parameter in request")
models.RespondError(conn, req.ID, "invalid target parameter")
@@ -31,14 +31,11 @@ func handleOpen(conn net.Conn, req models.Request, manager *Manager) {
event := OpenEvent{
Target: target,
RequestType: "url",
RequestType: models.GetOr(req, "requestType", "url"),
MimeType: models.GetOr(req, "mimeType", ""),
}
if mimeType, ok := req.Params["mimeType"].(string); ok {
event.MimeType = mimeType
}
if categories, ok := req.Params["categories"].([]any); ok {
if categories, ok := models.Get[[]any](req, "categories"); ok {
event.Categories = make([]string, 0, len(categories))
for _, cat := range categories {
if catStr, ok := cat.(string); ok {
@@ -47,10 +44,6 @@ func handleOpen(conn net.Conn, req models.Request, manager *Manager) {
}
}
if requestType, ok := req.Params["requestType"].(string); ok {
event.RequestType = requestType
}
log.Infof("AppPicker: Broadcasting event: %+v", event)
manager.RequestOpen(event)
models.Respond(conn, req.ID, "ok")

View File

@@ -9,7 +9,7 @@ import (
func HandleRequest(conn net.Conn, req models.Request, manager *Manager) {
switch req.Method {
case "browser.open":
url, ok := req.Params["url"].(string)
url, ok := models.Get[string](req, "url")
if !ok {
models.RespondError(conn, req.ID, "invalid url parameter")
return

View File

@@ -168,14 +168,14 @@ func handleSearch(conn net.Conn, req models.Request, m *Manager) {
Offset: params.IntOpt(req.Params, "offset", 0),
}
if img, ok := req.Params["isImage"].(bool); ok {
if img, ok := models.Get[bool](req, "isImage"); ok {
p.IsImage = &img
}
if b, ok := req.Params["before"].(float64); ok {
if b, ok := models.Get[float64](req, "before"); ok {
v := int64(b)
p.Before = &v
}
if a, ok := req.Params["after"].(float64); ok {
if a, ok := models.Get[float64](req, "after"); ok {
v := int64(a)
p.After = &v
}
@@ -190,24 +190,21 @@ func handleGetConfig(conn net.Conn, req models.Request, m *Manager) {
func handleSetConfig(conn net.Conn, req models.Request, m *Manager) {
cfg := m.GetConfig()
if _, ok := req.Params["maxHistory"]; ok {
cfg.MaxHistory = params.IntOpt(req.Params, "maxHistory", cfg.MaxHistory)
if v, ok := models.Get[float64](req, "maxHistory"); ok {
cfg.MaxHistory = int(v)
}
if _, ok := req.Params["maxEntrySize"]; ok {
cfg.MaxEntrySize = int64(params.IntOpt(req.Params, "maxEntrySize", int(cfg.MaxEntrySize)))
if v, ok := models.Get[float64](req, "maxEntrySize"); ok {
cfg.MaxEntrySize = int64(v)
}
if _, ok := req.Params["autoClearDays"]; ok {
cfg.AutoClearDays = params.IntOpt(req.Params, "autoClearDays", cfg.AutoClearDays)
if v, ok := models.Get[float64](req, "autoClearDays"); ok {
cfg.AutoClearDays = int(v)
}
if v, ok := req.Params["clearAtStartup"].(bool); ok {
if v, ok := models.Get[bool](req, "clearAtStartup"); ok {
cfg.ClearAtStartup = v
}
if v, ok := req.Params["disabled"].(bool); ok {
if v, ok := models.Get[bool](req, "disabled"); ok {
cfg.Disabled = v
}
if v, ok := req.Params["disableHistory"].(bool); ok {
cfg.DisableHistory = v
}
if err := m.SetConfig(cfg); err != nil {
models.RespondError(conn, req.ID, err.Error())

View File

@@ -36,10 +36,6 @@ var sensitiveMimeTypes = []string{
}
func NewManager(wlCtx wlcontext.WaylandContext, config Config) (*Manager, error) {
if config.Disabled {
return nil, fmt.Errorf("clipboard disabled in config")
}
display := wlCtx.Display()
dbPath, err := getDBPath()
if err != nil {
@@ -61,8 +57,10 @@ func NewManager(wlCtx wlcontext.WaylandContext, config Config) (*Manager, error)
dbPath: dbPath,
}
if err := m.setupRegistry(); err != nil {
return nil, err
if !config.Disabled {
if err := m.setupRegistry(); err != nil {
return nil, err
}
}
m.notifierWg.Add(1)
@@ -70,17 +68,17 @@ func NewManager(wlCtx wlcontext.WaylandContext, config Config) (*Manager, error)
go m.watchConfig()
if !config.DisableHistory {
db, err := openDB(dbPath)
if err != nil {
return nil, fmt.Errorf("failed to open db: %w", err)
}
m.db = db
db, err := openDB(dbPath)
if err != nil {
return nil, fmt.Errorf("failed to open db: %w", err)
}
m.db = db
if err := m.migrateHashes(); err != nil {
log.Errorf("Failed to migrate hashes: %v", err)
}
if err := m.migrateHashes(); err != nil {
log.Errorf("Failed to migrate hashes: %v", err)
}
if !config.Disabled {
if config.ClearAtStartup {
if err := m.clearHistoryInternal(); err != nil {
log.Errorf("Failed to clear history at startup: %v", err)
@@ -97,7 +95,7 @@ func NewManager(wlCtx wlcontext.WaylandContext, config Config) (*Manager, error)
m.alive = true
m.updateState()
if m.dataControlMgr != nil && m.seat != nil {
if !config.Disabled && m.dataControlMgr != nil && m.seat != nil {
m.setupDataDeviceSync()
}
@@ -326,7 +324,7 @@ func (m *Manager) readAndStore(r *os.File, mimeType string) {
return
}
if !cfg.DisableHistory && m.db != nil {
if !cfg.Disabled && m.db != nil {
m.storeClipboardEntry(data, mimeType)
}
@@ -1211,23 +1209,13 @@ func (m *Manager) applyConfigChange(newCfg Config) {
m.config = newCfg
m.configMutex.Unlock()
if newCfg.DisableHistory && !oldCfg.DisableHistory && m.db != nil {
log.Info("Clipboard history disabled, closing database")
m.db.Close()
m.db = nil
switch {
case newCfg.Disabled && !oldCfg.Disabled:
log.Info("Clipboard tracking disabled")
case !newCfg.Disabled && oldCfg.Disabled:
log.Info("Clipboard tracking enabled")
}
if !newCfg.DisableHistory && oldCfg.DisableHistory && m.db == nil {
log.Info("Clipboard history enabled, opening database")
if db, err := openDB(m.dbPath); err == nil {
m.db = db
} else {
log.Errorf("Failed to reopen database: %v", err)
}
}
log.Infof("Clipboard config reloaded: disableHistory=%v", newCfg.DisableHistory)
m.updateState()
m.notifySubscribers()
}
@@ -1235,8 +1223,8 @@ func (m *Manager) applyConfigChange(newCfg Config) {
func (m *Manager) StoreData(data []byte, mimeType string) error {
cfg := m.getConfig()
if cfg.DisableHistory {
return fmt.Errorf("clipboard history disabled")
if cfg.Disabled {
return fmt.Errorf("clipboard tracking disabled")
}
if m.db == nil {

View File

@@ -457,7 +457,6 @@ func TestDefaultConfig(t *testing.T) {
assert.Equal(t, 0, cfg.AutoClearDays)
assert.False(t, cfg.ClearAtStartup)
assert.False(t, cfg.Disabled)
assert.False(t, cfg.DisableHistory)
}
func TestManager_PostDelegatesToWlContext(t *testing.T) {

View File

@@ -18,9 +18,7 @@ type Config struct {
MaxEntrySize int64 `json:"maxEntrySize"`
AutoClearDays int `json:"autoClearDays"`
ClearAtStartup bool `json:"clearAtStartup"`
Disabled bool `json:"disabled"`
DisableHistory bool `json:"disableHistory"`
Disabled bool `json:"disabled"`
}
func DefaultConfig() Config {

View File

@@ -41,19 +41,19 @@ func handleGetState(conn net.Conn, req models.Request, manager *Manager) {
}
func handleSetTags(conn net.Conn, req models.Request, manager *Manager) {
output, ok := req.Params["output"].(string)
output, ok := models.Get[string](req, "output")
if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'output' parameter")
return
}
tagmask, ok := req.Params["tagmask"].(float64)
tagmask, ok := models.Get[float64](req, "tagmask")
if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'tagmask' parameter")
return
}
toggleTagset, ok := req.Params["toggleTagset"].(float64)
toggleTagset, ok := models.Get[float64](req, "toggleTagset")
if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'toggleTagset' parameter")
return
@@ -68,19 +68,19 @@ func handleSetTags(conn net.Conn, req models.Request, manager *Manager) {
}
func handleSetClientTags(conn net.Conn, req models.Request, manager *Manager) {
output, ok := req.Params["output"].(string)
output, ok := models.Get[string](req, "output")
if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'output' parameter")
return
}
andTags, ok := req.Params["andTags"].(float64)
andTags, ok := models.Get[float64](req, "andTags")
if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'andTags' parameter")
return
}
xorTags, ok := req.Params["xorTags"].(float64)
xorTags, ok := models.Get[float64](req, "xorTags")
if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'xorTags' parameter")
return
@@ -95,13 +95,13 @@ func handleSetClientTags(conn net.Conn, req models.Request, manager *Manager) {
}
func handleSetLayout(conn net.Conn, req models.Request, manager *Manager) {
output, ok := req.Params["output"].(string)
output, ok := models.Get[string](req, "output")
if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'output' parameter")
return
}
index, ok := req.Params["index"].(float64)
index, ok := models.Get[float64](req, "index")
if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'index' parameter")
return

View File

@@ -43,12 +43,8 @@ func handleGetState(conn net.Conn, req models.Request, manager *Manager) {
}
func handleActivateWorkspace(conn net.Conn, req models.Request, manager *Manager) {
groupID, ok := req.Params["groupID"].(string)
if !ok {
groupID = ""
}
workspaceID, ok := req.Params["workspaceID"].(string)
groupID := models.GetOr(req, "groupID", "")
workspaceID, ok := models.Get[string](req, "workspaceID")
if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'workspaceID' parameter")
return
@@ -63,12 +59,8 @@ func handleActivateWorkspace(conn net.Conn, req models.Request, manager *Manager
}
func handleDeactivateWorkspace(conn net.Conn, req models.Request, manager *Manager) {
groupID, ok := req.Params["groupID"].(string)
if !ok {
groupID = ""
}
workspaceID, ok := req.Params["workspaceID"].(string)
groupID := models.GetOr(req, "groupID", "")
workspaceID, ok := models.Get[string](req, "workspaceID")
if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'workspaceID' parameter")
return
@@ -83,12 +75,8 @@ func handleDeactivateWorkspace(conn net.Conn, req models.Request, manager *Manag
}
func handleRemoveWorkspace(conn net.Conn, req models.Request, manager *Manager) {
groupID, ok := req.Params["groupID"].(string)
if !ok {
groupID = ""
}
workspaceID, ok := req.Params["workspaceID"].(string)
groupID := models.GetOr(req, "groupID", "")
workspaceID, ok := models.Get[string](req, "workspaceID")
if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'workspaceID' parameter")
return
@@ -103,13 +91,13 @@ func handleRemoveWorkspace(conn net.Conn, req models.Request, manager *Manager)
}
func handleCreateWorkspace(conn net.Conn, req models.Request, manager *Manager) {
groupID, ok := req.Params["groupID"].(string)
groupID, ok := models.Get[string](req, "groupID")
if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'groupID' parameter")
return
}
workspaceName, ok := req.Params["name"].(string)
workspaceName, ok := models.Get[string](req, "name")
if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'name' parameter")
return

View File

@@ -15,37 +15,23 @@ type MatugenQueueResult struct {
}
func handleMatugenQueue(conn net.Conn, req models.Request) {
getString := func(key string) string {
if v, ok := req.Params[key].(string); ok {
return v
}
return ""
}
getBool := func(key string, def bool) bool {
if v, ok := req.Params[key].(bool); ok {
return v
}
return def
}
opts := matugen.Options{
StateDir: getString("stateDir"),
ShellDir: getString("shellDir"),
ConfigDir: getString("configDir"),
Kind: getString("kind"),
Value: getString("value"),
Mode: getString("mode"),
IconTheme: getString("iconTheme"),
MatugenType: getString("matugenType"),
RunUserTemplates: getBool("runUserTemplates", true),
StockColors: getString("stockColors"),
SyncModeWithPortal: getBool("syncModeWithPortal", false),
TerminalsAlwaysDark: getBool("terminalsAlwaysDark", false),
SkipTemplates: getString("skipTemplates"),
StateDir: models.GetOr(req, "stateDir", ""),
ShellDir: models.GetOr(req, "shellDir", ""),
ConfigDir: models.GetOr(req, "configDir", ""),
Kind: models.GetOr(req, "kind", ""),
Value: models.GetOr(req, "value", ""),
Mode: matugen.ColorMode(models.GetOr(req, "mode", "")),
IconTheme: models.GetOr(req, "iconTheme", ""),
MatugenType: models.GetOr(req, "matugenType", ""),
RunUserTemplates: models.GetOr(req, "runUserTemplates", true),
StockColors: models.GetOr(req, "stockColors", ""),
SyncModeWithPortal: models.GetOr(req, "syncModeWithPortal", false),
TerminalsAlwaysDark: models.GetOr(req, "terminalsAlwaysDark", false),
SkipTemplates: models.GetOr(req, "skipTemplates", ""),
}
wait := getBool("wait", true)
wait := models.GetOr(req, "wait", true)
queue := matugen.GetQueue()
resultCh := queue.Submit(opts)

View File

@@ -5,6 +5,7 @@ import (
"net"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/params"
)
type Request struct {
@@ -13,6 +14,15 @@ type Request struct {
Params map[string]any `json:"params,omitempty"`
}
func Get[T any](r Request, key string) (T, bool) {
v, err := params.Get[T](r.Params, key)
return v, err == nil
}
func GetOr[T any](r Request, key string, def T) T {
return params.GetOpt(r.Params, key, def)
}
type Response[T any] struct {
ID int `json:"id,omitempty"`
Result *T `json:"result,omitempty"`

View File

@@ -0,0 +1,52 @@
package models
import "testing"
func TestGet(t *testing.T) {
req := Request{Params: map[string]any{"name": "test", "count": 42, "enabled": true}}
name, ok := Get[string](req, "name")
if !ok || name != "test" {
t.Errorf("Get[string] = %q, %v; want 'test', true", name, ok)
}
count, ok := Get[int](req, "count")
if !ok || count != 42 {
t.Errorf("Get[int] = %d, %v; want 42, true", count, ok)
}
enabled, ok := Get[bool](req, "enabled")
if !ok || !enabled {
t.Errorf("Get[bool] = %v, %v; want true, true", enabled, ok)
}
_, ok = Get[string](req, "missing")
if ok {
t.Error("Get missing key should return false")
}
_, ok = Get[int](req, "name")
if ok {
t.Error("Get wrong type should return false")
}
}
func TestGetOr(t *testing.T) {
req := Request{Params: map[string]any{"name": "test", "enabled": true}}
if v := GetOr(req, "name", "default"); v != "test" {
t.Errorf("GetOr existing = %q; want 'test'", v)
}
if v := GetOr(req, "missing", "default"); v != "default" {
t.Errorf("GetOr missing = %q; want 'default'", v)
}
if v := GetOr(req, "enabled", false); !v {
t.Errorf("GetOr bool = %v; want true", v)
}
if v := GetOr(req, "name", 0); v != 0 {
t.Errorf("GetOr wrong type = %d; want 0 (default)", v)
}
}

View File

@@ -233,6 +233,9 @@ func (a *SecretAgent) GetSecrets(
if a.manager != nil && connType == "802-11-wireless" && a.manager.WasRecentlyFailed(ssid) {
reason = "wrong-password"
}
if settingName == "vpn" && isPKCS11Auth(conn, vpnSvc) {
reason = "pkcs11"
}
var connId, connUuid string
if c, ok := conn["connection"]; ok {
@@ -249,6 +252,28 @@ func (a *SecretAgent) GetSecrets(
}
if settingName == "vpn" && a.backend != nil {
// Check for cached PKCS11 PIN first
isPKCS11Request := len(fields) == 1 && fields[0] == "key_pass"
if isPKCS11Request {
a.backend.cachedPKCS11Mu.Lock()
cached := a.backend.cachedPKCS11PIN
if cached != nil && cached.ConnectionUUID == connUuid {
a.backend.cachedPKCS11PIN = nil
a.backend.cachedPKCS11Mu.Unlock()
log.Infof("[SecretAgent] Using cached PKCS11 PIN")
out := nmSettingMap{}
vpnSec := nmVariantMap{}
vpnSec["secrets"] = dbus.MakeVariant(map[string]string{"key_pass": cached.PIN})
out[settingName] = vpnSec
return out, nil
}
a.backend.cachedPKCS11Mu.Unlock()
}
// Check for cached VPN password
a.backend.cachedVPNCredsMu.Lock()
cached := a.backend.cachedVPNCreds
if cached != nil && cached.ConnectionUUID == connUuid {
@@ -258,9 +283,9 @@ func (a *SecretAgent) GetSecrets(
log.Infof("[SecretAgent] Using cached password from pre-activation prompt")
out := nmSettingMap{}
sec := nmVariantMap{}
sec["password"] = dbus.MakeVariant(cached.Password)
out[settingName] = sec
vpnSec := nmVariantMap{}
vpnSec["secrets"] = dbus.MakeVariant(map[string]string{"password": cached.Password})
out[settingName] = vpnSec
if cached.SavePassword {
a.backend.pendingVPNSaveMu.Lock()
@@ -364,16 +389,41 @@ func (a *SecretAgent) GetSecrets(
}
sec[k] = dbus.MakeVariant(v)
}
out[settingName] = sec
// Check if this is PKCS11 auth (key_pass)
pin, isPKCS11 := reply.Secrets["key_pass"]
switch settingName {
case "802-1x":
log.Infof("[SecretAgent] Returning 802-1x enterprise secrets with %d fields", len(sec))
case "vpn":
log.Infof("[SecretAgent] Returning VPN secrets with %d fields for %s", len(sec), vpnSvc)
}
// VPN secrets must be wrapped in a "secrets" key per NM spec
secretsDict := make(map[string]string)
for k, v := range reply.Secrets {
if k != "username" {
secretsDict[k] = v
}
}
vpnSec := nmVariantMap{}
vpnSec["secrets"] = dbus.MakeVariant(secretsDict)
out[settingName] = vpnSec
log.Infof("[SecretAgent] Returning VPN secrets with %d fields for %s", len(secretsDict), vpnSvc)
if settingName == "vpn" && a.backend != nil && (vpnUsername != "" || reply.Save) {
// Cache PKCS11 PIN in case GetSecrets is called again during activation
if isPKCS11 && a.backend != nil {
a.backend.cachedPKCS11Mu.Lock()
a.backend.cachedPKCS11PIN = &cachedPKCS11PIN{
ConnectionUUID: connUuid,
PIN: pin,
}
a.backend.cachedPKCS11Mu.Unlock()
log.Infof("[SecretAgent] Cached PKCS11 PIN for potential re-request")
}
case "802-1x":
out[settingName] = sec
log.Infof("[SecretAgent] Returning 802-1x enterprise secrets with %d fields", len(sec))
default:
out[settingName] = sec
}
if settingName == "vpn" && a.backend != nil && !isPKCS11 && (vpnUsername != "" || reply.Save) {
pw := reply.Secrets["password"]
a.backend.pendingVPNSaveMu.Lock()
a.backend.pendingVPNSave = &pendingVPNCredentials{
@@ -579,6 +629,15 @@ func inferVPNFields(conn map[string]nmVariantMap, vpnService string) []string {
connType := dataMap["connection-type"]
switch {
case strings.Contains(vpnService, "openconnect"):
authType := dataMap["authtype"]
userCert := dataMap["usercert"]
if authType == "cert" && strings.HasPrefix(userCert, "pkcs11:") {
return []string{"key_pass"}
}
if dataMap["username"] == "" {
fields = []string{"username", "password"}
}
case strings.Contains(vpnService, "openvpn"):
if connType == "password" || connType == "password-tls" {
if dataMap["username"] == "" {
@@ -586,7 +645,7 @@ func inferVPNFields(conn map[string]nmVariantMap, vpnService string) []string {
}
}
case strings.Contains(vpnService, "vpnc"), strings.Contains(vpnService, "l2tp"),
strings.Contains(vpnService, "pptp"), strings.Contains(vpnService, "openconnect"):
strings.Contains(vpnService, "pptp"):
if dataMap["username"] == "" {
fields = []string{"username", "password"}
}
@@ -597,6 +656,8 @@ func inferVPNFields(conn map[string]nmVariantMap, vpnService string) []string {
func vpnFieldMeta(field, vpnService string) (label string, isSecret bool) {
switch field {
case "key_pass":
return "PIN", true
case "password":
return "Password", true
case "Xauth password":
@@ -624,6 +685,25 @@ func vpnFieldMeta(field, vpnService string) (label string, isSecret bool) {
return titleCaser.String(strings.ReplaceAll(field, "-", " ")), false
}
func isPKCS11Auth(conn map[string]nmVariantMap, vpnService string) bool {
if !strings.Contains(vpnService, "openconnect") {
return false
}
vpnSettings, ok := conn["vpn"]
if !ok {
return false
}
dataVariant, ok := vpnSettings["data"]
if !ok {
return false
}
dataMap, ok := dataVariant.Value().(map[string]string)
if !ok {
return false
}
return dataMap["authtype"] == "cert" && strings.HasPrefix(dataMap["usercert"], "pkcs11:")
}
func readVPNPasswordFlags(conn map[string]nmVariantMap, settingName string) uint32 {
if settingName != "vpn" {
return 0xFFFF

View File

@@ -72,6 +72,8 @@ type NetworkManagerBackend struct {
pendingVPNSaveMu sync.Mutex
cachedVPNCreds *cachedVPNCredentials
cachedVPNCredsMu sync.Mutex
cachedPKCS11PIN *cachedPKCS11PIN
cachedPKCS11Mu sync.Mutex
onStateChange func()
}
@@ -89,6 +91,11 @@ type cachedVPNCredentials struct {
SavePassword bool
}
type cachedPKCS11PIN struct {
ConnectionUUID string
PIN string
}
func NewNetworkManagerBackend(nmConn ...gonetworkmanager.NetworkManager) (*NetworkManagerBackend, error) {
var nm gonetworkmanager.NetworkManager
var err error

View File

@@ -282,111 +282,26 @@ func (b *NetworkManagerBackend) ConnectVPN(uuidOrName string, singleActive bool)
}
}
needsUsernamePrePrompt := false
var vpnServiceType string
var vpnData map[string]string
if vpnSettings, ok := targetSettings["vpn"]; ok {
if svc, ok := vpnSettings["service-type"].(string); ok {
vpnServiceType = svc
}
if data, ok := vpnSettings["data"].(map[string]string); ok {
connType := data["connection-type"]
username := data["username"]
// OpenVPN password auth needs username in vpn.data
if strings.Contains(vpnServiceType, "openvpn") &&
(connType == "password" || connType == "password-tls") &&
username == "" {
needsUsernamePrePrompt = true
}
vpnData = data
}
}
// If username is needed but missing, prompt for it before activating
if needsUsernamePrePrompt && b.promptBroker != nil {
log.Infof("[ConnectVPN] OpenVPN requires username in vpn.data - prompting before activation")
authAction := detectVPNAuthAction(vpnServiceType, vpnData)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
token, err := b.promptBroker.Ask(ctx, PromptRequest{
Name: connName,
ConnType: "vpn",
VpnService: vpnServiceType,
SettingName: "vpn",
Fields: []string{"username", "password"},
FieldsInfo: []FieldInfo{{Name: "username", Label: "Username", IsSecret: false}, {Name: "password", Label: "Password", IsSecret: true}},
Reason: "required",
ConnectionId: connName,
ConnectionUuid: targetUUID,
ConnectionPath: string(targetConn.GetPath()),
})
if err != nil {
return fmt.Errorf("failed to request credentials: %w", err)
switch authAction {
case "openvpn_username":
if b.promptBroker == nil {
return fmt.Errorf("OpenVPN password authentication requires interactive prompt")
}
reply, err := b.promptBroker.Wait(ctx, token)
if err != nil {
return fmt.Errorf("credentials prompt failed: %w", err)
}
username := reply.Secrets["username"]
password := reply.Secrets["password"]
if username != "" {
connObj := b.dbusConn.Object("org.freedesktop.NetworkManager", targetConn.GetPath())
var existingSettings map[string]map[string]dbus.Variant
if err := connObj.Call("org.freedesktop.NetworkManager.Settings.Connection.GetSettings", 0).Store(&existingSettings); err != nil {
return fmt.Errorf("failed to get settings for username save: %w", err)
}
settings := make(map[string]map[string]dbus.Variant)
if connSection, ok := existingSettings["connection"]; ok {
settings["connection"] = connSection
}
vpn := existingSettings["vpn"]
var data map[string]string
if dataVariant, ok := vpn["data"]; ok {
if dm, ok := dataVariant.Value().(map[string]string); ok {
data = make(map[string]string)
for k, v := range dm {
data[k] = v
}
} else {
data = make(map[string]string)
}
} else {
data = make(map[string]string)
}
data["username"] = username
if reply.Save && password != "" {
data["password-flags"] = "0"
secs := make(map[string]string)
secs["password"] = password
vpn["secrets"] = dbus.MakeVariant(secs)
log.Infof("[ConnectVPN] Saving username and password to vpn.data")
} else {
log.Infof("[ConnectVPN] Saving username to vpn.data (password will be prompted)")
}
vpn["data"] = dbus.MakeVariant(data)
settings["vpn"] = vpn
var result map[string]dbus.Variant
if err := connObj.Call("org.freedesktop.NetworkManager.Settings.Connection.Update2", 0,
settings, uint32(0x1), map[string]dbus.Variant{}).Store(&result); err != nil {
return fmt.Errorf("failed to save username: %w", err)
}
log.Infof("[ConnectVPN] Username saved to connection, now activating")
if password != "" && !reply.Save {
b.cachedVPNCredsMu.Lock()
b.cachedVPNCreds = &cachedVPNCredentials{
ConnectionUUID: targetUUID,
Password: password,
SavePassword: reply.Save,
}
b.cachedVPNCredsMu.Unlock()
log.Infof("[ConnectVPN] Cached password for GetSecrets")
}
if err := b.handleOpenVPNUsernameAuth(targetConn, connName, targetUUID, vpnServiceType); err != nil {
return err
}
}
@@ -417,6 +332,119 @@ func (b *NetworkManagerBackend) ConnectVPN(uuidOrName string, singleActive bool)
return nil
}
func detectVPNAuthAction(serviceType string, data map[string]string) string {
if data == nil {
return ""
}
switch {
case strings.Contains(serviceType, "openvpn"):
connType := data["connection-type"]
username := data["username"]
if (connType == "password" || connType == "password-tls") && username == "" {
return "openvpn_username"
}
}
return ""
}
func (b *NetworkManagerBackend) handleOpenVPNUsernameAuth(targetConn gonetworkmanager.Connection, connName, targetUUID, vpnServiceType string) error {
log.Infof("[ConnectVPN] OpenVPN requires username in vpn.data - prompting before activation")
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
token, err := b.promptBroker.Ask(ctx, PromptRequest{
Name: connName,
ConnType: "vpn",
VpnService: vpnServiceType,
SettingName: "vpn",
Fields: []string{"username", "password"},
FieldsInfo: []FieldInfo{{Name: "username", Label: "Username", IsSecret: false}, {Name: "password", Label: "Password", IsSecret: true}},
Reason: "required",
ConnectionId: connName,
ConnectionUuid: targetUUID,
ConnectionPath: string(targetConn.GetPath()),
})
if err != nil {
return fmt.Errorf("failed to request credentials: %w", err)
}
reply, err := b.promptBroker.Wait(ctx, token)
if err != nil {
return fmt.Errorf("credentials prompt failed: %w", err)
}
if reply.Cancel {
return fmt.Errorf("user cancelled authentication")
}
username := reply.Secrets["username"]
password := reply.Secrets["password"]
if username == "" {
return nil
}
connObj := b.dbusConn.Object("org.freedesktop.NetworkManager", targetConn.GetPath())
var existingSettings map[string]map[string]dbus.Variant
if err := connObj.Call("org.freedesktop.NetworkManager.Settings.Connection.GetSettings", 0).Store(&existingSettings); err != nil {
return fmt.Errorf("failed to get settings for username save: %w", err)
}
settings := make(map[string]map[string]dbus.Variant)
if connSection, ok := existingSettings["connection"]; ok {
settings["connection"] = connSection
}
vpn := existingSettings["vpn"]
var data map[string]string
if dataVariant, ok := vpn["data"]; ok {
if dm, ok := dataVariant.Value().(map[string]string); ok {
data = make(map[string]string)
for k, v := range dm {
data[k] = v
}
} else {
data = make(map[string]string)
}
} else {
data = make(map[string]string)
}
data["username"] = username
if reply.Save && password != "" {
data["password-flags"] = "0"
secs := make(map[string]string)
secs["password"] = password
vpn["secrets"] = dbus.MakeVariant(secs)
log.Infof("[ConnectVPN] Saving username and password to vpn.data")
} else {
log.Infof("[ConnectVPN] Saving username to vpn.data (password will be prompted)")
}
vpn["data"] = dbus.MakeVariant(data)
settings["vpn"] = vpn
var result map[string]dbus.Variant
if err := connObj.Call("org.freedesktop.NetworkManager.Settings.Connection.Update2", 0,
settings, uint32(0x1), map[string]dbus.Variant{}).Store(&result); err != nil {
return fmt.Errorf("failed to save username: %w", err)
}
log.Infof("[ConnectVPN] Username saved to connection")
if password != "" && !reply.Save {
b.cachedVPNCredsMu.Lock()
b.cachedVPNCreds = &cachedVPNCredentials{
ConnectionUUID: targetUUID,
Password: password,
SavePassword: reply.Save,
}
b.cachedVPNCredsMu.Unlock()
log.Infof("[ConnectVPN] Cached password for GetSecrets")
}
return nil
}
func (b *NetworkManagerBackend) DisconnectVPN(uuidOrName string) error {
nm := b.nmConn.(gonetworkmanager.NetworkManager)
@@ -655,6 +683,11 @@ func (b *NetworkManagerBackend) updateVPNConnectionState() {
b.state.LastError = ""
b.stateMutex.Unlock()
// Clear cached PKCS11 PIN on success
b.cachedPKCS11Mu.Lock()
b.cachedPKCS11PIN = nil
b.cachedPKCS11Mu.Unlock()
b.pendingVPNSaveMu.Lock()
pending := b.pendingVPNSave
b.pendingVPNSave = nil
@@ -671,6 +704,11 @@ func (b *NetworkManagerBackend) updateVPNConnectionState() {
b.state.ConnectingVPNUUID = ""
b.state.LastError = "VPN connection failed"
b.stateMutex.Unlock()
// Clear cached PKCS11 PIN on failure
b.cachedPKCS11Mu.Lock()
b.cachedPKCS11PIN = nil
b.cachedPKCS11Mu.Unlock()
return
}
}
@@ -683,6 +721,11 @@ func (b *NetworkManagerBackend) updateVPNConnectionState() {
b.state.ConnectingVPNUUID = ""
b.state.LastError = "VPN connection failed"
b.stateMutex.Unlock()
// Clear cached PKCS11 PIN
b.cachedPKCS11Mu.Lock()
b.cachedPKCS11PIN = nil
b.cachedPKCS11Mu.Unlock()
}
}

View File

@@ -157,7 +157,7 @@ func handleConnectWiFi(conn net.Conn, req models.Request, manager *Manager) {
connReq.Username = params.StringOpt(req.Params, "username", "")
connReq.Device = params.StringOpt(req.Params, "device", "")
if interactive, ok := req.Params["interactive"].(bool); ok {
if interactive, ok := models.Get[bool](req, "interactive"); ok {
connReq.Interactive = interactive
} else {
state := manager.GetState()
@@ -185,7 +185,7 @@ func handleConnectWiFi(conn net.Conn, req models.Request, manager *Manager) {
connReq.ClientCertPath = params.StringOpt(req.Params, "clientCertPath", "")
connReq.PrivateKeyPath = params.StringOpt(req.Params, "privateKeyPath", "")
if useSystemCACerts, ok := req.Params["useSystemCACerts"].(bool); ok {
if useSystemCACerts, ok := models.Get[bool](req, "useSystemCACerts"); ok {
connReq.UseSystemCACerts = &useSystemCACerts
}
@@ -528,13 +528,13 @@ func handleUpdateVPNConfig(conn net.Conn, req models.Request, manager *Manager)
updates := make(map[string]any)
if name, ok := req.Params["name"].(string); ok {
if name, ok := models.Get[string](req, "name"); ok {
updates["name"] = name
}
if autoconnect, ok := req.Params["autoconnect"].(bool); ok {
if autoconnect, ok := models.Get[bool](req, "autoconnect"); ok {
updates["autoconnect"] = autoconnect
}
if data, ok := req.Params["data"].(map[string]any); ok {
if data, ok := models.Get[map[string]any](req, "data"); ok {
updates["data"] = data
}

View File

@@ -9,7 +9,7 @@ import (
)
func HandleInstall(conn net.Conn, req models.Request) {
idOrName, ok := req.Params["name"].(string)
idOrName, ok := models.Get[string](req, "name")
if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'name' parameter")
return

View File

@@ -10,7 +10,7 @@ import (
)
func HandleSearch(conn net.Conn, req models.Request) {
query, ok := req.Params["query"].(string)
query, ok := models.Get[string](req, "query")
if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'query' parameter")
return
@@ -30,15 +30,15 @@ func HandleSearch(conn net.Conn, req models.Request) {
searchResults := plugins.FuzzySearch(query, pluginList)
if category, ok := req.Params["category"].(string); ok && category != "" {
if category := models.GetOr(req, "category", ""); category != "" {
searchResults = plugins.FilterByCategory(category, searchResults)
}
if compositor, ok := req.Params["compositor"].(string); ok && compositor != "" {
if compositor := models.GetOr(req, "compositor", ""); compositor != "" {
searchResults = plugins.FilterByCompositor(compositor, searchResults)
}
if capability, ok := req.Params["capability"].(string); ok && capability != "" {
if capability := models.GetOr(req, "capability", ""); capability != "" {
searchResults = plugins.FilterByCapability(capability, searchResults)
}

View File

@@ -9,7 +9,7 @@ import (
)
func HandleUninstall(conn net.Conn, req models.Request) {
name, ok := req.Params["name"].(string)
name, ok := models.Get[string](req, "name")
if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'name' parameter")
return

View File

@@ -9,7 +9,7 @@ import (
)
func HandleUpdate(conn net.Conn, req models.Request) {
name, ok := req.Params["name"].(string)
name, ok := models.Get[string](req, "name")
if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'name' parameter")
return

View File

@@ -192,24 +192,21 @@ func RouteRequest(conn net.Conn, req models.Request) {
func handleClipboardSetConfig(conn net.Conn, req models.Request) {
cfg := clipboard.LoadConfig()
if v, ok := req.Params["maxHistory"].(float64); ok {
if v, ok := models.Get[float64](req, "maxHistory"); ok {
cfg.MaxHistory = int(v)
}
if v, ok := req.Params["maxEntrySize"].(float64); ok {
if v, ok := models.Get[float64](req, "maxEntrySize"); ok {
cfg.MaxEntrySize = int64(v)
}
if v, ok := req.Params["autoClearDays"].(float64); ok {
if v, ok := models.Get[float64](req, "autoClearDays"); ok {
cfg.AutoClearDays = int(v)
}
if v, ok := req.Params["clearAtStartup"].(bool); ok {
if v, ok := models.Get[bool](req, "clearAtStartup"); ok {
cfg.ClearAtStartup = v
}
if v, ok := req.Params["disabled"].(bool); ok {
if v, ok := models.Get[bool](req, "disabled"); ok {
cfg.Disabled = v
}
if v, ok := req.Params["disableHistory"].(bool); ok {
cfg.DisableHistory = v
}
if err := clipboard.SaveConfig(cfg); err != nil {
models.RespondError(conn, req.ID, err.Error())

View File

@@ -520,7 +520,7 @@ func handleSubscribe(conn net.Conn, req models.Request) {
clientID := fmt.Sprintf("meta-client-%p", conn)
var services []string
if servicesParam, ok := req.Params["services"].([]any); ok {
if servicesParam, ok := models.Get[[]any](req, "services"); ok {
for _, s := range servicesParam {
if str, ok := s.(string); ok {
services = append(services, str)

View File

@@ -9,7 +9,7 @@ import (
)
func HandleInstall(conn net.Conn, req models.Request) {
idOrName, ok := req.Params["name"].(string)
idOrName, ok := models.Get[string](req, "name")
if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'name' parameter")
return

View File

@@ -9,7 +9,7 @@ import (
)
func HandleSearch(conn net.Conn, req models.Request) {
query, ok := req.Params["query"].(string)
query, ok := models.Get[string](req, "query")
if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'query' parameter")
return

View File

@@ -9,7 +9,7 @@ import (
)
func HandleUninstall(conn net.Conn, req models.Request) {
idOrName, ok := req.Params["name"].(string)
idOrName, ok := models.Get[string](req, "name")
if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'name' parameter")
return

View File

@@ -9,7 +9,7 @@ import (
)
func HandleUpdate(conn net.Conn, req models.Request) {
idOrName, ok := req.Params["name"].(string)
idOrName, ok := models.Get[string](req, "name")
if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'name' parameter")
return

View File

@@ -45,7 +45,7 @@ func handleGetState(conn net.Conn, req models.Request, manager *Manager) {
func handleSetTemperature(conn net.Conn, req models.Request, manager *Manager) {
var lowTemp, highTemp int
if temp, ok := req.Params["temp"].(float64); ok {
if temp, ok := models.Get[float64](req, "temp"); ok {
lowTemp = int(temp)
highTemp = int(temp)
} else {
@@ -93,24 +93,10 @@ func handleSetLocation(conn net.Conn, req models.Request, manager *Manager) {
}
func handleSetManualTimes(conn net.Conn, req models.Request, manager *Manager) {
sunriseParam := req.Params["sunrise"]
sunsetParam := req.Params["sunset"]
sunriseStr, sunriseOK := models.Get[string](req, "sunrise")
sunsetStr, sunsetOK := models.Get[string](req, "sunset")
if sunriseParam == nil || sunsetParam == nil {
manager.ClearManualTimes()
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "manual times cleared"})
return
}
sunriseStr, ok := sunriseParam.(string)
if !ok || sunriseStr == "" {
manager.ClearManualTimes()
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "manual times cleared"})
return
}
sunsetStr, ok := sunsetParam.(string)
if !ok || sunsetStr == "" {
if !sunriseOK || !sunsetOK || sunriseStr == "" || sunsetStr == "" {
manager.ClearManualTimes()
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "manual times cleared"})
return

View File

@@ -878,18 +878,35 @@ func (m *Manager) handleDBusSignal(sig *dbus.Signal) {
return
}
preparing, ok := sig.Body[0].(bool)
if !ok {
return
}
if preparing {
if !ok || preparing {
return
}
m.configMutex.RLock()
enabled := m.config.Enabled
m.configMutex.RUnlock()
if enabled {
m.triggerUpdate()
if !enabled {
return
}
time.AfterFunc(500*time.Millisecond, func() {
m.post(func() {
m.configMutex.RLock()
stillEnabled := m.config.Enabled
m.configMutex.RUnlock()
if !stillEnabled || !m.controlsInitialized {
return
}
m.outputs.Range(func(_ uint32, out *outputState) bool {
if out.gammaControl != nil {
out.gammaControl.(*wlr_gamma_control.ZwlrGammaControlV1).Destroy()
out.gammaControl = nil
}
out.retryCount = 0
out.failed = false
m.recreateOutputControl(out)
return true
})
})
})
}
func (m *Manager) triggerUpdate() {

View File

@@ -13,7 +13,7 @@ import (
func TestManager_ActorSerializesOutputStateAccess(t *testing.T) {
m := &Manager{
cmdq: make(chan cmd, 128),
cmdq: make(chan cmd, 8192),
stopChan: make(chan struct{}),
}

View File

@@ -5,13 +5,29 @@ import (
"testing"
"github.com/stretchr/testify/assert"
"golang.org/x/sys/unix"
)
func TestSharedContext_ConcurrentPostNonBlocking(t *testing.T) {
sc := &SharedContext{
cmdQueue: make(chan func(), 256),
stopChan: make(chan struct{}),
func newTestSharedContext(t *testing.T, queueSize int) *SharedContext {
t.Helper()
fds := make([]int, 2)
if err := unix.Pipe(fds); err != nil {
t.Fatalf("failed to create test pipe: %v", err)
}
t.Cleanup(func() {
unix.Close(fds[0])
unix.Close(fds[1])
})
return &SharedContext{
cmdQueue: make(chan func(), queueSize),
stopChan: make(chan struct{}),
wakeR: fds[0],
wakeW: fds[1],
}
}
func TestSharedContext_ConcurrentPostNonBlocking(t *testing.T) {
sc := newTestSharedContext(t, 256)
var wg sync.WaitGroup
const goroutines = 100
@@ -33,10 +49,7 @@ func TestSharedContext_ConcurrentPostNonBlocking(t *testing.T) {
}
func TestSharedContext_PostQueueFull(t *testing.T) {
sc := &SharedContext{
cmdQueue: make(chan func(), 2),
stopChan: make(chan struct{}),
}
sc := newTestSharedContext(t, 2)
sc.Post(func() {})
sc.Post(func() {})
@@ -47,11 +60,8 @@ func TestSharedContext_PostQueueFull(t *testing.T) {
}
func TestSharedContext_StartMultipleTimes(t *testing.T) {
sc := &SharedContext{
cmdQueue: make(chan func(), 256),
stopChan: make(chan struct{}),
started: false,
}
sc := newTestSharedContext(t, 256)
sc.started = true
var wg sync.WaitGroup
const goroutines = 10
@@ -70,10 +80,7 @@ func TestSharedContext_StartMultipleTimes(t *testing.T) {
}
func TestSharedContext_DrainCmdQueue(t *testing.T) {
sc := &SharedContext{
cmdQueue: make(chan func(), 256),
stopChan: make(chan struct{}),
}
sc := newTestSharedContext(t, 256)
counter := 0
for i := 0; i < 10; i++ {
@@ -89,10 +96,7 @@ func TestSharedContext_DrainCmdQueue(t *testing.T) {
}
func TestSharedContext_DrainCmdQueueEmpty(t *testing.T) {
sc := &SharedContext{
cmdQueue: make(chan func(), 256),
stopChan: make(chan struct{}),
}
sc := newTestSharedContext(t, 256)
sc.drainCmdQueue()
@@ -100,10 +104,7 @@ func TestSharedContext_DrainCmdQueueEmpty(t *testing.T) {
}
func TestSharedContext_ConcurrentDrainAndPost(t *testing.T) {
sc := &SharedContext{
cmdQueue: make(chan func(), 256),
stopChan: make(chan struct{}),
}
sc := newTestSharedContext(t, 256)
var wg sync.WaitGroup

View File

@@ -56,7 +56,7 @@ func handleGetState(conn net.Conn, req models.Request, manager *Manager) {
}
func handleApplyConfiguration(conn net.Conn, req models.Request, manager *Manager, test bool) {
headsParam, ok := req.Params["heads"]
headsParam, ok := models.Get[any](req, "heads")
if !ok {
models.RespondError(conn, req.ID, "missing 'heads' parameter")
return

View File

@@ -1,8 +1,33 @@
package utils
import "os/exec"
import (
"os/exec"
"strings"
)
func CommandExists(cmd string) bool {
_, err := exec.LookPath(cmd)
return err == nil
}
func AnyCommandExists(cmds ...string) bool {
for _, cmd := range cmds {
if CommandExists(cmd) {
return true
}
}
return false
}
func IsServiceActive(name string, userService bool) bool {
if !CommandExists("systemctl") {
return false
}
args := []string{"is-active", name}
if userService {
args = []string{"--user", "is-active", name}
}
output, _ := exec.Command("systemctl", args...).Output()
return strings.EqualFold(strings.TrimSpace(string(output)), "active")
}

View File

@@ -0,0 +1,83 @@
package utils
import (
"bytes"
"errors"
"os/exec"
"slices"
"strings"
)
func FlatpakInPath() bool {
_, err := exec.LookPath("flatpak")
return err == nil
}
func FlatpakExists(name string) bool {
if !FlatpakInPath() {
return false
}
cmd := exec.Command("flatpak", "info", name)
err := cmd.Run()
return err == nil
}
func FlatpakSearchBySubstring(substring string) bool {
if !FlatpakInPath() {
return false
}
cmd := exec.Command("flatpak", "list", "--app")
var stdout bytes.Buffer
cmd.Stdout = &stdout
if err := cmd.Run(); err != nil {
return false
}
out := stdout.String()
for line := range strings.SplitSeq(out, "\n") {
fields := strings.Fields(line)
if len(fields) > 1 {
id := fields[1]
idParts := strings.Split(id, ".")
// We are assuming that the last part of the ID is
// the package name we're looking for. This might
// not always be true, some developers use arbitrary
// suffixes.
if len(idParts) > 0 && idParts[len(idParts)-1] == substring {
cmd := exec.Command("flatpak", "info", id)
err := cmd.Run()
return err == nil
}
}
}
return false
}
func AnyFlatpakExists(flatpaks ...string) bool {
return slices.ContainsFunc(flatpaks, FlatpakExists)
}
func FlatpakInstallationDir(name string) (string, error) {
if !FlatpakInPath() {
return "", errors.New("flatpak not found in PATH")
}
cmd := exec.Command("flatpak", "info", "--show-location", name)
var stdout bytes.Buffer
cmd.Stdout = &stdout
if err := cmd.Run(); err != nil {
return "", errors.New("flatpak not installed: " + name)
}
location := strings.TrimSpace(stdout.String())
if location == "" {
return "", errors.New("installation directory not found for: " + name)
}
return location, nil
}

View File

@@ -0,0 +1,249 @@
package utils
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestFlatpakInPathAvailable(t *testing.T) {
result := FlatpakInPath()
if !result {
t.Skip("flatpak not in PATH")
}
if !result {
t.Errorf("expected true when flatpak is in PATH")
}
}
func TestFlatpakInPathUnavailable(t *testing.T) {
tempDir := t.TempDir()
t.Setenv("PATH", tempDir)
result := FlatpakInPath()
if result {
t.Errorf("expected false when flatpak not in PATH, got true")
}
}
func TestFlatpakExistsValidPackage(t *testing.T) {
if !FlatpakInPath() {
t.Skip("flatpak not in PATH")
}
result := FlatpakExists("com.nonexistent.package.test")
if result {
t.Logf("package exists (unexpected but not an error)")
}
}
func TestFlatpakExistsNoFlatpak(t *testing.T) {
tempDir := t.TempDir()
t.Setenv("PATH", tempDir)
result := FlatpakExists("any.package.name")
if result {
t.Errorf("expected false when flatpak not in PATH, got true")
}
}
func TestFlatpakSearchBySubstringNoFlatpak(t *testing.T) {
tempDir := t.TempDir()
t.Setenv("PATH", tempDir)
result := FlatpakSearchBySubstring("test")
if result {
t.Errorf("expected false when flatpak not in PATH, got true")
}
}
func TestFlatpakSearchBySubstringNonexistent(t *testing.T) {
if !FlatpakInPath() {
t.Skip("flatpak not in PATH")
}
result := FlatpakSearchBySubstring("ThisIsAVeryUnlikelyPackageName12345")
if result {
t.Errorf("expected false for nonexistent package substring")
}
}
func TestFlatpakInstallationDirNoFlatpak(t *testing.T) {
tempDir := t.TempDir()
t.Setenv("PATH", tempDir)
_, err := FlatpakInstallationDir("any.package.name")
if err == nil {
t.Errorf("expected error when flatpak not in PATH")
}
if err != nil && !strings.Contains(err.Error(), "not found in PATH") {
t.Errorf("expected 'not found in PATH' error, got: %v", err)
}
}
func TestFlatpakInstallationDirNonexistent(t *testing.T) {
if !FlatpakInPath() {
t.Skip("flatpak not in PATH")
}
_, err := FlatpakInstallationDir("com.nonexistent.package.test")
if err == nil {
t.Errorf("expected error for nonexistent package")
}
if err != nil && !strings.Contains(err.Error(), "not installed") {
t.Errorf("expected 'not installed' error, got: %v", err)
}
}
func TestFlatpakInstallationDirValid(t *testing.T) {
if !FlatpakInPath() {
t.Skip("flatpak not in PATH")
}
// This test requires a known installed flatpak
// We can't guarantee any specific flatpak is installed,
// so we'll skip if we can't find a common one
commonFlatpaks := []string{
"org.mozilla.firefox",
"org.gnome.Calculator",
"org.freedesktop.Platform",
}
var testPackage string
for _, pkg := range commonFlatpaks {
if FlatpakExists(pkg) {
testPackage = pkg
break
}
}
if testPackage == "" {
t.Skip("no common flatpak packages found for testing")
}
result, err := FlatpakInstallationDir(testPackage)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result == "" {
t.Errorf("expected non-empty installation directory")
}
if !strings.Contains(result, testPackage) {
t.Logf("installation directory %s doesn't contain package name (may be expected)", result)
}
}
func TestFlatpakExistsCommandFailure(t *testing.T) {
if !FlatpakInPath() {
t.Skip("flatpak not in PATH")
}
// Mock a failing flatpak command through PATH interception
tempDir := t.TempDir()
fakeFlatpak := filepath.Join(tempDir, "flatpak")
script := "#!/bin/sh\nexit 1\n"
err := os.WriteFile(fakeFlatpak, []byte(script), 0755)
if err != nil {
t.Fatalf("failed to create fake flatpak: %v", err)
}
originalPath := os.Getenv("PATH")
t.Setenv("PATH", tempDir+":"+originalPath)
result := FlatpakExists("test.package")
if result {
t.Errorf("expected false when flatpak command fails, got true")
}
}
func TestFlatpakSearchBySubstringCommandFailure(t *testing.T) {
if !FlatpakInPath() {
t.Skip("flatpak not in PATH")
}
// Mock a failing flatpak command through PATH interception
tempDir := t.TempDir()
fakeFlatpak := filepath.Join(tempDir, "flatpak")
script := "#!/bin/sh\nexit 1\n"
err := os.WriteFile(fakeFlatpak, []byte(script), 0755)
if err != nil {
t.Fatalf("failed to create fake flatpak: %v", err)
}
originalPath := os.Getenv("PATH")
t.Setenv("PATH", tempDir+":"+originalPath)
result := FlatpakSearchBySubstring("test")
if result {
t.Errorf("expected false when flatpak command fails, got true")
}
}
func TestFlatpakInstallationDirCommandFailure(t *testing.T) {
if !FlatpakInPath() {
t.Skip("flatpak not in PATH")
}
// Mock a failing flatpak command through PATH interception
tempDir := t.TempDir()
fakeFlatpak := filepath.Join(tempDir, "flatpak")
script := "#!/bin/sh\nexit 1\n"
err := os.WriteFile(fakeFlatpak, []byte(script), 0755)
if err != nil {
t.Fatalf("failed to create fake flatpak: %v", err)
}
originalPath := os.Getenv("PATH")
t.Setenv("PATH", tempDir+":"+originalPath)
_, err = FlatpakInstallationDir("test.package")
if err == nil {
t.Errorf("expected error when flatpak command fails")
}
if err != nil && !strings.Contains(err.Error(), "not installed") {
t.Errorf("expected 'not installed' error, got: %v", err)
}
}
func TestAnyFlatpakExistsSomeExist(t *testing.T) {
if !FlatpakInPath() {
t.Skip("flatpak not in PATH")
}
result := AnyFlatpakExists("com.nonexistent.flatpak", "app.zen_browser.zen", "com.another.nonexistent")
if !result {
t.Errorf("expected true when at least one flatpak exists")
}
}
func TestAnyFlatpakExistsNoneExist(t *testing.T) {
if !FlatpakInPath() {
t.Skip("flatpak not in PATH")
}
result := AnyFlatpakExists("com.nonexistent.flatpak1", "com.nonexistent.flatpak2")
if result {
t.Errorf("expected false when no flatpaks exist")
}
}
func TestAnyFlatpakExistsNoFlatpak(t *testing.T) {
tempDir := t.TempDir()
t.Setenv("PATH", tempDir)
result := AnyFlatpakExists("any.package.name", "another.package")
if result {
t.Errorf("expected false when flatpak not in PATH, got true")
}
}
func TestAnyFlatpakExistsEmpty(t *testing.T) {
result := AnyFlatpakExists()
if result {
t.Errorf("expected false when no flatpaks specified")
}
}

View File

@@ -6,6 +6,15 @@ import (
"strings"
)
func XDGStateHome() string {
if dir := os.Getenv("XDG_STATE_HOME"); dir != "" {
return dir
}
home, _ := os.UserHomeDir()
return filepath.Join(append([]string{home}, ".local", "state")...)
}
func ExpandPath(path string) (string, error) {
expanded := os.ExpandEnv(path)
expanded = filepath.Clean(expanded)

View File

@@ -15,10 +15,93 @@ in
niri = {
enableKeybinds = lib.mkEnableOption "DankMaterialShell niri keybinds";
enableSpawn = lib.mkEnableOption "DankMaterialShell niri spawn-at-startup";
includes = {
enable = (lib.mkEnableOption "includes for niri-flake") // {
default = true;
};
override = lib.mkOption {
type = lib.types.bool;
description = ''
Whether DMS settings will be prioritized over settings defined in niri-flake or not
'';
default = true;
example = false;
};
originalFileName = lib.mkOption {
type = lib.types.str;
description = ''
A new name for the config file generated by niri-flake
'';
default = "hm";
example = "niri-flake";
};
filesToInclude = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = ''
A list of dms-generated files to include
'';
default = [
"alttab"
"binds"
"colors"
"layout"
"outputs"
"wpblur"
];
example = [
"outputs"
"wpblur"
];
};
};
};
};
config = lib.mkIf cfg.enable {
warnings = (
lib.optional (cfg.niri.enableKeybinds && cfg.niri.includes.enable) ''
It is not recommended to use both `enableKeybinds` and `includes.enable` at the same time.
''
);
# HACK: niri-flake does not support config includes yet, but we can "fix" that
# TODO: replace with proper config includes after https://github.com/sodiboo/niri-flake/pull/1548 merge
xdg.configFile = lib.mkIf cfg.niri.includes.enable (
let
cfg' = cfg.niri.includes;
withOriginalConfig =
dmsFiles:
if cfg'.override then
[ cfg'.originalFileName ] ++ dmsFiles
else
dmsFiles ++ [ cfg'.originalFileName ];
fixes = map (fix: "\n${fix}") (
lib.optional (cfg'.enable && config.programs.niri.settings.layout.border.enable)
# kdl
''
// Border fix
// See https://yalter.github.io/niri/Configuration%3A-Include.html#border-special-case for details
layout { border { on; }; }
''
);
in
{
niri-config.target = lib.mkForce "niri/${cfg'.originalFileName}.kdl";
niri-config-dms = {
target = "niri/config.kdl";
text = lib.pipe cfg'.filesToInclude [
(map (filename: "dms/${filename}"))
withOriginalConfig
(map (filename: "include \"${filename}.kdl\""))
(files: files ++ fixes)
(builtins.concatStringsSep "\n")
];
};
}
);
programs.niri.settings = lib.mkMerge [
(lib.mkIf cfg.niri.enableKeybinds {
binds =

View File

@@ -7,14 +7,20 @@ import Quickshell
import Quickshell.Io
import qs.Common
import qs.Services
import "settings/SessionSpec.js" as Spec
import "settings/SessionStore.js" as Store
Singleton {
id: root
readonly property int sessionConfigVersion: 1
readonly property int sessionConfigVersion: 2
readonly property bool isGreeterMode: Quickshell.env("DMS_RUN_GREETER") === "1" || Quickshell.env("DMS_RUN_GREETER") === "true"
property bool hasTriedDefaultSession: false
property bool _parseError: false
property bool _hasLoaded: false
property bool _isReadOnly: false
property bool _hasUnsavedChanges: false
property var _loadedSessionSnapshot: null
readonly property string _stateUrl: StandardPaths.writableLocation(StandardPaths.GenericStateLocation)
readonly property string _stateDir: Paths.strip(_stateUrl)
@@ -93,160 +99,127 @@ Singleton {
property string wifiDeviceOverride: ""
property bool weatherHourlyDetailed: true
property string weatherLocation: "New York, NY"
property string weatherCoordinates: "40.7128,-74.0060"
Component.onCompleted: {
if (!isGreeterMode) {
loadSettings();
}
}
property var _pendingMigration: null
function loadSettings() {
_hasUnsavedChanges = false;
_pendingMigration = null;
if (isGreeterMode) {
parseSettings(greeterSessionFile.text());
} else {
parseSettings(settingsFile.text());
return;
}
parseSettings(settingsFile.text());
_checkSessionWritable();
}
function _checkSessionWritable() {
sessionWritableCheckProcess.running = true;
}
function _onWritableCheckComplete(writable) {
_isReadOnly = !writable;
if (_isReadOnly) {
console.info("SessionData: session.json is read-only (NixOS home-manager mode)");
} else if (_pendingMigration) {
settingsFile.setText(JSON.stringify(_pendingMigration, null, 2));
}
_pendingMigration = null;
}
function _checkForUnsavedChanges() {
if (!_hasLoaded || !_loadedSessionSnapshot)
return false;
const current = getCurrentSessionJson();
return current !== _loadedSessionSnapshot;
}
function getCurrentSessionJson() {
return JSON.stringify(Store.toJson(root), null, 2);
}
function parseSettings(content) {
_parseError = false;
try {
if (content && content.trim()) {
var settings = JSON.parse(content);
isLightMode = settings.isLightMode !== undefined ? settings.isLightMode : false;
if (!content || !content.trim()) {
_parseError = true;
return;
}
if (settings.wallpaperPath && settings.wallpaperPath.startsWith("we:")) {
console.warn("WallpaperEngine wallpaper detected, resetting wallpaper");
wallpaperPath = "";
Quickshell.execDetached(["notify-send", "-u", "critical", "-a", "DMS", "-i", "dialog-warning", "WallpaperEngine Support Moved", "WallpaperEngine support has been moved to a plugin. Please enable the Linux Wallpaper Engine plugin in Settings → Plugins to continue using WallpaperEngine."]);
} else {
wallpaperPath = settings.wallpaperPath !== undefined ? settings.wallpaperPath : "";
}
perMonitorWallpaper = settings.perMonitorWallpaper !== undefined ? settings.perMonitorWallpaper : false;
monitorWallpapers = settings.monitorWallpapers !== undefined ? settings.monitorWallpapers : {};
perModeWallpaper = settings.perModeWallpaper !== undefined ? settings.perModeWallpaper : false;
wallpaperPathLight = settings.wallpaperPathLight !== undefined ? settings.wallpaperPathLight : "";
wallpaperPathDark = settings.wallpaperPathDark !== undefined ? settings.wallpaperPathDark : "";
monitorWallpapersLight = settings.monitorWallpapersLight !== undefined ? settings.monitorWallpapersLight : {};
monitorWallpapersDark = settings.monitorWallpapersDark !== undefined ? settings.monitorWallpapersDark : {};
brightnessExponentialDevices = settings.brightnessExponentialDevices !== undefined ? settings.brightnessExponentialDevices : (settings.brightnessLogarithmicDevices || {});
brightnessUserSetValues = settings.brightnessUserSetValues !== undefined ? settings.brightnessUserSetValues : {};
brightnessExponentValues = settings.brightnessExponentValues !== undefined ? settings.brightnessExponentValues : {};
doNotDisturb = settings.doNotDisturb !== undefined ? settings.doNotDisturb : false;
nightModeEnabled = settings.nightModeEnabled !== undefined ? settings.nightModeEnabled : false;
nightModeTemperature = settings.nightModeTemperature !== undefined ? settings.nightModeTemperature : 4500;
nightModeHighTemperature = settings.nightModeHighTemperature !== undefined ? settings.nightModeHighTemperature : 6500;
nightModeAutoEnabled = settings.nightModeAutoEnabled !== undefined ? settings.nightModeAutoEnabled : false;
nightModeAutoMode = settings.nightModeAutoMode !== undefined ? settings.nightModeAutoMode : "time";
if (settings.nightModeStartTime !== undefined) {
const parts = settings.nightModeStartTime.split(":");
nightModeStartHour = parseInt(parts[0]) || 18;
nightModeStartMinute = parseInt(parts[1]) || 0;
} else {
nightModeStartHour = settings.nightModeStartHour !== undefined ? settings.nightModeStartHour : 18;
nightModeStartMinute = settings.nightModeStartMinute !== undefined ? settings.nightModeStartMinute : 0;
}
if (settings.nightModeEndTime !== undefined) {
const parts = settings.nightModeEndTime.split(":");
nightModeEndHour = parseInt(parts[0]) || 6;
nightModeEndMinute = parseInt(parts[1]) || 0;
} else {
nightModeEndHour = settings.nightModeEndHour !== undefined ? settings.nightModeEndHour : 6;
nightModeEndMinute = settings.nightModeEndMinute !== undefined ? settings.nightModeEndMinute : 0;
}
latitude = settings.latitude !== undefined ? settings.latitude : 0.0;
longitude = settings.longitude !== undefined ? settings.longitude : 0.0;
nightModeUseIPLocation = settings.nightModeUseIPLocation !== undefined ? settings.nightModeUseIPLocation : false;
nightModeLocationProvider = settings.nightModeLocationProvider !== undefined ? settings.nightModeLocationProvider : "";
pinnedApps = settings.pinnedApps !== undefined ? settings.pinnedApps : [];
hiddenTrayIds = settings.hiddenTrayIds !== undefined ? settings.hiddenTrayIds : [];
selectedGpuIndex = settings.selectedGpuIndex !== undefined ? settings.selectedGpuIndex : 0;
nvidiaGpuTempEnabled = settings.nvidiaGpuTempEnabled !== undefined ? settings.nvidiaGpuTempEnabled : false;
nonNvidiaGpuTempEnabled = settings.nonNvidiaGpuTempEnabled !== undefined ? settings.nonNvidiaGpuTempEnabled : false;
enabledGpuPciIds = settings.enabledGpuPciIds !== undefined ? settings.enabledGpuPciIds : [];
wifiDeviceOverride = settings.wifiDeviceOverride !== undefined ? settings.wifiDeviceOverride : "";
weatherHourlyDetailed = settings.weatherHourlyDetailed !== undefined ? settings.weatherHourlyDetailed : true;
wallpaperCyclingEnabled = settings.wallpaperCyclingEnabled !== undefined ? settings.wallpaperCyclingEnabled : false;
wallpaperCyclingMode = settings.wallpaperCyclingMode !== undefined ? settings.wallpaperCyclingMode : "interval";
wallpaperCyclingInterval = settings.wallpaperCyclingInterval !== undefined ? settings.wallpaperCyclingInterval : 300;
wallpaperCyclingTime = settings.wallpaperCyclingTime !== undefined ? settings.wallpaperCyclingTime : "06:00";
monitorCyclingSettings = settings.monitorCyclingSettings !== undefined ? settings.monitorCyclingSettings : {};
lastBrightnessDevice = settings.lastBrightnessDevice !== undefined ? settings.lastBrightnessDevice : "";
launchPrefix = settings.launchPrefix !== undefined ? settings.launchPrefix : "";
wallpaperTransition = settings.wallpaperTransition !== undefined ? settings.wallpaperTransition : "fade";
includedTransitions = settings.includedTransitions !== undefined ? settings.includedTransitions : availableWallpaperTransitions.filter(t => t !== "none");
recentColors = settings.recentColors !== undefined ? settings.recentColors : [];
showThirdPartyPlugins = settings.showThirdPartyPlugins !== undefined ? settings.showThirdPartyPlugins : false;
let obj = JSON.parse(content);
if (settings.configVersion === undefined) {
migrateFromUndefinedToV1(settings);
saveSettings();
} else if (settings.configVersion === sessionConfigVersion) {
cleanupUnusedKeys();
}
if (obj.brightnessLogarithmicDevices && !obj.brightnessExponentialDevices) {
obj.brightnessExponentialDevices = obj.brightnessLogarithmicDevices;
}
if (!isGreeterMode) {
if (typeof Theme !== "undefined") {
Theme.generateSystemThemesFromCurrentTheme();
}
}
if (obj.nightModeStartTime !== undefined) {
const parts = obj.nightModeStartTime.split(":");
obj.nightModeStartHour = parseInt(parts[0]) || 18;
obj.nightModeStartMinute = parseInt(parts[1]) || 0;
}
if (obj.nightModeEndTime !== undefined) {
const parts = obj.nightModeEndTime.split(":");
obj.nightModeEndHour = parseInt(parts[0]) || 6;
obj.nightModeEndMinute = parseInt(parts[1]) || 0;
}
if (typeof WallpaperCyclingService !== "undefined") {
WallpaperCyclingService.updateCyclingState();
const oldVersion = obj.configVersion ?? 0;
if (oldVersion === 0) {
migrateFromUndefinedToV1(obj);
}
if (oldVersion < sessionConfigVersion) {
const settingsDataRef = (typeof SettingsData !== "undefined") ? SettingsData : null;
const migrated = Store.migrateToVersion(obj, sessionConfigVersion, settingsDataRef);
if (migrated) {
_pendingMigration = migrated;
obj = migrated;
}
}
} catch (e) {}
Store.parse(root, obj);
if (wallpaperPath && wallpaperPath.startsWith("we:")) {
console.warn("WallpaperEngine wallpaper detected, resetting wallpaper");
wallpaperPath = "";
Quickshell.execDetached(["notify-send", "-u", "critical", "-a", "DMS", "-i", "dialog-warning", "WallpaperEngine Support Moved", "WallpaperEngine support has been moved to a plugin. Please enable the Linux Wallpaper Engine plugin in Settings → Plugins to continue using WallpaperEngine."]);
}
_hasLoaded = true;
_loadedSessionSnapshot = getCurrentSessionJson();
if (!isGreeterMode && typeof Theme !== "undefined") {
Theme.generateSystemThemesFromCurrentTheme();
}
if (typeof WallpaperCyclingService !== "undefined") {
WallpaperCyclingService.updateCyclingState();
}
} catch (e) {
_parseError = true;
const msg = e.message;
console.error("SessionData: Failed to parse session.json - file will not be overwritten. Error:", msg);
Qt.callLater(() => ToastService.showError(I18n.tr("Failed to parse session.json"), msg));
}
}
function saveSettings() {
if (isGreeterMode)
if (isGreeterMode || _parseError || !_hasLoaded)
return;
settingsFile.setText(JSON.stringify({
"isLightMode": isLightMode,
"wallpaperPath": wallpaperPath,
"perMonitorWallpaper": perMonitorWallpaper,
"monitorWallpapers": monitorWallpapers,
"perModeWallpaper": perModeWallpaper,
"wallpaperPathLight": wallpaperPathLight,
"wallpaperPathDark": wallpaperPathDark,
"monitorWallpapersLight": monitorWallpapersLight,
"monitorWallpapersDark": monitorWallpapersDark,
"brightnessExponentialDevices": brightnessExponentialDevices,
"brightnessUserSetValues": brightnessUserSetValues,
"brightnessExponentValues": brightnessExponentValues,
"doNotDisturb": doNotDisturb,
"nightModeEnabled": nightModeEnabled,
"nightModeTemperature": nightModeTemperature,
"nightModeHighTemperature": nightModeHighTemperature,
"nightModeAutoEnabled": nightModeAutoEnabled,
"nightModeAutoMode": nightModeAutoMode,
"nightModeStartHour": nightModeStartHour,
"nightModeStartMinute": nightModeStartMinute,
"nightModeEndHour": nightModeEndHour,
"nightModeEndMinute": nightModeEndMinute,
"latitude": latitude,
"longitude": longitude,
"nightModeUseIPLocation": nightModeUseIPLocation,
"nightModeLocationProvider": nightModeLocationProvider,
"pinnedApps": pinnedApps,
"hiddenTrayIds": hiddenTrayIds,
"selectedGpuIndex": selectedGpuIndex,
"nvidiaGpuTempEnabled": nvidiaGpuTempEnabled,
"nonNvidiaGpuTempEnabled": nonNvidiaGpuTempEnabled,
"enabledGpuPciIds": enabledGpuPciIds,
"wifiDeviceOverride": wifiDeviceOverride,
"weatherHourlyDetailed": weatherHourlyDetailed,
"wallpaperCyclingEnabled": wallpaperCyclingEnabled,
"wallpaperCyclingMode": wallpaperCyclingMode,
"wallpaperCyclingInterval": wallpaperCyclingInterval,
"wallpaperCyclingTime": wallpaperCyclingTime,
"monitorCyclingSettings": monitorCyclingSettings,
"lastBrightnessDevice": lastBrightnessDevice,
"launchPrefix": launchPrefix,
"wallpaperTransition": wallpaperTransition,
"includedTransitions": includedTransitions,
"recentColors": recentColors,
"showThirdPartyPlugins": showThirdPartyPlugins,
"configVersion": sessionConfigVersion
}, null, 2));
if (_isReadOnly) {
_hasUnsavedChanges = _checkForUnsavedChanges();
return;
}
settingsFile.setText(getCurrentSessionJson());
}
function migrateFromUndefinedToV1(settings) {
@@ -297,32 +270,6 @@ Singleton {
}
}
function cleanupUnusedKeys() {
const validKeys = ["isLightMode", "wallpaperPath", "perMonitorWallpaper", "monitorWallpapers", "perModeWallpaper", "wallpaperPathLight", "wallpaperPathDark", "monitorWallpapersLight", "monitorWallpapersDark", "doNotDisturb", "nightModeEnabled", "nightModeTemperature", "nightModeHighTemperature", "nightModeAutoEnabled", "nightModeAutoMode", "nightModeStartHour", "nightModeStartMinute", "nightModeEndHour", "nightModeEndMinute", "latitude", "longitude", "nightModeUseIPLocation", "nightModeLocationProvider", "pinnedApps", "hiddenTrayIds", "selectedGpuIndex", "nvidiaGpuTempEnabled", "nonNvidiaGpuTempEnabled", "enabledGpuPciIds", "wifiDeviceOverride", "weatherHourlyDetailed", "wallpaperCyclingEnabled", "wallpaperCyclingMode", "wallpaperCyclingInterval", "wallpaperCyclingTime", "monitorCyclingSettings", "lastBrightnessDevice", "brightnessExponentialDevices", "brightnessUserSetValues", "brightnessExponentValues", "launchPrefix", "wallpaperTransition", "includedTransitions", "recentColors", "showThirdPartyPlugins", "configVersion"];
try {
const content = settingsFile.text();
if (!content || !content.trim())
return;
const settings = JSON.parse(content);
let needsSave = false;
for (const key in settings) {
if (!validKeys.includes(key)) {
console.log("SessionData: Removing unused key:", key);
delete settings[key];
needsSave = true;
}
}
if (needsSave) {
settingsFile.setText(JSON.stringify(settings, null, 2));
}
} catch (e) {
console.warn("SessionData: Failed to cleanup unused keys:", e.message);
}
}
function setLightMode(lightMode) {
isSwitchingMode = true;
isLightMode = lightMode;
@@ -914,6 +861,12 @@ Singleton {
saveSettings();
}
function setWeatherLocation(displayName, coordinates) {
weatherLocation = displayName;
weatherCoordinates = coordinates;
saveSettings();
}
function syncWallpaperForCurrentMode() {
if (!perModeWallpaper)
return;
@@ -996,14 +949,8 @@ Singleton {
watchChanges: !isGreeterMode
onLoaded: {
if (!isGreeterMode) {
_hasUnsavedChanges = false;
parseSettings(settingsFile.text());
hasTriedDefaultSession = false;
}
}
onLoadFailed: error => {
if (!isGreeterMode && !hasTriedDefaultSession) {
hasTriedDefaultSession = true;
defaultSessionCheckProcess.running = true;
}
}
}
@@ -1028,14 +975,17 @@ Singleton {
}
Process {
id: defaultSessionCheckProcess
id: sessionWritableCheckProcess
command: ["sh", "-c", "CONFIG_DIR=\"" + _stateDir + "/DankMaterialShell\"; if [ -f \"$CONFIG_DIR/default-session.json\" ] && [ ! -f \"$CONFIG_DIR/session.json\" ]; then cp --no-preserve=mode \"$CONFIG_DIR/default-session.json\" \"$CONFIG_DIR/session.json\" && echo 'copied'; else echo 'not_found'; fi"]
property string sessionPath: Paths.strip(settingsFile.path)
command: ["sh", "-c", "[ ! -f \"" + sessionPath + "\" ] || [ -w \"" + sessionPath + "\" ] && echo 'writable' || echo 'readonly'"]
running: false
onExited: exitCode => {
if (exitCode === 0) {
console.info("Copied default-session.json to session.json");
settingsFile.reload();
stdout: StdioCollector {
onStreamFinished: {
const result = text.trim();
root._onWritableCheckComplete(result === "writable");
}
}
}

View File

@@ -14,7 +14,7 @@ import "settings/SettingsStore.js" as Store
Singleton {
id: root
readonly property int settingsConfigVersion: 4
readonly property int settingsConfigVersion: 5
readonly property bool isGreeterMode: Quickshell.env("DMS_RUN_GREETER") === "1" || Quickshell.env("DMS_RUN_GREETER") === "true"
@@ -55,7 +55,12 @@ Singleton {
property bool _loading: false
property bool _pluginSettingsLoading: false
property bool hasTriedDefaultSettings: false
property bool _parseError: false
property bool _pluginParseError: false
property bool _hasLoaded: false
property bool _isReadOnly: false
property bool _hasUnsavedChanges: false
property var _loadedSettingsSnapshot: null
property var pluginSettings: ({})
property alias dankBarLeftWidgetsModel: leftWidgetsModel
@@ -109,7 +114,7 @@ Singleton {
property bool controlCenterShowNetworkIcon: true
property bool controlCenterShowBluetoothIcon: true
property bool controlCenterShowAudioIcon: true
property bool controlCenterShowAudioPercent: true
property bool controlCenterShowAudioPercent: false
property bool controlCenterShowVpnIcon: true
property bool controlCenterShowBrightnessIcon: false
property bool controlCenterShowBrightnessPercent: false
@@ -166,9 +171,11 @@ Singleton {
]
property bool showWorkspaceIndex: false
property bool showWorkspaceName: false
property bool showWorkspacePadding: false
property bool workspaceScrolling: false
property bool showWorkspaceApps: false
property bool groupWorkspaceApps: true
property int maxWorkspaceIcons: 3
property bool workspacesPerMonitor: true
property bool showOccupiedWorkspacesOnly: false
@@ -178,7 +185,7 @@ Singleton {
property bool waveProgressEnabled: true
property bool scrollTitleEnabled: true
property bool audioVisualizerEnabled: true
property bool audioScrollEnabled: true
property string audioScrollMode: "volume"
property bool clockCompactMode: false
property bool focusedWindowCompactMode: false
property bool runningAppsCompactMode: true
@@ -199,8 +206,10 @@ Singleton {
property bool spotlightCloseNiriOverview: true
property bool niriOverviewOverlayEnabled: true
property string weatherLocation: "New York, NY"
property string weatherCoordinates: "40.7128,-74.0060"
property string _legacyWeatherLocation: "New York, NY"
property string _legacyWeatherCoordinates: "40.7128,-74.0060"
readonly property string weatherLocation: SessionData.weatherLocation
readonly property string weatherCoordinates: SessionData.weatherCoordinates
property bool useAutoLocation: false
property bool weatherEnabled: true
@@ -269,6 +278,8 @@ Singleton {
property bool loginctlLockIntegration: true
property bool fadeToLockEnabled: false
property int fadeToLockGracePeriod: 5
property bool fadeToDpmsEnabled: false
property int fadeToDpmsGracePeriod: 5
property string launchPrefix: ""
property var brightnessDevicePins: ({})
property var wifiNetworkPins: ({})
@@ -342,6 +353,12 @@ Singleton {
property int notificationTimeoutNormal: 5000
property int notificationTimeoutCritical: 0
property int notificationPopupPosition: SettingsData.Position.Top
property bool notificationHistoryEnabled: true
property int notificationHistoryMaxCount: 50
property int notificationHistoryMaxAgeDays: 7
property bool notificationHistorySaveLow: true
property bool notificationHistorySaveNormal: true
property bool notificationHistorySaveCritical: true
property bool osdAlwaysShowValue: false
property int osdPosition: SettingsData.Position.BottomCenter
@@ -772,6 +789,10 @@ Singleton {
function loadSettings() {
_loading = true;
_parseError = false;
_hasUnsavedChanges = false;
_pendingMigration = null;
try {
const txt = settingsFile.text();
let obj = (txt && txt.trim()) ? JSON.parse(txt) : null;
@@ -780,17 +801,30 @@ Singleton {
if (oldVersion < settingsConfigVersion) {
const migrated = Store.migrateToVersion(obj, settingsConfigVersion);
if (migrated) {
settingsFile.setText(JSON.stringify(migrated, null, 2));
_pendingMigration = migrated;
obj = migrated;
}
}
Store.parse(root, obj);
if (obj?.weatherLocation !== undefined)
_legacyWeatherLocation = obj.weatherLocation;
if (obj?.weatherCoordinates !== undefined)
_legacyWeatherCoordinates = obj.weatherCoordinates;
_loadedSettingsSnapshot = JSON.stringify(Store.toJson(root));
_hasLoaded = true;
applyStoredTheme();
applyStoredIconTheme();
Processes.detectQtTools();
_checkSettingsWritable();
} catch (e) {
console.warn("SettingsData: Failed to load settings:", e.message);
_parseError = true;
const msg = e.message;
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));
applyStoredTheme();
applyStoredIconTheme();
} finally {
@@ -799,6 +833,33 @@ Singleton {
loadPluginSettings();
}
property var _pendingMigration: null
function _checkSettingsWritable() {
settingsWritableCheckProcess.running = true;
}
function _onWritableCheckComplete(writable) {
_isReadOnly = !writable;
if (_isReadOnly) {
console.info("SettingsData: settings.json is read-only (NixOS home-manager mode)");
} else if (_pendingMigration) {
settingsFile.setText(JSON.stringify(_pendingMigration, null, 2));
}
_pendingMigration = null;
}
function _checkForUnsavedChanges() {
if (!_hasLoaded || !_loadedSettingsSnapshot)
return false;
const current = JSON.stringify(Store.toJson(root));
return current !== _loadedSettingsSnapshot;
}
function getCurrentSettingsJson() {
return JSON.stringify(Store.toJson(root), null, 2);
}
function loadPluginSettings() {
_pluginSettingsLoading = true;
parsePluginSettings(pluginSettingsFile.text());
@@ -807,6 +868,7 @@ Singleton {
function parsePluginSettings(content) {
_pluginSettingsLoading = true;
_pluginParseError = false;
try {
if (content && content.trim()) {
pluginSettings = JSON.parse(content);
@@ -814,7 +876,10 @@ Singleton {
pluginSettings = {};
}
} catch (e) {
console.warn("SettingsData: Failed to parse plugin settings:", e.message);
_pluginParseError = true;
const msg = e.message;
console.error("SettingsData: Failed to parse plugin_settings.json - file will not be overwritten. Error:", msg);
Qt.callLater(() => ToastService.showError(I18n.tr("Failed to parse plugin_settings.json"), msg));
pluginSettings = {};
} finally {
_pluginSettingsLoading = false;
@@ -822,13 +887,17 @@ Singleton {
}
function saveSettings() {
if (_loading)
if (_loading || _parseError || !_hasLoaded)
return;
if (_isReadOnly) {
_hasUnsavedChanges = _checkForUnsavedChanges();
return;
}
settingsFile.setText(JSON.stringify(Store.toJson(root), null, 2));
}
function savePluginSettings() {
if (_pluginSettingsLoading)
if (_pluginSettingsLoading || _pluginParseError)
return;
pluginSettingsFile.setText(JSON.stringify(pluginSettings, null, 2));
}
@@ -1388,9 +1457,7 @@ Singleton {
}
function setWeatherLocation(displayName, coordinates) {
weatherLocation = displayName;
weatherCoordinates = coordinates;
saveSettings();
SessionData.setWeatherLocation(displayName, coordinates);
}
function setIconTheme(themeName) {
@@ -1785,24 +1852,40 @@ Singleton {
atomicWrites: true
watchChanges: !isGreeterMode
onLoaded: {
if (!isGreeterMode) {
try {
const txt = settingsFile.text();
const obj = (txt && txt.trim()) ? JSON.parse(txt) : null;
Store.parse(root, obj);
applyStoredTheme();
applyStoredIconTheme();
} catch (e) {
console.warn("SettingsData: Failed to reload settings:", e.message);
if (isGreeterMode)
return;
_loading = true;
_hasUnsavedChanges = false;
try {
const txt = settingsFile.text();
if (!txt || !txt.trim()) {
_parseError = true;
return;
}
hasTriedDefaultSettings = false;
const obj = JSON.parse(txt);
_parseError = false;
Store.parse(root, obj);
if (obj.weatherLocation !== undefined)
_legacyWeatherLocation = obj.weatherLocation;
if (obj.weatherCoordinates !== undefined)
_legacyWeatherCoordinates = obj.weatherCoordinates;
_loadedSettingsSnapshot = JSON.stringify(Store.toJson(root));
_hasLoaded = true;
applyStoredTheme();
applyStoredIconTheme();
} catch (e) {
_parseError = true;
const msg = e.message;
console.error("SettingsData: Failed to reload settings.json - file will not be overwritten. Error:", msg);
Qt.callLater(() => ToastService.showError(I18n.tr("Failed to parse settings.json"), msg));
} finally {
_loading = false;
}
}
onLoadFailed: error => {
if (!isGreeterMode && !hasTriedDefaultSettings) {
hasTriedDefaultSettings = true;
Processes.checkDefaultSettings();
} else if (!isGreeterMode) {
if (!isGreeterMode) {
applyStoredTheme();
}
}
@@ -1829,4 +1912,20 @@ Singleton {
}
property bool pluginSettingsFileExists: false
Process {
id: settingsWritableCheckProcess
property string settingsPath: Paths.strip(settingsFile.path)
command: ["sh", "-c", "[ ! -f \"" + settingsPath + "\" ] || [ -w \"" + settingsPath + "\" ] && echo 'writable' || echo 'readonly'"]
running: false
stdout: StdioCollector {
onStreamFinished: {
const result = text.trim();
root._onWritableCheckComplete(result === "writable");
}
}
}
}

View File

@@ -91,6 +91,32 @@ Singleton {
property var pendingThemeRequest: null
property var matugenColors: ({})
property var _pendingGenerateParams: null
readonly property var dank16: {
const raw = matugenColors?.dank16;
if (!raw)
return null;
const dark = {};
const light = {};
const def = {};
for (let i = 0; i < 16; i++) {
const key = "color" + i;
const c = raw[key];
if (!c)
continue;
dark[key] = c.dark;
light[key] = c.light;
def[key] = c.default;
}
return {
dark,
light,
"default": def
};
}
property var customThemeData: null
property var customThemeRawData: null
readonly property var currentThemeVariants: customThemeRawData?.variants || null
@@ -1302,7 +1328,7 @@ Singleton {
const colorsPath = SessionData.isGreeterMode ? greetCfgDir + "/colors.json" : stateDir + "/dms-colors.json";
return colorsPath;
}
watchChanges: currentTheme === dynamic && !SessionData.isGreeterMode
watchChanges: !SessionData.isGreeterMode
function parseAndLoadColors() {
try {
@@ -1323,17 +1349,13 @@ Singleton {
}
onLoaded: {
if (currentTheme === dynamic) {
console.info("Theme: Dynamic colors file loaded successfully");
if (currentTheme === dynamic)
colorsFileLoadFailed = false;
parseAndLoadColors();
}
parseAndLoadColors();
}
onFileChanged: {
if (currentTheme === dynamic) {
dynamicColorsFileView.reload();
}
dynamicColorsFileView.reload();
}
onLoadFailed: function (error) {

View File

@@ -22,10 +22,6 @@ Singleton {
pluginSettingsCheckProcess.running = true;
}
function checkDefaultSettings() {
defaultSettingsCheckProcess.running = true;
}
property var qtToolsDetectionProcess: Process {
command: ["sh", "-c", "echo -n 'qt5ct:'; command -v qt5ct >/dev/null && echo 'true' || echo 'false'; echo -n 'qt6ct:'; command -v qt6ct >/dev/null && echo 'true' || echo 'false'; echo -n 'gtk:'; (command -v gsettings >/dev/null || command -v dconf >/dev/null) && echo 'true' || echo 'false'"]
running: false
@@ -51,25 +47,6 @@ Singleton {
}
}
property var defaultSettingsCheckProcess: Process {
command: ["sh", "-c", "CONFIG_DIR=\"" + (settingsRoot?._configDir || "") + "/DankMaterialShell\"; if [ -f \"$CONFIG_DIR/default-settings.json\" ] && [ ! -f \"$CONFIG_DIR/settings.json\" ]; then cp --no-preserve=mode \"$CONFIG_DIR/default-settings.json\" \"$CONFIG_DIR/settings.json\" && echo 'copied'; else echo 'not_found'; fi"]
running: false
onExited: function (exitCode) {
if (!settingsRoot)
return;
if (exitCode === 0) {
console.info("Copied default-settings.json to settings.json");
if (settingsRoot.settingsFile) {
settingsRoot.settingsFile.reload();
}
} else {
if (typeof ThemeApplier !== "undefined") {
ThemeApplier.applyStoredTheme(settingsRoot);
}
}
}
}
property var fprintdDetectionProcess: Process {
command: ["sh", "-c", "command -v fprintd-list >/dev/null 2>&1"]
running: false

View File

@@ -0,0 +1,63 @@
.pragma library
var SPEC = {
isLightMode: { def: false },
doNotDisturb: { def: false },
wallpaperPath: { def: "" },
perMonitorWallpaper: { def: false },
monitorWallpapers: { def: {} },
perModeWallpaper: { def: false },
wallpaperPathLight: { def: "" },
wallpaperPathDark: { def: "" },
monitorWallpapersLight: { def: {} },
monitorWallpapersDark: { def: {} },
wallpaperTransition: { def: "fade" },
includedTransitions: { def: ["fade", "wipe", "disc", "stripes", "iris bloom", "pixelate", "portal"] },
wallpaperCyclingEnabled: { def: false },
wallpaperCyclingMode: { def: "interval" },
wallpaperCyclingInterval: { def: 300 },
wallpaperCyclingTime: { def: "06:00" },
monitorCyclingSettings: { def: {} },
nightModeEnabled: { def: false },
nightModeTemperature: { def: 4500 },
nightModeHighTemperature: { def: 6500 },
nightModeAutoEnabled: { def: false },
nightModeAutoMode: { def: "time" },
nightModeStartHour: { def: 18 },
nightModeStartMinute: { def: 0 },
nightModeEndHour: { def: 6 },
nightModeEndMinute: { def: 0 },
latitude: { def: 0.0 },
longitude: { def: 0.0 },
nightModeUseIPLocation: { def: false },
nightModeLocationProvider: { def: "" },
weatherLocation: { def: "New York, NY" },
weatherCoordinates: { def: "40.7128,-74.0060" },
pinnedApps: { def: [] },
hiddenTrayIds: { def: [] },
recentColors: { def: [] },
showThirdPartyPlugins: { def: false },
launchPrefix: { def: "" },
lastBrightnessDevice: { def: "" },
brightnessExponentialDevices: { def: {} },
brightnessUserSetValues: { def: {} },
brightnessExponentValues: { def: {} },
selectedGpuIndex: { def: 0 },
nvidiaGpuTempEnabled: { def: false },
nonNvidiaGpuTempEnabled: { def: false },
enabledGpuPciIds: { def: [] },
wifiDeviceOverride: { def: "" },
weatherHourlyDetailed: { def: true }
};
function getValidKeys() {
return Object.keys(SPEC).concat(["configVersion"]);
}

View File

@@ -0,0 +1,95 @@
.pragma library
.import "./SessionSpec.js" as SpecModule
function parse(root, jsonObj) {
var SPEC = SpecModule.SPEC;
if (!jsonObj) return;
for (var k in SPEC) {
if (!(k in jsonObj)) {
root[k] = SPEC[k].def;
}
}
for (var k in jsonObj) {
if (!SPEC[k]) continue;
var raw = jsonObj[k];
var spec = SPEC[k];
var coerce = spec.coerce;
root[k] = coerce ? (coerce(raw) !== undefined ? coerce(raw) : root[k]) : raw;
}
}
function toJson(root) {
var SPEC = SpecModule.SPEC;
var out = {};
for (var k in SPEC) {
if (SPEC[k].persist === false) continue;
out[k] = root[k];
}
out.configVersion = root.sessionConfigVersion;
return out;
}
function migrateToVersion(obj, targetVersion, settingsData) {
if (!obj) return null;
var session = JSON.parse(JSON.stringify(obj));
var currentVersion = session.configVersion || 0;
if (currentVersion >= targetVersion) {
return null;
}
if (currentVersion < 2) {
console.info("SessionData: Migrating session from version", currentVersion, "to version 2");
console.info("SessionData: Importing weather location and coordinates from settings");
if (settingsData && typeof settingsData !== "undefined") {
if (session.weatherLocation === undefined || session.weatherLocation === "New York, NY") {
var settingsWeatherLocation = settingsData._legacyWeatherLocation;
if (settingsWeatherLocation && settingsWeatherLocation !== "New York, NY") {
session.weatherLocation = settingsWeatherLocation;
console.info("SessionData: Migrated weatherLocation:", settingsWeatherLocation);
}
}
if (session.weatherCoordinates === undefined || session.weatherCoordinates === "40.7128,-74.0060") {
var settingsWeatherCoordinates = settingsData._legacyWeatherCoordinates;
if (settingsWeatherCoordinates && settingsWeatherCoordinates !== "40.7128,-74.0060") {
session.weatherCoordinates = settingsWeatherCoordinates;
console.info("SessionData: Migrated weatherCoordinates:", settingsWeatherCoordinates);
}
}
}
session.configVersion = 2;
}
return session;
}
function cleanup(fileText) {
var getValidKeys = SpecModule.getValidKeys;
if (!fileText || !fileText.trim()) return null;
try {
var session = JSON.parse(fileText);
var validKeys = getValidKeys();
var needsSave = false;
for (var key in session) {
if (validKeys.indexOf(key) < 0) {
delete session[key];
needsSave = true;
}
}
return needsSave ? JSON.stringify(session, null, 2) : null;
} catch (e) {
console.warn("SessionData: Failed to cleanup unused keys:", e.message);
return null;
}
}

View File

@@ -81,10 +81,12 @@ var SPEC = {
]},
showWorkspaceIndex: { def: false },
showWorkspaceName: { def: false },
showWorkspacePadding: { def: false },
workspaceScrolling: { def: false },
showWorkspaceApps: { def: false },
maxWorkspaceIcons: { def: 3 },
groupWorkspaceApps: { def: true },
workspacesPerMonitor: { def: true },
showOccupiedWorkspacesOnly: { def: false },
reverseScrolling: { def: false },
@@ -93,7 +95,7 @@ var SPEC = {
waveProgressEnabled: { def: true },
scrollTitleEnabled: { def: true },
audioVisualizerEnabled: { def: true },
audioScrollEnabled: { def: true },
audioScrollMode: { def: "volume" },
clockCompactMode: { def: false },
focusedWindowCompactMode: { def: false },
runningAppsCompactMode: { def: true },
@@ -112,8 +114,6 @@ var SPEC = {
spotlightCloseNiriOverview: { def: true },
niriOverviewOverlayEnabled: { def: true },
weatherLocation: { def: "New York, NY" },
weatherCoordinates: { def: "40.7128,-74.0060" },
useAutoLocation: { def: false },
weatherEnabled: { def: true },
@@ -168,6 +168,8 @@ var SPEC = {
loginctlLockIntegration: { def: true },
fadeToLockEnabled: { def: false },
fadeToLockGracePeriod: { def: 5 },
fadeToDpmsEnabled: { def: false },
fadeToDpmsGracePeriod: { def: 5 },
launchPrefix: { def: "" },
brightnessDevicePins: { def: {} },
wifiNetworkPins: { def: {} },
@@ -240,6 +242,12 @@ var SPEC = {
notificationTimeoutNormal: { def: 5000 },
notificationTimeoutCritical: { def: 0 },
notificationPopupPosition: { def: 0 },
notificationHistoryEnabled: { def: true },
notificationHistoryMaxCount: { def: 50 },
notificationHistoryMaxAgeDays: { def: 7 },
notificationHistorySaveLow: { def: true },
notificationHistorySaveNormal: { def: true },
notificationHistorySaveCritical: { def: true },
osdAlwaysShowValue: { def: false },
osdPosition: { def: 5 },

View File

@@ -4,14 +4,16 @@
function parse(root, jsonObj) {
var SPEC = SpecModule.SPEC;
for (var k in SPEC) {
if (k === "pluginSettings") continue;
var spec = SPEC[k];
root[k] = spec.def;
}
if (!jsonObj) return;
for (var k in SPEC) {
if (k === "pluginSettings") continue;
if (!(k in jsonObj)) {
root[k] = SPEC[k].def;
}
}
for (var k in jsonObj) {
if (!SPEC[k]) continue;
if (k === "pluginSettings") continue;
@@ -214,6 +216,16 @@ function migrateToVersion(obj, targetVersion) {
settings.configVersion = 4;
}
if (currentVersion < 5) {
console.info("Migrating settings from version", currentVersion, "to version 5");
console.info("Moving sensitive data (weather location, coordinates) to session.json");
delete settings.weatherLocation;
delete settings.weatherCoordinates;
settings.configVersion = 5;
}
return settings;
}

View File

@@ -104,6 +104,46 @@ Item {
}
}
Variants {
model: Quickshell.screens
delegate: Loader {
id: fadeDpmsWindowLoader
required property var modelData
active: SettingsData.fadeToDpmsEnabled
asynchronous: false
sourceComponent: FadeToDpmsWindow {
screen: fadeDpmsWindowLoader.modelData
onFadeCompleted: {
IdleService.requestMonitorOff();
}
onFadeCancelled: {
console.log("Fade to DPMS cancelled by user on screen:", fadeDpmsWindowLoader.modelData.name);
}
}
Connections {
target: IdleService
enabled: fadeDpmsWindowLoader.item !== null
function onFadeToDpmsRequested() {
if (fadeDpmsWindowLoader.item) {
fadeDpmsWindowLoader.item.startFade();
}
}
function onCancelFadeToDpms() {
if (fadeDpmsWindowLoader.item) {
fadeDpmsWindowLoader.item.cancelFade();
}
}
}
}
}
Repeater {
id: dankBarRepeater
model: ScriptModel {

View File

@@ -26,7 +26,7 @@ DankModal {
property Component clipboardContent
property int activeImageLoads: 0
readonly property int maxConcurrentLoads: 3
readonly property bool clipboardAvailable: DMSService.isConnected && DMSService.capabilities.includes("clipboard")
readonly property bool clipboardAvailable: DMSService.isConnected && (DMSService.capabilities.length === 0 || DMSService.capabilities.includes("clipboard"))
property bool wtypeAvailable: false
Process {

View File

@@ -18,6 +18,8 @@ DankModal {
property bool notificationModalOpen: false
property var notificationListRef: null
property var historyListRef: null
property int currentTab: 0
function show() {
notificationModalOpen = true;
@@ -61,7 +63,7 @@ DankModal {
NotificationService.clearAllNotifications();
}
function dismissAllPopups () {
function dismissAllPopups() {
NotificationService.dismissAllPopups();
}
@@ -80,7 +82,18 @@ DankModal {
NotificationService.onOverlayClose();
}
}
modalFocusScope.Keys.onPressed: event => modalKeyboardController.handleKey(event)
modalFocusScope.Keys.onPressed: event => {
if (event.key === Qt.Key_Escape) {
hide();
event.accepted = true;
return;
}
if (currentTab === 1 && historyListRef) {
historyListRef.handleKey(event);
return;
}
modalKeyboardController.handleKey(event);
}
NotificationKeyboardController {
id: modalKeyboardController
@@ -145,21 +158,20 @@ DankModal {
NotificationHeader {
id: notificationHeader
keyboardController: modalKeyboardController
onCurrentTabChanged: notificationModal.currentTab = currentTab
}
NotificationSettings {
id: notificationSettings
expanded: notificationHeader.showSettings
}
KeyboardNavigatedNotificationList {
id: notificationList
width: parent.width
height: parent.height - y
visible: notificationHeader.currentTab === 0
keyboardController: modalKeyboardController
Component.onCompleted: {
notificationModal.notificationListRef = notificationList;
@@ -169,6 +181,14 @@ DankModal {
}
}
}
HistoryNotificationList {
id: historyList
width: parent.width
height: parent.height - y
visible: notificationHeader.currentTab === 1
Component.onCompleted: notificationModal.historyListRef = historyList
}
}
NotificationKeyboardHints {
@@ -178,7 +198,7 @@ DankModal {
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: Theme.spacingL
showHints: modalKeyboardController.showKeyboardHints
showHints: notificationHeader.currentTab === 0 ? modalKeyboardController.showKeyboardHints : historyList.showKeyboardHints
}
}
}

View File

@@ -10,6 +10,7 @@ FloatingWindow {
property string passwordInput: ""
property var currentFlow: PolkitService.agent?.flow
property bool isLoading: false
readonly property int inputFieldHeight: Theme.fontSizeMedium + Theme.spacingL * 2
property int calculatedHeight: Math.max(240, headerRow.implicitHeight + mainColumn.implicitHeight + Theme.spacingM * 3)
function focusPasswordField() {
@@ -202,7 +203,7 @@ FloatingWindow {
Rectangle {
width: parent.width
height: 50
height: inputFieldHeight
radius: Theme.cornerRadius
color: Theme.surfaceHover
border.color: passwordField.activeFocus ? Theme.primary : Theme.outlineStrong

View File

@@ -220,9 +220,91 @@ FloatingWindow {
}
}
Rectangle {
id: readOnlyBanner
property bool showBanner: (SettingsData._isReadOnly && SettingsData._hasUnsavedChanges) || (SessionData._isReadOnly && SessionData._hasUnsavedChanges)
width: parent.width
height: showBanner ? bannerContent.implicitHeight + Theme.spacingM * 2 : 0
color: Theme.surfaceContainerHigh
visible: showBanner
clip: true
Behavior on height {
NumberAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
Row {
id: bannerContent
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: Theme.spacingL
anchors.rightMargin: Theme.spacingM
spacing: Theme.spacingM
DankIcon {
name: "info"
size: Theme.iconSize
color: Theme.warning
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
id: bannerText
text: I18n.tr("Settings are read-only. Changes will not persist.", "read-only settings warning for NixOS home-manager users")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
width: Math.max(100, parent.width - (copySettingsButton.visible ? copySettingsButton.width + Theme.spacingM : 0) - (copySessionButton.visible ? copySessionButton.width + Theme.spacingM : 0) - Theme.spacingM * 2 - Theme.iconSize)
wrapMode: Text.WordWrap
}
DankButton {
id: copySettingsButton
visible: SettingsData._isReadOnly && SettingsData._hasUnsavedChanges
text: "settings.json"
iconName: "content_copy"
backgroundColor: Theme.primary
textColor: Theme.primaryText
buttonHeight: 32
horizontalPadding: Theme.spacingM
anchors.verticalCenter: parent.verticalCenter
onClicked: {
Quickshell.execDetached(["dms", "cl", "copy", SettingsData.getCurrentSettingsJson()]);
ToastService.showInfo(I18n.tr("Copied to clipboard"));
}
}
DankButton {
id: copySessionButton
visible: SessionData._isReadOnly && SessionData._hasUnsavedChanges
text: "session.json"
iconName: "content_copy"
backgroundColor: Theme.primary
textColor: Theme.primaryText
buttonHeight: 32
horizontalPadding: Theme.spacingM
anchors.verticalCenter: parent.verticalCenter
onClicked: {
Quickshell.execDetached(["dms", "cl", "copy", SessionData.getCurrentSessionJson()]);
ToastService.showInfo(I18n.tr("Copied to clipboard"));
}
}
}
}
Item {
width: parent.width
height: parent.height - 48
height: parent.height - 48 - readOnlyBanner.height
clip: true
SettingsSidebar {

View File

@@ -28,14 +28,29 @@ FloatingWindow {
property var fieldsInfo: []
property var secretValues: ({})
readonly property bool showUsernameField: requiresEnterprise && !isVpnPrompt && fieldsInfo.length === 0
readonly property bool showPasswordField: fieldsInfo.length === 0
readonly property bool showAnonField: requiresEnterprise && !isVpnPrompt
readonly property bool showDomainField: requiresEnterprise && !isVpnPrompt
readonly property bool showShowPasswordCheckbox: fieldsInfo.length === 0
readonly property bool showSavePasswordCheckbox: (isVpnPrompt || fieldsInfo.length > 0) && promptReason !== "pkcs11"
readonly property int inputFieldHeight: Theme.fontSizeMedium + Theme.spacingL * 2
readonly property int inputFieldWithSpacing: inputFieldHeight + Theme.spacingM
readonly property int checkboxRowHeight: Theme.fontSizeMedium + Theme.spacingS
readonly property int headerHeight: Theme.fontSizeLarge + Theme.fontSizeMedium + Theme.spacingM * 2
readonly property int buttonRowHeight: 36 + Theme.spacingM
property int calculatedHeight: {
if (fieldsInfo.length > 0)
return 180 + (fieldsInfo.length * 60);
if (requiresEnterprise)
return 430;
if (isVpnPrompt)
return 260;
return 230;
let h = headerHeight + buttonRowHeight + Theme.spacingL * 2;
h += fieldsInfo.length * inputFieldWithSpacing;
if (showUsernameField) h += inputFieldWithSpacing;
if (showPasswordField) h += inputFieldWithSpacing;
if (showAnonField) h += inputFieldWithSpacing;
if (showDomainField) h += inputFieldWithSpacing;
if (showShowPasswordCheckbox) h += checkboxRowHeight;
if (showSavePasswordCheckbox) h += checkboxRowHeight;
return h;
}
function focusFirstField() {
@@ -127,6 +142,7 @@ FloatingWindow {
case "private-key-password":
return I18n.tr("Private Key Password");
case "pin":
case "key_pass":
return I18n.tr("PIN");
case "psk":
return I18n.tr("Password");
@@ -188,7 +204,13 @@ FloatingWindow {
}
objectName: "wifiPasswordModal"
title: isVpnPrompt ? I18n.tr("VPN Password") : I18n.tr("Wi-Fi Password")
title: {
if (promptReason === "pkcs11")
return I18n.tr("Smartcard PIN");
if (isVpnPrompt)
return I18n.tr("VPN Password");
return I18n.tr("Wi-Fi Password");
}
minimumSize: Qt.size(420, calculatedHeight)
maximumSize: Qt.size(420, calculatedHeight)
color: Theme.surfaceContainer
@@ -242,7 +264,7 @@ FloatingWindow {
Column {
id: contentCol
anchors.centerIn: parent
width: parent.width - Theme.spacingM * 2
width: parent.width - Theme.spacingL * 2
spacing: Theme.spacingM
Row {
@@ -260,7 +282,13 @@ FloatingWindow {
spacing: Theme.spacingXS
StyledText {
text: isVpnPrompt ? I18n.tr("Connect to VPN") : I18n.tr("Connect to Wi-Fi")
text: {
if (promptReason === "pkcs11")
return I18n.tr("Smartcard Authentication");
if (isVpnPrompt)
return I18n.tr("Connect to VPN");
return I18n.tr("Connect to Wi-Fi");
}
font.pixelSize: Theme.fontSizeLarge
color: Theme.surfaceText
font.weight: Font.Medium
@@ -272,6 +300,8 @@ FloatingWindow {
StyledText {
text: {
if (promptReason === "pkcs11")
return I18n.tr("Enter PIN for ") + wifiPasswordSSID;
if (fieldsInfo.length > 0)
return I18n.tr("Enter credentials for ") + wifiPasswordSSID;
if (isVpnPrompt)
@@ -325,7 +355,7 @@ FloatingWindow {
required property int index
width: contentCol.width
height: 50
height: inputFieldHeight
radius: Theme.cornerRadius
color: Theme.surfaceHover
border.color: fieldInput.activeFocus ? Theme.primary : Theme.outlineStrong
@@ -388,12 +418,12 @@ FloatingWindow {
Rectangle {
width: parent.width
height: 50
height: inputFieldHeight
radius: Theme.cornerRadius
color: Theme.surfaceHover
border.color: usernameInput.activeFocus ? Theme.primary : Theme.outlineStrong
border.width: usernameInput.activeFocus ? 2 : 1
visible: requiresEnterprise && !isVpnPrompt && fieldsInfo.length === 0
visible: showUsernameField
MouseArea {
anchors.fill: parent
@@ -419,12 +449,12 @@ FloatingWindow {
Rectangle {
width: parent.width
height: 50
height: inputFieldHeight
radius: Theme.cornerRadius
color: Theme.surfaceHover
border.color: passwordInput.activeFocus ? Theme.primary : Theme.outlineStrong
border.width: passwordInput.activeFocus ? 2 : 1
visible: fieldsInfo.length === 0
visible: showPasswordField
MouseArea {
anchors.fill: parent
@@ -456,9 +486,9 @@ FloatingWindow {
}
Rectangle {
visible: requiresEnterprise && !isVpnPrompt
visible: showAnonField
width: parent.width
height: 50
height: inputFieldHeight
radius: Theme.cornerRadius
color: Theme.surfaceHover
border.color: anonInput.activeFocus ? Theme.primary : Theme.outlineStrong
@@ -487,9 +517,9 @@ FloatingWindow {
}
Rectangle {
visible: requiresEnterprise && !isVpnPrompt
visible: showDomainField
width: parent.width
height: 50
height: inputFieldHeight
radius: Theme.cornerRadius
color: Theme.surfaceHover
border.color: domainMatchInput.activeFocus ? Theme.primary : Theme.outlineStrong
@@ -523,7 +553,7 @@ FloatingWindow {
Row {
spacing: Theme.spacingS
visible: fieldsInfo.length === 0
visible: showShowPasswordCheckbox
Rectangle {
id: showPasswordCheckbox
@@ -563,7 +593,7 @@ FloatingWindow {
Row {
spacing: Theme.spacingS
visible: isVpnPrompt || fieldsInfo.length > 0
visible: showSavePasswordCheckbox
Rectangle {
id: savePasswordCheckbox

View File

@@ -51,6 +51,10 @@ Item {
function onPluginLoaded() { updateCategories() }
function onPluginUnloaded() { updateCategories() }
function onPluginListUpdated() { updateCategories() }
function onRequestLauncherUpdate(pluginId) {
// Only update if we are actually looking at this plugin or in All category
updateFilteredModel()
}
}
Connections {

View File

@@ -524,8 +524,11 @@ PanelWindow {
}
property bool reveal: {
const showOnWindowsSetting = barConfig?.showOnWindowsOpen ?? false;
const inOverviewWithShow = CompositorService.isNiri && NiriService.inOverview && (barConfig?.openOnOverview ?? false);
if (inOverviewWithShow)
return true;
const showOnWindowsSetting = barConfig?.showOnWindowsOpen ?? false;
if (showOnWindowsSetting && autoHide && (CompositorService.isNiri || CompositorService.isHyprland)) {
if (barWindow.shouldHideForWindows)
return topBarMouseArea.containsMouse || hasActivePopout || revealSticky;
@@ -533,7 +536,7 @@ PanelWindow {
}
if (CompositorService.isNiri && NiriService.inOverview)
return (barConfig?.openOnOverview ?? false) || topBarMouseArea.containsMouse || hasActivePopout || revealSticky;
return topBarMouseArea.containsMouse || hasActivePopout || revealSticky;
return (barConfig?.visible ?? true) && (!autoHide || topBarMouseArea.containsMouse || hasActivePopout || revealSticky);
}
@@ -666,9 +669,10 @@ PanelWindow {
propagateComposedEvents: true
z: -1
property real scrollAccumulatorY: 0
property real scrollAccumulatorX: 0
property real touchpadThreshold: 500
property real touchpadAccumulatorY: 0
property real touchpadAccumulatorX: 0
property real mouseAccumulatorY: 0
property real mouseAccumulatorX: 0
property bool actionInProgress: false
Timer {
@@ -696,40 +700,39 @@ PanelWindow {
}
onWheel: wheel => {
if (!(barConfig?.scrollEnabled ?? true)) {
wheel.accepted = false;
return;
}
if (actionInProgress) {
if (!(barConfig?.scrollEnabled ?? true) || actionInProgress) {
wheel.accepted = false;
return;
}
const deltaY = wheel.angleDelta.y;
const deltaX = wheel.angleDelta.x;
const isTouchpadY = wheel.pixelDelta && wheel.pixelDelta.y !== 0;
const isTouchpadX = wheel.pixelDelta && wheel.pixelDelta.x !== 0;
const xBehavior = barConfig?.scrollXBehavior ?? "column";
const yBehavior = barConfig?.scrollYBehavior ?? "workspace";
const reverse = SettingsData.reverseScrolling ? -1 : 1;
if (CompositorService.isNiri && xBehavior !== "none" && Math.abs(deltaX) > Math.abs(deltaY)) {
const isMouseWheel = Math.abs(deltaX) >= 120 && (Math.abs(deltaX) % 120) === 0;
const reverse = SettingsData.reverseScrolling ? -1 : 1;
const direction = deltaX * reverse < 0 ? 1 : -1;
if (isMouseWheel) {
if (handleScrollAction(xBehavior, direction)) {
actionInProgress = true;
cooldownTimer.restart();
}
} else {
scrollAccumulatorX += deltaX;
if (Math.abs(scrollAccumulatorX) >= touchpadThreshold) {
const touchDirection = scrollAccumulatorX < 0 ? 1 : -1;
if (handleScrollAction(xBehavior, touchDirection)) {
if (isTouchpadX) {
touchpadAccumulatorX += deltaX;
if (Math.abs(touchpadAccumulatorX) >= 500) {
const direction = touchpadAccumulatorX * reverse < 0 ? 1 : -1;
if (handleScrollAction(xBehavior, direction)) {
actionInProgress = true;
cooldownTimer.restart();
}
scrollAccumulatorX = 0;
touchpadAccumulatorX = 0;
}
} else {
mouseAccumulatorX += deltaX;
if (Math.abs(mouseAccumulatorX) >= 120) {
const direction = mouseAccumulatorX * reverse < 0 ? 1 : -1;
if (handleScrollAction(xBehavior, direction)) {
actionInProgress = true;
cooldownTimer.restart();
}
mouseAccumulatorX = 0;
}
}
wheel.accepted = false;
@@ -741,24 +744,25 @@ PanelWindow {
return;
}
const isMouseWheel = Math.abs(deltaY) >= 120 && (Math.abs(deltaY) % 120) === 0;
const reverse = SettingsData.reverseScrolling ? -1 : 1;
const direction = deltaY * reverse < 0 ? 1 : -1;
if (isMouseWheel) {
if (handleScrollAction(yBehavior, direction)) {
actionInProgress = true;
cooldownTimer.restart();
}
} else {
scrollAccumulatorY += deltaY;
if (Math.abs(scrollAccumulatorY) >= touchpadThreshold) {
const touchDirection = scrollAccumulatorY < 0 ? 1 : -1;
if (handleScrollAction(yBehavior, touchDirection)) {
if (isTouchpadY) {
touchpadAccumulatorY += deltaY;
if (Math.abs(touchpadAccumulatorY) >= 500) {
const direction = touchpadAccumulatorY * reverse < 0 ? 1 : -1;
if (handleScrollAction(yBehavior, direction)) {
actionInProgress = true;
cooldownTimer.restart();
}
scrollAccumulatorY = 0;
touchpadAccumulatorY = 0;
}
} else {
mouseAccumulatorY += deltaY;
if (Math.abs(mouseAccumulatorY) >= 120) {
const direction = mouseAccumulatorY * reverse < 0 ? 1 : -1;
if (handleScrollAction(yBehavior, direction)) {
actionInProgress = true;
cooldownTimer.restart();
}
mouseAccumulatorY = 0;
}
}

View File

@@ -16,6 +16,8 @@ BasePill {
implicitWidth: root.isVerticalOrientation ? (root.widgetThickness - root.horizontalPadding * 2) : clockRow.implicitWidth
implicitHeight: root.isVerticalOrientation ? clockColumn.implicitHeight : (root.widgetThickness - root.horizontalPadding * 2)
readonly property bool compact: widgetData?.clockCompactMode !== undefined ? widgetData.clockCompactMode : SettingsData.clockCompactMode
Column {
id: clockColumn
visible: root.isVerticalOrientation
@@ -106,6 +108,7 @@ BasePill {
width: parent.width
height: Theme.spacingM
anchors.horizontalCenter: parent.horizontalCenter
visible: !compact
Rectangle {
width: parent.width * 0.6
@@ -118,6 +121,7 @@ BasePill {
Row {
spacing: 0
anchors.horizontalCenter: parent.horizontalCenter
visible: !compact
StyledText {
text: {
@@ -151,6 +155,7 @@ BasePill {
Row {
spacing: 0
anchors.horizontalCenter: parent.horizontalCenter
visible: !compact
StyledText {
text: {
@@ -204,7 +209,7 @@ BasePill {
font.pixelSize: Theme.fontSizeSmall
color: Theme.outlineButton
anchors.baseline: dateText.baseline
visible: !(widgetData?.clockCompactMode !== undefined ? widgetData.clockCompactMode : SettingsData.clockCompactMode)
visible: !compact
}
StyledText {
@@ -218,7 +223,7 @@ BasePill {
font.pixelSize: Theme.barTextSize(root.barThickness, root.barConfig?.fontScale)
color: Theme.widgetTextColor
anchors.verticalCenter: parent.verticalCenter
visible: !(widgetData?.clockCompactMode !== undefined ? widgetData.clockCompactMode : SettingsData.clockCompactMode)
visible: !compact
}
}

View File

@@ -26,6 +26,8 @@ BasePill {
return 0;
case 2:
return 180;
case 3:
return 240;
default:
return 120;
}
@@ -52,38 +54,67 @@ BasePill {
property real touchpadThreshold: 100
onWheel: function (wheelEvent) {
if (!usePlayerVolume)
return;
if (!SettingsData.audioScrollEnabled)
if (SettingsData.audioScrollMode === "nothing")
return;
wheelEvent.accepted = true;
if (SettingsData.audioScrollMode === "volume") {
if (!usePlayerVolume)
return;
const deltaY = wheelEvent.angleDelta.y;
const isMouseWheelY = Math.abs(deltaY) >= 120 && (Math.abs(deltaY) % 120) === 0;
wheelEvent.accepted = true;
const currentVolume = activePlayer.volume * 100;
const deltaY = wheelEvent.angleDelta.y;
const isMouseWheelY = Math.abs(deltaY) >= 120 && (Math.abs(deltaY) % 120) === 0;
let newVolume = currentVolume;
if (isMouseWheelY) {
if (deltaY > 0) {
newVolume = Math.min(100, currentVolume + 5);
} else if (deltaY < 0) {
newVolume = Math.max(0, currentVolume - 5);
}
} else {
scrollAccumulatorY += deltaY;
if (Math.abs(scrollAccumulatorY) >= touchpadThreshold) {
if (scrollAccumulatorY > 0) {
newVolume = Math.min(100, currentVolume + 1);
} else {
newVolume = Math.max(0, currentVolume - 1);
const currentVolume = activePlayer.volume * 100;
let newVolume = currentVolume;
if (isMouseWheelY) {
if (deltaY > 0) {
newVolume = Math.min(100, currentVolume + 5);
} else if (deltaY < 0) {
newVolume = Math.max(0, currentVolume - 5);
}
} else {
scrollAccumulatorY += deltaY;
if (Math.abs(scrollAccumulatorY) >= touchpadThreshold) {
if (scrollAccumulatorY > 0) {
newVolume = Math.min(100, currentVolume + 1);
} else {
newVolume = Math.max(0, currentVolume - 1);
}
scrollAccumulatorY = 0;
}
}
activePlayer.volume = newVolume / 100;
} else if (SettingsData.audioScrollMode === "song") {
if (!activePlayer)
return;
wheelEvent.accepted = true;
const deltaY = wheelEvent.angleDelta.y;
const isMouseWheelY = Math.abs(deltaY) >= 120 && (Math.abs(deltaY) % 120) === 0;
if (isMouseWheelY) {
if (deltaY > 0) {
activePlayer.previous();
} else {
activePlayer.next();
}
} else {
scrollAccumulatorY += deltaY;
if (Math.abs(scrollAccumulatorY) >= touchpadThreshold) {
if (scrollAccumulatorY > 0) {
activePlayer.previous();
} else {
activePlayer.next();
}
scrollAccumulatorY = 0;
}
scrollAccumulatorY = 0;
}
}
activePlayer.volume = newVolume / 100;
}
content: Component {

View File

@@ -241,7 +241,7 @@ Item {
}
const keyBase = (w.app_id || w.appId || w.class || w.windowClass || "unknown");
const key = isActiveWs ? `${keyBase}_${i}` : keyBase;
const key = isActiveWs || !SettingsData.groupWorkspaceApps ? `${keyBase}_${i}` : keyBase;
if (!byApp[key]) {
const moddedId = Paths.moddedAppId(keyBase);
@@ -565,6 +565,17 @@ Item {
if (isPlaceholder)
return index + 1;
if (SettingsData.showWorkspaceName) {
let workspaceName = modelData?.name;
if (workspaceName && workspaceName !== "") {
if (root.isVertical) {
return workspaceName.charAt(0);
}
return workspaceName;
}
}
if (root.useExtWorkspace)
return index + 1;
if (CompositorService.isHyprland)
@@ -649,8 +660,8 @@ Item {
anchors.fill: parent
acceptedButtons: Qt.RightButton
property real scrollAccumulator: 0
property real touchpadThreshold: 500
property real touchpadAccumulator: 0
property real mouseAccumulator: 0
property bool scrollInProgress: false
Timer {
@@ -674,24 +685,29 @@ Item {
return;
const delta = wheel.angleDelta.y;
const isMouseWheel = Math.abs(delta) >= 120 && (Math.abs(delta) % 120) === 0;
const isTouchpad = wheel.pixelDelta && wheel.pixelDelta.y !== 0;
const reverse = SettingsData.reverseScrolling ? -1 : 1;
const direction = delta * reverse < 0 ? 1 : -1;
if (isMouseWheel) {
if (isTouchpad) {
touchpadAccumulator += delta;
if (Math.abs(touchpadAccumulator) < 500)
return;
const direction = touchpadAccumulator * reverse < 0 ? 1 : -1;
root.switchWorkspace(direction);
scrollInProgress = true;
scrollCooldown.restart();
} else {
scrollAccumulator += delta;
if (Math.abs(scrollAccumulator) >= touchpadThreshold) {
const touchDirection = scrollAccumulator < 0 ? 1 : -1;
root.switchWorkspace(touchDirection);
scrollInProgress = true;
scrollCooldown.restart();
scrollAccumulator = 0;
}
touchpadAccumulator = 0;
return;
}
mouseAccumulator += delta;
if (Math.abs(mouseAccumulator) < 120)
return;
const direction = mouseAccumulator * reverse < 0 ? 1 : -1;
root.switchWorkspace(direction);
scrollInProgress = true;
scrollCooldown.restart();
mouseAccumulator = 0;
}
}
@@ -937,7 +953,7 @@ Item {
id: rowLayout
Row {
spacing: 4
visible: loadedIcons.length > 0 || SettingsData.showWorkspaceIndex || loadedHasIcon
visible: loadedIcons.length > 0 || SettingsData.showWorkspaceIndex || SettingsData.showWorkspaceName || loadedHasIcon
Item {
visible: loadedHasIcon && loadedIconData?.type === "icon"
@@ -970,7 +986,7 @@ Item {
}
Item {
visible: SettingsData.showWorkspaceIndex && !loadedHasIcon
visible: (SettingsData.showWorkspaceIndex || SettingsData.showWorkspaceName) && !loadedHasIcon
width: wsIndexText.implicitWidth + (isActive && loadedIcons.length > 0 ? 4 : 0)
height: root.appIconSize
@@ -1067,7 +1083,7 @@ Item {
id: columnLayout
Column {
spacing: 4
visible: loadedIcons.length > 0 || loadedHasIcon
visible: loadedIcons.length > 0 || SettingsData.showWorkspaceIndex || SettingsData.showWorkspaceName || loadedHasIcon
DankIcon {
visible: loadedHasIcon && loadedIconData?.type === "icon"
@@ -1204,7 +1220,7 @@ Item {
Loader {
id: indexLoader
anchors.fill: parent
active: SettingsData.showWorkspaceIndex && !loadedHasIcon && !SettingsData.showWorkspaceApps
active: (SettingsData.showWorkspaceIndex || SettingsData.showWorkspaceName) && !loadedHasIcon && !SettingsData.showWorkspaceApps
sourceComponent: Item {
StyledText {
anchors.centerIn: parent

View File

@@ -401,7 +401,7 @@ Item {
currentIndex = clampedIndex;
positionViewAtIndex(clampedIndex, GridView.Contain);
}
enableAnimation = true;
Qt.callLater(() => { enableAnimation = true; });
}
Connections {
@@ -465,12 +465,6 @@ Item {
}
}
BusyIndicator {
anchors.centerIn: parent
running: thumbnailImage.status === Image.Loading
visible: running
}
StateLayer {
anchors.fill: parent
cornerRadius: parent.radius

View File

@@ -35,6 +35,17 @@ Variants {
readonly property real widgetHeight: SettingsData.dockIconSize
readonly property real effectiveBarHeight: widgetHeight + SettingsData.dockSpacing * 2 + 10 + borderThickness * 2
function getBarHeight(barConfig) {
if (!barConfig)
return 0;
const innerPadding = barConfig.innerPadding ?? 4;
const widgetThickness = Math.max(20, 26 + innerPadding * 0.6);
const barThickness = Math.max(widgetThickness + innerPadding + 4, Theme.barHeight - 4 - (8 - innerPadding));
const spacing = barConfig.spacing ?? 4;
const bottomGap = barConfig.bottomGap ?? 0;
return barThickness + spacing + bottomGap;
}
readonly property real barSpacing: {
const defaultBar = SettingsData.barConfigs[0] || SettingsData.getBarConfig("default");
if (!defaultBar)
@@ -60,6 +71,36 @@ Variants {
return 0;
}
readonly property real adjacentTopBarHeight: {
if (!isVertical || autoHide)
return 0;
const screenName = dock.modelData?.name ?? "";
const topBar = SettingsData.barConfigs.find(bc => {
if (!bc.enabled || bc.autoHide || !(bc.visible ?? true))
return false;
if (bc.position !== SettingsData.Position.Top && bc.position !== 0)
return false;
const onThisScreen = bc.screenPreferences.length === 0 || bc.screenPreferences.includes("all") || bc.screenPreferences.includes(screenName);
return onThisScreen;
});
return getBarHeight(topBar);
}
readonly property real adjacentLeftBarWidth: {
if (isVertical || autoHide)
return 0;
const screenName = dock.modelData?.name ?? "";
const leftBar = SettingsData.barConfigs.find(bc => {
if (!bc.enabled || bc.autoHide || !(bc.visible ?? true))
return false;
if (bc.position !== SettingsData.Position.Left && bc.position !== 2)
return false;
const onThisScreen = bc.screenPreferences.length === 0 || bc.screenPreferences.includes("all") || bc.screenPreferences.includes(screenName);
return onThisScreen;
});
return getBarHeight(leftBar);
}
readonly property real dockMargin: SettingsData.dockSpacing
readonly property real positionSpacing: barSpacing + SettingsData.dockBottomGap + SettingsData.dockMargin
readonly property real _dpr: (dock.screen && dock.screen.devicePixelRatio) ? dock.screen.devicePixelRatio : 1
@@ -186,27 +227,31 @@ Variants {
function showTooltipForHoveredButton() {
dockTooltip.hide();
if (dock.hoveredButton && dock.reveal && !slideXAnimation.running && !slideYAnimation.running) {
const buttonGlobalPos = dock.hoveredButton.mapToGlobal(0, 0);
const tooltipText = dock.hoveredButton.tooltipText || "";
if (tooltipText) {
const screenX = dock.screen ? (dock.screen.x || 0) : 0;
const screenY = dock.screen ? (dock.screen.y || 0) : 0;
const screenHeight = dock.screen ? dock.screen.height : 0;
if (!dock.isVertical) {
const isBottom = SettingsData.dockPosition === SettingsData.Position.Bottom;
const globalX = buttonGlobalPos.x + dock.hoveredButton.width / 2;
const screenRelativeY = isBottom ? (screenHeight - dock.effectiveBarHeight - SettingsData.dockSpacing - SettingsData.dockBottomGap - SettingsData.dockMargin - 35) : (buttonGlobalPos.y - screenY + dock.hoveredButton.height + Theme.spacingS);
dockTooltip.show(tooltipText, globalX, screenRelativeY, dock.screen, false, false);
} else {
const isLeft = SettingsData.dockPosition === SettingsData.Position.Left;
const tooltipOffset = dock.effectiveBarHeight + SettingsData.dockSpacing + SettingsData.dockMargin + Theme.spacingXS;
const tooltipX = isLeft ? tooltipOffset : (dock.screen.width - tooltipOffset);
const screenRelativeY = buttonGlobalPos.y - screenY + dock.hoveredButton.height / 2;
dockTooltip.show(tooltipText, screenX + tooltipX, screenRelativeY, dock.screen, isLeft, !isLeft);
}
}
if (!dock.hoveredButton || !dock.reveal || slideXAnimation.running || slideYAnimation.running)
return;
const buttonGlobalPos = dock.hoveredButton.mapToGlobal(0, 0);
const tooltipText = dock.hoveredButton.tooltipText || "";
if (!tooltipText)
return;
const screenX = dock.screen ? (dock.screen.x || 0) : 0;
const screenY = dock.screen ? (dock.screen.y || 0) : 0;
const screenHeight = dock.screen ? dock.screen.height : 0;
if (!dock.isVertical) {
const isBottom = SettingsData.dockPosition === SettingsData.Position.Bottom;
const globalX = buttonGlobalPos.x + dock.hoveredButton.width / 2 + adjacentLeftBarWidth;
const screenRelativeY = isBottom ? (screenHeight - dock.effectiveBarHeight - SettingsData.dockSpacing - SettingsData.dockBottomGap - SettingsData.dockMargin - barSpacing - 35) : (buttonGlobalPos.y - screenY + dock.hoveredButton.height + Theme.spacingS);
dockTooltip.show(tooltipText, globalX, screenRelativeY, dock.screen, false, false);
return;
}
const isLeft = SettingsData.dockPosition === SettingsData.Position.Left;
const tooltipOffset = dock.effectiveBarHeight + SettingsData.dockSpacing + SettingsData.dockMargin + barSpacing + Theme.spacingXS;
const tooltipX = isLeft ? tooltipOffset : (dock.screen.width - tooltipOffset);
const screenRelativeY = buttonGlobalPos.y - screenY + dock.hoveredButton.height / 2 + adjacentTopBarHeight;
dockTooltip.show(tooltipText, screenX + tooltipX, screenRelativeY, dock.screen, isLeft, !isLeft);
}
Connections {

View File

@@ -1,11 +1,9 @@
pragma Singleton
pragma ComponentBehavior: Bound
import QtCore
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Common
Singleton {
id: root
@@ -20,40 +18,40 @@ Singleton {
property bool nightModeEnabled: false
Component.onCompleted: {
Quickshell.execDetached(["mkdir", "-p", greetCfgDir])
loadMemory()
loadSessionConfig()
Quickshell.execDetached(["mkdir", "-p", greetCfgDir]);
loadMemory();
loadSessionConfig();
}
function loadMemory() {
parseMemory(memoryFileView.text())
parseMemory(memoryFileView.text());
}
function loadSessionConfig() {
parseSessionConfig(sessionConfigFileView.text())
parseSessionConfig(sessionConfigFileView.text());
}
function parseSessionConfig(content) {
try {
if (content && content.trim()) {
const config = JSON.parse(content)
isLightMode = config.isLightMode !== undefined ? config.isLightMode : false
nightModeEnabled = config.nightModeEnabled !== undefined ? config.nightModeEnabled : false
const config = JSON.parse(content);
isLightMode = config.isLightMode !== undefined ? config.isLightMode : false;
nightModeEnabled = config.nightModeEnabled !== undefined ? config.nightModeEnabled : false;
}
} catch (e) {
console.warn("Failed to parse greeter session config:", e)
console.warn("Failed to parse greeter session config:", e);
}
}
function parseMemory(content) {
try {
if (content && content.trim()) {
const memory = JSON.parse(content)
lastSessionId = memory.lastSessionId !== undefined ? memory.lastSessionId : ""
lastSuccessfulUser = memory.lastSuccessfulUser !== undefined ? memory.lastSuccessfulUser : ""
}
if (!content || !content.trim())
return;
const memory = JSON.parse(content);
lastSessionId = memory.lastSessionId || "";
lastSuccessfulUser = memory.lastSuccessfulUser || "";
} catch (e) {
console.warn("Failed to parse greetd memory:", e)
console.warn("Failed to parse greetd memory:", e);
}
}
@@ -61,17 +59,17 @@ Singleton {
memoryFileView.setText(JSON.stringify({
"lastSessionId": lastSessionId,
"lastSuccessfulUser": lastSuccessfulUser
}, null, 2))
}, null, 2));
}
function setLastSessionId(id) {
lastSessionId = id || ""
saveMemory()
lastSessionId = id || "";
saveMemory();
}
function setLastSuccessfulUser(username) {
lastSuccessfulUser = username || ""
saveMemory()
lastSuccessfulUser = username || "";
saveMemory();
}
FileView {
@@ -83,7 +81,7 @@ Singleton {
watchChanges: false
printErrors: false
onLoaded: {
parseMemory(memoryFileView.text())
parseMemory(memoryFileView.text());
}
}
@@ -96,10 +94,10 @@ Singleton {
watchChanges: false
printErrors: true
onLoaded: {
parseSessionConfig(sessionConfigFileView.text())
parseSessionConfig(sessionConfigFileView.text());
}
onLoadFailed: error => {
console.warn("Could not load greeter session config from", root.sessionConfigPath, "error:", error)
console.warn("Could not load greeter session config from", root.sessionConfigPath, "error:", error);
}
}
}

View File

@@ -2,6 +2,7 @@ import QtCore
import QtQuick
import QtQuick.Effects
import QtQuick.Layouts
import Qt.labs.folderlistmodel
import Quickshell
import Quickshell.Hyprland
import Quickshell.Io
@@ -54,10 +55,8 @@ Item {
pickRandomFact();
initWeatherService();
if (isPrimaryScreen) {
sessionListProc.running = true;
if (isPrimaryScreen)
applyLastSuccessfulUser();
}
if (CompositorService.isHyprland)
updateHyprlandLayout();
@@ -1030,130 +1029,146 @@ Item {
alignPopupRight: true
onValueChanged: value => {
const idx = GreeterState.sessionList.indexOf(value);
if (idx >= 0) {
GreeterState.currentSessionIndex = idx;
GreeterState.selectedSession = GreeterState.sessionExecs[idx];
GreetdMemory.setLastSessionId(GreeterState.sessionPaths[idx]);
}
if (idx < 0)
return;
GreeterState.currentSessionIndex = idx;
GreeterState.selectedSession = GreeterState.sessionExecs[idx];
GreeterState.selectedSessionPath = GreeterState.sessionPaths[idx];
}
}
}
}
property string currentSessionName: GreeterState.sessionList[GreeterState.currentSessionIndex] || ""
property int pendingParsers: 0
function finalizeSessionSelection() {
if (GreeterState.sessionList.length === 0) {
if (GreeterState.sessionList.length === 0)
return;
}
const savedSession = GreetdMemory.lastSessionId;
let foundSaved = false;
if (savedSession) {
for (var i = 0; i < GreeterState.sessionPaths.length; i++) {
if (GreeterState.sessionPaths[i] === savedSession) {
GreeterState.currentSessionIndex = i;
foundSaved = true;
break;
GreeterState.selectedSession = GreeterState.sessionExecs[i] || "";
GreeterState.selectedSessionPath = GreeterState.sessionPaths[i];
return;
}
}
}
if (!foundSaved) {
GreeterState.currentSessionIndex = 0;
}
GreeterState.selectedSession = GreeterState.sessionExecs[GreeterState.currentSessionIndex] || GreeterState.sessionExecs[0] || "";
GreeterState.currentSessionIndex = 0;
GreeterState.selectedSession = GreeterState.sessionExecs[0] || "";
GreeterState.selectedSessionPath = GreeterState.sessionPaths[0] || "";
}
Process {
id: sessionListProc
property string homeDir: Quickshell.env("HOME") || ""
property string xdgDirs: xdgDataDirs || ""
command: {
var paths = ["/usr/share/wayland-sessions", "/usr/share/xsessions", "/usr/local/share/wayland-sessions", "/usr/local/share/xsessions"];
if (homeDir) {
paths.push(homeDir + "/.local/share/wayland-sessions");
paths.push(homeDir + "/.local/share/xsessions");
}
// Add XDG_DATA_DIRS paths
if (xdgDirs) {
xdgDirs.split(":").forEach(function (dir) {
if (dir) {
paths.push(dir + "/wayland-sessions");
paths.push(dir + "/xsessions");
}
});
}
// 1. Explicit system/user paths
var explicitFind = "find " + paths.join(" ") + " -maxdepth 1 -name '*.desktop' -type f -follow 2>/dev/null";
// 2. Scan all /home user directories for local session files
var homeScan = "find /home -maxdepth 5 \\( -path '*/wayland-sessions/*.desktop' -o -path '*/xsessions/*.desktop' \\) -type f -follow 2>/dev/null";
var findCmd = "(" + explicitFind + "; " + homeScan + ") | sort -u";
return ["sh", "-c", findCmd];
}
running: false
property var sessionDirs: {
const homeDir = Quickshell.env("HOME") || "";
const dirs = ["/usr/share/wayland-sessions", "/usr/share/xsessions", "/usr/local/share/wayland-sessions", "/usr/local/share/xsessions"];
stdout: SplitParser {
onRead: data => {
if (data.trim()) {
root.pendingParsers++;
parseDesktopFile(data.trim());
if (homeDir) {
dirs.push(homeDir + "/.local/share/wayland-sessions");
dirs.push(homeDir + "/.local/share/xsessions");
}
if (xdgDataDirs) {
xdgDataDirs.split(":").forEach(dir => {
if (dir) {
dirs.push(dir + "/wayland-sessions");
dirs.push(dir + "/xsessions");
}
}
});
}
return dirs;
}
function parseDesktopFile(path) {
const parser = desktopParser.createObject(null, {
"desktopPath": path
property var _pendingFiles: ({})
property int _pendingCount: 0
function _addSession(path, name, exec) {
if (!name || !exec || GreeterState.sessionList.includes(name))
return;
GreeterState.sessionList = GreeterState.sessionList.concat([name]);
GreeterState.sessionExecs = GreeterState.sessionExecs.concat([exec]);
GreeterState.sessionPaths = GreeterState.sessionPaths.concat([path]);
}
function _parseDesktopFile(content, path) {
let name = "";
let exec = "";
const lines = content.split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (!name && line.startsWith("Name="))
name = line.substring(5).trim();
else if (!exec && line.startsWith("Exec="))
exec = line.substring(5).trim();
if (name && exec)
break;
}
_addSession(path, name, exec);
}
function _loadDesktopFile(filePath) {
if (_pendingFiles[filePath])
return;
_pendingFiles[filePath] = true;
_pendingCount++;
const loader = desktopFileLoader.createObject(root, {
"filePath": filePath
});
}
function _onFileLoaded(filePath) {
_pendingCount--;
if (_pendingCount === 0)
Qt.callLater(finalizeSessionSelection);
}
Component {
id: desktopParser
Process {
property string desktopPath: ""
command: ["bash", "-c", `grep -E '^(Name|Exec)=' "${desktopPath}"`]
running: true
id: desktopFileLoader
stdout: StdioCollector {
onStreamFinished: {
const lines = text.split("\n");
let name = "";
let exec = "";
FileView {
id: fv
property string filePath: ""
path: filePath
for (const line of lines) {
if (line.startsWith("Name=")) {
name = line.substring(5).trim();
} else if (line.startsWith("Exec=")) {
exec = line.substring(5).trim();
}
}
if (name && exec) {
if (!GreeterState.sessionList.includes(name)) {
let newList = GreeterState.sessionList.slice();
let newExecs = GreeterState.sessionExecs.slice();
let newPaths = GreeterState.sessionPaths.slice();
newList.push(name);
newExecs.push(exec);
newPaths.push(desktopPath);
GreeterState.sessionList = newList;
GreeterState.sessionExecs = newExecs;
GreeterState.sessionPaths = newPaths;
}
}
}
onLoaded: {
root._parseDesktopFile(text(), filePath);
root._onFileLoaded(filePath);
fv.destroy();
}
onExited: code => {
root.pendingParsers--;
if (root.pendingParsers === 0) {
Qt.callLater(root.finalizeSessionSelection);
onLoadFailed: {
root._onFileLoaded(filePath);
fv.destroy();
}
}
}
Repeater {
model: isPrimaryScreen ? sessionDirs : []
Item {
required property string modelData
FolderListModel {
folder: "file://" + modelData
nameFilters: ["*.desktop"]
showDirs: false
showDotAndDotDot: false
onStatusChanged: {
if (status !== FolderListModel.Ready)
return;
for (let i = 0; i < count; i++) {
let fp = get(i, "filePath");
if (fp.startsWith("file://"))
fp = fp.substring(7);
root._loadDesktopFile(fp);
}
}
destroy();
}
}
}
@@ -1167,22 +1182,31 @@ Item {
Greetd.respond(GreeterState.passwordBuffer);
GreeterState.passwordBuffer = "";
inputField.text = "";
} else if (!error) {
Greetd.respond("");
return;
}
if (!error)
Greetd.respond("");
}
function onReadyToLaunch() {
GreeterState.unlocking = true;
const sessionCmd = GreeterState.selectedSession || GreeterState.sessionExecs[GreeterState.currentSessionIndex];
if (sessionCmd) {
GreetdMemory.setLastSessionId(GreeterState.sessionPaths[GreeterState.currentSessionIndex]);
GreetdMemory.setLastSuccessfulUser(GreeterState.username);
Greetd.launch(sessionCmd.split(" "), ["XDG_SESSION_TYPE=wayland"]);
const sessionPath = GreeterState.selectedSessionPath || GreeterState.sessionPaths[GreeterState.currentSessionIndex];
if (!sessionCmd) {
GreeterState.pamState = "error";
placeholderDelay.restart();
return;
}
GreeterState.unlocking = true;
launchTimeout.restart();
GreetdMemory.setLastSessionId(sessionPath);
GreetdMemory.setLastSuccessfulUser(GreeterState.username);
Greetd.launch(sessionCmd.split(" "), ["XDG_SESSION_TYPE=wayland"]);
}
function onAuthFailure(message) {
launchTimeout.stop();
GreeterState.unlocking = false;
GreeterState.pamState = "fail";
GreeterState.passwordBuffer = "";
inputField.text = "";
@@ -1190,8 +1214,26 @@ Item {
}
function onError(error) {
launchTimeout.stop();
GreeterState.unlocking = false;
GreeterState.pamState = "error";
GreeterState.passwordBuffer = "";
inputField.text = "";
placeholderDelay.restart();
Greetd.cancelSession();
}
}
Timer {
id: launchTimeout
interval: 8000
onTriggered: {
if (!GreeterState.unlocking)
return;
GreeterState.unlocking = false;
GreeterState.pamState = "error";
placeholderDelay.restart();
Greetd.cancelSession();
}
}

View File

@@ -1,7 +1,7 @@
import QtQuick
import Quickshell
pragma Singleton
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
Singleton {
id: root
@@ -11,6 +11,7 @@ Singleton {
property string usernameInput: ""
property bool showPasswordInput: false
property string selectedSession: ""
property string selectedSessionPath: ""
property string pamState: ""
property bool unlocking: false
@@ -20,10 +21,10 @@ Singleton {
property int currentSessionIndex: 0
function reset() {
showPasswordInput = false
username = ""
usernameInput = ""
passwordBuffer = ""
pamState = ""
showPasswordInput = false;
username = "";
usernameInput = "";
passwordBuffer = "";
pamState = "";
}
}

View File

@@ -186,7 +186,11 @@ exec = sh -c "$QS_CMD; hyprctl dispatch exit"
HYPRLAND_EOF
COMPOSITOR_CONFIG="$TEMP_CONFIG"
fi
exec Hyprland -c "$COMPOSITOR_CONFIG"
if command -v start-hyprland >/dev/null 2>&1; then
exec start-hyprland -- --config "$COMPOSITOR_CONFIG"
else
exec Hyprland -c "$COMPOSITOR_CONFIG"
fi
;;
sway)

View File

@@ -4,5 +4,8 @@ export XDG_SESSION_TYPE=wayland
export QT_QPA_PLATFORM=wayland
export QT_WAYLAND_DISABLE_WINDOWDECORATION=1
export EGL_PLATFORM=gbm
exec Hyprland -c /etc/greetd/dms-hypr.conf
if command -v start-hyprland >/dev/null 2>&1; then
exec start-hyprland -- -c /etc/greetd/dms-hypr.conf
else
exec Hyprland -c /etc/greetd/dms-hypr.conf
fi

View File

@@ -0,0 +1,102 @@
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import Quickshell.Wayland
import qs.Common
PanelWindow {
id: root
property bool active: false
signal fadeCompleted
signal fadeCancelled
visible: active
color: "transparent"
WlrLayershell.namespace: "dms:fade-to-dpms"
WlrLayershell.layer: WlrLayershell.Overlay
WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: active ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None
anchors {
left: true
right: true
top: true
bottom: true
}
Rectangle {
id: fadeOverlay
anchors.fill: parent
color: "black"
opacity: 0
onOpacityChanged: {
if (opacity >= 0.99 && root.active) {
root.fadeCompleted();
}
}
}
SequentialAnimation {
id: fadeSeq
running: false
NumberAnimation {
target: fadeOverlay
property: "opacity"
from: 0.0
to: 1.0
duration: SettingsData.fadeToDpmsGracePeriod * 1000
easing.type: Easing.OutCubic
}
}
function startFade() {
if (!SettingsData.fadeToDpmsEnabled)
return;
active = true;
fadeOverlay.opacity = 0.0;
fadeSeq.stop();
fadeSeq.start();
}
function cancelFade() {
fadeSeq.stop();
fadeOverlay.opacity = 0.0;
active = false;
fadeCancelled();
}
MouseArea {
anchors.fill: parent
enabled: root.active
onClicked: root.cancelFade()
onPressed: root.cancelFade()
}
FocusScope {
anchors.fill: parent
focus: root.active
Keys.onPressed: event => {
root.cancelFade();
event.accepted = true;
}
}
Component.onCompleted: {
if (active) {
forceActiveFocus();
}
}
onActiveChanged: {
if (active) {
forceActiveFocus();
}
}
}

View File

@@ -23,15 +23,12 @@ Scope {
Quickshell.execDetached(["sh", "-c", SettingsData.customPowerActionLock]);
return;
}
shouldLock = true;
if (!processingExternalEvent && SettingsData.loginctlLockIntegration && DMSService.isConnected) {
DMSService.lockSession(response => {
if (response.error) {
console.warn("Lock: Failed to call loginctl.lock:", response.error);
shouldLock = true;
}
if (response.error)
console.warn("Lock: loginctl.lock failed:", response.error);
});
} else {
shouldLock = true;
}
}
@@ -81,6 +78,11 @@ Scope {
locked: shouldLock
onLockedChanged: {
if (locked)
dpmsReapplyTimer.start();
}
WlSessionLockSurface {
id: lockSurface
@@ -120,15 +122,12 @@ Scope {
target: "lock"
function lock() {
if (!root.processingExternalEvent && SettingsData.loginctlLockIntegration && DMSService.isConnected) {
root.shouldLock = true;
if (SettingsData.loginctlLockIntegration && DMSService.isConnected) {
DMSService.lockSession(response => {
if (response.error) {
console.warn("Lock: Failed to call loginctl.lock:", response.error);
root.shouldLock = true;
}
if (response.error)
console.warn("Lock: loginctl.lock failed:", response.error);
});
} else {
root.shouldLock = true;
}
}
@@ -140,4 +139,11 @@ Scope {
return sessionLock.locked;
}
}
Timer {
id: dpmsReapplyTimer
interval: 100
repeat: false
onTriggered: IdleService.reapplyDpmsIfNeeded()
}
}

View File

@@ -0,0 +1,189 @@
import QtQuick
import Quickshell
import qs.Common
import qs.Services
import qs.Widgets
Rectangle {
id: root
required property var historyItem
property bool isSelected: false
property bool keyboardNavigationActive: false
width: parent ? parent.width : 400
height: 116
radius: Theme.cornerRadius
clip: true
color: {
if (isSelected && keyboardNavigationActive)
return Theme.primaryPressed;
return Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency);
}
border.color: {
if (isSelected && keyboardNavigationActive)
return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.5);
if (historyItem.urgency === 2)
return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.3);
return Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.05);
}
border.width: {
if (isSelected && keyboardNavigationActive)
return 1.5;
if (historyItem.urgency === 2)
return 2;
return 1;
}
Behavior on border.color {
ColorAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
Rectangle {
anchors.fill: parent
radius: parent.radius
visible: historyItem.urgency === 2
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop {
position: 0.0
color: Theme.primary
}
GradientStop {
position: 0.02
color: Theme.primary
}
GradientStop {
position: 0.021
color: "transparent"
}
}
}
Item {
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.topMargin: 12
anchors.leftMargin: 16
anchors.rightMargin: 56
height: 92
DankCircularImage {
id: iconContainer
readonly property bool hasNotificationImage: historyItem.image && historyItem.image !== ""
width: 63
height: 63
anchors.left: parent.left
anchors.top: parent.top
anchors.topMargin: 14
imageSource: {
if (hasNotificationImage)
return historyItem.image;
if (historyItem.appIcon) {
const appIcon = historyItem.appIcon;
if (appIcon.startsWith("file://") || appIcon.startsWith("http://") || appIcon.startsWith("https://"))
return appIcon;
return Quickshell.iconPath(appIcon, true);
}
return "";
}
hasImage: hasNotificationImage
fallbackIcon: ""
fallbackText: {
const appName = historyItem.appName || "?";
return appName.charAt(0).toUpperCase();
}
Rectangle {
anchors.fill: parent
anchors.margins: -2
radius: width / 2
color: "transparent"
border.color: root.color
border.width: 5
visible: parent.hasImage
antialiasing: true
}
}
Rectangle {
anchors.left: iconContainer.right
anchors.leftMargin: 12
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.bottomMargin: 8
color: "transparent"
Item {
width: parent.width
height: parent.height
anchors.top: parent.top
anchors.topMargin: -2
Column {
width: parent.width
spacing: 2
StyledText {
width: parent.width
text: {
const timeStr = NotificationService.formatHistoryTime(historyItem.timestamp);
const appName = historyItem.appName || "";
return timeStr.length > 0 ? `${appName} ${timeStr}` : appName;
}
color: Theme.surfaceVariantText
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
elide: Text.ElideRight
maximumLineCount: 1
}
StyledText {
text: historyItem.summary || ""
color: Theme.surfaceText
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
width: parent.width
elide: Text.ElideRight
maximumLineCount: 1
visible: text.length > 0
}
StyledText {
id: descriptionText
text: historyItem.htmlBody || historyItem.body || ""
color: Theme.surfaceVariantText
font.pixelSize: Theme.fontSizeSmall
width: parent.width
elide: Text.ElideRight
maximumLineCount: 2
wrapMode: Text.WordWrap
visible: text.length > 0
linkColor: Theme.primary
onLinkActivated: link => Qt.openUrlExternally(link)
}
}
}
}
}
DankActionButton {
anchors.top: parent.top
anchors.right: parent.right
anchors.topMargin: 12
anchors.rightMargin: 16
iconName: "close"
iconSize: 18
buttonSize: 28
onClicked: NotificationService.removeFromHistory(historyItem.id)
}
}

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