1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-01-27 15:02:50 -05:00

Compare commits

...

196 Commits

Author SHA1 Message Date
Flux
a7cdb39b0b labwc patch (#1391) 2026-01-16 09:52:13 -05:00
bbedward
0ceba92a23 i18n: more RTL fixes across settings 2026-01-16 09:52:13 -05:00
bbedward
4daa7a4c88 popout: fix cross-monitor handling of widgets fixes #1364 2026-01-16 09:52:13 -05:00
bbedward
cc4a6a5899 doctor: add mango and labwc to compositors fixes #1394 2026-01-16 09:52:13 -05:00
bbedward
994947477c greeter: remove WLR_DRM_DEVICES setting fixes #1393 2026-01-16 09:52:13 -05:00
bbedward
311817ee97 dankbar: fix property preservation in widgets fixes #1392 2026-01-16 09:52:13 -05:00
bbedward
b80c73f9b9 weather: fix precipitationw weekly propability fixes #1395 2026-01-16 09:52:13 -05:00
bbedward
a85101c099 plugins: ensure daemon plugins not instantiated twice 2026-01-16 09:52:13 -05:00
bbedward
3513d57e06 cc: fixed width column, remove anchoring from individual icons on vbar maybe #1376 2026-01-16 09:52:13 -05:00
Lucas
1234847abb nix: fix home module (#1387) 2026-01-16 09:52:13 -05:00
Bailey
0ed595b43d nix: Support specifying systemd target (#1385) 2026-01-16 09:52:13 -05:00
Ivan Molodetskikh
060cbefc79 Add screencast indicator for niri (#1361)
* niri: Handle new Cast events

* bar: Add screen sharing indicator

Configurable like other icons; on by default.

* lockscreen: Add screen sharing indicator
2026-01-16 09:52:10 -05:00
bbedward
e022c04519 bump version 2026-01-15 23:45:21 -05:00
bbedward
f534384e5e cc: wrap icons in fixed size containers
maybe #1376
2026-01-15 23:45:09 -05:00
bbedward
a25cdb43d5 controlcenter: fix visibility condition of no icons fixes #1377 2026-01-15 23:45:09 -05:00
purian23
4e9b4ca400 Fix fedora version format 2026-01-15 23:44:19 -05:00
bbedward
5bab1c98b1 plugins: fix plugin confirm third part repo window 2026-01-15 23:44:19 -05:00
purian23
2284bb002f distro: Update Fedora dynamic versioning 2026-01-15 23:44:19 -05:00
purian23
b0611d6104 feat: Allow more pinned services in Control Center/Settings 2026-01-15 23:44:19 -05:00
purian23
27965862d6 core: Update ghostty on dankinstall 2026-01-15 23:44:19 -05:00
Abhinav Chalise
e74a901e05 fix volume osd sliding ui update for vertical layout (#1382) 2026-01-15 23:44:19 -05:00
bbedward
77794deb2c widgets: add fallback for steam apps 2026-01-15 23:44:19 -05:00
Lucas
1c10746e50 doctor: use dbus for checking on services (#1384)
* doctor: use dbus for checking on services

* doctor: show docs URL for failed checks

* core: remove unused function
2026-01-15 23:44:19 -05:00
bbedward
8ecb7282b9 dankdash: fix weather open IPC fixes #1367 2026-01-15 23:44:19 -05:00
bbedward
9b3fa804ab matugen: fix nvim ID in skipTemplates 2026-01-15 23:44:19 -05:00
purian23
b2ad31a27e dankdash: Center Media Art & Controls 2026-01-15 23:44:19 -05:00
bbedward
db17e4cb14 i18n: update terms 2026-01-15 23:44:02 -05:00
bbedward
1b7dcf56a8 bump VERSION 2026-01-14 08:00:15 -05:00
bbedward
502bb88e92 modals: fix wifi passowrd, polkit, and VPN import 2026-01-14 08:00:05 -05:00
bbedward
b76d0ce97d settings: fix child windows on newer quickshell-git 2026-01-13 16:58:08 -05:00
bbedward
fa66d330cf bump VERSION 2026-01-13 16:42:49 -05:00
Lucas
157eab2d07 settings: fix modal not opening on latest quickshell (#1357) 2026-01-13 16:42:38 -05:00
Lucas
f50ad2dc22 nix: escape version string (#1353) 2026-01-13 16:42:38 -05:00
bbedward
cd9d92d884 update changelog link and VERSION 2026-01-13 08:31:50 -05:00
Lucas
1b69a5e62b nix: add wtype dependency (#1346) 2026-01-13 08:27:46 -05:00
bbedward
61d311b157 widgets: fix running apps positioning and popup manager 2026-01-13 08:26:29 -05:00
bbedward
6b76b86930 notifications: remove redundant trimStored and add null safety 2026-01-12 23:37:49 -05:00
bbedward
dcfb947c36 desktop widgets: sync position across screens option, clickthrough
option, grouping in settings, repositioning, new IPCs for control
fixes #1300
fixes #1301
2026-01-12 15:31:34 -05:00
bbedward
59893b7f44 notifications: use Theme.primary to represent do not distrub in bar 2026-01-12 11:57:42 -05:00
bbedward
d2c62f5533 matugen: add support for vscode-insiders 2026-01-12 11:46:29 -05:00
bbedward
2bbe9a0c45 core/wlcontext: use infinite poll timeout 2026-01-12 11:26:35 -05:00
bbedward
4e2ce82c0a notifications: swipe to dismiss on history 2026-01-12 11:08:22 -05:00
bbedward
104762186f widgets: respect radius for inactive DankButtonGroup i tems 2026-01-12 10:26:50 -05:00
bbedward
f1233ab1e3 matugen: add post_hook for mango 2026-01-12 10:05:19 -05:00
bbedward
d6b407ec37 settings: fix wallpaper preview cache update on per-mode change 2026-01-12 09:58:58 -05:00
bbedward
022b4b4bb3 enable changelog 2026-01-12 09:46:50 -05:00
bbedward
49b322582d keybinds: fix sh, fix screenshot-window options, empty args
part of #914
2026-01-12 09:35:30 -05:00
bbedward
1280bd047d settings: fix sidebar binding when clicked by emitting signal 2026-01-11 22:43:29 -05:00
bbedward
6f206d7523 dankdash: fix 24H format in weather tab
fixes #1283
2026-01-11 21:45:28 -05:00
bbedward
2e58283859 dgop: use used mem directly from API
- conditionally because it depends on newer dgop
2026-01-11 17:32:36 -05:00
Marcus Ramberg
99a5721fe8 settings: extract tab headings for search (#1333)
* settings: extract tab headings for search

* fix pre-commit

---------

Co-authored-by: bbedward <bbedward@gmail.com>
2026-01-11 17:14:45 -05:00
bbedward
5302ebd840 notifications: spacing improvements
fixes #1241
2026-01-11 14:35:34 -05:00
bbedward
fa427ea1ac settings: fix clipping of generic color selector
fixes #1242
2026-01-11 14:04:48 -05:00
bbedward
7027bd1646 systemtray: use Theme radius for menu options
fixes #1331
2026-01-11 14:03:23 -05:00
bbedward
3c38e17472 notifications: add compact mode, expansion in history, expansion in
popup
fixes #1282
2026-01-11 12:11:44 -05:00
shalevc1098
510ea5d2e4 feat: configurable app id substitutions (#1317)
* feat: add configurable app ID substitutions setting

* feat: add live icon updates when substitutions change

* fix: cursor not showing on headerActions in non-collapsible cards

* fix: address PR review feedback

- add tags for search index
- remove hardcoded height from text fields
2026-01-10 21:00:15 -05:00
bbedward
bb2234d328 cc: dont show preference flip if not on ethernet and wifi 2026-01-10 10:35:48 -05:00
bbedward
edbdeb0fb8 widgets: add artix and void NF mappings 2026-01-10 10:18:09 -05:00
Kostiantyn To
19541fc573 update-service: add Artix Linux to supported distributions list (#1318) 2026-01-10 10:18:00 -05:00
bbedward
7c936cacfb niri: fix effectiveScreenAssignment in modal 2026-01-10 10:13:41 -05:00
bbedward
c60cd3a341 modals/auth: add show password option
fixes #1311
2026-01-09 22:20:18 -05:00
shalevc1098
e37135f80d feat: map steam_app_ID to steam_icon_ID for actual game icons (#1312)
Steam Proton games use window class steam_app_XXXXX. Steam installs
icons as steam_icon_XXXXX. This maps between them so actual game
icons display instead of generic controller fallback.
2026-01-09 21:40:35 -05:00
bbedward
aac937cbcc settingns: fix missing help text on desktop widgets 2026-01-09 19:07:37 -05:00
bbedward
4b46d022af workspaces: add color options, add focus follows monitor, remove
per-monitor option (was misleading)
relevant to #1207
2026-01-09 14:10:57 -05:00
bbedward
7f0181b310 matugen/vscode: fix selection contrast 2026-01-09 10:16:03 -05:00
bbedward
6a109274f8 hyprland: always use single window 2026-01-09 09:57:31 -05:00
bbedward
0f09cc693a lock: handle case where session lock is rejected 2026-01-09 09:46:39 -05:00
bbedward
af0166a553 dankbar: add bar get/setPosition IPC 2026-01-09 00:09:49 -05:00
bbedward
a283017f26 audio: recreate media players on pipewire device change 2026-01-08 23:35:42 -05:00
bbedward
5ae2cd1dfb i18n: fix RTL in plugin settings 2026-01-08 19:16:55 -05:00
bbedward
eece811fb0 i18n: more RTL repairs 2026-01-08 18:45:38 -05:00
bbedward
1ff1f3a7f2 i18n: more RTL layout enhancements 2026-01-08 16:11:30 -05:00
bbedward
a21a846bf5 wallpaper: encode image URIs
fixes #1306
2026-01-08 14:32:12 -05:00
Anton Kesy
f5f21e738a fix typos (#1304) 2026-01-08 14:10:24 -05:00
bbedward
033e62418a hyprland: fix cursor setting 2026-01-08 09:30:52 -05:00
bbedward
3c69e8b1cc revert readme 2026-01-07 22:59:28 -05:00
bbedward
118be27796 update readme 2026-01-07 22:56:16 -05:00
bbedward
721d35d417 readme:update vid url 2026-01-07 22:54:38 -05:00
bbedward
7bc3d5910d settings: fade to lock and monitor off by default on 2026-01-07 21:31:12 -05:00
bbedward
ccc7047be0 welcome: make the first page stuff clickable
fixes #1295
2026-01-07 21:22:15 -05:00
bbedward
a5e107c89d changelog: capability to display new release message 2026-01-07 20:15:50 -05:00
bbedward
646d60dcbf displays: fix text-alignment in model mode 2026-01-07 16:54:31 -05:00
bbedward
5dc7c0d797 core: add resolve-include recursive
fixes #1294
2026-01-07 16:45:31 -05:00
bbedward
db1de9df38 keybinds: fix empty string args, more writable provider options 2026-01-07 15:38:44 -05:00
bbedward
3dd21382ba network: support hidden SSIDs 2026-01-07 14:13:03 -05:00
bbedward
ec2b3d0d4b vpn: aggregate all import errors
- we are dumb about importing by just trying to import everythting
- that caused errors to not be represented correctly
- just aggregate them all and present them in toast details
- Better would be to detect the type of file being imported, but this is
  better than nothing
2026-01-07 13:22:56 -05:00
bbedward
a205df1bd6 keybinds: initial support for writable hyprland and mangoWC
fixes #1204
2026-01-07 12:15:38 -05:00
bbedward
e822fa73da cursor: make min/max wider 2026-01-07 10:04:47 -05:00
bbedward
634e75b80c plugins: improve version check 2026-01-07 09:46:55 -05:00
bbedward
ec5b507efc greeter: change hypr startup to exec-once 2026-01-07 09:18:32 -05:00
bbedward
e6d289d48c workflow: update stable workflow to use GH app 2026-01-06 22:45:34 -05:00
bbedward
745d7f26ce cursor: create/update XResources for XWL apps 2026-01-06 22:06:01 -05:00
bbedward
ad43053b94 cursor: hypr, mango, and dankinstall support for configs 2026-01-06 20:35:22 -05:00
purian23
721700190b feat: DMS Cursor Control - Size & Theme in niri 2026-01-06 19:08:05 -05:00
bbedward
8c9c936d0e clipboard: add cliphist-migrate CLI 2026-01-06 16:49:18 -05:00
dms-ci[bot]
842bf6e3ff nix: update vendorHash for go.mod changes 2026-01-06 21:03:38 +00:00
bbedward
c1fbeb3f5e network: listen to NM Wired interface + use nmcli for route metrics
- Some other misc floating window change, too lazy to separate the
  commit
2026-01-06 16:01:28 -05:00
bbedward
c45eb2cccf plugins: ipc visibility conditions 2026-01-06 13:22:36 -05:00
bbedward
1b5abca83a launcher remove right key 2026-01-06 10:37:58 -05:00
bbedward
45818b202f launcher: support for plugins to define context menus
fixes #1279
2026-01-06 10:08:22 -05:00
Ethan Todd
1c8ce46f25 notifications: fix notifications being completely transient if history is disabled (#1284) 2026-01-06 09:39:33 -05:00
bbedward
f762f9ae49 theme: fix gtk apply button on empty file
fixes #1280
2026-01-05 21:56:50 -05:00
bbedward
4484f6bd61 launcher: built-in plugins, add settings search plugin with ? default
trigger
2026-01-05 21:46:12 -05:00
purian23
0076c45496 shell: dmsCoreApp updates 2026-01-05 20:31:06 -05:00
bbedward
ab071e12aa icons: fix transmission-gtk modded app ID again 2026-01-05 16:44:31 -05:00
bbedward
8386b40c50 launcher: F10 as alt for menu key 2026-01-05 14:58:50 -05:00
bbedward
03a985228d dankbar: add shadow option
fixes #916
2026-01-05 13:43:15 -05:00
bbedward
ef7d7ec13d desktop widgets: niri overview only option + grid on overlay when on
overview
2026-01-05 13:01:10 -05:00
bbedward
824792cca7 notifications: add support for none, count, app name, and full detail
for lock screen
fixes #557
2026-01-05 12:22:05 -05:00
bbedward
850e5b6572 session: handle hibernate error
fixes #308
2026-01-05 12:01:17 -05:00
bbedward
64310854a6 compositor+matugen: border override, hypr/mango layout overrides, new
templates, respect XDG paths
- Add Hyprland and MangoWC templates
- Add GUI gaps, window radius, and border thickness overrides for niri,
  Hyprland, and MangoWC
- Add replacement support in matugen templates for DATA_DIR, CACHE_DIR,
  CONFIG_DIR
fixes #1274
fixes #1273
2026-01-05 11:25:13 -05:00
bbedward
4005a55bf2 session: blockLoading true 2026-01-05 09:11:19 -05:00
bbedward
0236fe3276 session: fix persist on empty file 2026-01-05 09:07:00 -05:00
bbedward
c1d95a3086 launcher: fix invalid icon rendering wrong icon 2026-01-04 22:58:20 -05:00
bbedward
9b027df1d5 doctor: add links to dr command 2026-01-04 22:44:19 -05:00
purian23
5e03afe7f0 feat: Implement DMS Core Persistent Apps 2026-01-04 22:33:50 -05:00
bbedward
145a974b6d welcome: add IPC targets and button on about page 2026-01-04 21:45:02 -05:00
bbedward
d23fc9f2df welcome: add a first launch welcome page with doctor integration
fixes #760
2026-01-04 19:07:34 -05:00
bbedward
7ac5191e8d matugen: fix app checking
- double nil for flatpak + bin required to skip
2026-01-04 17:53:47 -05:00
bbedward
29d27ebd6d mautgen: update vscode package 2026-01-04 17:19:51 -05:00
bbedward
e45075dd84 launcher: fix binding loop 2026-01-04 17:19:35 -05:00
bbedward
80bc87e76b clock: fixed width chars in vertical mode 2026-01-04 13:20:20 -05:00
bbedward
76d88517ec matugen: publish vscode theme to marketplace/ovsix 2026-01-04 13:07:23 -05:00
bbedward
151d695212 launcher: optimize bindings and filters 2026-01-04 11:49:24 -05:00
Ethan Todd
2e1bed5fb5 nix: update home-manager module to remove default*, add clsettings (#1233)
* nix: update home-manager module, add clsettings

* nix: resolve message and rename clsettings->clipboardSettings.

* nix: fix home-manager plugin_settings management. add option for whether plugin settings should be managed by nix.
2026-01-04 11:18:28 -03:00
Lucas
f163b97c17 doctor: add json output (#1263)
* doctor: add json output

* doctor: fix systemd failed state as ok
2026-01-03 20:41:10 -05:00
bbedward
436c99927e settings: detect read-only on save attempts 2026-01-03 20:34:36 -05:00
bbedward
aa72eacae7 notifications: add image persistence 2026-01-03 19:56:08 -05:00
bbedward
913bb2ff67 niri: ensure outputs.kdl and binds.kdl exist 2026-01-03 18:31:36 -05:00
Lucas
3bb2696263 Add doctor command (#1259)
* feat: doctor command

* doctor: use console.warn for quickshell feature logs

* doctor: show compositor, quickshell and cli path in verbose

* doctor: show useful env variables

* doctor: indicate if config files are readonly

* doctor: add power-profiles-daemon and i2c

* doctor: cleanup code

* doctor: fix icon theme env variable

* doctor: use builtin config/cache dir functions

* doctor: refactor to use DoctorStatus struct and 'enum'

* doctor: use network backend detector
2026-01-03 18:28:23 -05:00
bbedward
166843ded4 niri: preserve remaining settings when turning off output 2026-01-03 16:17:16 -05: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
johngalt
c281bf3b53 Adding Zen Browser matugen template (#1181)
* Adding Zen Browser matugen template

* Fixing indentation for matugen.go edits

* Trying to fix linting again..

* Tweaking contrasting surface colors in css, renamed file to match how firefox userchrome is named, also changed output directory to DMS config directory (like firefox)

* Modifing Zen userChrome again: removing unused css stuff, tweaking colors to better align with how pywalfox handles backgrounds/toolbars

* Last few tweaks on CSS - changing url bar highlight color, changing contrast on selected urls in dropdown

* matugen.go: fix check command for zen browser

* search_index: add zen browser setting
2025-12-28 12:52:41 -05:00
bbedward
45b8b2a895 clipboard: don't store sensitive mime types in history
fixes #1185
2025-12-28 11:13:34 -05:00
Tacticalsmooth
7b9ba840fb fixed lambda issue on nixos (#1188) 2025-12-28 12:50:07 +01:00
370 changed files with 34682 additions and 7792 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 - name: Checkout
uses: actions/checkout@v4 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 - name: Set up Go
uses: actions/setup-go@v5 uses: actions/setup-go@v5
with: with:

View File

@@ -11,5 +11,14 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 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 - name: run pre-commit hooks
uses: j178/prek-action@v1 uses: j178/prek-action@v1

View File

@@ -5,15 +5,27 @@ on:
tags: tags:
- "v*" - "v*"
permissions:
contents: write
jobs: jobs:
update-stable: update-stable:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: write
steps: steps:
- uses: actions/checkout@v4 - name: Create GitHub App token
id: app_token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Checkout
uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
token: ${{ steps.app_token.outputs.token }}
- name: Push to stable branch - name: Push to stable branch
run: git push origin HEAD:refs/heads/stable --force env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
run: git push https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git HEAD:refs/heads/stable --force

2
.gitignore vendored
View File

@@ -108,3 +108,5 @@ bin/
# direnv # direnv
.envrc .envrc
.direnv/ .direnv/
quickshell/dms-plugins
__pycache__

View File

@@ -15,3 +15,9 @@ This file is more of a quick reference so I know what to account for before next
- new IPC targets - new IPC targets
- Initial RTL support/i18n - Initial RTL support/i18n
- Theme registry - Theme registry
- Notification persistence & history
- **BREAKING** vscode theme needs re-installed
- dms doctor cmd
- niri/hypr/mango gaps/window/border overrides
- settings search
- notification display ops on lock screen

View File

@@ -68,3 +68,9 @@ packages:
outpkg: mocks_wlclient outpkg: mocks_wlclient
interfaces: interfaces:
WaylandDisplay: WaylandDisplay:
github.com/AvengeMedia/DankMaterialShell/core/internal/utils:
config:
dir: "internal/mocks/utils"
outpkg: mocks_utils
interfaces:
AppChecker:

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") fmt.Printf("%-*s %-12s %-*s %s\n", idPad, "Device", "Class", namePad, "Name", "Brightness")
sepLen := idPad + 2 + 12 + 2 + namePad + 2 + 15 sepLen := idPad + 2 + 12 + 2 + namePad + 2 + 15
for i := 0; i < sepLen; i++ { for range sepLen {
fmt.Print("─") fmt.Print("─")
} }
fmt.Println() fmt.Println()

View File

@@ -1,17 +1,29 @@
package main package main
import ( import (
"bytes"
"context" "context"
"encoding/base64"
"encoding/binary"
"encoding/json" "encoding/json"
"fmt" "fmt"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"io" "io"
"os" "os"
"os/exec" "os/exec"
"os/signal" "os/signal"
"path/filepath"
"strconv" "strconv"
"syscall" "syscall"
"time" "time"
bolt "go.etcd.io/bbolt"
_ "golang.org/x/image/bmp"
_ "golang.org/x/image/tiff"
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard" "github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
@@ -142,10 +154,32 @@ var (
clipConfigNoClearStartup bool clipConfigNoClearStartup bool
clipConfigDisabled bool clipConfigDisabled bool
clipConfigEnabled bool clipConfigEnabled bool
clipConfigDisableHistory bool
clipConfigEnableHistory bool
) )
var clipExportCmd = &cobra.Command{
Use: "export [file]",
Short: "Export clipboard history to JSON",
Long: "Export clipboard history to JSON file. If no file specified, writes to stdout.",
Run: runClipExport,
}
var clipImportCmd = &cobra.Command{
Use: "import <file>",
Short: "Import clipboard history from JSON",
Long: "Import clipboard history from JSON file exported by 'dms cl export'.",
Args: cobra.ExactArgs(1),
Run: runClipImport,
}
var clipMigrateCmd = &cobra.Command{
Use: "cliphist-migrate [db-path]",
Short: "Migrate from cliphist",
Long: "Migrate clipboard history from cliphist. Uses default cliphist path if not specified.",
Run: runClipMigrate,
}
var clipMigrateDelete bool
func init() { func init() {
clipCopyCmd.Flags().BoolVarP(&clipCopyForeground, "foreground", "f", false, "Stay in foreground instead of forking") clipCopyCmd.Flags().BoolVarP(&clipCopyForeground, "foreground", "f", false, "Stay in foreground instead of forking")
clipCopyCmd.Flags().BoolVarP(&clipCopyPasteOnce, "paste-once", "o", false, "Exit after first paste") clipCopyCmd.Flags().BoolVarP(&clipCopyPasteOnce, "paste-once", "o", false, "Exit after first paste")
@@ -167,15 +201,15 @@ func init() {
clipConfigSetCmd.Flags().IntVar(&clipConfigAutoClearDays, "auto-clear-days", -1, "Auto-clear entries older than N days (0 to disable)") 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(&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(&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(&clipConfigDisabled, "disable", false, "Disable clipboard tracking")
clipConfigSetCmd.Flags().BoolVar(&clipConfigEnabled, "enable", false, "Enable clipboard manager") clipConfigSetCmd.Flags().BoolVar(&clipConfigEnabled, "enable", false, "Enable clipboard tracking")
clipConfigSetCmd.Flags().BoolVar(&clipConfigDisableHistory, "disable-history", false, "Disable clipboard history persistence")
clipConfigSetCmd.Flags().BoolVar(&clipConfigEnableHistory, "enable-history", false, "Enable clipboard history persistence")
clipWatchCmd.Flags().BoolVarP(&clipWatchStore, "store", "s", false, "Store clipboard changes to history (no server required)") clipWatchCmd.Flags().BoolVarP(&clipWatchStore, "store", "s", false, "Store clipboard changes to history (no server required)")
clipMigrateCmd.Flags().BoolVar(&clipMigrateDelete, "delete", false, "Delete cliphist db after successful migration")
clipConfigCmd.AddCommand(clipConfigGetCmd, clipConfigSetCmd) clipConfigCmd.AddCommand(clipConfigGetCmd, clipConfigSetCmd)
clipboardCmd.AddCommand(clipCopyCmd, clipPasteCmd, clipWatchCmd, clipHistoryCmd, clipGetCmd, clipDeleteCmd, clipClearCmd, clipSearchCmd, clipConfigCmd) clipboardCmd.AddCommand(clipCopyCmd, clipPasteCmd, clipWatchCmd, clipHistoryCmd, clipGetCmd, clipDeleteCmd, clipClearCmd, clipSearchCmd, clipConfigCmd, clipExportCmd, clipImportCmd, clipMigrateCmd)
} }
func runClipCopy(cmd *cobra.Command, args []string) { func runClipCopy(cmd *cobra.Command, args []string) {
@@ -587,12 +621,6 @@ func runClipConfigSet(cmd *cobra.Command, args []string) {
if clipConfigEnabled { if clipConfigEnabled {
params["disabled"] = false params["disabled"] = false
} }
if clipConfigDisableHistory {
params["disableHistory"] = true
}
if clipConfigEnableHistory {
params["disableHistory"] = false
}
if len(params) == 0 { if len(params) == 0 {
fmt.Println("No config options specified") fmt.Println("No config options specified")
@@ -616,3 +644,154 @@ func runClipConfigSet(cmd *cobra.Command, args []string) {
fmt.Println("Config updated") fmt.Println("Config updated")
} }
func runClipExport(cmd *cobra.Command, args []string) {
req := models.Request{
ID: 1,
Method: "clipboard.getHistory",
}
resp, err := sendServerRequest(req)
if err != nil {
log.Fatalf("Failed to get clipboard history: %v", err)
}
if resp.Error != "" {
log.Fatalf("Error: %s", resp.Error)
}
if resp.Result == nil {
log.Fatal("No clipboard history")
}
out, err := json.MarshalIndent(resp.Result, "", " ")
if err != nil {
log.Fatalf("Failed to marshal: %v", err)
}
if len(args) == 0 {
fmt.Println(string(out))
return
}
if err := os.WriteFile(args[0], out, 0644); err != nil {
log.Fatalf("Failed to write file: %v", err)
}
fmt.Printf("Exported to %s\n", args[0])
}
func runClipImport(cmd *cobra.Command, args []string) {
data, err := os.ReadFile(args[0])
if err != nil {
log.Fatalf("Failed to read file: %v", err)
}
var entries []map[string]any
if err := json.Unmarshal(data, &entries); err != nil {
log.Fatalf("Failed to parse JSON: %v", err)
}
var imported int
for _, entry := range entries {
dataStr, ok := entry["data"].(string)
if !ok {
continue
}
mimeType, _ := entry["mimeType"].(string)
if mimeType == "" {
mimeType = "text/plain"
}
var entryData []byte
if decoded, err := base64.StdEncoding.DecodeString(dataStr); err == nil {
entryData = decoded
} else {
entryData = []byte(dataStr)
}
if err := clipboard.Store(entryData, mimeType); err != nil {
log.Errorf("Failed to store entry: %v", err)
continue
}
imported++
}
fmt.Printf("Imported %d entries\n", imported)
}
func runClipMigrate(cmd *cobra.Command, args []string) {
dbPath := getCliphistPath()
if len(args) > 0 {
dbPath = args[0]
}
if _, err := os.Stat(dbPath); err != nil {
log.Fatalf("Cliphist db not found: %s", dbPath)
}
db, err := bolt.Open(dbPath, 0644, &bolt.Options{
ReadOnly: true,
Timeout: 1 * time.Second,
})
if err != nil {
log.Fatalf("Failed to open cliphist db: %v", err)
}
defer db.Close()
var migrated int
err = db.View(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("b"))
if b == nil {
return fmt.Errorf("cliphist bucket not found")
}
c := b.Cursor()
for k, v := c.First(); k != nil; k, v = c.Next() {
if len(v) == 0 {
continue
}
mimeType := detectMimeType(v)
if err := clipboard.Store(v, mimeType); err != nil {
log.Errorf("Failed to store entry %d: %v", btoi(k), err)
continue
}
migrated++
}
return nil
})
if err != nil {
log.Fatalf("Migration failed: %v", err)
}
fmt.Printf("Migrated %d entries from cliphist\n", migrated)
if !clipMigrateDelete {
return
}
db.Close()
if err := os.Remove(dbPath); err != nil {
log.Errorf("Failed to delete cliphist db: %v", err)
return
}
os.Remove(filepath.Dir(dbPath))
fmt.Println("Deleted cliphist db")
}
func getCliphistPath() string {
cacheDir, err := os.UserCacheDir()
if err != nil {
return filepath.Join(os.Getenv("HOME"), ".cache", "cliphist", "db")
}
return filepath.Join(cacheDir, "cliphist", "db")
}
func detectMimeType(data []byte) string {
if _, _, err := image.DecodeConfig(bytes.NewReader(data)); err == nil {
return "image/png"
}
return "text/plain"
}
func btoi(v []byte) uint64 {
return binary.BigEndian.Uint64(v)
}

View File

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

View File

@@ -0,0 +1,318 @@
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
"github.com/spf13/cobra"
)
var configCmd = &cobra.Command{
Use: "config",
Short: "Configuration utilities",
}
var resolveIncludeCmd = &cobra.Command{
Use: "resolve-include <compositor> <filename>",
Short: "Check if a file is included in compositor config",
Long: "Recursively check if a file is included/sourced in compositor configuration. Returns JSON with exists and included status.",
Args: cobra.ExactArgs(2),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
switch len(args) {
case 0:
return []string{"hyprland", "niri", "mangowc"}, cobra.ShellCompDirectiveNoFileComp
case 1:
return []string{"cursor.kdl", "cursor.conf", "outputs.kdl", "outputs.conf", "binds.kdl", "binds.conf"}, cobra.ShellCompDirectiveNoFileComp
}
return nil, cobra.ShellCompDirectiveNoFileComp
},
Run: runResolveInclude,
}
func init() {
configCmd.AddCommand(resolveIncludeCmd)
}
type IncludeResult struct {
Exists bool `json:"exists"`
Included bool `json:"included"`
}
func runResolveInclude(cmd *cobra.Command, args []string) {
compositor := strings.ToLower(args[0])
filename := args[1]
var result IncludeResult
var err error
switch compositor {
case "hyprland":
result, err = checkHyprlandInclude(filename)
case "niri":
result, err = checkNiriInclude(filename)
case "mangowc", "dwl", "mango":
result, err = checkMangoWCInclude(filename)
default:
log.Fatalf("Unknown compositor: %s", compositor)
}
if err != nil {
log.Fatalf("Error checking include: %v", err)
}
output, _ := json.Marshal(result)
fmt.Fprintln(os.Stdout, string(output))
}
func checkHyprlandInclude(filename string) (IncludeResult, error) {
configDir, err := utils.ExpandPath("$HOME/.config/hypr")
if err != nil {
return IncludeResult{}, err
}
targetPath := filepath.Join(configDir, "dms", filename)
result := IncludeResult{}
if _, err := os.Stat(targetPath); err == nil {
result.Exists = true
}
mainConfig := filepath.Join(configDir, "hyprland.conf")
if _, err := os.Stat(mainConfig); os.IsNotExist(err) {
return result, nil
}
processed := make(map[string]bool)
result.Included = hyprlandFindInclude(mainConfig, "dms/"+filename, processed)
return result, nil
}
func hyprlandFindInclude(filePath, target string, processed map[string]bool) bool {
absPath, err := filepath.Abs(filePath)
if err != nil {
return false
}
if processed[absPath] {
return false
}
processed[absPath] = true
data, err := os.ReadFile(absPath)
if err != nil {
return false
}
baseDir := filepath.Dir(absPath)
lines := strings.Split(string(data), "\n")
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "#") || trimmed == "" {
continue
}
if !strings.HasPrefix(trimmed, "source") {
continue
}
parts := strings.SplitN(trimmed, "=", 2)
if len(parts) < 2 {
continue
}
sourcePath := strings.TrimSpace(parts[1])
if matchesTarget(sourcePath, target) {
return true
}
fullPath := sourcePath
if !filepath.IsAbs(sourcePath) {
fullPath = filepath.Join(baseDir, sourcePath)
}
expanded, err := utils.ExpandPath(fullPath)
if err != nil {
continue
}
if hyprlandFindInclude(expanded, target, processed) {
return true
}
}
return false
}
func checkNiriInclude(filename string) (IncludeResult, error) {
configDir, err := utils.ExpandPath("$HOME/.config/niri")
if err != nil {
return IncludeResult{}, err
}
targetPath := filepath.Join(configDir, "dms", filename)
result := IncludeResult{}
if _, err := os.Stat(targetPath); err == nil {
result.Exists = true
}
mainConfig := filepath.Join(configDir, "config.kdl")
if _, err := os.Stat(mainConfig); os.IsNotExist(err) {
return result, nil
}
processed := make(map[string]bool)
result.Included = niriFindInclude(mainConfig, "dms/"+filename, processed)
return result, nil
}
func niriFindInclude(filePath, target string, processed map[string]bool) bool {
absPath, err := filepath.Abs(filePath)
if err != nil {
return false
}
if processed[absPath] {
return false
}
processed[absPath] = true
data, err := os.ReadFile(absPath)
if err != nil {
return false
}
baseDir := filepath.Dir(absPath)
content := string(data)
for _, line := range strings.Split(content, "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "//") || trimmed == "" {
continue
}
if !strings.HasPrefix(trimmed, "include") {
continue
}
startQuote := strings.Index(trimmed, "\"")
if startQuote == -1 {
continue
}
endQuote := strings.LastIndex(trimmed, "\"")
if endQuote <= startQuote {
continue
}
includePath := trimmed[startQuote+1 : endQuote]
if matchesTarget(includePath, target) {
return true
}
fullPath := includePath
if !filepath.IsAbs(includePath) {
fullPath = filepath.Join(baseDir, includePath)
}
if niriFindInclude(fullPath, target, processed) {
return true
}
}
return false
}
func checkMangoWCInclude(filename string) (IncludeResult, error) {
configDir, err := utils.ExpandPath("$HOME/.config/mango")
if err != nil {
return IncludeResult{}, err
}
targetPath := filepath.Join(configDir, "dms", filename)
result := IncludeResult{}
if _, err := os.Stat(targetPath); err == nil {
result.Exists = true
}
mainConfig := filepath.Join(configDir, "config.conf")
if _, err := os.Stat(mainConfig); os.IsNotExist(err) {
mainConfig = filepath.Join(configDir, "mango.conf")
}
if _, err := os.Stat(mainConfig); os.IsNotExist(err) {
return result, nil
}
processed := make(map[string]bool)
result.Included = mangowcFindInclude(mainConfig, "dms/"+filename, processed)
return result, nil
}
func mangowcFindInclude(filePath, target string, processed map[string]bool) bool {
absPath, err := filepath.Abs(filePath)
if err != nil {
return false
}
if processed[absPath] {
return false
}
processed[absPath] = true
data, err := os.ReadFile(absPath)
if err != nil {
return false
}
baseDir := filepath.Dir(absPath)
lines := strings.Split(string(data), "\n")
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "#") || trimmed == "" {
continue
}
if !strings.HasPrefix(trimmed, "source") {
continue
}
parts := strings.SplitN(trimmed, "=", 2)
if len(parts) < 2 {
continue
}
sourcePath := strings.TrimSpace(parts[1])
if matchesTarget(sourcePath, target) {
return true
}
fullPath := sourcePath
if !filepath.IsAbs(sourcePath) {
fullPath = filepath.Join(baseDir, sourcePath)
}
expanded, err := utils.ExpandPath(fullPath)
if err != nil {
continue
}
if mangowcFindInclude(expanded, target, processed) {
return true
}
}
return false
}
func matchesTarget(path, target string) bool {
path = strings.TrimPrefix(path, "./")
target = strings.TrimPrefix(target, "./")
return path == target || strings.HasSuffix(path, "/"+target)
}

View File

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

View File

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

View File

@@ -211,8 +211,8 @@ func checkGroupExists(groupName string) bool {
return false return false
} }
lines := strings.Split(string(data), "\n") lines := strings.SplitSeq(string(data), "\n")
for _, line := range lines { for line := range lines {
if strings.HasPrefix(line, groupName+":") { if strings.HasPrefix(line, groupName+":") {
return true return true
} }
@@ -521,7 +521,7 @@ func enableGreeter() error {
newConfig := strings.Join(finalLines, "\n") newConfig := strings.Join(finalLines, "\n")
tmpFile := "/tmp/greetd-config.toml" tmpFile := "/tmp/greetd-config.toml"
if err := os.WriteFile(tmpFile, []byte(newConfig), 0644); err != nil { if err := os.WriteFile(tmpFile, []byte(newConfig), 0o644); err != nil {
return fmt.Errorf("failed to write temp config: %w", err) return fmt.Errorf("failed to write temp config: %w", err)
} }
@@ -592,8 +592,8 @@ func checkGreeterStatus() error {
if data, err := os.ReadFile(configPath); err == nil { if data, err := os.ReadFile(configPath); err == nil {
configContent := string(data) configContent := string(data)
if strings.Contains(configContent, "dms-greeter") { if strings.Contains(configContent, "dms-greeter") {
lines := strings.Split(configContent, "\n") lines := strings.SplitSeq(configContent, "\n")
for _, line := range lines { for line := range lines {
trimmed := strings.TrimSpace(line) trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "command =") || strings.HasPrefix(trimmed, "command=") { if strings.HasPrefix(trimmed, "command =") || strings.HasPrefix(trimmed, "command=") {
parts := strings.SplitN(trimmed, "=", 2) parts := strings.SplitN(trimmed, "=", 2)

View File

@@ -57,12 +57,14 @@ var keybindsRemoveCmd = &cobra.Command{
} }
func init() { func init() {
keybindsListCmd.Flags().BoolP("json", "j", false, "Output as JSON")
keybindsShowCmd.Flags().String("path", "", "Override config path for the provider") keybindsShowCmd.Flags().String("path", "", "Override config path for the provider")
keybindsSetCmd.Flags().String("desc", "", "Description for hotkey overlay") keybindsSetCmd.Flags().String("desc", "", "Description for hotkey overlay")
keybindsSetCmd.Flags().Bool("allow-when-locked", false, "Allow when screen is locked") keybindsSetCmd.Flags().Bool("allow-when-locked", false, "Allow when screen is locked")
keybindsSetCmd.Flags().Int("cooldown-ms", 0, "Cooldown in milliseconds") keybindsSetCmd.Flags().Int("cooldown-ms", 0, "Cooldown in milliseconds")
keybindsSetCmd.Flags().Bool("no-repeat", false, "Disable key repeat") keybindsSetCmd.Flags().Bool("no-repeat", false, "Disable key repeat")
keybindsSetCmd.Flags().String("replace-key", "", "Original key to replace (removes old key)") keybindsSetCmd.Flags().String("replace-key", "", "Original key to replace (removes old key)")
keybindsSetCmd.Flags().String("flags", "", "Hyprland bind flags (e.g., 'e' for repeat, 'l' for locked, 'r' for release)")
keybindsCmd.AddCommand(keybindsListCmd) keybindsCmd.AddCommand(keybindsListCmd)
keybindsCmd.AddCommand(keybindsShowCmd) keybindsCmd.AddCommand(keybindsShowCmd)
@@ -110,12 +112,21 @@ func initializeProviders() {
} }
} }
func runKeybindsList(_ *cobra.Command, _ []string) { func runKeybindsList(cmd *cobra.Command, _ []string) {
providerList := keybinds.GetDefaultRegistry().List() providerList := keybinds.GetDefaultRegistry().List()
asJSON, _ := cmd.Flags().GetBool("json")
if asJSON {
output, _ := json.Marshal(providerList)
fmt.Fprintln(os.Stdout, string(output))
return
}
if len(providerList) == 0 { if len(providerList) == 0 {
fmt.Fprintln(os.Stdout, "No providers available") fmt.Fprintln(os.Stdout, "No providers available")
return return
} }
fmt.Fprintln(os.Stdout, "Available providers:") fmt.Fprintln(os.Stdout, "Available providers:")
for _, name := range providerList { for _, name := range providerList {
fmt.Fprintf(os.Stdout, " - %s\n", name) fmt.Fprintf(os.Stdout, " - %s\n", name)
@@ -201,6 +212,9 @@ func runKeybindsSet(cmd *cobra.Command, args []string) {
if v, _ := cmd.Flags().GetBool("no-repeat"); v { if v, _ := cmd.Flags().GetBool("no-repeat"); v {
options["repeat"] = false options["repeat"] = false
} }
if v, _ := cmd.Flags().GetString("flags"); v != "" {
options["flags"] = v
}
desc, _ := cmd.Flags().GetString("desc") desc, _ := cmd.Flags().GetString("desc")
if err := writable.SetBind(key, action, desc, options); err != nil { if err := writable.SetBind(key, action, desc, options); err != nil {

View File

@@ -2,6 +2,7 @@ package main
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"time" "time"
@@ -29,9 +30,16 @@ var matugenQueueCmd = &cobra.Command{
Run: runMatugenQueue, Run: runMatugenQueue,
} }
var matugenCheckCmd = &cobra.Command{
Use: "check",
Short: "Check which template apps are detected",
Run: runMatugenCheck,
}
func init() { func init() {
matugenCmd.AddCommand(matugenGenerateCmd) matugenCmd.AddCommand(matugenGenerateCmd)
matugenCmd.AddCommand(matugenQueueCmd) matugenCmd.AddCommand(matugenQueueCmd)
matugenCmd.AddCommand(matugenCheckCmd)
for _, cmd := range []*cobra.Command{matugenGenerateCmd, matugenQueueCmd} { for _, cmd := range []*cobra.Command{matugenGenerateCmd, matugenQueueCmd} {
cmd.Flags().String("state-dir", "", "State directory for cache files") cmd.Flags().String("state-dir", "", "State directory for cache files")
@@ -74,7 +82,7 @@ func buildMatugenOptions(cmd *cobra.Command) matugen.Options {
ConfigDir: configDir, ConfigDir: configDir,
Kind: kind, Kind: kind,
Value: value, Value: value,
Mode: mode, Mode: matugen.ColorMode(mode),
IconTheme: iconTheme, IconTheme: iconTheme,
MatugenType: matugenType, MatugenType: matugenType,
RunUserTemplates: runUserTemplates, RunUserTemplates: runUserTemplates,
@@ -162,3 +170,12 @@ func runMatugenQueue(cmd *cobra.Command, args []string) {
log.Fatalf("Timeout waiting for theme generation") log.Fatalf("Timeout waiting for theme generation")
} }
} }
func runMatugenCheck(cmd *cobra.Command, args []string) {
checks := matugen.CheckTemplates(nil)
data, err := json.Marshal(checks)
if err != nil {
log.Fatalf("Failed to marshal check results: %v", err)
}
fmt.Println(string(data))
}

View File

@@ -50,15 +50,18 @@ func findConfig(cmd *cobra.Command, args []string) error {
configStateFile := filepath.Join(getRuntimeDir(), "danklinux.path") configStateFile := filepath.Join(getRuntimeDir(), "danklinux.path")
if data, readErr := os.ReadFile(configStateFile); readErr == nil { if data, readErr := os.ReadFile(configStateFile); readErr == nil {
statePath := strings.TrimSpace(string(data)) if len(getAllDMSPIDs()) == 0 {
shellPath := filepath.Join(statePath, "shell.qml") os.Remove(configStateFile)
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
} else { } 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) os.Remove(configStateFile)
} }
} }

View File

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

View File

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

View File

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

View File

@@ -9,28 +9,28 @@ require (
github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/lipgloss v1.1.0
github.com/charmbracelet/log v0.4.2 github.com/charmbracelet/log v0.4.2
github.com/fsnotify/fsnotify v1.9.0 github.com/fsnotify/fsnotify v1.9.0
github.com/godbus/dbus/v5 v5.2.0 github.com/godbus/dbus/v5 v5.2.2
github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83 github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83
github.com/pilebones/go-udev v0.9.1 github.com/pilebones/go-udev v0.9.1
github.com/sblinch/kdl-go v0.0.0-20250930225324-bf4099d4614a github.com/sblinch/kdl-go v0.0.0-20251203232544-981d4ecc17c3
github.com/spf13/cobra v1.10.1 github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
go.etcd.io/bbolt v1.4.3 go.etcd.io/bbolt v1.4.3
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93
golang.org/x/image v0.34.0 golang.org/x/image v0.34.0
) )
require ( require (
github.com/Microsoft/go-winio v0.6.2 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.3.0 // indirect github.com/ProtonMail/go-crypto v1.3.0 // indirect
github.com/clipperhouse/displaywidth v0.6.0 // indirect github.com/clipperhouse/displaywidth v0.6.2 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
github.com/cloudflare/circl v1.6.1 // indirect github.com/cloudflare/circl v1.6.2 // indirect
github.com/cyphar/filepath-securejoin v0.6.1 // indirect github.com/cyphar/filepath-securejoin v0.6.1 // indirect
github.com/emirpasic/gods v1.18.1 // indirect github.com/emirpasic/gods v1.18.1 // indirect
github.com/go-git/gcfg/v2 v2.0.2 // indirect github.com/go-git/gcfg/v2 v2.0.2 // indirect
github.com/go-git/go-billy/v6 v6.0.0-20251126203821-7f9c95185ee0 // indirect github.com/go-git/go-billy/v6 v6.0.0-20251217170237-e9738f50a3cd // indirect
github.com/go-logfmt/logfmt v0.6.1 // indirect github.com/go-logfmt/logfmt v0.6.1 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/kevinburke/ssh_config v1.4.0 // indirect github.com/kevinburke/ssh_config v1.4.0 // indirect
@@ -38,21 +38,21 @@ require (
github.com/pjbgf/sha1cd v0.5.0 // indirect github.com/pjbgf/sha1cd v0.5.0 // indirect
github.com/sergi/go-diff v1.4.0 // indirect github.com/sergi/go-diff v1.4.0 // indirect
github.com/stretchr/objx v0.5.3 // indirect github.com/stretchr/objx v0.5.3 // indirect
golang.org/x/crypto v0.45.0 // indirect golang.org/x/crypto v0.46.0 // indirect
golang.org/x/net v0.47.0 // indirect golang.org/x/net v0.48.0 // indirect
) )
require ( require (
github.com/atotto/clipboard v0.1.4 // indirect github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.3.3 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/harmonica v0.2.0 // indirect github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/charmbracelet/x/ansi v0.11.2 // indirect github.com/charmbracelet/x/ansi v0.11.3 // indirect
github.com/charmbracelet/x/cellbuf v0.0.14 // indirect github.com/charmbracelet/x/cellbuf v0.0.14 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/go-git/go-git/v6 v6.0.0-20251128074608-48f817f57805 github.com/go-git/go-git/v6 v6.0.0-20251231065035-29ae690a9f19
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 github.com/lucasb-eyer/go-colorful v1.3.0
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
@@ -66,7 +66,7 @@ require (
github.com/spf13/afero v1.15.0 github.com/spf13/afero v1.15.0
github.com/spf13/pflag v1.0.10 // indirect github.com/spf13/pflag v1.0.10 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.38.0 golang.org/x/sys v0.39.0
golang.org/x/text v0.32.0 golang.org/x/text v0.32.0
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )

View File

@@ -18,6 +18,8 @@ github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlv
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.3.3 h1:DjJzJtLP6/NZ8p7Cgjno0CKGr7wwRJGxWUwh2IyhfAI= github.com/charmbracelet/colorprofile v0.3.3 h1:DjJzJtLP6/NZ8p7Cgjno0CKGr7wwRJGxWUwh2IyhfAI=
github.com/charmbracelet/colorprofile v0.3.3/go.mod h1:nB1FugsAbzq284eJcjfah2nhdSLppN2NqvfotkfRYP4= github.com/charmbracelet/colorprofile v0.3.3/go.mod h1:nB1FugsAbzq284eJcjfah2nhdSLppN2NqvfotkfRYP4=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
@@ -26,18 +28,24 @@ github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsy
github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw= github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw=
github.com/charmbracelet/x/ansi v0.11.2 h1:XAG3FSjiVtFvgEgGrNBkCNNYrsucAt8c6bfxHyROLLs= github.com/charmbracelet/x/ansi v0.11.2 h1:XAG3FSjiVtFvgEgGrNBkCNNYrsucAt8c6bfxHyROLLs=
github.com/charmbracelet/x/ansi v0.11.2/go.mod h1:9tY2bzX5SiJCU0iWyskjBeI2BRQfvPqI+J760Mjf+Rg= github.com/charmbracelet/x/ansi v0.11.2/go.mod h1:9tY2bzX5SiJCU0iWyskjBeI2BRQfvPqI+J760Mjf+Rg=
github.com/charmbracelet/x/ansi v0.11.3 h1:6DcVaqWI82BBVM/atTyq6yBoRLZFBsnoDoX9GCu2YOI=
github.com/charmbracelet/x/ansi v0.11.3/go.mod h1:yI7Zslym9tCJcedxz5+WBq+eUGMJT0bM06Fqy1/Y4dI=
github.com/charmbracelet/x/cellbuf v0.0.14 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaLOXuc+/Kj4= github.com/charmbracelet/x/cellbuf v0.0.14 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaLOXuc+/Kj4=
github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA= github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.6.0 h1:k32vueaksef9WIKCNcoqRNyKbyvkvkysNYnAWz2fN4s= github.com/clipperhouse/displaywidth v0.6.0 h1:k32vueaksef9WIKCNcoqRNyKbyvkvkysNYnAWz2fN4s=
github.com/clipperhouse/displaywidth v0.6.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o= github.com/clipperhouse/displaywidth v0.6.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
github.com/clipperhouse/displaywidth v0.6.2 h1:ZDpTkFfpHOKte4RG5O/BOyf3ysnvFswpyYrV7z2uAKo=
github.com/clipperhouse/displaywidth v0.6.2/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
github.com/cloudflare/circl v1.6.2 h1:hL7VBpHHKzrV5WTfHCaBsgx/HGbBYlgrwvNXEVDYYsQ=
github.com/cloudflare/circl v1.6.2/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
@@ -58,15 +66,22 @@ github.com/go-git/gcfg/v2 v2.0.2 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo=
github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs= github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs=
github.com/go-git/go-billy/v6 v6.0.0-20251126203821-7f9c95185ee0 h1:eY5aB2GXiVdgTueBcqsBt53WuJTRZAuCdIS/86Pcq5c= github.com/go-git/go-billy/v6 v6.0.0-20251126203821-7f9c95185ee0 h1:eY5aB2GXiVdgTueBcqsBt53WuJTRZAuCdIS/86Pcq5c=
github.com/go-git/go-billy/v6 v6.0.0-20251126203821-7f9c95185ee0/go.mod h1:0NjwVNrwtVFZBReAp5OoGklGJIgJFEbVyHneAr4lc8k= github.com/go-git/go-billy/v6 v6.0.0-20251126203821-7f9c95185ee0/go.mod h1:0NjwVNrwtVFZBReAp5OoGklGJIgJFEbVyHneAr4lc8k=
github.com/go-git/go-billy/v6 v6.0.0-20251217170237-e9738f50a3cd h1:Gd/f9cGi/3h1JOPaa6er+CkKUGyGX2DBJdFbDKVO+R0=
github.com/go-git/go-billy/v6 v6.0.0-20251217170237-e9738f50a3cd/go.mod h1:d3XQcsHu1idnquxt48kAv+h+1MUiYKLH/e7LAzjP+pI=
github.com/go-git/go-git-fixtures/v5 v5.1.1 h1:OH8i1ojV9bWfr0ZfasfpgtUXQHQyVS8HXik/V1C099w= github.com/go-git/go-git-fixtures/v5 v5.1.1 h1:OH8i1ojV9bWfr0ZfasfpgtUXQHQyVS8HXik/V1C099w=
github.com/go-git/go-git-fixtures/v5 v5.1.1/go.mod h1:Altk43lx3b1ks+dVoAG2300o5WWUnktvfY3VI6bcaXU= github.com/go-git/go-git-fixtures/v5 v5.1.1/go.mod h1:Altk43lx3b1ks+dVoAG2300o5WWUnktvfY3VI6bcaXU=
github.com/go-git/go-git-fixtures/v5 v5.1.2-0.20251229094738-4b14af179146 h1:xYfxAopYyL44ot6dMBIb1Z1njFM0ZBQ99HdIB99KxLs=
github.com/go-git/go-git/v6 v6.0.0-20251128074608-48f817f57805 h1:jxQ3BzYeErNRvlI/4+0mpwqMzvB4g97U+ksfgvrUEbY= github.com/go-git/go-git/v6 v6.0.0-20251128074608-48f817f57805 h1:jxQ3BzYeErNRvlI/4+0mpwqMzvB4g97U+ksfgvrUEbY=
github.com/go-git/go-git/v6 v6.0.0-20251128074608-48f817f57805/go.mod h1:dIwT3uWK1ooHInyVnK2JS5VfQ3peVGYaw2QPqX7uFvs= github.com/go-git/go-git/v6 v6.0.0-20251128074608-48f817f57805/go.mod h1:dIwT3uWK1ooHInyVnK2JS5VfQ3peVGYaw2QPqX7uFvs=
github.com/go-git/go-git/v6 v6.0.0-20251231065035-29ae690a9f19 h1:0lz2eJScP8v5YZQsrEw+ggWC5jNySjg4bIZo5BIh6iI=
github.com/go-git/go-git/v6 v6.0.0-20251231065035-29ae690a9f19/go.mod h1:L+Evfcs7EdTqxwv854354cb6+++7TFL3hJn3Wy4g+3w=
github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE= github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE=
github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk= github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/godbus/dbus/v5 v5.2.0 h1:3WexO+U+yg9T70v9FdHr9kCxYlazaAXUhx2VMkbfax8= github.com/godbus/dbus/v5 v5.2.0 h1:3WexO+U+yg9T70v9FdHr9kCxYlazaAXUhx2VMkbfax8=
github.com/godbus/dbus/v5 v5.2.0/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/godbus/dbus/v5 v5.2.0/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@@ -114,12 +129,16 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sblinch/kdl-go v0.0.0-20250930225324-bf4099d4614a h1:8ZZwZWIQKC0YVMyaCkbrdeI8faTjD1QBrRAAWc1TjMI= github.com/sblinch/kdl-go v0.0.0-20250930225324-bf4099d4614a h1:8ZZwZWIQKC0YVMyaCkbrdeI8faTjD1QBrRAAWc1TjMI=
github.com/sblinch/kdl-go v0.0.0-20250930225324-bf4099d4614a/go.mod h1:b3oNGuAKOQzhsCKmuLc/urEOPzgHj6fB8vl8bwTBh28= github.com/sblinch/kdl-go v0.0.0-20250930225324-bf4099d4614a/go.mod h1:b3oNGuAKOQzhsCKmuLc/urEOPzgHj6fB8vl8bwTBh28=
github.com/sblinch/kdl-go v0.0.0-20251203232544-981d4ecc17c3 h1:msKaIZrrNpvofLPDzNBW3152PJBsnPZsoNNosOCS+C0=
github.com/sblinch/kdl-go v0.0.0-20251203232544-981d4ecc17c3/go.mod h1:b3oNGuAKOQzhsCKmuLc/urEOPzgHj6fB8vl8bwTBh28=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
@@ -133,22 +152,32 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 h1:DHNhtq3sNNzrvduZZIiFyXWOL9IWaDPHqTnLJp+rCBY= golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 h1:DHNhtq3sNNzrvduZZIiFyXWOL9IWaDPHqTnLJp+rCBY=
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0= golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0=
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8= golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8=
golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU= golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -55,7 +55,7 @@ func StoreWithConfig(data []byte, mimeType string, cfg StoreConfig) error {
return fmt.Errorf("data too large: %d > %d", len(data), cfg.MaxEntrySize) return fmt.Errorf("data too large: %d > %d", len(data), cfg.MaxEntrySize)
} }
dbPath, err := getDBPath() dbPath, err := GetDBPath()
if err != nil { if err != nil {
return fmt.Errorf("get db path: %w", err) return fmt.Errorf("get db path: %w", err)
} }
@@ -111,7 +111,7 @@ func StoreWithConfig(data []byte, mimeType string, cfg StoreConfig) error {
}) })
} }
func getDBPath() (string, error) { func GetDBPath() (string, error) {
cacheDir, err := os.UserCacheDir() cacheDir, err := os.UserCacheDir()
if err != nil { if err != nil {
homeDir, err := os.UserHomeDir() homeDir, err := os.UserHomeDir()
@@ -121,12 +121,31 @@ func getDBPath() (string, error) {
cacheDir = filepath.Join(homeDir, ".cache") cacheDir = filepath.Join(homeDir, ".cache")
} }
dbDir := filepath.Join(cacheDir, "dms-clipboard") newDir := filepath.Join(cacheDir, "DankMaterialShell", "clipboard")
if err := os.MkdirAll(dbDir, 0700); err != nil { newPath := filepath.Join(newDir, "db")
return "", err
if _, err := os.Stat(newPath); err == nil {
return newPath, nil
} }
return filepath.Join(dbDir, "db"), nil oldDir := filepath.Join(cacheDir, "dms-clipboard")
oldPath := filepath.Join(oldDir, "db")
if _, err := os.Stat(oldPath); err == nil {
if err := os.MkdirAll(newDir, 0700); err != nil {
return "", err
}
if err := os.Rename(oldPath, newPath); err != nil {
return "", err
}
os.Remove(oldDir)
return newPath, nil
}
if err := os.MkdirAll(newDir, 0700); err != nil {
return "", err
}
return newPath, nil
} }
func deduplicateInTx(b *bolt.Bucket, hash uint64) error { func deduplicateInTx(b *bolt.Bucket, hash uint64) error {

View File

@@ -221,10 +221,7 @@ func (p *Picker) handleGlobal(e client.RegistryGlobalEvent) {
case client.OutputInterfaceName: case client.OutputInterfaceName:
output := client.NewOutput(p.ctx) output := client.NewOutput(p.ctx)
version := e.Version version := min(e.Version, 4)
if version > 4 {
version = 4
}
if err := p.registry.Bind(e.Name, e.Interface, version, output); err == nil { if err := p.registry.Bind(e.Name, e.Interface, version, output); err == nil {
p.outputsMu.Lock() p.outputsMu.Lock()
p.outputs[e.Name] = &Output{ p.outputs[e.Name] = &Output{
@@ -239,20 +236,14 @@ func (p *Picker) handleGlobal(e client.RegistryGlobalEvent) {
case wlr_layer_shell.ZwlrLayerShellV1InterfaceName: case wlr_layer_shell.ZwlrLayerShellV1InterfaceName:
layerShell := wlr_layer_shell.NewZwlrLayerShellV1(p.ctx) layerShell := wlr_layer_shell.NewZwlrLayerShellV1(p.ctx)
version := e.Version version := min(e.Version, 4)
if version > 4 {
version = 4
}
if err := p.registry.Bind(e.Name, e.Interface, version, layerShell); err == nil { if err := p.registry.Bind(e.Name, e.Interface, version, layerShell); err == nil {
p.layerShell = layerShell p.layerShell = layerShell
} }
case wlr_screencopy.ZwlrScreencopyManagerV1InterfaceName: case wlr_screencopy.ZwlrScreencopyManagerV1InterfaceName:
screencopy := wlr_screencopy.NewZwlrScreencopyManagerV1(p.ctx) screencopy := wlr_screencopy.NewZwlrScreencopyManagerV1(p.ctx)
version := e.Version version := min(e.Version, 3)
if version > 3 {
version = 3
}
if err := p.registry.Bind(e.Name, e.Interface, version, screencopy); err == nil { if err := p.registry.Bind(e.Name, e.Interface, version, screencopy); err == nil {
p.screencopy = screencopy 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 rOff, bOff = 2, 0
} }
for row := 0; row < fontH; row++ { for row := range fontH {
yy := y + row yy := y + row
if yy < 0 || yy >= height { if yy < 0 || yy >= height {
continue continue
@@ -1165,7 +1165,7 @@ func drawGlyph(data []byte, stride, width, height, x, y int, r rune, col Color,
rowPattern := g[row] rowPattern := g[row]
dstRowOff := yy * stride dstRowOff := yy * stride
for colIdx := 0; colIdx < fontW; colIdx++ { for colIdx := range fontW {
if (rowPattern & (1 << (fontW - 1 - colIdx))) == 0 { if (rowPattern & (1 << (fontW - 1 - colIdx))) == 0 {
continue continue
} }

View File

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

View File

@@ -176,7 +176,7 @@ func (cd *ConfigDeployer) deployNiriConfig(terminal deps.Terminal, useSystemd bo
} }
if existingConfig != "" { if existingConfig != "" {
mergedConfig, err := cd.mergeNiriOutputSections(newConfig, existingConfig) mergedConfig, err := cd.mergeNiriOutputSections(newConfig, existingConfig, dmsDir)
if err != nil { if err != nil {
cd.log(fmt.Sprintf("Warning: Failed to merge output sections: %v", err)) cd.log(fmt.Sprintf("Warning: Failed to merge output sections: %v", err))
} else { } else {
@@ -209,6 +209,8 @@ func (cd *ConfigDeployer) deployNiriDmsConfigs(dmsDir, terminalCommand string) e
{"layout.kdl", NiriLayoutConfig}, {"layout.kdl", NiriLayoutConfig},
{"alttab.kdl", NiriAlttabConfig}, {"alttab.kdl", NiriAlttabConfig},
{"binds.kdl", strings.ReplaceAll(NiriBindsConfig, "{{TERMINAL_COMMAND}}", terminalCommand)}, {"binds.kdl", strings.ReplaceAll(NiriBindsConfig, "{{TERMINAL_COMMAND}}", terminalCommand)},
{"outputs.kdl", ""},
{"cursor.kdl", ""},
} }
for _, cfg := range configs { for _, cfg := range configs {
@@ -421,24 +423,31 @@ func (cd *ConfigDeployer) deployAlacrittyConfig() ([]DeploymentResult, error) {
return results, nil return results, nil
} }
// mergeNiriOutputSections extracts output sections from existing config and merges them into the new config func (cd *ConfigDeployer) mergeNiriOutputSections(newConfig, existingConfig, dmsDir string) (string, error) {
func (cd *ConfigDeployer) mergeNiriOutputSections(newConfig, existingConfig string) (string, error) {
// Regular expression to match output sections (including commented ones)
outputRegex := regexp.MustCompile(`(?m)^(/-)?\s*output\s+"[^"]+"\s*\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}`) outputRegex := regexp.MustCompile(`(?m)^(/-)?\s*output\s+"[^"]+"\s*\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}`)
// Find all output sections in the existing config
existingOutputs := outputRegex.FindAllString(existingConfig, -1) existingOutputs := outputRegex.FindAllString(existingConfig, -1)
if len(existingOutputs) == 0 { if len(existingOutputs) == 0 {
// No output sections to merge
return newConfig, nil return newConfig, nil
} }
// Remove the example output section from the new config outputsPath := filepath.Join(dmsDir, "outputs.kdl")
if _, err := os.Stat(outputsPath); err != nil {
var outputsContent strings.Builder
for _, output := range existingOutputs {
outputsContent.WriteString(output)
outputsContent.WriteString("\n\n")
}
if err := os.WriteFile(outputsPath, []byte(outputsContent.String()), 0644); err != nil {
cd.log(fmt.Sprintf("Warning: Failed to migrate outputs to %s: %v", outputsPath, err))
} else {
cd.log("Migrated output sections to dms/outputs.kdl")
}
}
exampleOutputRegex := regexp.MustCompile(`(?m)^/-output "eDP-2" \{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}`) exampleOutputRegex := regexp.MustCompile(`(?m)^/-output "eDP-2" \{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}`)
mergedConfig := exampleOutputRegex.ReplaceAllString(newConfig, "") mergedConfig := exampleOutputRegex.ReplaceAllString(newConfig, "")
// Find where to insert the output sections (after the input section)
inputEndRegex := regexp.MustCompile(`(?m)^}$`) inputEndRegex := regexp.MustCompile(`(?m)^}$`)
inputMatches := inputEndRegex.FindAllStringIndex(newConfig, -1) inputMatches := inputEndRegex.FindAllStringIndex(newConfig, -1)
@@ -446,7 +455,6 @@ func (cd *ConfigDeployer) mergeNiriOutputSections(newConfig, existingConfig stri
return "", fmt.Errorf("could not find insertion point for output sections") return "", fmt.Errorf("could not find insertion point for output sections")
} }
// Insert after the first closing brace (end of input section)
insertPos := inputMatches[0][1] insertPos := inputMatches[0][1]
var builder strings.Builder var builder strings.Builder
@@ -476,6 +484,12 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem
return result, result.Error return result, result.Error
} }
dmsDir := filepath.Join(configDir, "dms")
if err := os.MkdirAll(dmsDir, 0755); err != nil {
result.Error = fmt.Errorf("failed to create dms directory: %w", err)
return result, result.Error
}
var existingConfig string var existingConfig string
if _, err := os.Stat(result.Path); err == nil { if _, err := os.Stat(result.Path); err == nil {
cd.log("Found existing Hyprland configuration") cd.log("Found existing Hyprland configuration")
@@ -515,7 +529,7 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem
} }
if existingConfig != "" { if existingConfig != "" {
mergedConfig, err := cd.mergeHyprlandMonitorSections(newConfig, existingConfig) mergedConfig, err := cd.mergeHyprlandMonitorSections(newConfig, existingConfig, dmsDir)
if err != nil { if err != nil {
cd.log(fmt.Sprintf("Warning: Failed to merge monitor sections: %v", err)) cd.log(fmt.Sprintf("Warning: Failed to merge monitor sections: %v", err))
} else { } else {
@@ -529,13 +543,44 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem
return result, result.Error return result, result.Error
} }
if err := cd.deployHyprlandDmsConfigs(dmsDir, terminalCommand); err != nil {
result.Error = fmt.Errorf("failed to deploy dms configs: %w", err)
return result, result.Error
}
result.Deployed = true result.Deployed = true
cd.log("Successfully deployed Hyprland configuration") cd.log("Successfully deployed Hyprland configuration")
return result, nil return result, nil
} }
// mergeHyprlandMonitorSections extracts monitor sections from existing config and merges them into the new config func (cd *ConfigDeployer) deployHyprlandDmsConfigs(dmsDir string, terminalCommand string) error {
func (cd *ConfigDeployer) mergeHyprlandMonitorSections(newConfig, existingConfig string) (string, error) { configs := []struct {
name string
content string
}{
{"colors.conf", HyprColorsConfig},
{"layout.conf", HyprLayoutConfig},
{"binds.conf", strings.ReplaceAll(HyprBindsConfig, "{{TERMINAL_COMMAND}}", terminalCommand)},
{"outputs.conf", ""},
{"cursor.conf", ""},
}
for _, cfg := range configs {
path := filepath.Join(dmsDir, cfg.name)
if _, err := os.Stat(path); err == nil {
cd.log(fmt.Sprintf("Skipping %s (already exists)", cfg.name))
continue
}
if err := os.WriteFile(path, []byte(cfg.content), 0644); err != nil {
return fmt.Errorf("failed to write %s: %w", cfg.name, err)
}
cd.log(fmt.Sprintf("Deployed %s", cfg.name))
}
return nil
}
func (cd *ConfigDeployer) mergeHyprlandMonitorSections(newConfig, existingConfig, dmsDir string) (string, error) {
monitorRegex := regexp.MustCompile(`(?m)^#?\s*monitor\s*=.*$`) monitorRegex := regexp.MustCompile(`(?m)^#?\s*monitor\s*=.*$`)
existingMonitors := monitorRegex.FindAllString(existingConfig, -1) existingMonitors := monitorRegex.FindAllString(existingConfig, -1)
@@ -543,6 +588,20 @@ func (cd *ConfigDeployer) mergeHyprlandMonitorSections(newConfig, existingConfig
return newConfig, nil return newConfig, nil
} }
outputsPath := filepath.Join(dmsDir, "outputs.conf")
if _, err := os.Stat(outputsPath); err != nil {
var outputsContent strings.Builder
for _, monitor := range existingMonitors {
outputsContent.WriteString(monitor)
outputsContent.WriteString("\n")
}
if err := os.WriteFile(outputsPath, []byte(outputsContent.String()), 0644); err != nil {
cd.log(fmt.Sprintf("Warning: Failed to migrate monitors to %s: %v", outputsPath, err))
} else {
cd.log("Migrated monitor sections to dms/outputs.conf")
}
}
exampleMonitorRegex := regexp.MustCompile(`(?m)^# monitor = eDP-2.*$`) exampleMonitorRegex := regexp.MustCompile(`(?m)^# monitor = eDP-2.*$`)
mergedConfig := exampleMonitorRegex.ReplaceAllString(newConfig, "") mergedConfig := exampleMonitorRegex.ReplaceAllString(newConfig, "")

View File

@@ -161,7 +161,8 @@ layout {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
result, err := cd.mergeNiriOutputSections(tt.newConfig, tt.existingConfig) tmpDir := t.TempDir()
result, err := cd.mergeNiriOutputSections(tt.newConfig, tt.existingConfig, tmpDir)
if tt.wantError { if tt.wantError {
assert.Error(t, err) assert.Error(t, err)
@@ -362,7 +363,8 @@ input {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
result, err := cd.mergeHyprlandMonitorSections(tt.newConfig, tt.existingConfig) tmpDir := t.TempDir()
result, err := cd.mergeHyprlandMonitorSections(tt.newConfig, tt.existingConfig, tmpDir)
if tt.wantError { if tt.wantError {
assert.Error(t, err) assert.Error(t, err)
@@ -406,7 +408,7 @@ func TestHyprlandConfigDeployment(t *testing.T) {
content, err := os.ReadFile(result.Path) content, err := os.ReadFile(result.Path)
require.NoError(t, err) require.NoError(t, err)
assert.Contains(t, string(content), "# MONITOR CONFIG") assert.Contains(t, string(content), "# MONITOR CONFIG")
assert.Contains(t, string(content), "bind = $mod, T, exec, ghostty") assert.Contains(t, string(content), "source = ./dms/binds.conf")
assert.Contains(t, string(content), "exec-once = ") assert.Contains(t, string(content), "exec-once = ")
}) })
@@ -442,7 +444,7 @@ general {
require.NoError(t, err) require.NoError(t, err)
assert.Contains(t, string(newContent), "monitor = DP-1, 1920x1080@144") assert.Contains(t, string(newContent), "monitor = DP-1, 1920x1080@144")
assert.Contains(t, string(newContent), "monitor = HDMI-A-1, 3840x2160@60") assert.Contains(t, string(newContent), "monitor = HDMI-A-1, 3840x2160@60")
assert.Contains(t, string(newContent), "bind = $mod, T, exec, kitty") assert.Contains(t, string(newContent), "source = ./dms/binds.conf")
assert.NotContains(t, string(newContent), "monitor = eDP-2") assert.NotContains(t, string(newContent), "monitor = eDP-2")
}) })
} }
@@ -459,10 +461,7 @@ func TestHyprlandConfigStructure(t *testing.T) {
assert.Contains(t, HyprlandConfig, "# MONITOR CONFIG") assert.Contains(t, HyprlandConfig, "# MONITOR CONFIG")
assert.Contains(t, HyprlandConfig, "# STARTUP APPS") assert.Contains(t, HyprlandConfig, "# STARTUP APPS")
assert.Contains(t, HyprlandConfig, "# INPUT CONFIG") assert.Contains(t, HyprlandConfig, "# INPUT CONFIG")
assert.Contains(t, HyprlandConfig, "# KEYBINDINGS") assert.Contains(t, HyprlandConfig, "source = ./dms/binds.conf")
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)$")
} }
func TestGhosttyConfigStructure(t *testing.T) { func TestGhosttyConfigStructure(t *testing.T) {

View File

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

View File

@@ -0,0 +1,156 @@
# === Application Launchers ===
bind = SUPER, T, exec, {{TERMINAL_COMMAND}}
bind = SUPER, space, exec, dms ipc call spotlight toggle
bind = SUPER, V, exec, dms ipc call clipboard toggle
bind = SUPER, M, exec, dms ipc call processlist focusOrToggle
bind = SUPER, comma, exec, dms ipc call settings focusOrToggle
bind = SUPER, N, exec, dms ipc call notifications toggle
bind = SUPER SHIFT, N, exec, dms ipc call notepad toggle
bind = SUPER, Y, exec, dms ipc call dankdash wallpaper
bind = SUPER, TAB, exec, dms ipc call hypr toggleOverview
bind = SUPER, X, exec, dms ipc call powermenu toggle
# === Cheat sheet
bind = SUPER SHIFT, Slash, exec, dms ipc call keybinds toggle hyprland
# === Security ===
bind = SUPER ALT, L, exec, dms ipc call lock lock
bind = SUPER SHIFT, E, exit
bind = CTRL ALT, Delete, exec, dms ipc call processlist focusOrToggle
# === Audio Controls ===
bindel = , XF86AudioRaiseVolume, exec, dms ipc call audio increment 3
bindel = , XF86AudioLowerVolume, exec, dms ipc call audio decrement 3
bindl = , XF86AudioMute, exec, dms ipc call audio mute
bindl = , XF86AudioMicMute, exec, dms ipc call audio micmute
bindl = , XF86AudioPause, exec, dms ipc call mpris playPause
bindl = , XF86AudioPlay, exec, dms ipc call mpris playPause
bindl = , XF86AudioPrev, exec, dms ipc call mpris previous
bindl = , XF86AudioNext, exec, dms ipc call mpris next
# === Brightness Controls ===
bindel = , XF86MonBrightnessUp, exec, dms ipc call brightness increment 5 ""
bindel = , XF86MonBrightnessDown, exec, dms ipc call brightness decrement 5 ""
# === Window Management ===
bind = SUPER, Q, killactive
bind = SUPER, F, fullscreen, 1
bind = SUPER SHIFT, F, fullscreen, 0
bind = SUPER SHIFT, T, togglefloating
bind = SUPER, W, togglegroup
# === Focus Navigation ===
bind = SUPER, left, movefocus, l
bind = SUPER, down, movefocus, d
bind = SUPER, up, movefocus, u
bind = SUPER, right, movefocus, r
bind = SUPER, H, movefocus, l
bind = SUPER, J, movefocus, d
bind = SUPER, K, movefocus, u
bind = SUPER, L, movefocus, r
# === Window Movement ===
bind = SUPER SHIFT, left, movewindow, l
bind = SUPER SHIFT, down, movewindow, d
bind = SUPER SHIFT, up, movewindow, u
bind = SUPER SHIFT, right, movewindow, r
bind = SUPER SHIFT, H, movewindow, l
bind = SUPER SHIFT, J, movewindow, d
bind = SUPER SHIFT, K, movewindow, u
bind = SUPER SHIFT, L, movewindow, r
# === Column Navigation ===
bind = SUPER, Home, focuswindow, first
bind = SUPER, End, focuswindow, last
# === Monitor Navigation ===
bind = SUPER CTRL, left, focusmonitor, l
bind = SUPER CTRL, right, focusmonitor, r
bind = SUPER CTRL, H, focusmonitor, l
bind = SUPER CTRL, J, focusmonitor, d
bind = SUPER CTRL, K, focusmonitor, u
bind = SUPER CTRL, L, focusmonitor, r
# === Move to Monitor ===
bind = SUPER SHIFT CTRL, left, movewindow, mon:l
bind = SUPER SHIFT CTRL, down, movewindow, mon:d
bind = SUPER SHIFT CTRL, up, movewindow, mon:u
bind = SUPER SHIFT CTRL, right, movewindow, mon:r
bind = SUPER SHIFT CTRL, H, movewindow, mon:l
bind = SUPER SHIFT CTRL, J, movewindow, mon:d
bind = SUPER SHIFT CTRL, K, movewindow, mon:u
bind = SUPER SHIFT CTRL, L, movewindow, mon:r
# === Workspace Navigation ===
bind = SUPER, Page_Down, workspace, e+1
bind = SUPER, Page_Up, workspace, e-1
bind = SUPER, U, workspace, e+1
bind = SUPER, I, workspace, e-1
bind = SUPER CTRL, down, movetoworkspace, e+1
bind = SUPER CTRL, up, movetoworkspace, e-1
bind = SUPER CTRL, U, movetoworkspace, e+1
bind = SUPER CTRL, I, movetoworkspace, e-1
# === Move Workspaces ===
bind = SUPER SHIFT, Page_Down, movetoworkspace, e+1
bind = SUPER SHIFT, Page_Up, movetoworkspace, e-1
bind = SUPER SHIFT, U, movetoworkspace, e+1
bind = SUPER SHIFT, I, movetoworkspace, e-1
# === Mouse Wheel Navigation ===
bind = SUPER, mouse_down, workspace, e+1
bind = SUPER, mouse_up, workspace, e-1
bind = SUPER CTRL, mouse_down, movetoworkspace, e+1
bind = SUPER CTRL, mouse_up, movetoworkspace, e-1
# === Numbered Workspaces ===
bind = SUPER, 1, workspace, 1
bind = SUPER, 2, workspace, 2
bind = SUPER, 3, workspace, 3
bind = SUPER, 4, workspace, 4
bind = SUPER, 5, workspace, 5
bind = SUPER, 6, workspace, 6
bind = SUPER, 7, workspace, 7
bind = SUPER, 8, workspace, 8
bind = SUPER, 9, workspace, 9
# === Move to Numbered Workspaces ===
bind = SUPER SHIFT, 1, movetoworkspace, 1
bind = SUPER SHIFT, 2, movetoworkspace, 2
bind = SUPER SHIFT, 3, movetoworkspace, 3
bind = SUPER SHIFT, 4, movetoworkspace, 4
bind = SUPER SHIFT, 5, movetoworkspace, 5
bind = SUPER SHIFT, 6, movetoworkspace, 6
bind = SUPER SHIFT, 7, movetoworkspace, 7
bind = SUPER SHIFT, 8, movetoworkspace, 8
bind = SUPER SHIFT, 9, movetoworkspace, 9
# === Column Management ===
bind = SUPER, bracketleft, layoutmsg, preselect l
bind = SUPER, bracketright, layoutmsg, preselect r
# === Sizing & Layout ===
bind = SUPER, R, layoutmsg, togglesplit
bind = SUPER CTRL, F, resizeactive, exact 100%
# === Move/resize windows with mainMod + LMB/RMB and dragging ===
bindmd = SUPER, mouse:272, Move window, movewindow
bindmd = SUPER, mouse:273, Resize window, resizewindow
# === Move/resize windows with mainMod + LMB/RMB and dragging ===
bindd = SUPER, code:20, Expand window left, resizeactive, -100 0
bindd = SUPER, code:21, Shrink window left, resizeactive, 100 0
# === Manual Sizing ===
binde = SUPER, minus, resizeactive, -10% 0
binde = SUPER, equal, resizeactive, 10% 0
binde = SUPER SHIFT, minus, resizeactive, 0 -10%
binde = SUPER SHIFT, equal, resizeactive, 0 10%
# === Screenshots ===
bind = , Print, exec, dms screenshot
bind = CTRL, Print, exec, dms screenshot full
bind = ALT, Print, exec, dms screenshot window
# === System Controls ===
bind = SUPER SHIFT, P, dpms, toggle

View File

@@ -0,0 +1,25 @@
# ! Auto-generated file. Do not edit directly.
# Remove source = ./dms/colors.conf from your config to override.
$primary = rgb(d0bcff)
$outline = rgb(948f99)
$error = rgb(f2b8b5)
general {
col.active_border = $primary
col.inactive_border = $outline
}
group {
col.border_active = $primary
col.border_inactive = $outline
col.border_locked_active = $error
col.border_locked_inactive = $outline
groupbar {
col.active = $primary
col.inactive = $outline
col.locked_active = $error
col.locked_inactive = $outline
}
}

View File

@@ -0,0 +1,11 @@
# Auto-generated by DMS - do not edit manually
general {
gaps_in = 4
gaps_out = 4
border_size = 2
}
decoration {
rounding = 12
}

View File

@@ -27,10 +27,7 @@ input {
general { general {
gaps_in = 5 gaps_in = 5
gaps_out = 5 gaps_out = 5
border_size = 0 # off in niri border_size = 2
col.active_border = rgba(707070ff)
col.inactive_border = rgba(d0d0d0ff)
layout = dwindle layout = dwindle
} }
@@ -42,7 +39,7 @@ decoration {
rounding = 12 rounding = 12
active_opacity = 1.0 active_opacity = 1.0
inactive_opacity = 0.9 inactive_opacity = 1.0
shadow { shadow {
enabled = true enabled = true
@@ -90,190 +87,32 @@ misc {
# ================== # ==================
# WINDOW RULES # WINDOW RULES
# ================== # ==================
windowrulev2 = tile, class:^(org\.wezfurlong\.wezterm)$ windowrule = tile on, match:class ^(org\.wezfurlong\.wezterm)$
windowrulev2 = rounding 12, class:^(org\.gnome\.) windowrule = rounding 12, match:class ^(org\.gnome\.)
windowrulev2 = noborder, class:^(org\.gnome\.)
windowrulev2 = tile, class:^(gnome-control-center)$ windowrule = tile on, match:class ^(gnome-control-center)$
windowrulev2 = tile, class:^(pavucontrol)$ windowrule = tile on, match:class ^(pavucontrol)$
windowrulev2 = tile, class:^(nm-connection-editor)$ windowrule = tile on, match:class ^(nm-connection-editor)$
windowrulev2 = float, class:^(gnome-calculator)$ windowrule = float on, match:class ^(gnome-calculator)$
windowrulev2 = float, class:^(galculator)$ windowrule = float on, match:class ^(galculator)$
windowrulev2 = float, class:^(blueman-manager)$ windowrule = float on, match:class ^(blueman-manager)$
windowrulev2 = float, class:^(org\.gnome\.Nautilus)$ windowrule = float on, match:class ^(org\.gnome\.Nautilus)$
windowrulev2 = float, class:^(steam)$ windowrule = float on, match:class ^(steam)$
windowrulev2 = float, class:^(xdg-desktop-portal)$ windowrule = float on, match:class ^(xdg-desktop-portal)$
windowrulev2 = noborder, class:^(org\.wezfurlong\.wezterm)$ windowrule = float on, match:class ^(firefox)$, match:title ^(Picture-in-Picture)$
windowrulev2 = noborder, class:^(Alacritty)$ windowrule = float on, match:class ^(zoom)$
windowrulev2 = noborder, class:^(zen)$
windowrulev2 = noborder, class:^(com\.mitchellh\.ghostty)$
windowrulev2 = noborder, class:^(kitty)$
windowrulev2 = float, class:^(firefox)$, title:^(Picture-in-Picture)$
windowrulev2 = float, class:^(zoom)$
# DMS windows floating by default # DMS windows floating by default
windowrulev2 = float, class:^(org.quickshell)$ # ! Hyprland doesn't size these windows correctly so disabling by default here
windowrulev2 = opacity 0.9 0.9, floating:0, focus:0 # windowrule = float on, match:class ^(org.quickshell)$
layerrule = noanim, ^(quickshell)$ layerrule = no_anim on, match:namespace ^(quickshell)$
# ================== source = ./dms/colors.conf
# KEYBINDINGS source = ./dms/outputs.conf
# ================== source = ./dms/layout.conf
$mod = SUPER source = ./dms/cursor.conf
source = ./dms/binds.conf
# === Application Launchers ===
bind = $mod, T, exec, {{TERMINAL_COMMAND}}
bind = $mod, space, exec, dms ipc call spotlight toggle
bind = $mod, V, exec, dms ipc call clipboard toggle
bind = $mod, M, exec, dms ipc call processlist focusOrToggle
bind = $mod, comma, exec, dms ipc call settings focusOrToggle
bind = $mod, N, exec, dms ipc call notifications toggle
bind = $mod SHIFT, N, exec, dms ipc call notepad toggle
bind = $mod, Y, exec, dms ipc call dankdash wallpaper
bind = $mod, TAB, exec, dms ipc call hypr toggleOverview
# === Cheat sheet
bind = $mod SHIFT, Slash, exec, dms ipc call keybinds toggle hyprland
# === Security ===
bind = $mod ALT, L, exec, dms ipc call lock lock
bind = $mod SHIFT, E, exit
bind = CTRL ALT, Delete, exec, dms ipc call processlist focusOrToggle
# === Audio Controls ===
bindel = , XF86AudioRaiseVolume, exec, dms ipc call audio increment 3
bindel = , XF86AudioLowerVolume, exec, dms ipc call audio decrement 3
bindl = , XF86AudioMute, exec, dms ipc call audio mute
bindl = , XF86AudioMicMute, exec, dms ipc call audio micmute
# === Brightness Controls ===
bindel = , XF86MonBrightnessUp, exec, dms ipc call brightness increment 5 ""
bindel = , XF86MonBrightnessDown, exec, dms ipc call brightness decrement 5 ""
# === Window Management ===
bind = $mod, Q, killactive
bind = $mod, F, fullscreen, 1
bind = $mod SHIFT, F, fullscreen, 0
bind = $mod SHIFT, T, togglefloating
bind = $mod, W, togglegroup
# === Focus Navigation ===
bind = $mod, left, movefocus, l
bind = $mod, down, movefocus, d
bind = $mod, up, movefocus, u
bind = $mod, right, movefocus, r
bind = $mod, H, movefocus, l
bind = $mod, J, movefocus, d
bind = $mod, K, movefocus, u
bind = $mod, L, movefocus, r
# === Window Movement ===
bind = $mod SHIFT, left, movewindow, l
bind = $mod SHIFT, down, movewindow, d
bind = $mod SHIFT, up, movewindow, u
bind = $mod SHIFT, right, movewindow, r
bind = $mod SHIFT, H, movewindow, l
bind = $mod SHIFT, J, movewindow, d
bind = $mod SHIFT, K, movewindow, u
bind = $mod SHIFT, L, movewindow, r
# === Column Navigation ===
bind = $mod, Home, focuswindow, first
bind = $mod, End, focuswindow, last
# === Monitor Navigation ===
bind = $mod CTRL, left, focusmonitor, l
bind = $mod CTRL, right, focusmonitor, r
bind = $mod CTRL, H, focusmonitor, l
bind = $mod CTRL, J, focusmonitor, d
bind = $mod CTRL, K, focusmonitor, u
bind = $mod CTRL, L, focusmonitor, r
# === Move to Monitor ===
bind = $mod SHIFT CTRL, left, movewindow, mon:l
bind = $mod SHIFT CTRL, down, movewindow, mon:d
bind = $mod SHIFT CTRL, up, movewindow, mon:u
bind = $mod SHIFT CTRL, right, movewindow, mon:r
bind = $mod SHIFT CTRL, H, movewindow, mon:l
bind = $mod SHIFT CTRL, J, movewindow, mon:d
bind = $mod SHIFT CTRL, K, movewindow, mon:u
bind = $mod SHIFT CTRL, L, movewindow, mon:r
# === Workspace Navigation ===
bind = $mod, Page_Down, workspace, e+1
bind = $mod, Page_Up, workspace, e-1
bind = $mod, U, workspace, e+1
bind = $mod, I, workspace, e-1
bind = $mod CTRL, down, movetoworkspace, e+1
bind = $mod CTRL, up, movetoworkspace, e-1
bind = $mod CTRL, U, movetoworkspace, e+1
bind = $mod CTRL, I, movetoworkspace, e-1
# === Move Workspaces ===
bind = $mod SHIFT, Page_Down, movetoworkspace, e+1
bind = $mod SHIFT, Page_Up, movetoworkspace, e-1
bind = $mod SHIFT, U, movetoworkspace, e+1
bind = $mod SHIFT, I, movetoworkspace, e-1
# === Mouse Wheel Navigation ===
bind = $mod, mouse_down, workspace, e+1
bind = $mod, mouse_up, workspace, e-1
bind = $mod CTRL, mouse_down, movetoworkspace, e+1
bind = $mod CTRL, mouse_up, movetoworkspace, e-1
# === Numbered Workspaces ===
bind = $mod, 1, workspace, 1
bind = $mod, 2, workspace, 2
bind = $mod, 3, workspace, 3
bind = $mod, 4, workspace, 4
bind = $mod, 5, workspace, 5
bind = $mod, 6, workspace, 6
bind = $mod, 7, workspace, 7
bind = $mod, 8, workspace, 8
bind = $mod, 9, workspace, 9
# === Move to Numbered Workspaces ===
bind = $mod SHIFT, 1, movetoworkspace, 1
bind = $mod SHIFT, 2, movetoworkspace, 2
bind = $mod SHIFT, 3, movetoworkspace, 3
bind = $mod SHIFT, 4, movetoworkspace, 4
bind = $mod SHIFT, 5, movetoworkspace, 5
bind = $mod SHIFT, 6, movetoworkspace, 6
bind = $mod SHIFT, 7, movetoworkspace, 7
bind = $mod SHIFT, 8, movetoworkspace, 8
bind = $mod SHIFT, 9, movetoworkspace, 9
# === Column Management ===
bind = $mod, bracketleft, layoutmsg, preselect l
bind = $mod, bracketright, layoutmsg, preselect r
# === Sizing & Layout ===
bind = $mod, R, layoutmsg, togglesplit
bind = $mod CTRL, F, resizeactive, exact 100%
# === Move/resize windows with mainMod + LMB/RMB and dragging ===
bindmd = $mod, mouse:272, Move window, movewindow
bindmd = $mod, mouse:273, Resize window, resizewindow
# === Move/resize windows with mainMod + LMB/RMB and dragging ===
bindd = $mod, code:20, Expand window left, resizeactive, -100 0
bindd = $mod, code:21, Shrink window left, resizeactive, 100 0
# === Manual Sizing ===
binde = $mod, minus, resizeactive, -10% 0
binde = $mod, equal, resizeactive, 10% 0
binde = $mod SHIFT, minus, resizeactive, 0 -10%
binde = $mod SHIFT, equal, resizeactive, 0 10%
# === Screenshots ===
bind = , Print, exec, dms screenshot
bind = CTRL, Print, exec, dms screenshot full
bind = ALT, Print, exec, dms screenshot window
# === System Controls ===
bind = $mod SHIFT, P, dpms, toggle

View File

@@ -1,8 +1,3 @@
// ! DO NOT EDIT !
// ! AUTO-GENERATED BY DMS !
// ! CHANGES WILL BE OVERWRITTEN !
// ! PLACE YOUR CUSTOM CONFIGURATION ELSEWHERE !
binds { binds {
// === System & Overview === // === System & Overview ===
Mod+D repeat=false { toggle-overview; } Mod+D repeat=false { toggle-overview; }
@@ -20,6 +15,8 @@ binds {
Mod+M hotkey-overlay-title="Task Manager" { Mod+M hotkey-overlay-title="Task Manager" {
spawn "dms" "ipc" "call" "processlist" "focusOrToggle"; spawn "dms" "ipc" "call" "processlist" "focusOrToggle";
} }
Super+X hotkey-overlay-title="Power Menu: Toggle" { spawn "dms" "ipc" "call" "powermenu" "toggle"; }
Mod+Comma hotkey-overlay-title="Settings" { Mod+Comma hotkey-overlay-title="Settings" {
spawn "dms" "ipc" "call" "settings" "focusOrToggle"; spawn "dms" "ipc" "call" "settings" "focusOrToggle";
} }
@@ -51,6 +48,18 @@ binds {
XF86AudioMicMute allow-when-locked=true { XF86AudioMicMute allow-when-locked=true {
spawn "dms" "ipc" "call" "audio" "micmute"; 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 === // === Brightness Controls ===
XF86MonBrightnessUp allow-when-locked=true { XF86MonBrightnessUp allow-when-locked=true {

View File

@@ -1,21 +1,19 @@
// ! DO NOT EDIT ! // ! Auto-generated file. Do not edit directly.
// ! AUTO-GENERATED BY DMS ! // Remove `include "dms/colors.kdl"` from your config to override.
// ! CHANGES WILL BE OVERWRITTEN !
// ! PLACE YOUR CUSTOM CONFIGURATION ELSEWHERE !
layout { layout {
background-color "transparent" background-color "transparent"
focus-ring { focus-ring {
active-color "#9dcbfb" active-color "#d0bcff"
inactive-color "#8c9199" inactive-color "#948f99"
urgent-color "#ffb4ab" urgent-color "#f2b8b5"
} }
border { border {
active-color "#9dcbfb" active-color "#d0bcff"
inactive-color "#8c9199" inactive-color "#948f99"
urgent-color "#ffb4ab" urgent-color "#f2b8b5"
} }
shadow { shadow {
@@ -23,19 +21,19 @@ layout {
} }
tab-indicator { tab-indicator {
active-color "#9dcbfb" active-color "#d0bcff"
inactive-color "#8c9199" inactive-color "#948f99"
urgent-color "#ffb4ab" urgent-color "#f2b8b5"
} }
insert-hint { insert-hint {
color "#9dcbfb80" color "#d0bcff80"
} }
} }
recent-windows { recent-windows {
highlight { highlight {
active-color "#124a73" active-color "#4f378b"
urgent-color "#ffb4ab" urgent-color "#f2b8b5"
} }
} }

View File

@@ -240,10 +240,6 @@ window-rule {
match app-id="kitty" match app-id="kitty"
draw-border-with-background false draw-border-with-background false
} }
window-rule {
match is-active=false
opacity 0.9
}
window-rule { window-rule {
match app-id=r#"firefox$"# title="^Picture-in-Picture$" match app-id=r#"firefox$"# title="^Picture-in-Picture$"
match app-id="zoom" match app-id="zoom"
@@ -273,3 +269,5 @@ include "dms/colors.kdl"
include "dms/layout.kdl" include "dms/layout.kdl"
include "dms/alttab.kdl" include "dms/alttab.kdl"
include "dms/binds.kdl" include "dms/binds.kdl"
include "dms/outputs.kdl"
include "dms/cursor.kdl"

View File

@@ -4,3 +4,12 @@ import _ "embed"
//go:embed embedded/hyprland.conf //go:embed embedded/hyprland.conf
var HyprlandConfig string var HyprlandConfig string
//go:embed embedded/hypr-colors.conf
var HyprColorsConfig string
//go:embed embedded/hypr-layout.conf
var HyprLayoutConfig string
//go:embed embedded/hypr-binds.conf
var HyprBindsConfig string

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"runtime" "runtime"
"slices"
"strings" "strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps" "github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
@@ -514,12 +515,9 @@ func (a *ArchDistribution) reorderAURPackages(packages []string) []string {
dmsShell = append(dmsShell, pkg) dmsShell = append(dmsShell, pkg)
} else { } else {
isDep := false isDep := false
for _, dep := range dmsDepencies { if slices.Contains(dmsDepencies, pkg) {
if pkg == dep { deps = append(deps, pkg)
deps = append(deps, pkg) isDep = true
isDep = true
break
}
} }
if !isDep { if !isDep {
others = append(others, pkg) 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)) 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) return fmt.Errorf("failed to create build directory: %w", err)
} }
defer func() { defer func() {

View File

@@ -153,7 +153,7 @@ func (f *FedoraDistribution) getDmsMapping(variant deps.PackageVariant) PackageM
} }
func (f *FedoraDistribution) getHyprlandMapping(_ deps.PackageVariant) PackageMapping { func (f *FedoraDistribution) getHyprlandMapping(_ deps.PackageVariant) PackageMapping {
return PackageMapping{Name: "hyprland", Repository: RepoTypeCOPR, RepoURL: "solopasha/hyprland"} return PackageMapping{Name: "hyprland", Repository: RepoTypeCOPR, RepoURL: "sdegler/hyprland"}
} }
func (f *FedoraDistribution) getNiriMapping(variant deps.PackageVariant) PackageMapping { func (f *FedoraDistribution) getNiriMapping(variant deps.PackageVariant) PackageMapping {

View File

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

View File

@@ -108,7 +108,6 @@ func (o *OpenSUSEDistribution) GetPackageMappingWithVariants(wm deps.WindowManag
packages := map[string]PackageMapping{ packages := map[string]PackageMapping{
// Standard zypper packages // Standard zypper packages
"git": {Name: "git", Repository: RepoTypeSystem}, "git": {Name: "git", Repository: RepoTypeSystem},
"ghostty": {Name: "ghostty", Repository: RepoTypeSystem},
"kitty": {Name: "kitty", Repository: RepoTypeSystem}, "kitty": {Name: "kitty", Repository: RepoTypeSystem},
"alacritty": {Name: "alacritty", Repository: RepoTypeSystem}, "alacritty": {Name: "alacritty", Repository: RepoTypeSystem},
"xdg-desktop-portal-gtk": {Name: "xdg-desktop-portal-gtk", Repository: RepoTypeSystem}, "xdg-desktop-portal-gtk": {Name: "xdg-desktop-portal-gtk", Repository: RepoTypeSystem},
@@ -117,6 +116,7 @@ func (o *OpenSUSEDistribution) GetPackageMappingWithVariants(wm deps.WindowManag
// DMS packages from OBS // DMS packages from OBS
"dms (DankMaterialShell)": o.getDmsMapping(variants["dms (DankMaterialShell)"]), "dms (DankMaterialShell)": o.getDmsMapping(variants["dms (DankMaterialShell)"]),
"quickshell": o.getQuickshellMapping(variants["quickshell"]), "quickshell": o.getQuickshellMapping(variants["quickshell"]),
"ghostty": {Name: "ghostty", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
"matugen": {Name: "matugen", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"}, "matugen": {Name: "matugen", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
"dgop": {Name: "dgop", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"}, "dgop": {Name: "dgop", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
} }

View File

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

View File

@@ -2,45 +2,93 @@ package providers
import ( import (
"fmt" "fmt"
"os"
"path/filepath"
"sort"
"strings" "strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds" "github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
) )
type HyprlandProvider struct { type HyprlandProvider struct {
configPath string configPath string
dmsBindsIncluded bool
parsed bool
} }
func NewHyprlandProvider(configPath string) *HyprlandProvider { func NewHyprlandProvider(configPath string) *HyprlandProvider {
if configPath == "" { if configPath == "" {
configPath = "$HOME/.config/hypr" configPath = defaultHyprlandConfigDir()
} }
return &HyprlandProvider{ return &HyprlandProvider{
configPath: configPath, configPath: configPath,
} }
} }
func defaultHyprlandConfigDir() string {
configDir, err := os.UserConfigDir()
if err != nil {
return ""
}
return filepath.Join(configDir, "hypr")
}
func (h *HyprlandProvider) Name() string { func (h *HyprlandProvider) Name() string {
return "hyprland" return "hyprland"
} }
func (h *HyprlandProvider) GetCheatSheet() (*keybinds.CheatSheet, error) { func (h *HyprlandProvider) GetCheatSheet() (*keybinds.CheatSheet, error) {
section, err := ParseHyprlandKeys(h.configPath) result, err := ParseHyprlandKeysWithDMS(h.configPath)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse hyprland config: %w", err) return nil, fmt.Errorf("failed to parse hyprland config: %w", err)
} }
categorizedBinds := make(map[string][]keybinds.Keybind) h.dmsBindsIncluded = result.DMSBindsIncluded
h.convertSection(section, "", categorizedBinds) h.parsed = true
return &keybinds.CheatSheet{ categorizedBinds := make(map[string][]keybinds.Keybind)
Title: "Hyprland Keybinds", h.convertSection(result.Section, "", categorizedBinds, result.ConflictingConfigs)
Provider: h.Name(),
Binds: categorizedBinds, sheet := &keybinds.CheatSheet{
}, nil Title: "Hyprland Keybinds",
Provider: h.Name(),
Binds: categorizedBinds,
DMSBindsIncluded: result.DMSBindsIncluded,
}
if result.DMSStatus != nil {
sheet.DMSStatus = &keybinds.DMSBindsStatus{
Exists: result.DMSStatus.Exists,
Included: result.DMSStatus.Included,
IncludePosition: result.DMSStatus.IncludePosition,
TotalIncludes: result.DMSStatus.TotalIncludes,
BindsAfterDMS: result.DMSStatus.BindsAfterDMS,
Effective: result.DMSStatus.Effective,
OverriddenBy: result.DMSStatus.OverriddenBy,
StatusMessage: result.DMSStatus.StatusMessage,
}
}
return sheet, nil
} }
func (h *HyprlandProvider) convertSection(section *HyprlandSection, subcategory string, categorizedBinds map[string][]keybinds.Keybind) { func (h *HyprlandProvider) HasDMSBindsIncluded() bool {
if h.parsed {
return h.dmsBindsIncluded
}
result, err := ParseHyprlandKeysWithDMS(h.configPath)
if err != nil {
return false
}
h.dmsBindsIncluded = result.DMSBindsIncluded
h.parsed = true
return h.dmsBindsIncluded
}
func (h *HyprlandProvider) convertSection(section *HyprlandSection, subcategory string, categorizedBinds map[string][]keybinds.Keybind, conflicts map[string]*HyprlandKeyBinding) {
currentSubcat := subcategory currentSubcat := subcategory
if section.Name != "" { if section.Name != "" {
currentSubcat = section.Name currentSubcat = section.Name
@@ -48,12 +96,12 @@ func (h *HyprlandProvider) convertSection(section *HyprlandSection, subcategory
for _, kb := range section.Keybinds { for _, kb := range section.Keybinds {
category := h.categorizeByDispatcher(kb.Dispatcher) category := h.categorizeByDispatcher(kb.Dispatcher)
bind := h.convertKeybind(&kb, currentSubcat) bind := h.convertKeybind(&kb, currentSubcat, conflicts)
categorizedBinds[category] = append(categorizedBinds[category], bind) categorizedBinds[category] = append(categorizedBinds[category], bind)
} }
for _, child := range section.Children { for _, child := range section.Children {
h.convertSection(&child, currentSubcat, categorizedBinds) h.convertSection(&child, currentSubcat, categorizedBinds, conflicts)
} }
} }
@@ -85,8 +133,8 @@ func (h *HyprlandProvider) categorizeByDispatcher(dispatcher string) string {
} }
} }
func (h *HyprlandProvider) convertKeybind(kb *HyprlandKeyBinding, subcategory string) keybinds.Keybind { func (h *HyprlandProvider) convertKeybind(kb *HyprlandKeyBinding, subcategory string, conflicts map[string]*HyprlandKeyBinding) keybinds.Keybind {
key := h.formatKey(kb) keyStr := h.formatKey(kb)
rawAction := h.formatRawAction(kb.Dispatcher, kb.Params) rawAction := h.formatRawAction(kb.Dispatcher, kb.Params)
desc := kb.Comment desc := kb.Comment
@@ -94,12 +142,33 @@ func (h *HyprlandProvider) convertKeybind(kb *HyprlandKeyBinding, subcategory st
desc = rawAction desc = rawAction
} }
return keybinds.Keybind{ source := "config"
Key: key, if strings.Contains(kb.Source, "dms/binds.conf") {
source = "dms"
}
bind := keybinds.Keybind{
Key: keyStr,
Description: desc, Description: desc,
Action: rawAction, Action: rawAction,
Subcategory: subcategory, Subcategory: subcategory,
Source: source,
Flags: kb.Flags,
} }
if source == "dms" && conflicts != nil {
normalizedKey := strings.ToLower(keyStr)
if conflictKb, ok := conflicts[normalizedKey]; ok {
bind.Conflict = &keybinds.Keybind{
Key: keyStr,
Description: conflictKb.Comment,
Action: h.formatRawAction(conflictKb.Dispatcher, conflictKb.Params),
Source: "config",
}
}
}
return bind
} }
func (h *HyprlandProvider) formatRawAction(dispatcher, params string) string { func (h *HyprlandProvider) formatRawAction(dispatcher, params string) string {
@@ -115,3 +184,314 @@ func (h *HyprlandProvider) formatKey(kb *HyprlandKeyBinding) string {
parts = append(parts, kb.Key) parts = append(parts, kb.Key)
return strings.Join(parts, "+") return strings.Join(parts, "+")
} }
func (h *HyprlandProvider) GetOverridePath() string {
expanded, err := utils.ExpandPath(h.configPath)
if err != nil {
return filepath.Join(h.configPath, "dms", "binds.conf")
}
return filepath.Join(expanded, "dms", "binds.conf")
}
func (h *HyprlandProvider) validateAction(action string) error {
action = strings.TrimSpace(action)
switch {
case action == "":
return fmt.Errorf("action cannot be empty")
case action == "exec" || action == "exec ":
return fmt.Errorf("exec dispatcher requires arguments")
case strings.HasPrefix(action, "exec "):
rest := strings.TrimSpace(strings.TrimPrefix(action, "exec "))
if rest == "" {
return fmt.Errorf("exec dispatcher requires arguments")
}
}
return nil
}
func (h *HyprlandProvider) SetBind(key, action, description string, options map[string]any) error {
if err := h.validateAction(action); err != nil {
return err
}
overridePath := h.GetOverridePath()
if err := os.MkdirAll(filepath.Dir(overridePath), 0755); err != nil {
return fmt.Errorf("failed to create dms directory: %w", err)
}
existingBinds, err := h.loadOverrideBinds()
if err != nil {
existingBinds = make(map[string]*hyprlandOverrideBind)
}
// Extract flags from options
var flags string
if options != nil {
if f, ok := options["flags"].(string); ok {
flags = f
}
}
normalizedKey := strings.ToLower(key)
existingBinds[normalizedKey] = &hyprlandOverrideBind{
Key: key,
Action: action,
Description: description,
Flags: flags,
Options: options,
}
return h.writeOverrideBinds(existingBinds)
}
func (h *HyprlandProvider) RemoveBind(key string) error {
existingBinds, err := h.loadOverrideBinds()
if err != nil {
return nil
}
normalizedKey := strings.ToLower(key)
delete(existingBinds, normalizedKey)
return h.writeOverrideBinds(existingBinds)
}
type hyprlandOverrideBind struct {
Key string
Action string
Description string
Flags string // Bind flags: l=locked, r=release, e=repeat, n=non-consuming, m=mouse, t=transparent, i=ignore-mods, s=separate, d=description, o=long-press
Options map[string]any
}
func (h *HyprlandProvider) loadOverrideBinds() (map[string]*hyprlandOverrideBind, error) {
overridePath := h.GetOverridePath()
binds := make(map[string]*hyprlandOverrideBind)
data, err := os.ReadFile(overridePath)
if os.IsNotExist(err) {
return binds, nil
}
if err != nil {
return nil, err
}
lines := strings.Split(string(data), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if !strings.HasPrefix(line, "bind") {
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) < 2 {
continue
}
// Extract flags from bind type
bindType := strings.TrimSpace(parts[0])
flags := extractBindFlags(bindType)
hasDescFlag := strings.Contains(flags, "d")
content := strings.TrimSpace(parts[1])
commentParts := strings.SplitN(content, "#", 2)
bindContent := strings.TrimSpace(commentParts[0])
var comment string
if len(commentParts) > 1 {
comment = strings.TrimSpace(commentParts[1])
}
// For bindd, format is: mods, key, description, dispatcher, params
var minFields, descIndex, dispatcherIndex int
if hasDescFlag {
minFields = 4
descIndex = 2
dispatcherIndex = 3
} else {
minFields = 3
dispatcherIndex = 2
}
fields := strings.SplitN(bindContent, ",", minFields+2)
if len(fields) < minFields {
continue
}
mods := strings.TrimSpace(fields[0])
keyName := strings.TrimSpace(fields[1])
var dispatcher, params string
if hasDescFlag {
if comment == "" {
comment = strings.TrimSpace(fields[descIndex])
}
dispatcher = strings.TrimSpace(fields[dispatcherIndex])
if len(fields) > dispatcherIndex+1 {
paramParts := fields[dispatcherIndex+1:]
params = strings.TrimSpace(strings.Join(paramParts, ","))
}
} else {
dispatcher = strings.TrimSpace(fields[dispatcherIndex])
if len(fields) > dispatcherIndex+1 {
paramParts := fields[dispatcherIndex+1:]
params = strings.TrimSpace(strings.Join(paramParts, ","))
}
}
keyStr := h.buildKeyString(mods, keyName)
normalizedKey := strings.ToLower(keyStr)
action := dispatcher
if params != "" {
action = dispatcher + " " + params
}
binds[normalizedKey] = &hyprlandOverrideBind{
Key: keyStr,
Action: action,
Description: comment,
Flags: flags,
}
}
return binds, nil
}
func (h *HyprlandProvider) buildKeyString(mods, key string) string {
if mods == "" {
return key
}
modList := strings.FieldsFunc(mods, func(r rune) bool {
return r == '+' || r == ' '
})
parts := append(modList, key)
return strings.Join(parts, "+")
}
func (h *HyprlandProvider) getBindSortPriority(action string) int {
switch {
case strings.HasPrefix(action, "exec") && strings.Contains(action, "dms"):
return 0
case strings.Contains(action, "workspace"):
return 1
case strings.Contains(action, "window") || strings.Contains(action, "focus") ||
strings.Contains(action, "move") || strings.Contains(action, "swap") ||
strings.Contains(action, "resize"):
return 2
case strings.Contains(action, "monitor"):
return 3
case strings.HasPrefix(action, "exec"):
return 4
case action == "exit" || strings.Contains(action, "dpms"):
return 5
default:
return 6
}
}
func (h *HyprlandProvider) writeOverrideBinds(binds map[string]*hyprlandOverrideBind) error {
overridePath := h.GetOverridePath()
content := h.generateBindsContent(binds)
return os.WriteFile(overridePath, []byte(content), 0644)
}
func (h *HyprlandProvider) generateBindsContent(binds map[string]*hyprlandOverrideBind) string {
if len(binds) == 0 {
return ""
}
bindList := make([]*hyprlandOverrideBind, 0, len(binds))
for _, bind := range binds {
bindList = append(bindList, bind)
}
sort.Slice(bindList, func(i, j int) bool {
pi, pj := h.getBindSortPriority(bindList[i].Action), h.getBindSortPriority(bindList[j].Action)
if pi != pj {
return pi < pj
}
return bindList[i].Key < bindList[j].Key
})
var sb strings.Builder
for _, bind := range bindList {
h.writeBindLine(&sb, bind)
}
return sb.String()
}
func (h *HyprlandProvider) writeBindLine(sb *strings.Builder, bind *hyprlandOverrideBind) {
mods, key := h.parseKeyString(bind.Key)
dispatcher, params := h.parseAction(bind.Action)
// Write bind type with flags (e.g., "bind", "binde", "bindel")
sb.WriteString("bind")
if bind.Flags != "" {
sb.WriteString(bind.Flags)
}
sb.WriteString(" = ")
sb.WriteString(mods)
sb.WriteString(", ")
sb.WriteString(key)
sb.WriteString(", ")
// For bindd (description flag), include description before dispatcher
if strings.Contains(bind.Flags, "d") && bind.Description != "" {
sb.WriteString(bind.Description)
sb.WriteString(", ")
}
sb.WriteString(dispatcher)
if params != "" {
sb.WriteString(", ")
sb.WriteString(params)
}
// Only add comment if not using bindd (which has inline description)
if bind.Description != "" && !strings.Contains(bind.Flags, "d") {
sb.WriteString(" # ")
sb.WriteString(bind.Description)
}
sb.WriteString("\n")
}
func (h *HyprlandProvider) parseKeyString(keyStr string) (mods, key string) {
parts := strings.Split(keyStr, "+")
switch len(parts) {
case 0:
return "", keyStr
case 1:
return "", parts[0]
default:
return strings.Join(parts[:len(parts)-1], " "), parts[len(parts)-1]
}
}
func (h *HyprlandProvider) parseAction(action string) (dispatcher, params string) {
parts := strings.SplitN(action, " ", 2)
switch len(parts) {
case 0:
return action, ""
case 1:
dispatcher = parts[0]
default:
dispatcher = parts[0]
params = parts[1]
}
// Convert internal spawn format to Hyprland's exec
if dispatcher == "spawn" {
dispatcher = "exec"
}
return dispatcher, params
}

View File

@@ -23,6 +23,8 @@ type HyprlandKeyBinding struct {
Dispatcher string `json:"dispatcher"` Dispatcher string `json:"dispatcher"`
Params string `json:"params"` Params string `json:"params"`
Comment string `json:"comment"` Comment string `json:"comment"`
Source string `json:"source"`
Flags string `json:"flags"` // Bind flags: l=locked, r=release, e=repeat, n=non-consuming, m=mouse, t=transparent, i=ignore-mods, s=separate, d=description, o=long-press
} }
type HyprlandSection struct { type HyprlandSection struct {
@@ -32,14 +34,36 @@ type HyprlandSection struct {
} }
type HyprlandParser struct { type HyprlandParser struct {
contentLines []string contentLines []string
readingLine int readingLine int
configDir string
currentSource string
dmsBindsExists bool
dmsBindsIncluded bool
includeCount int
dmsIncludePos int
bindsAfterDMS int
dmsBindKeys map[string]bool
configBindKeys map[string]bool
conflictingConfigs map[string]*HyprlandKeyBinding
bindMap map[string]*HyprlandKeyBinding
bindOrder []string
processedFiles map[string]bool
dmsProcessed bool
} }
func NewHyprlandParser() *HyprlandParser { func NewHyprlandParser(configDir string) *HyprlandParser {
return &HyprlandParser{ return &HyprlandParser{
contentLines: []string{}, contentLines: []string{},
readingLine: 0, readingLine: 0,
configDir: configDir,
dmsIncludePos: -1,
dmsBindKeys: make(map[string]bool),
configBindKeys: make(map[string]bool),
conflictingConfigs: make(map[string]*HyprlandKeyBinding),
bindMap: make(map[string]*HyprlandKeyBinding),
bindOrder: []string{},
processedFiles: make(map[string]bool),
} }
} }
@@ -195,71 +219,7 @@ func hyprlandAutogenerateComment(dispatcher, params string) string {
func (p *HyprlandParser) getKeybindAtLine(lineNumber int) *HyprlandKeyBinding { func (p *HyprlandParser) getKeybindAtLine(lineNumber int) *HyprlandKeyBinding {
line := p.contentLines[lineNumber] line := p.contentLines[lineNumber]
parts := strings.SplitN(line, "=", 2) return p.parseBindLine(line)
if len(parts) < 2 {
return nil
}
keys := parts[1]
keyParts := strings.SplitN(keys, "#", 2)
keys = keyParts[0]
var comment string
if len(keyParts) > 1 {
comment = strings.TrimSpace(keyParts[1])
}
keyFields := strings.SplitN(keys, ",", 5)
if len(keyFields) < 3 {
return nil
}
mods := strings.TrimSpace(keyFields[0])
key := strings.TrimSpace(keyFields[1])
dispatcher := strings.TrimSpace(keyFields[2])
var params string
if len(keyFields) > 3 {
paramParts := keyFields[3:]
params = strings.TrimSpace(strings.Join(paramParts, ","))
}
if comment != "" {
if strings.HasPrefix(comment, HideComment) {
return nil
}
} else {
comment = hyprlandAutogenerateComment(dispatcher, params)
}
var modList []string
if mods != "" {
modstring := mods + string(ModSeparators[0])
p := 0
for index, char := range modstring {
isModSep := false
for _, sep := range ModSeparators {
if char == sep {
isModSep = true
break
}
}
if isModSep {
if index-p > 1 {
modList = append(modList, modstring[p:index])
}
p = index + 1
}
}
}
return &HyprlandKeyBinding{
Mods: modList,
Key: key,
Dispatcher: dispatcher,
Params: params,
Comment: comment,
}
} }
func (p *HyprlandParser) getBindsRecursive(currentContent *HyprlandSection, scope int) *HyprlandSection { func (p *HyprlandParser) getBindsRecursive(currentContent *HyprlandSection, scope int) *HyprlandSection {
@@ -320,9 +280,348 @@ func (p *HyprlandParser) ParseKeys() *HyprlandSection {
} }
func ParseHyprlandKeys(path string) (*HyprlandSection, error) { func ParseHyprlandKeys(path string) (*HyprlandSection, error) {
parser := NewHyprlandParser() parser := NewHyprlandParser(path)
if err := parser.ReadContent(path); err != nil { if err := parser.ReadContent(path); err != nil {
return nil, err return nil, err
} }
return parser.ParseKeys(), nil return parser.ParseKeys(), nil
} }
type HyprlandParseResult struct {
Section *HyprlandSection
DMSBindsIncluded bool
DMSStatus *HyprlandDMSStatus
ConflictingConfigs map[string]*HyprlandKeyBinding
}
type HyprlandDMSStatus struct {
Exists bool
Included bool
IncludePosition int
TotalIncludes int
BindsAfterDMS int
Effective bool
OverriddenBy int
StatusMessage string
}
func (p *HyprlandParser) buildDMSStatus() *HyprlandDMSStatus {
status := &HyprlandDMSStatus{
Exists: p.dmsBindsExists,
Included: p.dmsBindsIncluded,
IncludePosition: p.dmsIncludePos,
TotalIncludes: p.includeCount,
BindsAfterDMS: p.bindsAfterDMS,
}
switch {
case !p.dmsBindsExists:
status.Effective = false
status.StatusMessage = "dms/binds.conf does not exist"
case !p.dmsBindsIncluded:
status.Effective = false
status.StatusMessage = "dms/binds.conf is not sourced in config"
case p.bindsAfterDMS > 0:
status.Effective = true
status.OverriddenBy = p.bindsAfterDMS
status.StatusMessage = "Some DMS binds may be overridden by config binds"
default:
status.Effective = true
status.StatusMessage = "DMS binds are active"
}
return status
}
func (p *HyprlandParser) formatBindKey(kb *HyprlandKeyBinding) string {
parts := make([]string, 0, len(kb.Mods)+1)
parts = append(parts, kb.Mods...)
parts = append(parts, kb.Key)
return strings.Join(parts, "+")
}
func (p *HyprlandParser) normalizeKey(key string) string {
return strings.ToLower(key)
}
func (p *HyprlandParser) addBind(kb *HyprlandKeyBinding) bool {
key := p.formatBindKey(kb)
normalizedKey := p.normalizeKey(key)
isDMSBind := strings.Contains(kb.Source, "dms/binds.conf")
if isDMSBind {
p.dmsBindKeys[normalizedKey] = true
} else if p.dmsBindKeys[normalizedKey] {
p.bindsAfterDMS++
p.conflictingConfigs[normalizedKey] = kb
p.configBindKeys[normalizedKey] = true
return false
} else {
p.configBindKeys[normalizedKey] = true
}
if _, exists := p.bindMap[normalizedKey]; !exists {
p.bindOrder = append(p.bindOrder, key)
}
p.bindMap[normalizedKey] = kb
return true
}
func (p *HyprlandParser) ParseWithDMS() (*HyprlandSection, error) {
expandedDir, err := utils.ExpandPath(p.configDir)
if err != nil {
return nil, err
}
dmsBindsPath := filepath.Join(expandedDir, "dms", "binds.conf")
if _, err := os.Stat(dmsBindsPath); err == nil {
p.dmsBindsExists = true
}
mainConfig := filepath.Join(expandedDir, "hyprland.conf")
section, err := p.parseFileWithSource(mainConfig, "")
if err != nil {
return nil, err
}
if p.dmsBindsExists && !p.dmsProcessed {
p.parseDMSBindsDirectly(dmsBindsPath, section)
}
return section, nil
}
func (p *HyprlandParser) parseFileWithSource(filePath, sectionName string) (*HyprlandSection, error) {
absPath, err := filepath.Abs(filePath)
if err != nil {
return nil, err
}
if p.processedFiles[absPath] {
return &HyprlandSection{Name: sectionName}, nil
}
p.processedFiles[absPath] = true
data, err := os.ReadFile(absPath)
if err != nil {
return nil, err
}
prevSource := p.currentSource
p.currentSource = absPath
section := &HyprlandSection{Name: sectionName}
lines := strings.Split(string(data), "\n")
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "source") {
p.handleSource(trimmed, section, filepath.Dir(absPath))
continue
}
if !strings.HasPrefix(trimmed, "bind") {
continue
}
kb := p.parseBindLine(line)
if kb == nil {
continue
}
kb.Source = p.currentSource
if p.addBind(kb) {
section.Keybinds = append(section.Keybinds, *kb)
}
}
p.currentSource = prevSource
return section, nil
}
func (p *HyprlandParser) handleSource(line string, section *HyprlandSection, baseDir string) {
parts := strings.SplitN(line, "=", 2)
if len(parts) < 2 {
return
}
sourcePath := strings.TrimSpace(parts[1])
isDMSSource := sourcePath == "dms/binds.conf" || strings.HasSuffix(sourcePath, "/dms/binds.conf")
p.includeCount++
if isDMSSource {
p.dmsBindsIncluded = true
p.dmsIncludePos = p.includeCount
p.dmsProcessed = true
}
fullPath := sourcePath
if !filepath.IsAbs(sourcePath) {
fullPath = filepath.Join(baseDir, sourcePath)
}
expanded, err := utils.ExpandPath(fullPath)
if err != nil {
return
}
includedSection, err := p.parseFileWithSource(expanded, "")
if err != nil {
return
}
section.Children = append(section.Children, *includedSection)
}
func (p *HyprlandParser) parseDMSBindsDirectly(dmsBindsPath string, section *HyprlandSection) {
data, err := os.ReadFile(dmsBindsPath)
if err != nil {
return
}
prevSource := p.currentSource
p.currentSource = dmsBindsPath
lines := strings.Split(string(data), "\n")
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if !strings.HasPrefix(trimmed, "bind") {
continue
}
kb := p.parseBindLine(line)
if kb == nil {
continue
}
kb.Source = dmsBindsPath
if p.addBind(kb) {
section.Keybinds = append(section.Keybinds, *kb)
}
}
p.currentSource = prevSource
p.dmsProcessed = true
}
func (p *HyprlandParser) parseBindLine(line string) *HyprlandKeyBinding {
parts := strings.SplitN(line, "=", 2)
if len(parts) < 2 {
return nil
}
// Extract bind type and flags from the left side of "="
bindType := strings.TrimSpace(parts[0])
flags := extractBindFlags(bindType)
hasDescFlag := strings.Contains(flags, "d")
keys := parts[1]
keyParts := strings.SplitN(keys, "#", 2)
keys = keyParts[0]
var comment string
if len(keyParts) > 1 {
comment = strings.TrimSpace(keyParts[1])
}
// For bindd, the format is: bindd = MODS, key, description, dispatcher, params
// For regular binds: bind = MODS, key, dispatcher, params
var minFields, descIndex, dispatcherIndex int
if hasDescFlag {
minFields = 4 // mods, key, description, dispatcher
descIndex = 2
dispatcherIndex = 3
} else {
minFields = 3 // mods, key, dispatcher
dispatcherIndex = 2
}
keyFields := strings.SplitN(keys, ",", minFields+2) // Allow for params
if len(keyFields) < minFields {
return nil
}
mods := strings.TrimSpace(keyFields[0])
key := strings.TrimSpace(keyFields[1])
var dispatcher, params string
if hasDescFlag {
// bindd format: description is in the bind itself
if comment == "" {
comment = strings.TrimSpace(keyFields[descIndex])
}
dispatcher = strings.TrimSpace(keyFields[dispatcherIndex])
if len(keyFields) > dispatcherIndex+1 {
paramParts := keyFields[dispatcherIndex+1:]
params = strings.TrimSpace(strings.Join(paramParts, ","))
}
} else {
dispatcher = strings.TrimSpace(keyFields[dispatcherIndex])
if len(keyFields) > dispatcherIndex+1 {
paramParts := keyFields[dispatcherIndex+1:]
params = strings.TrimSpace(strings.Join(paramParts, ","))
}
}
if comment != "" && strings.HasPrefix(comment, HideComment) {
return nil
}
if comment == "" {
comment = hyprlandAutogenerateComment(dispatcher, params)
}
var modList []string
if mods != "" {
modstring := mods + string(ModSeparators[0])
idx := 0
for index, char := range modstring {
isModSep := false
for _, sep := range ModSeparators {
if char == sep {
isModSep = true
break
}
}
if isModSep {
if index-idx > 1 {
modList = append(modList, modstring[idx:index])
}
idx = index + 1
}
}
}
return &HyprlandKeyBinding{
Mods: modList,
Key: key,
Dispatcher: dispatcher,
Params: params,
Comment: comment,
Flags: flags,
}
}
// extractBindFlags extracts the flags from a bind type string
// e.g., "binde" -> "e", "bindel" -> "el", "bindd" -> "d"
func extractBindFlags(bindType string) string {
bindType = strings.TrimSpace(bindType)
if !strings.HasPrefix(bindType, "bind") {
return ""
}
return bindType[4:] // Everything after "bind"
}
func ParseHyprlandKeysWithDMS(path string) (*HyprlandParseResult, error) {
parser := NewHyprlandParser(path)
section, err := parser.ParseWithDMS()
if err != nil {
return nil, err
}
return &HyprlandParseResult{
Section: section,
DMSBindsIncluded: parser.dmsBindsIncluded,
DMSStatus: parser.buildDMSStatus(),
ConflictingConfigs: parser.conflictingConfigs,
}, nil
}

View File

@@ -130,7 +130,7 @@ func TestHyprlandGetKeybindAtLine(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
parser := NewHyprlandParser() parser := NewHyprlandParser("")
parser.contentLines = []string{tt.line} parser.contentLines = []string{tt.line}
result := parser.getKeybindAtLine(0) result := parser.getKeybindAtLine(0)
@@ -285,7 +285,7 @@ func TestHyprlandReadContentMultipleFiles(t *testing.T) {
t.Fatalf("Failed to write file2: %v", err) t.Fatalf("Failed to write file2: %v", err)
} }
parser := NewHyprlandParser() parser := NewHyprlandParser("")
if err := parser.ReadContent(tmpDir); err != nil { if err := parser.ReadContent(tmpDir); err != nil {
t.Fatalf("ReadContent failed: %v", err) t.Fatalf("ReadContent failed: %v", err)
} }
@@ -343,7 +343,7 @@ func TestHyprlandReadContentWithTildeExpansion(t *testing.T) {
t.Skip("Cannot create relative path") t.Skip("Cannot create relative path")
} }
parser := NewHyprlandParser() parser := NewHyprlandParser("")
tildePathMatch := "~/" + relPath tildePathMatch := "~/" + relPath
err = parser.ReadContent(tildePathMatch) err = parser.ReadContent(tildePathMatch)
@@ -353,7 +353,7 @@ func TestHyprlandReadContentWithTildeExpansion(t *testing.T) {
} }
func TestHyprlandKeybindWithParamsContainingCommas(t *testing.T) { func TestHyprlandKeybindWithParamsContainingCommas(t *testing.T) {
parser := NewHyprlandParser() parser := NewHyprlandParser("")
parser.contentLines = []string{"bind = SUPER, R, exec, notify-send 'Title' 'Message, with comma'"} parser.contentLines = []string{"bind = SUPER, R, exec, notify-send 'Title' 'Message, with comma'"}
result := parser.getKeybindAtLine(0) result := parser.getKeybindAtLine(0)
@@ -394,3 +394,126 @@ bind = SUPER, T, exec, kitty
t.Errorf("Expected 2 keybinds (comments ignored), got %d", len(section.Keybinds)) t.Errorf("Expected 2 keybinds (comments ignored), got %d", len(section.Keybinds))
} }
} }
func TestExtractBindFlags(t *testing.T) {
tests := []struct {
bindType string
expected string
}{
{"bind", ""},
{"binde", "e"},
{"bindl", "l"},
{"bindr", "r"},
{"bindd", "d"},
{"bindo", "o"},
{"bindel", "el"},
{"bindler", "ler"},
{"bindem", "em"},
{" bind ", ""},
{" binde ", "e"},
{"notbind", ""},
{"", ""},
}
for _, tt := range tests {
t.Run(tt.bindType, func(t *testing.T) {
result := extractBindFlags(tt.bindType)
if result != tt.expected {
t.Errorf("extractBindFlags(%q) = %q, want %q", tt.bindType, result, tt.expected)
}
})
}
}
func TestHyprlandBindFlags(t *testing.T) {
tests := []struct {
name string
line string
expectedFlags string
expectedKey string
expectedDisp string
expectedDesc string
}{
{
name: "regular bind",
line: "bind = SUPER, Q, killactive",
expectedFlags: "",
expectedKey: "Q",
expectedDisp: "killactive",
expectedDesc: "Close window",
},
{
name: "binde (repeat on hold)",
line: "binde = , XF86AudioRaiseVolume, exec, wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%+",
expectedFlags: "e",
expectedKey: "XF86AudioRaiseVolume",
expectedDisp: "exec",
expectedDesc: "wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%+",
},
{
name: "bindl (locked/inhibitor bypass)",
line: "bindl = , XF86AudioLowerVolume, exec, wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-",
expectedFlags: "l",
expectedKey: "XF86AudioLowerVolume",
expectedDisp: "exec",
expectedDesc: "wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-",
},
{
name: "bindr (release trigger)",
line: "bindr = SUPER, SUPER_L, exec, pkill wofi || wofi",
expectedFlags: "r",
expectedKey: "SUPER_L",
expectedDisp: "exec",
expectedDesc: "pkill wofi || wofi",
},
{
name: "bindd (description)",
line: "bindd = SUPER, Q, Open my favourite terminal, exec, kitty",
expectedFlags: "d",
expectedKey: "Q",
expectedDisp: "exec",
expectedDesc: "Open my favourite terminal",
},
{
name: "bindo (long press)",
line: "bindo = SUPER, XF86AudioNext, exec, playerctl next",
expectedFlags: "o",
expectedKey: "XF86AudioNext",
expectedDisp: "exec",
expectedDesc: "playerctl next",
},
{
name: "bindel (combined flags)",
line: "bindel = , XF86AudioRaiseVolume, exec, wpctl set-volume -l 1.5 @DEFAULT_AUDIO_SINK@ 5%+",
expectedFlags: "el",
expectedKey: "XF86AudioRaiseVolume",
expectedDisp: "exec",
expectedDesc: "wpctl set-volume -l 1.5 @DEFAULT_AUDIO_SINK@ 5%+",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parser := NewHyprlandParser("")
parser.contentLines = []string{tt.line}
result := parser.getKeybindAtLine(0)
if result == nil {
t.Fatal("Expected keybind, got nil")
}
if result.Flags != tt.expectedFlags {
t.Errorf("Flags = %q, want %q", result.Flags, tt.expectedFlags)
}
if result.Key != tt.expectedKey {
t.Errorf("Key = %q, want %q", result.Key, tt.expectedKey)
}
if result.Dispatcher != tt.expectedDisp {
t.Errorf("Dispatcher = %q, want %q", result.Dispatcher, tt.expectedDisp)
}
if result.Comment != tt.expectedDesc {
t.Errorf("Comment = %q, want %q", result.Comment, tt.expectedDesc)
}
})
}
}

View File

@@ -7,35 +7,30 @@ import (
) )
func TestNewHyprlandProvider(t *testing.T) { func TestNewHyprlandProvider(t *testing.T) {
tests := []struct { t.Run("custom path", func(t *testing.T) {
name string p := NewHyprlandProvider("/custom/path")
configPath string if p == nil {
wantPath string t.Fatal("NewHyprlandProvider returned nil")
}{ }
{ if p.configPath != "/custom/path" {
name: "custom path", t.Errorf("configPath = %q, want %q", p.configPath, "/custom/path")
configPath: "/custom/path", }
wantPath: "/custom/path", })
},
{
name: "empty path defaults",
configPath: "",
wantPath: "$HOME/.config/hypr",
},
}
for _, tt := range tests { t.Run("empty path defaults", func(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { p := NewHyprlandProvider("")
p := NewHyprlandProvider(tt.configPath) if p == nil {
if p == nil { t.Fatal("NewHyprlandProvider returned nil")
t.Fatal("NewHyprlandProvider returned nil") }
} configDir, err := os.UserConfigDir()
if err != nil {
if p.configPath != tt.wantPath { t.Fatalf("UserConfigDir failed: %v", err)
t.Errorf("configPath = %q, want %q", p.configPath, tt.wantPath) }
} expected := filepath.Join(configDir, "hypr")
}) if p.configPath != expected {
} t.Errorf("configPath = %q, want %q", p.configPath, expected)
}
})
} }
func TestHyprlandProviderName(t *testing.T) { func TestHyprlandProviderName(t *testing.T) {
@@ -109,7 +104,7 @@ func TestHyprlandProviderGetCheatSheetError(t *testing.T) {
func TestFormatKey(t *testing.T) { func TestFormatKey(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "test.conf") configFile := filepath.Join(tmpDir, "hyprland.conf")
tests := []struct { tests := []struct {
name string name string
@@ -163,7 +158,7 @@ func TestFormatKey(t *testing.T) {
func TestDescriptionFallback(t *testing.T) { func TestDescriptionFallback(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "test.conf") configFile := filepath.Join(tmpDir, "hyprland.conf")
tests := []struct { tests := []struct {
name string name string

View File

@@ -12,7 +12,7 @@ func TestNewJSONFileProvider(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "test.json") 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) 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) t.Fatalf("Failed to write test file: %v", err)
} }
@@ -135,7 +135,7 @@ func TestJSONFileProviderGetCheatSheetNoProvider(t *testing.T) {
"binds": {} "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) 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) t.Fatalf("Failed to write test file: %v", err)
} }
@@ -216,7 +216,7 @@ func TestJSONFileProviderInvalidJSON(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "invalid.json") 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) t.Fatalf("Failed to write test file: %v", err)
} }

View File

@@ -2,46 +2,94 @@ package providers
import ( import (
"fmt" "fmt"
"os"
"path/filepath"
"sort"
"strings" "strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds" "github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
) )
type MangoWCProvider struct { type MangoWCProvider struct {
configPath string configPath string
dmsBindsIncluded bool
parsed bool
} }
func NewMangoWCProvider(configPath string) *MangoWCProvider { func NewMangoWCProvider(configPath string) *MangoWCProvider {
if configPath == "" { if configPath == "" {
configPath = "$HOME/.config/mango" configPath = defaultMangoWCConfigDir()
} }
return &MangoWCProvider{ return &MangoWCProvider{
configPath: configPath, configPath: configPath,
} }
} }
func defaultMangoWCConfigDir() string {
configDir, err := os.UserConfigDir()
if err != nil {
return ""
}
return filepath.Join(configDir, "mango")
}
func (m *MangoWCProvider) Name() string { func (m *MangoWCProvider) Name() string {
return "mangowc" return "mangowc"
} }
func (m *MangoWCProvider) GetCheatSheet() (*keybinds.CheatSheet, error) { func (m *MangoWCProvider) GetCheatSheet() (*keybinds.CheatSheet, error) {
keybinds_list, err := ParseMangoWCKeys(m.configPath) result, err := ParseMangoWCKeysWithDMS(m.configPath)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse mangowc config: %w", err) return nil, fmt.Errorf("failed to parse mangowc config: %w", err)
} }
m.dmsBindsIncluded = result.DMSBindsIncluded
m.parsed = true
categorizedBinds := make(map[string][]keybinds.Keybind) categorizedBinds := make(map[string][]keybinds.Keybind)
for _, kb := range keybinds_list { for _, kb := range result.Keybinds {
category := m.categorizeByCommand(kb.Command) category := m.categorizeByCommand(kb.Command)
bind := m.convertKeybind(&kb) bind := m.convertKeybind(&kb, result.ConflictingConfigs)
categorizedBinds[category] = append(categorizedBinds[category], bind) categorizedBinds[category] = append(categorizedBinds[category], bind)
} }
return &keybinds.CheatSheet{ sheet := &keybinds.CheatSheet{
Title: "MangoWC Keybinds", Title: "MangoWC Keybinds",
Provider: m.Name(), Provider: m.Name(),
Binds: categorizedBinds, Binds: categorizedBinds,
}, nil DMSBindsIncluded: result.DMSBindsIncluded,
}
if result.DMSStatus != nil {
sheet.DMSStatus = &keybinds.DMSBindsStatus{
Exists: result.DMSStatus.Exists,
Included: result.DMSStatus.Included,
IncludePosition: result.DMSStatus.IncludePosition,
TotalIncludes: result.DMSStatus.TotalIncludes,
BindsAfterDMS: result.DMSStatus.BindsAfterDMS,
Effective: result.DMSStatus.Effective,
OverriddenBy: result.DMSStatus.OverriddenBy,
StatusMessage: result.DMSStatus.StatusMessage,
}
}
return sheet, nil
}
func (m *MangoWCProvider) HasDMSBindsIncluded() bool {
if m.parsed {
return m.dmsBindsIncluded
}
result, err := ParseMangoWCKeysWithDMS(m.configPath)
if err != nil {
return false
}
m.dmsBindsIncluded = result.DMSBindsIncluded
m.parsed = true
return m.dmsBindsIncluded
} }
func (m *MangoWCProvider) categorizeByCommand(command string) string { func (m *MangoWCProvider) categorizeByCommand(command string) string {
@@ -82,8 +130,8 @@ func (m *MangoWCProvider) categorizeByCommand(command string) string {
} }
} }
func (m *MangoWCProvider) convertKeybind(kb *MangoWCKeyBinding) keybinds.Keybind { func (m *MangoWCProvider) convertKeybind(kb *MangoWCKeyBinding, conflicts map[string]*MangoWCKeyBinding) keybinds.Keybind {
key := m.formatKey(kb) keyStr := m.formatKey(kb)
rawAction := m.formatRawAction(kb.Command, kb.Params) rawAction := m.formatRawAction(kb.Command, kb.Params)
desc := kb.Comment desc := kb.Comment
@@ -91,11 +139,31 @@ func (m *MangoWCProvider) convertKeybind(kb *MangoWCKeyBinding) keybinds.Keybind
desc = rawAction desc = rawAction
} }
return keybinds.Keybind{ source := "config"
Key: key, if strings.Contains(kb.Source, "dms/binds.conf") || strings.Contains(kb.Source, "dms"+string(filepath.Separator)+"binds.conf") {
source = "dms"
}
bind := keybinds.Keybind{
Key: keyStr,
Description: desc, Description: desc,
Action: rawAction, Action: rawAction,
Source: source,
} }
if source == "dms" && conflicts != nil {
normalizedKey := strings.ToLower(keyStr)
if conflictKb, ok := conflicts[normalizedKey]; ok {
bind.Conflict = &keybinds.Keybind{
Key: keyStr,
Description: conflictKb.Comment,
Action: m.formatRawAction(conflictKb.Command, conflictKb.Params),
Source: "config",
}
}
}
return bind
} }
func (m *MangoWCProvider) formatRawAction(command, params string) string { func (m *MangoWCProvider) formatRawAction(command, params string) string {
@@ -111,3 +179,264 @@ func (m *MangoWCProvider) formatKey(kb *MangoWCKeyBinding) string {
parts = append(parts, kb.Key) parts = append(parts, kb.Key)
return strings.Join(parts, "+") return strings.Join(parts, "+")
} }
func (m *MangoWCProvider) GetOverridePath() string {
expanded, err := utils.ExpandPath(m.configPath)
if err != nil {
return filepath.Join(m.configPath, "dms", "binds.conf")
}
return filepath.Join(expanded, "dms", "binds.conf")
}
func (m *MangoWCProvider) validateAction(action string) error {
action = strings.TrimSpace(action)
switch {
case action == "":
return fmt.Errorf("action cannot be empty")
case action == "spawn" || action == "spawn ":
return fmt.Errorf("spawn command requires arguments")
case action == "spawn_shell" || action == "spawn_shell ":
return fmt.Errorf("spawn_shell command requires arguments")
case strings.HasPrefix(action, "spawn "):
rest := strings.TrimSpace(strings.TrimPrefix(action, "spawn "))
if rest == "" {
return fmt.Errorf("spawn command requires arguments")
}
case strings.HasPrefix(action, "spawn_shell "):
rest := strings.TrimSpace(strings.TrimPrefix(action, "spawn_shell "))
if rest == "" {
return fmt.Errorf("spawn_shell command requires arguments")
}
}
return nil
}
func (m *MangoWCProvider) SetBind(key, action, description string, options map[string]any) error {
if err := m.validateAction(action); err != nil {
return err
}
overridePath := m.GetOverridePath()
if err := os.MkdirAll(filepath.Dir(overridePath), 0755); err != nil {
return fmt.Errorf("failed to create dms directory: %w", err)
}
existingBinds, err := m.loadOverrideBinds()
if err != nil {
existingBinds = make(map[string]*mangowcOverrideBind)
}
normalizedKey := strings.ToLower(key)
existingBinds[normalizedKey] = &mangowcOverrideBind{
Key: key,
Action: action,
Description: description,
Options: options,
}
return m.writeOverrideBinds(existingBinds)
}
func (m *MangoWCProvider) RemoveBind(key string) error {
existingBinds, err := m.loadOverrideBinds()
if err != nil {
return nil
}
normalizedKey := strings.ToLower(key)
delete(existingBinds, normalizedKey)
return m.writeOverrideBinds(existingBinds)
}
type mangowcOverrideBind struct {
Key string
Action string
Description string
Options map[string]any
}
func (m *MangoWCProvider) loadOverrideBinds() (map[string]*mangowcOverrideBind, error) {
overridePath := m.GetOverridePath()
binds := make(map[string]*mangowcOverrideBind)
data, err := os.ReadFile(overridePath)
if os.IsNotExist(err) {
return binds, nil
}
if err != nil {
return nil, err
}
lines := strings.Split(string(data), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if !strings.HasPrefix(line, "bind") {
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) < 2 {
continue
}
content := strings.TrimSpace(parts[1])
commentParts := strings.SplitN(content, "#", 2)
bindContent := strings.TrimSpace(commentParts[0])
var comment string
if len(commentParts) > 1 {
comment = strings.TrimSpace(commentParts[1])
}
fields := strings.SplitN(bindContent, ",", 4)
if len(fields) < 3 {
continue
}
mods := strings.TrimSpace(fields[0])
keyName := strings.TrimSpace(fields[1])
command := strings.TrimSpace(fields[2])
var params string
if len(fields) > 3 {
params = strings.TrimSpace(fields[3])
}
keyStr := m.buildKeyString(mods, keyName)
normalizedKey := strings.ToLower(keyStr)
action := command
if params != "" {
action = command + " " + params
}
binds[normalizedKey] = &mangowcOverrideBind{
Key: keyStr,
Action: action,
Description: comment,
}
}
return binds, nil
}
func (m *MangoWCProvider) buildKeyString(mods, key string) string {
if mods == "" || strings.EqualFold(mods, "none") {
return key
}
modList := strings.FieldsFunc(mods, func(r rune) bool {
return r == '+' || r == ' '
})
parts := append(modList, key)
return strings.Join(parts, "+")
}
func (m *MangoWCProvider) getBindSortPriority(action string) int {
switch {
case strings.HasPrefix(action, "spawn") && strings.Contains(action, "dms"):
return 0
case strings.Contains(action, "view") || strings.Contains(action, "tag"):
return 1
case strings.Contains(action, "focus") || strings.Contains(action, "exchange") ||
strings.Contains(action, "resize") || strings.Contains(action, "move"):
return 2
case strings.Contains(action, "mon"):
return 3
case strings.HasPrefix(action, "spawn"):
return 4
case action == "quit" || action == "reload_config":
return 5
default:
return 6
}
}
func (m *MangoWCProvider) writeOverrideBinds(binds map[string]*mangowcOverrideBind) error {
overridePath := m.GetOverridePath()
content := m.generateBindsContent(binds)
return os.WriteFile(overridePath, []byte(content), 0644)
}
func (m *MangoWCProvider) generateBindsContent(binds map[string]*mangowcOverrideBind) string {
if len(binds) == 0 {
return ""
}
bindList := make([]*mangowcOverrideBind, 0, len(binds))
for _, bind := range binds {
bindList = append(bindList, bind)
}
sort.Slice(bindList, func(i, j int) bool {
pi, pj := m.getBindSortPriority(bindList[i].Action), m.getBindSortPriority(bindList[j].Action)
if pi != pj {
return pi < pj
}
return bindList[i].Key < bindList[j].Key
})
var sb strings.Builder
for _, bind := range bindList {
m.writeBindLine(&sb, bind)
}
return sb.String()
}
func (m *MangoWCProvider) writeBindLine(sb *strings.Builder, bind *mangowcOverrideBind) {
mods, key := m.parseKeyString(bind.Key)
command, params := m.parseAction(bind.Action)
sb.WriteString("bind=")
if mods == "" {
sb.WriteString("none")
} else {
sb.WriteString(mods)
}
sb.WriteString(",")
sb.WriteString(key)
sb.WriteString(",")
sb.WriteString(command)
if params != "" {
sb.WriteString(",")
sb.WriteString(params)
}
if bind.Description != "" {
sb.WriteString(" # ")
sb.WriteString(bind.Description)
}
sb.WriteString("\n")
}
func (m *MangoWCProvider) parseKeyString(keyStr string) (mods, key string) {
parts := strings.Split(keyStr, "+")
switch len(parts) {
case 0:
return "", keyStr
case 1:
return "", parts[0]
default:
return strings.Join(parts[:len(parts)-1], "+"), parts[len(parts)-1]
}
}
func (m *MangoWCProvider) parseAction(action string) (command, params string) {
parts := strings.SplitN(action, " ", 2)
switch len(parts) {
case 0:
return action, ""
case 1:
return parts[0], ""
default:
return parts[0], parts[1]
}
}

View File

@@ -21,17 +21,40 @@ type MangoWCKeyBinding struct {
Command string `json:"command"` Command string `json:"command"`
Params string `json:"params"` Params string `json:"params"`
Comment string `json:"comment"` Comment string `json:"comment"`
Source string `json:"source"`
} }
type MangoWCParser struct { type MangoWCParser struct {
contentLines []string contentLines []string
readingLine int readingLine int
configDir string
currentSource string
dmsBindsExists bool
dmsBindsIncluded bool
includeCount int
dmsIncludePos int
bindsAfterDMS int
dmsBindKeys map[string]bool
configBindKeys map[string]bool
conflictingConfigs map[string]*MangoWCKeyBinding
bindMap map[string]*MangoWCKeyBinding
bindOrder []string
processedFiles map[string]bool
dmsProcessed bool
} }
func NewMangoWCParser() *MangoWCParser { func NewMangoWCParser(configDir string) *MangoWCParser {
return &MangoWCParser{ return &MangoWCParser{
contentLines: []string{}, contentLines: []string{},
readingLine: 0, readingLine: 0,
configDir: configDir,
dmsIncludePos: -1,
dmsBindKeys: make(map[string]bool),
configBindKeys: make(map[string]bool),
conflictingConfigs: make(map[string]*MangoWCKeyBinding),
bindMap: make(map[string]*MangoWCKeyBinding),
bindOrder: []string{},
processedFiles: make(map[string]bool),
} }
} }
@@ -294,9 +317,320 @@ func (p *MangoWCParser) ParseKeys() []MangoWCKeyBinding {
} }
func ParseMangoWCKeys(path string) ([]MangoWCKeyBinding, error) { func ParseMangoWCKeys(path string) ([]MangoWCKeyBinding, error) {
parser := NewMangoWCParser() parser := NewMangoWCParser(path)
if err := parser.ReadContent(path); err != nil { if err := parser.ReadContent(path); err != nil {
return nil, err return nil, err
} }
return parser.ParseKeys(), nil return parser.ParseKeys(), nil
} }
type MangoWCParseResult struct {
Keybinds []MangoWCKeyBinding
DMSBindsIncluded bool
DMSStatus *MangoWCDMSStatus
ConflictingConfigs map[string]*MangoWCKeyBinding
}
type MangoWCDMSStatus struct {
Exists bool
Included bool
IncludePosition int
TotalIncludes int
BindsAfterDMS int
Effective bool
OverriddenBy int
StatusMessage string
}
func (p *MangoWCParser) buildDMSStatus() *MangoWCDMSStatus {
status := &MangoWCDMSStatus{
Exists: p.dmsBindsExists,
Included: p.dmsBindsIncluded,
IncludePosition: p.dmsIncludePos,
TotalIncludes: p.includeCount,
BindsAfterDMS: p.bindsAfterDMS,
}
switch {
case !p.dmsBindsExists:
status.Effective = false
status.StatusMessage = "dms/binds.conf does not exist"
case !p.dmsBindsIncluded:
status.Effective = false
status.StatusMessage = "dms/binds.conf is not sourced in config"
case p.bindsAfterDMS > 0:
status.Effective = true
status.OverriddenBy = p.bindsAfterDMS
status.StatusMessage = "Some DMS binds may be overridden by config binds"
default:
status.Effective = true
status.StatusMessage = "DMS binds are active"
}
return status
}
func (p *MangoWCParser) formatBindKey(kb *MangoWCKeyBinding) string {
parts := make([]string, 0, len(kb.Mods)+1)
parts = append(parts, kb.Mods...)
parts = append(parts, kb.Key)
return strings.Join(parts, "+")
}
func (p *MangoWCParser) normalizeKey(key string) string {
return strings.ToLower(key)
}
func (p *MangoWCParser) addBind(kb *MangoWCKeyBinding) {
key := p.formatBindKey(kb)
normalizedKey := p.normalizeKey(key)
isDMSBind := strings.Contains(kb.Source, "dms/binds.conf") || strings.Contains(kb.Source, "dms"+string(os.PathSeparator)+"binds.conf")
if isDMSBind {
p.dmsBindKeys[normalizedKey] = true
} else if p.dmsBindKeys[normalizedKey] {
p.bindsAfterDMS++
p.conflictingConfigs[normalizedKey] = kb
p.configBindKeys[normalizedKey] = true
return
} else {
p.configBindKeys[normalizedKey] = true
}
if _, exists := p.bindMap[normalizedKey]; !exists {
p.bindOrder = append(p.bindOrder, key)
}
p.bindMap[normalizedKey] = kb
}
func (p *MangoWCParser) ParseWithDMS() ([]MangoWCKeyBinding, error) {
expandedDir, err := utils.ExpandPath(p.configDir)
if err != nil {
return nil, err
}
dmsBindsPath := filepath.Join(expandedDir, "dms", "binds.conf")
if _, err := os.Stat(dmsBindsPath); err == nil {
p.dmsBindsExists = true
}
mainConfig := filepath.Join(expandedDir, "config.conf")
if _, err := os.Stat(mainConfig); os.IsNotExist(err) {
mainConfig = filepath.Join(expandedDir, "mango.conf")
}
_, err = p.parseFileWithSource(mainConfig)
if err != nil {
return nil, err
}
if p.dmsBindsExists && !p.dmsProcessed {
p.parseDMSBindsDirectly(dmsBindsPath)
}
var keybinds []MangoWCKeyBinding
for _, key := range p.bindOrder {
normalizedKey := p.normalizeKey(key)
if kb, exists := p.bindMap[normalizedKey]; exists {
keybinds = append(keybinds, *kb)
}
}
return keybinds, nil
}
func (p *MangoWCParser) parseFileWithSource(filePath string) ([]MangoWCKeyBinding, error) {
absPath, err := filepath.Abs(filePath)
if err != nil {
return nil, err
}
if p.processedFiles[absPath] {
return nil, nil
}
p.processedFiles[absPath] = true
data, err := os.ReadFile(absPath)
if err != nil {
return nil, err
}
prevSource := p.currentSource
p.currentSource = absPath
var keybinds []MangoWCKeyBinding
lines := strings.Split(string(data), "\n")
for lineNum, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "source") {
p.handleSource(trimmed, filepath.Dir(absPath), &keybinds)
continue
}
if !strings.HasPrefix(trimmed, "bind") {
continue
}
kb := p.getKeybindAtLineContent(line, lineNum)
if kb == nil {
continue
}
kb.Source = p.currentSource
p.addBind(kb)
keybinds = append(keybinds, *kb)
}
p.currentSource = prevSource
return keybinds, nil
}
func (p *MangoWCParser) handleSource(line, baseDir string, keybinds *[]MangoWCKeyBinding) {
parts := strings.SplitN(line, "=", 2)
if len(parts) < 2 {
return
}
sourcePath := strings.TrimSpace(parts[1])
isDMSSource := sourcePath == "dms/binds.conf" || sourcePath == "./dms/binds.conf" || strings.HasSuffix(sourcePath, "/dms/binds.conf")
p.includeCount++
if isDMSSource {
p.dmsBindsIncluded = true
p.dmsIncludePos = p.includeCount
p.dmsProcessed = true
}
fullPath := sourcePath
if !filepath.IsAbs(sourcePath) {
fullPath = filepath.Join(baseDir, sourcePath)
}
expanded, err := utils.ExpandPath(fullPath)
if err != nil {
return
}
includedBinds, err := p.parseFileWithSource(expanded)
if err != nil {
return
}
*keybinds = append(*keybinds, includedBinds...)
}
func (p *MangoWCParser) parseDMSBindsDirectly(dmsBindsPath string) []MangoWCKeyBinding {
data, err := os.ReadFile(dmsBindsPath)
if err != nil {
return nil
}
prevSource := p.currentSource
p.currentSource = dmsBindsPath
var keybinds []MangoWCKeyBinding
lines := strings.Split(string(data), "\n")
for lineNum, line := range lines {
trimmed := strings.TrimSpace(line)
if !strings.HasPrefix(trimmed, "bind") {
continue
}
kb := p.getKeybindAtLineContent(line, lineNum)
if kb == nil {
continue
}
kb.Source = dmsBindsPath
p.addBind(kb)
keybinds = append(keybinds, *kb)
}
p.currentSource = prevSource
p.dmsProcessed = true
return keybinds
}
func (p *MangoWCParser) getKeybindAtLineContent(line string, _ int) *MangoWCKeyBinding {
bindMatch := regexp.MustCompile(`^(bind[lsr]*)\s*=\s*(.+)$`)
matches := bindMatch.FindStringSubmatch(line)
if len(matches) < 3 {
return nil
}
content := matches[2]
parts := strings.SplitN(content, "#", 2)
keys := parts[0]
var comment string
if len(parts) > 1 {
comment = strings.TrimSpace(parts[1])
}
if strings.HasPrefix(comment, MangoWCHideComment) {
return nil
}
keyFields := strings.SplitN(keys, ",", 4)
if len(keyFields) < 3 {
return nil
}
mods := strings.TrimSpace(keyFields[0])
key := strings.TrimSpace(keyFields[1])
command := strings.TrimSpace(keyFields[2])
var params string
if len(keyFields) > 3 {
params = strings.TrimSpace(keyFields[3])
}
if comment == "" {
comment = mangowcAutogenerateComment(command, params)
}
var modList []string
if mods != "" && !strings.EqualFold(mods, "none") {
modstring := mods + string(MangoWCModSeparators[0])
idx := 0
for index, char := range modstring {
isModSep := false
for _, sep := range MangoWCModSeparators {
if char == sep {
isModSep = true
break
}
}
if isModSep {
if index-idx > 1 {
modList = append(modList, modstring[idx:index])
}
idx = index + 1
}
}
}
return &MangoWCKeyBinding{
Mods: modList,
Key: key,
Command: command,
Params: params,
Comment: comment,
}
}
func ParseMangoWCKeysWithDMS(path string) (*MangoWCParseResult, error) {
parser := NewMangoWCParser(path)
keybinds, err := parser.ParseWithDMS()
if err != nil {
return nil, err
}
return &MangoWCParseResult{
Keybinds: keybinds,
DMSBindsIncluded: parser.dmsBindsIncluded,
DMSStatus: parser.buildDMSStatus(),
ConflictingConfigs: parser.conflictingConfigs,
}, nil
}

View File

@@ -172,7 +172,7 @@ func TestMangoWCGetKeybindAtLine(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
parser := NewMangoWCParser() parser := NewMangoWCParser("")
parser.contentLines = []string{tt.line} parser.contentLines = []string{tt.line}
result := parser.getKeybindAtLine(0) result := parser.getKeybindAtLine(0)
@@ -283,7 +283,7 @@ func TestMangoWCReadContentMultipleFiles(t *testing.T) {
t.Fatalf("Failed to write file2: %v", err) t.Fatalf("Failed to write file2: %v", err)
} }
parser := NewMangoWCParser() parser := NewMangoWCParser("")
if err := parser.ReadContent(tmpDir); err != nil { if err := parser.ReadContent(tmpDir); err != nil {
t.Fatalf("ReadContent failed: %v", err) t.Fatalf("ReadContent failed: %v", err)
} }
@@ -304,7 +304,7 @@ func TestMangoWCReadContentSingleFile(t *testing.T) {
t.Fatalf("Failed to write config: %v", err) t.Fatalf("Failed to write config: %v", err)
} }
parser := NewMangoWCParser() parser := NewMangoWCParser("")
if err := parser.ReadContent(configFile); err != nil { if err := parser.ReadContent(configFile); err != nil {
t.Fatalf("ReadContent failed: %v", err) t.Fatalf("ReadContent failed: %v", err)
} }
@@ -362,7 +362,7 @@ func TestMangoWCReadContentWithTildeExpansion(t *testing.T) {
t.Skip("Cannot create relative path") t.Skip("Cannot create relative path")
} }
parser := NewMangoWCParser() parser := NewMangoWCParser("")
tildePathMatch := "~/" + relPath tildePathMatch := "~/" + relPath
err = parser.ReadContent(tildePathMatch) err = parser.ReadContent(tildePathMatch)
@@ -419,7 +419,7 @@ func TestMangoWCInvalidBindLines(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
parser := NewMangoWCParser() parser := NewMangoWCParser("")
parser.contentLines = []string{tt.line} parser.contentLines = []string{tt.line}
result := parser.getKeybindAtLine(0) result := parser.getKeybindAtLine(0)

View File

@@ -15,8 +15,17 @@ func TestMangoWCProviderName(t *testing.T) {
func TestMangoWCProviderDefaultPath(t *testing.T) { func TestMangoWCProviderDefaultPath(t *testing.T) {
provider := NewMangoWCProvider("") provider := NewMangoWCProvider("")
if provider.configPath != "$HOME/.config/mango" { configDir, err := os.UserConfigDir()
t.Errorf("configPath = %q, want %q", provider.configPath, "$HOME/.config/mango") if err != nil {
// Fall back to testing for non-empty path
if provider.configPath == "" {
t.Error("configPath should not be empty")
}
return
}
expected := filepath.Join(configDir, "mango")
if provider.configPath != expected {
t.Errorf("configPath = %q, want %q", provider.configPath, expected)
} }
} }
@@ -174,7 +183,7 @@ func TestMangoWCConvertKeybind(t *testing.T) {
provider := NewMangoWCProvider("") provider := NewMangoWCProvider("")
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
result := provider.convertKeybind(tt.keybind) result := provider.convertKeybind(tt.keybind, nil)
if result.Key != tt.wantKey { if result.Key != tt.wantKey {
t.Errorf("convertKeybind().Key = %q, want %q", result.Key, tt.wantKey) t.Errorf("convertKeybind().Key = %q, want %q", result.Key, tt.wantKey)
} }

View File

@@ -187,7 +187,15 @@ func (n *NiriProvider) formatRawAction(action string, args []string) string {
} }
} }
return action + " " + strings.Join(args, " ") quotedArgs := make([]string, len(args))
for i, arg := range args {
if arg == "" {
quotedArgs[i] = `""`
} else {
quotedArgs[i] = arg
}
}
return action + " " + strings.Join(quotedArgs, " ")
} }
func (n *NiriProvider) formatKey(kb *NiriKeyBinding) string { func (n *NiriProvider) formatKey(kb *NiriKeyBinding) string {
@@ -293,9 +301,15 @@ func (n *NiriProvider) loadOverrideBinds() (map[string]*overrideBind, error) {
continue continue
} }
keyStr := parser.formatBindKey(kb) keyStr := parser.formatBindKey(kb)
action := n.buildActionFromNode(child)
if action == "" {
action = n.formatRawAction(kb.Action, kb.Args)
}
binds[keyStr] = &overrideBind{ binds[keyStr] = &overrideBind{
Key: keyStr, Key: keyStr,
Action: n.formatRawAction(kb.Action, kb.Args), Action: action,
Description: kb.Description, Description: kb.Description,
Options: n.extractOptions(child), Options: n.extractOptions(child),
} }
@@ -305,6 +319,42 @@ func (n *NiriProvider) loadOverrideBinds() (map[string]*overrideBind, error) {
return binds, nil return binds, nil
} }
func (n *NiriProvider) buildActionFromNode(bindNode *document.Node) string {
if len(bindNode.Children) == 0 {
return ""
}
actionNode := bindNode.Children[0]
actionName := actionNode.Name.String()
if actionName == "" {
return ""
}
parts := []string{actionName}
for _, arg := range actionNode.Arguments {
val := arg.ValueString()
if val == "" {
parts = append(parts, `""`)
} else {
parts = append(parts, val)
}
}
if actionNode.Properties != nil {
if val, ok := actionNode.Properties.Get("focus"); ok {
parts = append(parts, "focus="+val.String())
}
if val, ok := actionNode.Properties.Get("show-pointer"); ok {
parts = append(parts, "show-pointer="+val.String())
}
if val, ok := actionNode.Properties.Get("write-to-disk"); ok {
parts = append(parts, "write-to-disk="+val.String())
}
}
return strings.Join(parts, " ")
}
func (n *NiriProvider) extractOptions(node *document.Node) map[string]any { func (n *NiriProvider) extractOptions(node *document.Node) map[string]any {
if node.Properties == nil { if node.Properties == nil {
return make(map[string]any) return make(map[string]any)
@@ -461,16 +511,9 @@ func (n *NiriProvider) getBindSortPriority(action string) int {
} }
} }
const dmsWarningHeader = `// ! DO NOT EDIT !
// ! AUTO-GENERATED BY DMS !
// ! CHANGES WILL BE OVERWRITTEN !
// ! PLACE YOUR CUSTOM CONFIGURATION ELSEWHERE !
`
func (n *NiriProvider) generateBindsContent(binds map[string]*overrideBind) string { func (n *NiriProvider) generateBindsContent(binds map[string]*overrideBind) string {
if len(binds) == 0 { if len(binds) == 0 {
return dmsWarningHeader + "binds {}\n" return "binds {}\n"
} }
var regularBinds, recentWindowsBinds []*overrideBind var regularBinds, recentWindowsBinds []*overrideBind
@@ -497,7 +540,6 @@ func (n *NiriProvider) generateBindsContent(binds map[string]*overrideBind) stri
var sb strings.Builder var sb strings.Builder
sb.WriteString(dmsWarningHeader)
sb.WriteString("binds {\n") sb.WriteString("binds {\n")
for _, bind := range regularBinds { for _, bind := range regularBinds {
n.writeBindNode(&sb, bind, " ") n.writeBindNode(&sb, bind, " ")

View File

@@ -6,13 +6,6 @@ import (
"testing" "testing"
) )
const testHeader = `// ! DO NOT EDIT !
// ! AUTO-GENERATED BY DMS !
// ! CHANGES WILL BE OVERWRITTEN !
// ! PLACE YOUR CUSTOM CONFIGURATION ELSEWHERE !
`
func TestNiriProviderName(t *testing.T) { func TestNiriProviderName(t *testing.T) {
provider := NewNiriProvider("") provider := NewNiriProvider("")
if provider.Name() != "niri" { if provider.Name() != "niri" {
@@ -128,6 +121,8 @@ func TestNiriFormatRawAction(t *testing.T) {
}{ }{
{"spawn", []string{"kitty"}, "spawn kitty"}, {"spawn", []string{"kitty"}, "spawn kitty"},
{"spawn", []string{"dms", "ipc", "call"}, "spawn dms ipc call"}, {"spawn", []string{"dms", "ipc", "call"}, "spawn dms ipc call"},
{"spawn", []string{"dms", "ipc", "call", "brightness", "increment", "5", ""}, `spawn dms ipc call brightness increment 5 ""`},
{"spawn", []string{"dms", "ipc", "call", "dash", "toggle", ""}, `spawn dms ipc call dash toggle ""`},
{"close-window", nil, "close-window"}, {"close-window", nil, "close-window"},
{"fullscreen-window", nil, "fullscreen-window"}, {"fullscreen-window", nil, "fullscreen-window"},
{"focus-workspace", []string{"1"}, "focus-workspace 1"}, {"focus-workspace", []string{"1"}, "focus-workspace 1"},
@@ -204,7 +199,7 @@ func TestNiriGenerateBindsContent(t *testing.T) {
{ {
name: "empty binds", name: "empty binds",
binds: map[string]*overrideBind{}, binds: map[string]*overrideBind{},
expected: testHeader + "binds {}\n", expected: "binds {}\n",
}, },
{ {
name: "simple spawn bind", name: "simple spawn bind",
@@ -215,7 +210,7 @@ func TestNiriGenerateBindsContent(t *testing.T) {
Description: "Open Terminal", Description: "Open Terminal",
}, },
}, },
expected: testHeader + `binds { expected: `binds {
Mod+T hotkey-overlay-title="Open Terminal" { spawn "kitty"; } Mod+T hotkey-overlay-title="Open Terminal" { spawn "kitty"; }
} }
`, `,
@@ -229,7 +224,7 @@ func TestNiriGenerateBindsContent(t *testing.T) {
Description: "Application Launcher", Description: "Application Launcher",
}, },
}, },
expected: testHeader + `binds { expected: `binds {
Mod+Space hotkey-overlay-title="Application Launcher" { spawn "dms" "ipc" "call" "spotlight" "toggle"; } Mod+Space hotkey-overlay-title="Application Launcher" { spawn "dms" "ipc" "call" "spotlight" "toggle"; }
} }
`, `,
@@ -243,7 +238,7 @@ func TestNiriGenerateBindsContent(t *testing.T) {
Options: map[string]any{"allow-when-locked": true}, Options: map[string]any{"allow-when-locked": true},
}, },
}, },
expected: testHeader + `binds { expected: `binds {
XF86AudioMute allow-when-locked=true { spawn "dms" "ipc" "call" "audio" "mute"; } XF86AudioMute allow-when-locked=true { spawn "dms" "ipc" "call" "audio" "mute"; }
} }
`, `,
@@ -257,7 +252,7 @@ func TestNiriGenerateBindsContent(t *testing.T) {
Description: "Close Window", Description: "Close Window",
}, },
}, },
expected: testHeader + `binds { expected: `binds {
Mod+Q hotkey-overlay-title="Close Window" { close-window; } Mod+Q hotkey-overlay-title="Close Window" { close-window; }
} }
`, `,
@@ -270,7 +265,7 @@ func TestNiriGenerateBindsContent(t *testing.T) {
Action: "next-window", Action: "next-window",
}, },
}, },
expected: testHeader + `binds { expected: `binds {
} }
recent-windows { recent-windows {
@@ -331,6 +326,58 @@ func TestNiriGenerateBindsContentRoundTrip(t *testing.T) {
} }
} }
func TestNiriEmptyArgsPreservation(t *testing.T) {
provider := NewNiriProvider("")
binds := map[string]*overrideBind{
"XF86MonBrightnessUp": {
Key: "XF86MonBrightnessUp",
Action: `spawn dms ipc call brightness increment 5 ""`,
Description: "Brightness Up",
},
"XF86MonBrightnessDown": {
Key: "XF86MonBrightnessDown",
Action: `spawn dms ipc call brightness decrement 5 ""`,
Description: "Brightness Down",
},
"Super+Alt+Page_Up": {
Key: "Super+Alt+Page_Up",
Action: `spawn dms ipc call dash toggle ""`,
Description: "Dashboard Toggle",
},
}
content := provider.generateBindsContent(binds)
tmpDir := t.TempDir()
dmsDir := filepath.Join(tmpDir, "dms")
if err := os.MkdirAll(dmsDir, 0755); err != nil {
t.Fatalf("Failed to create dms directory: %v", err)
}
bindsFile := filepath.Join(dmsDir, "binds.kdl")
if err := os.WriteFile(bindsFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write binds file: %v", err)
}
testProvider := NewNiriProvider(tmpDir)
loadedBinds, err := testProvider.loadOverrideBinds()
if err != nil {
t.Fatalf("Failed to load binds: %v\nContent was:\n%s", err, content)
}
for key, expected := range binds {
loaded, ok := loadedBinds[key]
if !ok {
t.Errorf("Missing bind for key %s", key)
continue
}
if loaded.Action != expected.Action {
t.Errorf("Action mismatch for %s:\n got: %q\n want: %q", key, loaded.Action, expected.Action)
}
}
}
func TestNiriProviderWithRealWorldConfig(t *testing.T) { func TestNiriProviderWithRealWorldConfig(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "config.kdl") configFile := filepath.Join(tmpDir, "config.kdl")
@@ -422,7 +469,7 @@ func TestNiriGenerateBindsContentNumericArgs(t *testing.T) {
Description: "Focus Workspace 1", Description: "Focus Workspace 1",
}, },
}, },
expected: testHeader + `binds { expected: `binds {
Mod+1 hotkey-overlay-title="Focus Workspace 1" { focus-workspace 1; } Mod+1 hotkey-overlay-title="Focus Workspace 1" { focus-workspace 1; }
} }
`, `,
@@ -436,7 +483,7 @@ func TestNiriGenerateBindsContentNumericArgs(t *testing.T) {
Description: "Focus Workspace 10", Description: "Focus Workspace 10",
}, },
}, },
expected: testHeader + `binds { expected: `binds {
Mod+0 hotkey-overlay-title="Focus Workspace 10" { focus-workspace 10; } Mod+0 hotkey-overlay-title="Focus Workspace 10" { focus-workspace 10; }
} }
`, `,
@@ -450,7 +497,7 @@ func TestNiriGenerateBindsContentNumericArgs(t *testing.T) {
Description: "Adjust Column Width -10%", Description: "Adjust Column Width -10%",
}, },
}, },
expected: testHeader + `binds { expected: `binds {
Super+Minus hotkey-overlay-title="Adjust Column Width -10%" { set-column-width "-10%"; } Super+Minus hotkey-overlay-title="Adjust Column Width -10%" { set-column-width "-10%"; }
} }
`, `,
@@ -464,7 +511,7 @@ func TestNiriGenerateBindsContentNumericArgs(t *testing.T) {
Description: "Adjust Column Width +10%", Description: "Adjust Column Width +10%",
}, },
}, },
expected: testHeader + `binds { expected: `binds {
Super+Equal hotkey-overlay-title="Adjust Column Width +10%" { set-column-width "+10%"; } Super+Equal hotkey-overlay-title="Adjust Column Width +10%" { set-column-width "+10%"; }
} }
`, `,
@@ -493,7 +540,7 @@ func TestNiriGenerateActionWithUnquotedPercentArg(t *testing.T) {
} }
content := provider.generateBindsContent(binds) content := provider.generateBindsContent(binds)
expected := testHeader + `binds { expected := `binds {
Super+Equal hotkey-overlay-title="Adjust Window Height +10%" { set-window-height "+10%"; } Super+Equal hotkey-overlay-title="Adjust Window Height +10%" { set-window-height "+10%"; }
} }
` `
@@ -514,7 +561,7 @@ func TestNiriGenerateSpawnWithNumericArgs(t *testing.T) {
} }
content := provider.generateBindsContent(binds) content := provider.generateBindsContent(binds)
expected := testHeader + `binds { expected := `binds {
XF86AudioLowerVolume allow-when-locked=true { spawn "dms" "ipc" "call" "audio" "decrement" "3"; } XF86AudioLowerVolume allow-when-locked=true { spawn "dms" "ipc" "call" "audio" "decrement" "3"; }
} }
` `
@@ -535,7 +582,7 @@ func TestNiriGenerateSpawnNumericArgFromCLI(t *testing.T) {
} }
content := provider.generateBindsContent(binds) content := provider.generateBindsContent(binds)
expected := testHeader + `binds { expected := `binds {
XF86AudioLowerVolume allow-when-locked=true { spawn "dms" "ipc" "call" "audio" "decrement" "3"; } XF86AudioLowerVolume allow-when-locked=true { spawn "dms" "ipc" "call" "audio" "decrement" "3"; }
} }
` `

View File

@@ -8,6 +8,7 @@ type Keybind struct {
Source string `json:"source,omitempty"` Source string `json:"source,omitempty"`
HideOnOverlay bool `json:"hideOnOverlay,omitempty"` HideOnOverlay bool `json:"hideOnOverlay,omitempty"`
CooldownMs int `json:"cooldownMs,omitempty"` CooldownMs int `json:"cooldownMs,omitempty"`
Flags string `json:"flags,omitempty"` // Hyprland bind flags: e=repeat, l=locked, r=release, o=long-press
Conflict *Keybind `json:"conflict,omitempty"` Conflict *Keybind `json:"conflict,omitempty"`
} }

View File

@@ -16,6 +16,63 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils" "github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
) )
type ColorMode string
const (
ColorModeDark ColorMode = "dark"
ColorModeLight ColorMode = "light"
)
type TemplateKind int
const (
TemplateKindNormal TemplateKind = iota
TemplateKindTerminal
TemplateKindGTK
TemplateKindVSCode
)
type TemplateDef struct {
ID string
Commands []string
Flatpaks []string
ConfigFile string
Kind TemplateKind
RunUnconditionally bool
}
var templateRegistry = []TemplateDef{
{ID: "gtk", Kind: TemplateKindGTK, RunUnconditionally: true},
{ID: "niri", Commands: []string{"niri"}, ConfigFile: "niri.toml"},
{ID: "hyprland", Commands: []string{"Hyprland"}, ConfigFile: "hyprland.toml"},
{ID: "mangowc", Commands: []string{"mango"}, ConfigFile: "mangowc.toml"},
{ID: "qt5ct", Commands: []string{"qt5ct"}, ConfigFile: "qt5ct.toml"},
{ID: "qt6ct", Commands: []string{"qt6ct"}, ConfigFile: "qt6ct.toml"},
{ID: "firefox", Commands: []string{"firefox"}, ConfigFile: "firefox.toml"},
{ID: "pywalfox", Commands: []string{"pywalfox"}, ConfigFile: "pywalfox.toml"},
{ID: "zenbrowser", Commands: []string{"zen", "zen-browser"}, Flatpaks: []string{"app.zen_browser.zen"}, ConfigFile: "zenbrowser.toml"},
{ID: "vesktop", Commands: []string{"vesktop"}, Flatpaks: []string{"dev.vencord.Vesktop"}, ConfigFile: "vesktop.toml"},
{ID: "equibop", Commands: []string{"equibop"}, ConfigFile: "equibop.toml"},
{ID: "ghostty", Commands: []string{"ghostty"}, ConfigFile: "ghostty.toml", Kind: TemplateKindTerminal},
{ID: "kitty", Commands: []string{"kitty"}, ConfigFile: "kitty.toml", Kind: TemplateKindTerminal},
{ID: "foot", Commands: []string{"foot"}, ConfigFile: "foot.toml", Kind: TemplateKindTerminal},
{ID: "alacritty", Commands: []string{"alacritty"}, ConfigFile: "alacritty.toml", Kind: TemplateKindTerminal},
{ID: "wezterm", Commands: []string{"wezterm"}, ConfigFile: "wezterm.toml", Kind: TemplateKindTerminal},
{ID: "nvim", Commands: []string{"nvim"}, ConfigFile: "neovim.toml", Kind: TemplateKindTerminal},
{ID: "dgop", Commands: []string{"dgop"}, ConfigFile: "dgop.toml"},
{ID: "kcolorscheme", ConfigFile: "kcolorscheme.toml", RunUnconditionally: true},
{ID: "vscode", Kind: TemplateKindVSCode},
}
func (c *ColorMode) GTKTheme() string {
switch *c {
case ColorModeDark:
return "adw-gtk3-dark"
default:
return "adw-gtk3"
}
}
var ( var (
matugenVersionOnce sync.Once matugenVersionOnce sync.Once
matugenSupportsCOE bool matugenSupportsCOE bool
@@ -27,7 +84,7 @@ type Options struct {
ConfigDir string ConfigDir string
Kind string Kind string
Value string Value string
Mode string Mode ColorMode
IconTheme string IconTheme string
MatugenType string MatugenType string
RunUserTemplates bool RunUserTemplates bool
@@ -35,6 +92,7 @@ type Options struct {
SyncModeWithPortal bool SyncModeWithPortal bool
TerminalsAlwaysDark bool TerminalsAlwaysDark bool
SkipTemplates string SkipTemplates string
AppChecker utils.AppChecker
} }
type ColorsOutput struct { type ColorsOutput struct {
@@ -77,7 +135,7 @@ func Run(opts Options) error {
return fmt.Errorf("value is required") return fmt.Errorf("value is required")
} }
if opts.Mode == "" { if opts.Mode == "" {
opts.Mode = "dark" opts.Mode = ColorModeDark
} }
if opts.MatugenType == "" { if opts.MatugenType == "" {
opts.MatugenType = "scheme-tonal-spot" opts.MatugenType = "scheme-tonal-spot"
@@ -85,6 +143,9 @@ func Run(opts Options) error {
if opts.IconTheme == "" { if opts.IconTheme == "" {
opts.IconTheme = "System Default" opts.IconTheme = "System Default"
} }
if opts.AppChecker == nil {
opts.AppChecker = utils.DefaultAppChecker{}
}
if err := os.MkdirAll(opts.StateDir, 0755); err != nil { if err := os.MkdirAll(opts.StateDir, 0755); err != nil {
return fmt.Errorf("failed to create state dir: %w", err) return fmt.Errorf("failed to create state dir: %w", err)
@@ -145,7 +206,7 @@ func buildOnce(opts *Options) error {
importArgs = []string{"--import-json-string", importData} importArgs = []string{"--import-json-string", importData}
log.Info("Running matugen color hex with stock color overrides") 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...) args = append(args, importArgs...)
if err := runMatugen(args); err != nil { if err := runMatugen(args); err != nil {
return err return err
@@ -181,7 +242,7 @@ func buildOnce(opts *Options) error {
default: default:
args = []string{opts.Kind, opts.Value} 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...) args = append(args, importArgs...)
if err := runMatugen(args); err != nil { if err := runMatugen(args); err != nil {
return err return err
@@ -220,7 +281,7 @@ func buildMergedConfig(opts *Options, cfgFile *os.File, tmpDir string) error {
if strings.TrimSpace(line) == "[config]" { if strings.TrimSpace(line) == "[config]" {
continue continue
} }
cfgFile.WriteString(substituteShellDir(line, opts.ShellDir) + "\n") cfgFile.WriteString(substituteVars(line, opts.ShellDir) + "\n")
} }
cfgFile.WriteString("\n") cfgFile.WriteString("\n")
} }
@@ -231,70 +292,32 @@ output_path = '%s'
`, opts.ShellDir, opts.ColorsOutput()) `, opts.ShellDir, opts.ColorsOutput())
if !opts.ShouldSkipTemplate("gtk") { homeDir, _ := os.UserHomeDir()
switch opts.Mode { for _, tmpl := range templateRegistry {
case "light": if opts.ShouldSkipTemplate(tmpl.ID) {
appendConfig(opts, cfgFile, "skip", "gtk3-light.toml") continue
default:
appendConfig(opts, cfgFile, "skip", "gtk3-dark.toml")
} }
}
if !opts.ShouldSkipTemplate("niri") { switch tmpl.Kind {
appendConfig(opts, cfgFile, "niri", "niri.toml") case TemplateKindGTK:
} switch opts.Mode {
if !opts.ShouldSkipTemplate("qt5ct") { case ColorModeLight:
appendConfig(opts, cfgFile, "qt5ct", "qt5ct.toml") appendConfig(opts, cfgFile, nil, nil, "gtk3-light.toml")
} default:
if !opts.ShouldSkipTemplate("qt6ct") { appendConfig(opts, cfgFile, nil, nil, "gtk3-dark.toml")
appendConfig(opts, cfgFile, "qt6ct", "qt6ct.toml") }
} case TemplateKindTerminal:
if !opts.ShouldSkipTemplate("firefox") { appendTerminalConfig(opts, cfgFile, tmpDir, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigFile)
appendConfig(opts, cfgFile, "firefox", "firefox.toml") case TemplateKindVSCode:
} appendVSCodeConfig(cfgFile, "vscode", filepath.Join(homeDir, ".vscode/extensions"), opts.ShellDir)
if !opts.ShouldSkipTemplate("pywalfox") { appendVSCodeConfig(cfgFile, "codium", filepath.Join(homeDir, ".vscode-oss/extensions"), opts.ShellDir)
appendConfig(opts, cfgFile, "pywalfox", "pywalfox.toml") appendVSCodeConfig(cfgFile, "codeoss", filepath.Join(homeDir, ".config/Code - OSS/extensions"), opts.ShellDir)
} appendVSCodeConfig(cfgFile, "cursor", filepath.Join(homeDir, ".cursor/extensions"), opts.ShellDir)
if !opts.ShouldSkipTemplate("vesktop") { appendVSCodeConfig(cfgFile, "windsurf", filepath.Join(homeDir, ".windsurf/extensions"), opts.ShellDir)
appendConfig(opts, cfgFile, "vesktop", "vesktop.toml") appendVSCodeConfig(cfgFile, "vscode-insiders", filepath.Join(homeDir, ".vscode-insiders/extensions"), opts.ShellDir)
} default:
if !opts.ShouldSkipTemplate("equibop") { appendConfig(opts, cfgFile, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigFile)
appendConfig(opts, cfgFile, "equibop", "equibop.toml") }
}
if !opts.ShouldSkipTemplate("ghostty") {
appendTerminalConfig(opts, cfgFile, tmpDir, "ghostty", "ghostty.toml")
}
if !opts.ShouldSkipTemplate("kitty") {
appendTerminalConfig(opts, cfgFile, tmpDir, "kitty", "kitty.toml")
}
if !opts.ShouldSkipTemplate("foot") {
appendTerminalConfig(opts, cfgFile, tmpDir, "foot", "foot.toml")
}
if !opts.ShouldSkipTemplate("alacritty") {
appendTerminalConfig(opts, cfgFile, tmpDir, "alacritty", "alacritty.toml")
}
if !opts.ShouldSkipTemplate("wezterm") {
appendTerminalConfig(opts, cfgFile, tmpDir, "wezterm", "wezterm.toml")
}
if !opts.ShouldSkipTemplate("nvim") {
appendTerminalConfig(opts, cfgFile, tmpDir, "nvim", "neovim.toml")
}
if !opts.ShouldSkipTemplate("dgop") {
appendConfig(opts, cfgFile, "dgop", "dgop.toml")
}
if !opts.ShouldSkipTemplate("kcolorscheme") {
appendConfig(opts, cfgFile, "skip", "kcolorscheme.toml")
}
if !opts.ShouldSkipTemplate("vscode") {
homeDir, _ := os.UserHomeDir()
appendVSCodeConfig(cfgFile, "vscode", filepath.Join(homeDir, ".vscode/extensions/local.dynamic-base16-dankshell-0.0.1"), opts.ShellDir)
appendVSCodeConfig(cfgFile, "codium", filepath.Join(homeDir, ".vscode-oss/extensions/local.dynamic-base16-dankshell-0.0.1"), opts.ShellDir)
appendVSCodeConfig(cfgFile, "codeoss", filepath.Join(homeDir, ".config/Code - OSS/extensions/local.dynamic-base16-dankshell-0.0.1"), opts.ShellDir)
appendVSCodeConfig(cfgFile, "cursor", filepath.Join(homeDir, ".cursor/extensions/local.dynamic-base16-dankshell-0.0.1"), opts.ShellDir)
appendVSCodeConfig(cfgFile, "windsurf", filepath.Join(homeDir, ".windsurf/extensions/local.dynamic-base16-dankshell-0.0.1"), opts.ShellDir)
} }
if opts.RunUserTemplates { if opts.RunUserTemplates {
@@ -323,28 +346,34 @@ output_path = '%s'
return nil 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) configPath := filepath.Join(opts.ShellDir, "matugen", "configs", fileName)
if _, err := os.Stat(configPath); err != nil { if _, err := os.Stat(configPath); err != nil {
return return
} }
if checkCmd != "skip" && !utils.CommandExists(checkCmd) { if !appExists(opts.AppChecker, checkCmd, checkFlatpaks) {
return return
} }
data, err := os.ReadFile(configPath) data, err := os.ReadFile(configPath)
if err != nil { if err != nil {
return return
} }
cfgFile.WriteString(substituteShellDir(string(data), opts.ShellDir)) cfgFile.WriteString(substituteVars(string(data), opts.ShellDir))
cfgFile.WriteString("\n") 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) configPath := filepath.Join(opts.ShellDir, "matugen", "configs", fileName)
if _, err := os.Stat(configPath); err != nil { if _, err := os.Stat(configPath); err != nil {
return return
} }
if checkCmd != "skip" && !utils.CommandExists(checkCmd) { if !appExists(opts.AppChecker, checkCmd, checkFlatpaks) {
return return
} }
data, err := os.ReadFile(configPath) data, err := os.ReadFile(configPath)
@@ -355,7 +384,7 @@ func appendTerminalConfig(opts *Options, cfgFile *os.File, tmpDir, checkCmd, fil
content := string(data) content := string(data)
if !opts.TerminalsAlwaysDark { if !opts.TerminalsAlwaysDark {
cfgFile.WriteString(substituteShellDir(content, opts.ShellDir)) cfgFile.WriteString(substituteVars(content, opts.ShellDir))
cfgFile.WriteString("\n") cfgFile.WriteString("\n")
return return
} }
@@ -393,14 +422,32 @@ func appendTerminalConfig(opts *Options, cfgFile *os.File, tmpDir, checkCmd, fil
fmt.Sprintf("'%s'", tmpPath)) fmt.Sprintf("'%s'", tmpPath))
} }
cfgFile.WriteString(substituteShellDir(content, opts.ShellDir)) cfgFile.WriteString(substituteVars(content, opts.ShellDir))
cfgFile.WriteString("\n") cfgFile.WriteString("\n")
} }
func appendVSCodeConfig(cfgFile *os.File, name, extDir, shellDir string) { func appExists(checker utils.AppChecker, checkCmd []string, checkFlatpaks []string) bool {
if _, err := os.Stat(extDir); err != nil { // Both nil is treated as "skip check" / unconditionally run
if checkCmd == nil && checkFlatpaks == nil {
return true
}
if checkCmd != nil && checker.AnyCommandExists(checkCmd...) {
return true
}
if checkFlatpaks != nil && checker.AnyFlatpakExists(checkFlatpaks...) {
return true
}
return false
}
func appendVSCodeConfig(cfgFile *os.File, name, extBaseDir, shellDir string) {
pattern := filepath.Join(extBaseDir, "danklinux.dms-theme-*")
matches, err := filepath.Glob(pattern)
if err != nil || len(matches) == 0 {
return return
} }
extDir := matches[0]
templateDir := filepath.Join(shellDir, "matugen", "templates") templateDir := filepath.Join(shellDir, "matugen", "templates")
fmt.Fprintf(cfgFile, `[templates.dms%sdefault] fmt.Fprintf(cfgFile, `[templates.dms%sdefault]
input_path = '%s/vscode-color-theme-default.json' input_path = '%s/vscode-color-theme-default.json'
@@ -420,8 +467,12 @@ output_path = '%s/themes/dankshell-light.json'
log.Infof("Added %s theme config (extension found at %s)", name, extDir) log.Infof("Added %s theme config (extension found at %s)", name, extDir)
} }
func substituteShellDir(content, shellDir string) string { func substituteVars(content, shellDir string) string {
return strings.ReplaceAll(content, "'SHELL_DIR/", "'"+shellDir+"/") result := strings.ReplaceAll(content, "'SHELL_DIR/", "'"+shellDir+"/")
result = strings.ReplaceAll(result, "'CONFIG_DIR/", "'"+utils.XDGConfigHome()+"/")
result = strings.ReplaceAll(result, "'DATA_DIR/", "'"+utils.XDGDataHome()+"/")
result = strings.ReplaceAll(result, "'CACHE_DIR/", "'"+utils.XDGCacheHome()+"/")
return result
} }
func extractTOMLSection(content, startMarker, endMarker string) string { func extractTOMLSection(content, startMarker, endMarker string) string {
@@ -553,19 +604,19 @@ func extractNestedColor(jsonStr, colorName, variant string) string {
return color return color
} }
func generateDank16Variants(primaryDark, primaryLight, surface, mode string) string { func generateDank16Variants(primaryDark, primaryLight, surface string, mode ColorMode) string {
variantOpts := dank16.VariantOptions{ variantOpts := dank16.VariantOptions{
PrimaryDark: primaryDark, PrimaryDark: primaryDark,
PrimaryLight: primaryLight, PrimaryLight: primaryLight,
Background: surface, Background: surface,
UseDPS: true, UseDPS: true,
IsLightMode: mode == "light", IsLightMode: mode == ColorModeLight,
} }
variantColors := dank16.GenerateVariantPalette(variantOpts) variantColors := dank16.GenerateVariantPalette(variantOpts)
return dank16.GenerateVariantJSON(variantColors) return dank16.GenerateVariantJSON(variantColors)
} }
func refreshGTK(configDir, mode string) { func refreshGTK(configDir string, mode ColorMode) {
gtkCSS := filepath.Join(configDir, "gtk-3.0", "gtk.css") gtkCSS := filepath.Join(configDir, "gtk-3.0", "gtk.css")
info, err := os.Lstat(gtkCSS) info, err := os.Lstat(gtkCSS)
@@ -591,7 +642,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", "").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() { func signalTerminals() {
@@ -621,9 +672,9 @@ func signalByName(name string, sig syscall.Signal) {
} }
} }
func syncColorScheme(mode string) { func syncColorScheme(mode ColorMode) {
scheme := "prefer-dark" scheme := "prefer-dark"
if mode == "light" { if mode == ColorModeLight {
scheme = "default" scheme = "default"
} }
@@ -631,3 +682,52 @@ func syncColorScheme(mode string) {
exec.Command("dconf", "write", "/org/gnome/desktop/interface/color-scheme", "'"+scheme+"'").Run() exec.Command("dconf", "write", "/org/gnome/desktop/interface/color-scheme", "'"+scheme+"'").Run()
} }
} }
type TemplateCheck struct {
ID string `json:"id"`
Detected bool `json:"detected"`
}
func CheckTemplates(checker utils.AppChecker) []TemplateCheck {
if checker == nil {
checker = utils.DefaultAppChecker{}
}
homeDir, _ := os.UserHomeDir()
checks := make([]TemplateCheck, 0, len(templateRegistry))
for _, tmpl := range templateRegistry {
detected := false
switch {
case tmpl.RunUnconditionally:
detected = true
case tmpl.Kind == TemplateKindVSCode:
detected = checkVSCodeExtension(homeDir)
default:
detected = appExists(checker, tmpl.Commands, tmpl.Flatpaks)
}
checks = append(checks, TemplateCheck{ID: tmpl.ID, Detected: detected})
}
return checks
}
func checkVSCodeExtension(homeDir string) bool {
extDirs := []string{
filepath.Join(homeDir, ".vscode/extensions"),
filepath.Join(homeDir, ".vscode-oss/extensions"),
filepath.Join(homeDir, ".config/Code - OSS/extensions"),
filepath.Join(homeDir, ".cursor/extensions"),
filepath.Join(homeDir, ".windsurf/extensions"),
}
for _, extDir := range extDirs {
pattern := filepath.Join(extDir, "danklinux.dms-theme-*")
if matches, err := filepath.Glob(pattern); err == nil && len(matches) > 0 {
return true
}
}
return false
}

View File

@@ -0,0 +1,394 @@
package matugen
import (
"os"
"path/filepath"
"testing"
mocks_utils "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/utils"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
"github.com/stretchr/testify/assert"
)
func TestAppendConfigBinaryExists(t *testing.T) {
tempDir := t.TempDir()
shellDir := filepath.Join(tempDir, "shell")
configsDir := filepath.Join(shellDir, "matugen", "configs")
if err := os.MkdirAll(configsDir, 0755); err != nil {
t.Fatalf("failed to create configs dir: %v", err)
}
testConfig := "test config content"
configPath := filepath.Join(configsDir, "test.toml")
if err := os.WriteFile(configPath, []byte(testConfig), 0644); err != nil {
t.Fatalf("failed to write config: %v", err)
}
outFile := filepath.Join(tempDir, "output.toml")
cfgFile, err := os.Create(outFile)
if err != nil {
t.Fatalf("failed to create output file: %v", err)
}
defer cfgFile.Close()
mockChecker := mocks_utils.NewMockAppChecker(t)
mockChecker.EXPECT().AnyCommandExists("sh").Return(true)
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
appendConfig(opts, cfgFile, []string{"sh"}, nil, "test.toml")
cfgFile.Close()
output, err := os.ReadFile(outFile)
if err != nil {
t.Fatalf("failed to read output: %v", err)
}
if len(output) == 0 {
t.Errorf("expected config to be written when binary exists")
}
if string(output) != testConfig+"\n" {
t.Errorf("expected %q, got %q", testConfig+"\n", string(output))
}
}
func TestAppendConfigBinaryDoesNotExist(t *testing.T) {
tempDir := t.TempDir()
shellDir := filepath.Join(tempDir, "shell")
configsDir := filepath.Join(shellDir, "matugen", "configs")
if err := os.MkdirAll(configsDir, 0755); err != nil {
t.Fatalf("failed to create configs dir: %v", err)
}
testConfig := "test config content"
configPath := filepath.Join(configsDir, "test.toml")
if err := os.WriteFile(configPath, []byte(testConfig), 0644); err != nil {
t.Fatalf("failed to write config: %v", err)
}
outFile := filepath.Join(tempDir, "output.toml")
cfgFile, err := os.Create(outFile)
if err != nil {
t.Fatalf("failed to create output file: %v", err)
}
defer cfgFile.Close()
mockChecker := mocks_utils.NewMockAppChecker(t)
mockChecker.EXPECT().AnyCommandExists("nonexistent-binary-12345").Return(false)
mockChecker.EXPECT().AnyFlatpakExists().Return(false)
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
appendConfig(opts, cfgFile, []string{"nonexistent-binary-12345"}, []string{}, "test.toml")
cfgFile.Close()
output, err := os.ReadFile(outFile)
if err != nil {
t.Fatalf("failed to read output: %v", err)
}
if len(output) != 0 {
t.Errorf("expected no config when binary doesn't exist, got: %q", string(output))
}
}
func TestAppendConfigFlatpakExists(t *testing.T) {
tempDir := t.TempDir()
shellDir := filepath.Join(tempDir, "shell")
configsDir := filepath.Join(shellDir, "matugen", "configs")
if err := os.MkdirAll(configsDir, 0755); err != nil {
t.Fatalf("failed to create configs dir: %v", err)
}
testConfig := "zen config content"
configPath := filepath.Join(configsDir, "test.toml")
if err := os.WriteFile(configPath, []byte(testConfig), 0644); err != nil {
t.Fatalf("failed to write config: %v", err)
}
outFile := filepath.Join(tempDir, "output.toml")
cfgFile, err := os.Create(outFile)
if err != nil {
t.Fatalf("failed to create output file: %v", err)
}
defer cfgFile.Close()
mockChecker := mocks_utils.NewMockAppChecker(t)
mockChecker.EXPECT().AnyFlatpakExists("app.zen_browser.zen").Return(true)
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
appendConfig(opts, cfgFile, nil, []string{"app.zen_browser.zen"}, "test.toml")
cfgFile.Close()
output, err := os.ReadFile(outFile)
if err != nil {
t.Fatalf("failed to read output: %v", err)
}
if len(output) == 0 {
t.Errorf("expected config to be written when flatpak exists")
}
}
func TestAppendConfigFlatpakDoesNotExist(t *testing.T) {
tempDir := t.TempDir()
shellDir := filepath.Join(tempDir, "shell")
configsDir := filepath.Join(shellDir, "matugen", "configs")
if err := os.MkdirAll(configsDir, 0755); err != nil {
t.Fatalf("failed to create configs dir: %v", err)
}
testConfig := "test config content"
configPath := filepath.Join(configsDir, "test.toml")
if err := os.WriteFile(configPath, []byte(testConfig), 0644); err != nil {
t.Fatalf("failed to write config: %v", err)
}
outFile := filepath.Join(tempDir, "output.toml")
cfgFile, err := os.Create(outFile)
if err != nil {
t.Fatalf("failed to create output file: %v", err)
}
defer cfgFile.Close()
mockChecker := mocks_utils.NewMockAppChecker(t)
mockChecker.EXPECT().AnyCommandExists().Return(false)
mockChecker.EXPECT().AnyFlatpakExists("com.nonexistent.flatpak").Return(false)
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
appendConfig(opts, cfgFile, []string{}, []string{"com.nonexistent.flatpak"}, "test.toml")
cfgFile.Close()
output, err := os.ReadFile(outFile)
if err != nil {
t.Fatalf("failed to read output: %v", err)
}
if len(output) != 0 {
t.Errorf("expected no config when flatpak doesn't exist, got: %q", string(output))
}
}
func TestAppendConfigBothExist(t *testing.T) {
tempDir := t.TempDir()
shellDir := filepath.Join(tempDir, "shell")
configsDir := filepath.Join(shellDir, "matugen", "configs")
if err := os.MkdirAll(configsDir, 0755); err != nil {
t.Fatalf("failed to create configs dir: %v", err)
}
testConfig := "zen config content"
configPath := filepath.Join(configsDir, "test.toml")
if err := os.WriteFile(configPath, []byte(testConfig), 0644); err != nil {
t.Fatalf("failed to write config: %v", err)
}
outFile := filepath.Join(tempDir, "output.toml")
cfgFile, err := os.Create(outFile)
if err != nil {
t.Fatalf("failed to create output file: %v", err)
}
defer cfgFile.Close()
mockChecker := mocks_utils.NewMockAppChecker(t)
mockChecker.EXPECT().AnyCommandExists("sh").Return(true)
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
appendConfig(opts, cfgFile, []string{"sh"}, []string{"app.zen_browser.zen"}, "test.toml")
cfgFile.Close()
output, err := os.ReadFile(outFile)
if err != nil {
t.Fatalf("failed to read output: %v", err)
}
if len(output) == 0 {
t.Errorf("expected config to be written when both binary and flatpak exist")
}
}
func TestAppendConfigNeitherExists(t *testing.T) {
tempDir := t.TempDir()
shellDir := filepath.Join(tempDir, "shell")
configsDir := filepath.Join(shellDir, "matugen", "configs")
if err := os.MkdirAll(configsDir, 0755); err != nil {
t.Fatalf("failed to create configs dir: %v", err)
}
testConfig := "test config content"
configPath := filepath.Join(configsDir, "test.toml")
if err := os.WriteFile(configPath, []byte(testConfig), 0644); err != nil {
t.Fatalf("failed to write config: %v", err)
}
outFile := filepath.Join(tempDir, "output.toml")
cfgFile, err := os.Create(outFile)
if err != nil {
t.Fatalf("failed to create output file: %v", err)
}
defer cfgFile.Close()
mockChecker := mocks_utils.NewMockAppChecker(t)
mockChecker.EXPECT().AnyCommandExists("nonexistent-binary-12345").Return(false)
mockChecker.EXPECT().AnyFlatpakExists("com.nonexistent.flatpak").Return(false)
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
appendConfig(opts, cfgFile, []string{"nonexistent-binary-12345"}, []string{"com.nonexistent.flatpak"}, "test.toml")
cfgFile.Close()
output, err := os.ReadFile(outFile)
if err != nil {
t.Fatalf("failed to read output: %v", err)
}
if len(output) != 0 {
t.Errorf("expected no config when neither exists, got: %q", string(output))
}
}
func TestAppendConfigNoChecks(t *testing.T) {
tempDir := t.TempDir()
shellDir := filepath.Join(tempDir, "shell")
configsDir := filepath.Join(shellDir, "matugen", "configs")
if err := os.MkdirAll(configsDir, 0755); err != nil {
t.Fatalf("failed to create configs dir: %v", err)
}
testConfig := "always include"
configPath := filepath.Join(configsDir, "test.toml")
if err := os.WriteFile(configPath, []byte(testConfig), 0644); err != nil {
t.Fatalf("failed to write config: %v", err)
}
outFile := filepath.Join(tempDir, "output.toml")
cfgFile, err := os.Create(outFile)
if err != nil {
t.Fatalf("failed to create output file: %v", err)
}
defer cfgFile.Close()
opts := &Options{ShellDir: shellDir}
appendConfig(opts, cfgFile, nil, nil, "test.toml")
cfgFile.Close()
output, err := os.ReadFile(outFile)
if err != nil {
t.Fatalf("failed to read output: %v", err)
}
if len(output) == 0 {
t.Errorf("expected config to be written when no checks specified")
}
}
func TestAppendConfigFileDoesNotExist(t *testing.T) {
tempDir := t.TempDir()
shellDir := filepath.Join(tempDir, "shell")
configsDir := filepath.Join(shellDir, "matugen", "configs")
if err := os.MkdirAll(configsDir, 0755); err != nil {
t.Fatalf("failed to create configs dir: %v", err)
}
outFile := filepath.Join(tempDir, "output.toml")
cfgFile, err := os.Create(outFile)
if err != nil {
t.Fatalf("failed to create output file: %v", err)
}
defer cfgFile.Close()
opts := &Options{ShellDir: shellDir}
appendConfig(opts, cfgFile, nil, nil, "nonexistent.toml")
cfgFile.Close()
output, err := os.ReadFile(outFile)
if err != nil {
t.Fatalf("failed to read output: %v", err)
}
if len(output) != 0 {
t.Errorf("expected no config when file doesn't exist, got: %q", string(output))
}
}
func TestSubstituteVars(t *testing.T) {
configDir := utils.XDGConfigHome()
dataDir := utils.XDGDataHome()
cacheDir := utils.XDGCacheHome()
tests := []struct {
name string
input string
shellDir string
expected string
}{
{
name: "substitutes SHELL_DIR",
input: "input_path = 'SHELL_DIR/matugen/templates/foo.conf'",
shellDir: "/home/user/shell",
expected: "input_path = '/home/user/shell/matugen/templates/foo.conf'",
},
{
name: "substitutes CONFIG_DIR",
input: "output_path = 'CONFIG_DIR/kitty/theme.conf'",
shellDir: "/home/user/shell",
expected: "output_path = '" + configDir + "/kitty/theme.conf'",
},
{
name: "substitutes DATA_DIR",
input: "output_path = 'DATA_DIR/color-schemes/theme.colors'",
shellDir: "/home/user/shell",
expected: "output_path = '" + dataDir + "/color-schemes/theme.colors'",
},
{
name: "substitutes CACHE_DIR",
input: "output_path = 'CACHE_DIR/wal/colors.json'",
shellDir: "/home/user/shell",
expected: "output_path = '" + cacheDir + "/wal/colors.json'",
},
{
name: "substitutes all dir types",
input: "'SHELL_DIR/a' 'CONFIG_DIR/b' 'DATA_DIR/c' 'CACHE_DIR/d'",
shellDir: "/shell",
expected: "'/shell/a' '" + configDir + "/b' '" + dataDir + "/c' '" + cacheDir + "/d'",
},
{
name: "no substitution when no placeholders",
input: "input_path = '/absolute/path/foo.conf'",
shellDir: "/home/user/shell",
expected: "input_path = '/absolute/path/foo.conf'",
},
{
name: "multiple SHELL_DIR occurrences",
input: "'SHELL_DIR/a' and 'SHELL_DIR/b'",
shellDir: "/shell",
expected: "'/shell/a' and '/shell/b'",
},
{
name: "only substitutes quoted paths",
input: "SHELL_DIR/unquoted and 'SHELL_DIR/quoted'",
shellDir: "/shell",
expected: "SHELL_DIR/unquoted and '/shell/quoted'",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := substituteVars(tc.input, tc.shellDir)
assert.Equal(t, tc.expected, result)
})
}
}

View File

@@ -0,0 +1,242 @@
// Code generated by mockery v2.53.5. DO NOT EDIT.
package mocks_utils
import mock "github.com/stretchr/testify/mock"
// MockAppChecker is an autogenerated mock type for the AppChecker type
type MockAppChecker struct {
mock.Mock
}
type MockAppChecker_Expecter struct {
mock *mock.Mock
}
func (_m *MockAppChecker) EXPECT() *MockAppChecker_Expecter {
return &MockAppChecker_Expecter{mock: &_m.Mock}
}
// AnyCommandExists provides a mock function with given fields: cmds
func (_m *MockAppChecker) AnyCommandExists(cmds ...string) bool {
_va := make([]interface{}, len(cmds))
for _i := range cmds {
_va[_i] = cmds[_i]
}
var _ca []interface{}
_ca = append(_ca, _va...)
ret := _m.Called(_ca...)
if len(ret) == 0 {
panic("no return value specified for AnyCommandExists")
}
var r0 bool
if rf, ok := ret.Get(0).(func(...string) bool); ok {
r0 = rf(cmds...)
} else {
r0 = ret.Get(0).(bool)
}
return r0
}
// MockAppChecker_AnyCommandExists_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AnyCommandExists'
type MockAppChecker_AnyCommandExists_Call struct {
*mock.Call
}
// AnyCommandExists is a helper method to define mock.On call
// - cmds ...string
func (_e *MockAppChecker_Expecter) AnyCommandExists(cmds ...interface{}) *MockAppChecker_AnyCommandExists_Call {
return &MockAppChecker_AnyCommandExists_Call{Call: _e.mock.On("AnyCommandExists",
append([]interface{}{}, cmds...)...)}
}
func (_c *MockAppChecker_AnyCommandExists_Call) Run(run func(cmds ...string)) *MockAppChecker_AnyCommandExists_Call {
_c.Call.Run(func(args mock.Arguments) {
variadicArgs := make([]string, len(args)-0)
for i, a := range args[0:] {
if a != nil {
variadicArgs[i] = a.(string)
}
}
run(variadicArgs...)
})
return _c
}
func (_c *MockAppChecker_AnyCommandExists_Call) Return(_a0 bool) *MockAppChecker_AnyCommandExists_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockAppChecker_AnyCommandExists_Call) RunAndReturn(run func(...string) bool) *MockAppChecker_AnyCommandExists_Call {
_c.Call.Return(run)
return _c
}
// AnyFlatpakExists provides a mock function with given fields: flatpaks
func (_m *MockAppChecker) AnyFlatpakExists(flatpaks ...string) bool {
_va := make([]interface{}, len(flatpaks))
for _i := range flatpaks {
_va[_i] = flatpaks[_i]
}
var _ca []interface{}
_ca = append(_ca, _va...)
ret := _m.Called(_ca...)
if len(ret) == 0 {
panic("no return value specified for AnyFlatpakExists")
}
var r0 bool
if rf, ok := ret.Get(0).(func(...string) bool); ok {
r0 = rf(flatpaks...)
} else {
r0 = ret.Get(0).(bool)
}
return r0
}
// MockAppChecker_AnyFlatpakExists_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AnyFlatpakExists'
type MockAppChecker_AnyFlatpakExists_Call struct {
*mock.Call
}
// AnyFlatpakExists is a helper method to define mock.On call
// - flatpaks ...string
func (_e *MockAppChecker_Expecter) AnyFlatpakExists(flatpaks ...interface{}) *MockAppChecker_AnyFlatpakExists_Call {
return &MockAppChecker_AnyFlatpakExists_Call{Call: _e.mock.On("AnyFlatpakExists",
append([]interface{}{}, flatpaks...)...)}
}
func (_c *MockAppChecker_AnyFlatpakExists_Call) Run(run func(flatpaks ...string)) *MockAppChecker_AnyFlatpakExists_Call {
_c.Call.Run(func(args mock.Arguments) {
variadicArgs := make([]string, len(args)-0)
for i, a := range args[0:] {
if a != nil {
variadicArgs[i] = a.(string)
}
}
run(variadicArgs...)
})
return _c
}
func (_c *MockAppChecker_AnyFlatpakExists_Call) Return(_a0 bool) *MockAppChecker_AnyFlatpakExists_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockAppChecker_AnyFlatpakExists_Call) RunAndReturn(run func(...string) bool) *MockAppChecker_AnyFlatpakExists_Call {
_c.Call.Return(run)
return _c
}
// CommandExists provides a mock function with given fields: cmd
func (_m *MockAppChecker) CommandExists(cmd string) bool {
ret := _m.Called(cmd)
if len(ret) == 0 {
panic("no return value specified for CommandExists")
}
var r0 bool
if rf, ok := ret.Get(0).(func(string) bool); ok {
r0 = rf(cmd)
} else {
r0 = ret.Get(0).(bool)
}
return r0
}
// MockAppChecker_CommandExists_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CommandExists'
type MockAppChecker_CommandExists_Call struct {
*mock.Call
}
// CommandExists is a helper method to define mock.On call
// - cmd string
func (_e *MockAppChecker_Expecter) CommandExists(cmd interface{}) *MockAppChecker_CommandExists_Call {
return &MockAppChecker_CommandExists_Call{Call: _e.mock.On("CommandExists", cmd)}
}
func (_c *MockAppChecker_CommandExists_Call) Run(run func(cmd string)) *MockAppChecker_CommandExists_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(string))
})
return _c
}
func (_c *MockAppChecker_CommandExists_Call) Return(_a0 bool) *MockAppChecker_CommandExists_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockAppChecker_CommandExists_Call) RunAndReturn(run func(string) bool) *MockAppChecker_CommandExists_Call {
_c.Call.Return(run)
return _c
}
// FlatpakExists provides a mock function with given fields: name
func (_m *MockAppChecker) FlatpakExists(name string) bool {
ret := _m.Called(name)
if len(ret) == 0 {
panic("no return value specified for FlatpakExists")
}
var r0 bool
if rf, ok := ret.Get(0).(func(string) bool); ok {
r0 = rf(name)
} else {
r0 = ret.Get(0).(bool)
}
return r0
}
// MockAppChecker_FlatpakExists_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FlatpakExists'
type MockAppChecker_FlatpakExists_Call struct {
*mock.Call
}
// FlatpakExists is a helper method to define mock.On call
// - name string
func (_e *MockAppChecker_Expecter) FlatpakExists(name interface{}) *MockAppChecker_FlatpakExists_Call {
return &MockAppChecker_FlatpakExists_Call{Call: _e.mock.On("FlatpakExists", name)}
}
func (_c *MockAppChecker_FlatpakExists_Call) Run(run func(name string)) *MockAppChecker_FlatpakExists_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(string))
})
return _c
}
func (_c *MockAppChecker_FlatpakExists_Call) Return(_a0 bool) *MockAppChecker_FlatpakExists_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockAppChecker_FlatpakExists_Call) RunAndReturn(run func(string) bool) *MockAppChecker_FlatpakExists_Call {
_c.Call.Return(run)
return _c
}
// NewMockAppChecker creates a new instance of MockAppChecker. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewMockAppChecker(t interface {
mock.TestingT
Cleanup(func())
}) *MockAppChecker {
mock := &MockAppChecker{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

View File

@@ -26,6 +26,7 @@ type Plugin struct {
Compositors []string `json:"compositors"` Compositors []string `json:"compositors"`
Distro []string `json:"distro"` Distro []string `json:"distro"`
Screenshot string `json:"screenshot,omitempty"` Screenshot string `json:"screenshot,omitempty"`
RequiresDMS string `json:"requires_dms,omitempty"`
} }
type GitClient interface { type GitClient interface {

View File

@@ -141,7 +141,7 @@ func (r *RegionSelector) setupKeyboardHandlers() {
for _, os := range r.surfaces { for _, os := range r.surfaces {
r.redrawSurface(os) r.redrawSurface(os)
} }
case 28, 57: case 28, 57, 96:
if r.selection.hasSelection { if r.selection.hasSelection {
r.finishSelection() 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) { func handleOpen(conn net.Conn, req models.Request, manager *Manager) {
log.Infof("AppPicker: Received %s request with params: %+v", req.Method, req.Params) 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 { if !ok {
target, ok = req.Params["url"].(string) target, ok = models.Get[string](req, "url")
if !ok { if !ok {
log.Warnf("AppPicker: Invalid target parameter in request") log.Warnf("AppPicker: Invalid target parameter in request")
models.RespondError(conn, req.ID, "invalid target parameter") models.RespondError(conn, req.ID, "invalid target parameter")
@@ -31,14 +31,11 @@ func handleOpen(conn net.Conn, req models.Request, manager *Manager) {
event := OpenEvent{ event := OpenEvent{
Target: target, Target: target,
RequestType: "url", RequestType: models.GetOr(req, "requestType", "url"),
MimeType: models.GetOr(req, "mimeType", ""),
} }
if mimeType, ok := req.Params["mimeType"].(string); ok { if categories, ok := models.Get[[]any](req, "categories"); ok {
event.MimeType = mimeType
}
if categories, ok := req.Params["categories"].([]any); ok {
event.Categories = make([]string, 0, len(categories)) event.Categories = make([]string, 0, len(categories))
for _, cat := range categories { for _, cat := range categories {
if catStr, ok := cat.(string); ok { 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) log.Infof("AppPicker: Broadcasting event: %+v", event)
manager.RequestOpen(event) manager.RequestOpen(event)
models.Respond(conn, req.ID, "ok") models.Respond(conn, req.ID, "ok")

View File

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

View File

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

View File

@@ -11,30 +11,34 @@ import (
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
"slices"
"strings" "strings"
"syscall" "syscall"
"time" "time"
"hash/fnv"
"github.com/fsnotify/fsnotify" "github.com/fsnotify/fsnotify"
_ "golang.org/x/image/bmp" _ "golang.org/x/image/bmp"
_ "golang.org/x/image/tiff" _ "golang.org/x/image/tiff"
"hash/fnv"
bolt "go.etcd.io/bbolt" bolt "go.etcd.io/bbolt"
clipboardstore "github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/ext_data_control" "github.com/AvengeMedia/DankMaterialShell/core/internal/proto/ext_data_control"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext"
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client" wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
) )
func NewManager(wlCtx wlcontext.WaylandContext, config Config) (*Manager, error) { // These mime types won't be stored in history
if config.Disabled { var sensitiveMimeTypes = []string{
return nil, fmt.Errorf("clipboard disabled in config") "x-kde-passwordManagerHint",
} }
func NewManager(wlCtx wlcontext.WaylandContext, config Config) (*Manager, error) {
display := wlCtx.Display() display := wlCtx.Display()
dbPath, err := getDBPath() dbPath, err := clipboardstore.GetDBPath()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get db path: %w", err) return nil, fmt.Errorf("failed to get db path: %w", err)
} }
@@ -54,8 +58,10 @@ func NewManager(wlCtx wlcontext.WaylandContext, config Config) (*Manager, error)
dbPath: dbPath, dbPath: dbPath,
} }
if err := m.setupRegistry(); err != nil { if !config.Disabled {
return nil, err if err := m.setupRegistry(); err != nil {
return nil, err
}
} }
m.notifierWg.Add(1) m.notifierWg.Add(1)
@@ -63,17 +69,17 @@ func NewManager(wlCtx wlcontext.WaylandContext, config Config) (*Manager, error)
go m.watchConfig() go m.watchConfig()
if !config.DisableHistory { db, err := openDB(dbPath)
db, err := openDB(dbPath) if err != nil {
if err != nil { return nil, fmt.Errorf("failed to open db: %w", err)
return nil, fmt.Errorf("failed to open db: %w", err) }
} m.db = db
m.db = db
if err := m.migrateHashes(); err != nil { if err := m.migrateHashes(); err != nil {
log.Errorf("Failed to migrate hashes: %v", err) log.Errorf("Failed to migrate hashes: %v", err)
} }
if !config.Disabled {
if config.ClearAtStartup { if config.ClearAtStartup {
if err := m.clearHistoryInternal(); err != nil { if err := m.clearHistoryInternal(); err != nil {
log.Errorf("Failed to clear history at startup: %v", err) log.Errorf("Failed to clear history at startup: %v", err)
@@ -90,31 +96,13 @@ func NewManager(wlCtx wlcontext.WaylandContext, config Config) (*Manager, error)
m.alive = true m.alive = true
m.updateState() m.updateState()
if m.dataControlMgr != nil && m.seat != nil { if !config.Disabled && m.dataControlMgr != nil && m.seat != nil {
m.setupDataDeviceSync() m.setupDataDeviceSync()
} }
return m, nil return m, nil
} }
func getDBPath() (string, error) {
cacheDir := os.Getenv("XDG_CACHE_HOME")
if cacheDir == "" {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", err
}
cacheDir = filepath.Join(homeDir, ".cache")
}
dbDir := filepath.Join(cacheDir, "dms-clipboard")
if err := os.MkdirAll(dbDir, 0700); err != nil {
return "", err
}
return filepath.Join(dbDir, "db"), nil
}
func openDB(path string) (*bolt.DB, error) { func openDB(path string) (*bolt.DB, error) {
db, err := bolt.Open(path, 0644, &bolt.Options{ db, err := bolt.Open(path, 0644, &bolt.Options{
Timeout: 1 * time.Second, Timeout: 1 * time.Second,
@@ -253,6 +241,10 @@ func (m *Manager) setupDataDeviceSync() {
return return
} }
if m.hasSensitiveMimeType(mimes) {
return
}
preferredMime := m.selectMimeType(mimes) preferredMime := m.selectMimeType(mimes)
if preferredMime == "" { if preferredMime == "" {
return return
@@ -315,7 +307,7 @@ func (m *Manager) readAndStore(r *os.File, mimeType string) {
return return
} }
if !cfg.DisableHistory && m.db != nil { if !cfg.Disabled && m.db != nil {
m.storeClipboardEntry(data, mimeType) m.storeClipboardEntry(data, mimeType)
} }
@@ -492,6 +484,12 @@ func extractHash(data []byte) uint64 {
return binary.BigEndian.Uint64(data[len(data)-8:]) return binary.BigEndian.Uint64(data[len(data)-8:])
} }
func (m *Manager) hasSensitiveMimeType(mimes []string) bool {
return slices.ContainsFunc(mimes, func(mime string) bool {
return slices.Contains(sensitiveMimeTypes, mime)
})
}
func (m *Manager) selectMimeType(mimes []string) string { func (m *Manager) selectMimeType(mimes []string) string {
preferredTypes := []string{ preferredTypes := []string{
"text/plain;charset=utf-8", "text/plain;charset=utf-8",
@@ -1194,23 +1192,13 @@ func (m *Manager) applyConfigChange(newCfg Config) {
m.config = newCfg m.config = newCfg
m.configMutex.Unlock() m.configMutex.Unlock()
if newCfg.DisableHistory && !oldCfg.DisableHistory && m.db != nil { switch {
log.Info("Clipboard history disabled, closing database") case newCfg.Disabled && !oldCfg.Disabled:
m.db.Close() log.Info("Clipboard tracking disabled")
m.db = nil 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.updateState()
m.notifySubscribers() m.notifySubscribers()
} }
@@ -1218,8 +1206,8 @@ func (m *Manager) applyConfigChange(newCfg Config) {
func (m *Manager) StoreData(data []byte, mimeType string) error { func (m *Manager) StoreData(data []byte, mimeType string) error {
cfg := m.getConfig() cfg := m.getConfig()
if cfg.DisableHistory { if cfg.Disabled {
return fmt.Errorf("clipboard history disabled") return fmt.Errorf("clipboard tracking disabled")
} }
if m.db == nil { if m.db == nil {

View File

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

View File

@@ -18,9 +18,7 @@ type Config struct {
MaxEntrySize int64 `json:"maxEntrySize"` MaxEntrySize int64 `json:"maxEntrySize"`
AutoClearDays int `json:"autoClearDays"` AutoClearDays int `json:"autoClearDays"`
ClearAtStartup bool `json:"clearAtStartup"` ClearAtStartup bool `json:"clearAtStartup"`
Disabled bool `json:"disabled"`
Disabled bool `json:"disabled"`
DisableHistory bool `json:"disableHistory"`
} }
func DefaultConfig() Config { 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) { 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 { if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'output' parameter") models.RespondError(conn, req.ID, "missing or invalid 'output' parameter")
return return
} }
tagmask, ok := req.Params["tagmask"].(float64) tagmask, ok := models.Get[float64](req, "tagmask")
if !ok { if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'tagmask' parameter") models.RespondError(conn, req.ID, "missing or invalid 'tagmask' parameter")
return return
} }
toggleTagset, ok := req.Params["toggleTagset"].(float64) toggleTagset, ok := models.Get[float64](req, "toggleTagset")
if !ok { if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'toggleTagset' parameter") models.RespondError(conn, req.ID, "missing or invalid 'toggleTagset' parameter")
return 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) { 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 { if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'output' parameter") models.RespondError(conn, req.ID, "missing or invalid 'output' parameter")
return return
} }
andTags, ok := req.Params["andTags"].(float64) andTags, ok := models.Get[float64](req, "andTags")
if !ok { if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'andTags' parameter") models.RespondError(conn, req.ID, "missing or invalid 'andTags' parameter")
return return
} }
xorTags, ok := req.Params["xorTags"].(float64) xorTags, ok := models.Get[float64](req, "xorTags")
if !ok { if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'xorTags' parameter") models.RespondError(conn, req.ID, "missing or invalid 'xorTags' parameter")
return 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) { 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 { if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'output' parameter") models.RespondError(conn, req.ID, "missing or invalid 'output' parameter")
return return
} }
index, ok := req.Params["index"].(float64) index, ok := models.Get[float64](req, "index")
if !ok { if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'index' parameter") models.RespondError(conn, req.ID, "missing or invalid 'index' parameter")
return 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) { func handleActivateWorkspace(conn net.Conn, req models.Request, manager *Manager) {
groupID, ok := req.Params["groupID"].(string) groupID := models.GetOr(req, "groupID", "")
if !ok { workspaceID, ok := models.Get[string](req, "workspaceID")
groupID = ""
}
workspaceID, ok := req.Params["workspaceID"].(string)
if !ok { if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'workspaceID' parameter") models.RespondError(conn, req.ID, "missing or invalid 'workspaceID' parameter")
return 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) { func handleDeactivateWorkspace(conn net.Conn, req models.Request, manager *Manager) {
groupID, ok := req.Params["groupID"].(string) groupID := models.GetOr(req, "groupID", "")
if !ok { workspaceID, ok := models.Get[string](req, "workspaceID")
groupID = ""
}
workspaceID, ok := req.Params["workspaceID"].(string)
if !ok { if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'workspaceID' parameter") models.RespondError(conn, req.ID, "missing or invalid 'workspaceID' parameter")
return 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) { func handleRemoveWorkspace(conn net.Conn, req models.Request, manager *Manager) {
groupID, ok := req.Params["groupID"].(string) groupID := models.GetOr(req, "groupID", "")
if !ok { workspaceID, ok := models.Get[string](req, "workspaceID")
groupID = ""
}
workspaceID, ok := req.Params["workspaceID"].(string)
if !ok { if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'workspaceID' parameter") models.RespondError(conn, req.ID, "missing or invalid 'workspaceID' parameter")
return 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) { 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 { if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'groupID' parameter") models.RespondError(conn, req.ID, "missing or invalid 'groupID' parameter")
return return
} }
workspaceName, ok := req.Params["name"].(string) workspaceName, ok := models.Get[string](req, "name")
if !ok { if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'name' parameter") models.RespondError(conn, req.ID, "missing or invalid 'name' parameter")
return return

View File

@@ -15,37 +15,23 @@ type MatugenQueueResult struct {
} }
func handleMatugenQueue(conn net.Conn, req models.Request) { 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{ opts := matugen.Options{
StateDir: getString("stateDir"), StateDir: models.GetOr(req, "stateDir", ""),
ShellDir: getString("shellDir"), ShellDir: models.GetOr(req, "shellDir", ""),
ConfigDir: getString("configDir"), ConfigDir: models.GetOr(req, "configDir", ""),
Kind: getString("kind"), Kind: models.GetOr(req, "kind", ""),
Value: getString("value"), Value: models.GetOr(req, "value", ""),
Mode: getString("mode"), Mode: matugen.ColorMode(models.GetOr(req, "mode", "")),
IconTheme: getString("iconTheme"), IconTheme: models.GetOr(req, "iconTheme", ""),
MatugenType: getString("matugenType"), MatugenType: models.GetOr(req, "matugenType", ""),
RunUserTemplates: getBool("runUserTemplates", true), RunUserTemplates: models.GetOr(req, "runUserTemplates", true),
StockColors: getString("stockColors"), StockColors: models.GetOr(req, "stockColors", ""),
SyncModeWithPortal: getBool("syncModeWithPortal", false), SyncModeWithPortal: models.GetOr(req, "syncModeWithPortal", false),
TerminalsAlwaysDark: getBool("terminalsAlwaysDark", false), TerminalsAlwaysDark: models.GetOr(req, "terminalsAlwaysDark", false),
SkipTemplates: getString("skipTemplates"), SkipTemplates: models.GetOr(req, "skipTemplates", ""),
} }
wait := getBool("wait", true) wait := models.GetOr(req, "wait", true)
queue := matugen.GetQueue() queue := matugen.GetQueue()
resultCh := queue.Submit(opts) resultCh := queue.Submit(opts)

View File

@@ -5,6 +5,7 @@ import (
"net" "net"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/params"
) )
type Request struct { type Request struct {
@@ -13,6 +14,15 @@ type Request struct {
Params map[string]any `json:"params,omitempty"` 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 { type Response[T any] struct {
ID int `json:"id,omitempty"` ID int `json:"id,omitempty"`
Result *T `json:"result,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) { if a.manager != nil && connType == "802-11-wireless" && a.manager.WasRecentlyFailed(ssid) {
reason = "wrong-password" reason = "wrong-password"
} }
if settingName == "vpn" && isPKCS11Auth(conn, vpnSvc) {
reason = "pkcs11"
}
var connId, connUuid string var connId, connUuid string
if c, ok := conn["connection"]; ok { if c, ok := conn["connection"]; ok {
@@ -249,6 +252,28 @@ func (a *SecretAgent) GetSecrets(
} }
if settingName == "vpn" && a.backend != nil { 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() a.backend.cachedVPNCredsMu.Lock()
cached := a.backend.cachedVPNCreds cached := a.backend.cachedVPNCreds
if cached != nil && cached.ConnectionUUID == connUuid { if cached != nil && cached.ConnectionUUID == connUuid {
@@ -258,9 +283,9 @@ func (a *SecretAgent) GetSecrets(
log.Infof("[SecretAgent] Using cached password from pre-activation prompt") log.Infof("[SecretAgent] Using cached password from pre-activation prompt")
out := nmSettingMap{} out := nmSettingMap{}
sec := nmVariantMap{} vpnSec := nmVariantMap{}
sec["password"] = dbus.MakeVariant(cached.Password) vpnSec["secrets"] = dbus.MakeVariant(map[string]string{"password": cached.Password})
out[settingName] = sec out[settingName] = vpnSec
if cached.SavePassword { if cached.SavePassword {
a.backend.pendingVPNSaveMu.Lock() a.backend.pendingVPNSaveMu.Lock()
@@ -364,16 +389,41 @@ func (a *SecretAgent) GetSecrets(
} }
sec[k] = dbus.MakeVariant(v) sec[k] = dbus.MakeVariant(v)
} }
out[settingName] = sec
// Check if this is PKCS11 auth (key_pass)
pin, isPKCS11 := reply.Secrets["key_pass"]
switch settingName { switch settingName {
case "802-1x":
log.Infof("[SecretAgent] Returning 802-1x enterprise secrets with %d fields", len(sec))
case "vpn": 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"] pw := reply.Secrets["password"]
a.backend.pendingVPNSaveMu.Lock() a.backend.pendingVPNSaveMu.Lock()
a.backend.pendingVPNSave = &pendingVPNCredentials{ a.backend.pendingVPNSave = &pendingVPNCredentials{
@@ -579,6 +629,15 @@ func inferVPNFields(conn map[string]nmVariantMap, vpnService string) []string {
connType := dataMap["connection-type"] connType := dataMap["connection-type"]
switch { 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"): case strings.Contains(vpnService, "openvpn"):
if connType == "password" || connType == "password-tls" { if connType == "password" || connType == "password-tls" {
if dataMap["username"] == "" { 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"), case strings.Contains(vpnService, "vpnc"), strings.Contains(vpnService, "l2tp"),
strings.Contains(vpnService, "pptp"), strings.Contains(vpnService, "openconnect"): strings.Contains(vpnService, "pptp"):
if dataMap["username"] == "" { if dataMap["username"] == "" {
fields = []string{"username", "password"} 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) { func vpnFieldMeta(field, vpnService string) (label string, isSecret bool) {
switch field { switch field {
case "key_pass":
return "PIN", true
case "password": case "password":
return "Password", true return "Password", true
case "Xauth password": case "Xauth password":
@@ -624,6 +685,25 @@ func vpnFieldMeta(field, vpnService string) (label string, isSecret bool) {
return titleCaser.String(strings.ReplaceAll(field, "-", " ")), false 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 { func readVPNPasswordFlags(conn map[string]nmVariantMap, settingName string) uint32 {
if settingName != "vpn" { if settingName != "vpn" {
return 0xFFFF return 0xFFFF

View File

@@ -13,6 +13,7 @@ const (
dbusNMPath = "/org/freedesktop/NetworkManager" dbusNMPath = "/org/freedesktop/NetworkManager"
dbusNMInterface = "org.freedesktop.NetworkManager" dbusNMInterface = "org.freedesktop.NetworkManager"
dbusNMDeviceInterface = "org.freedesktop.NetworkManager.Device" dbusNMDeviceInterface = "org.freedesktop.NetworkManager.Device"
dbusNMWiredInterface = "org.freedesktop.NetworkManager.Device.Wired"
dbusNMWirelessInterface = "org.freedesktop.NetworkManager.Device.Wireless" dbusNMWirelessInterface = "org.freedesktop.NetworkManager.Device.Wireless"
dbusNMAccessPointInterface = "org.freedesktop.NetworkManager.AccessPoint" dbusNMAccessPointInterface = "org.freedesktop.NetworkManager.AccessPoint"
dbusPropsInterface = "org.freedesktop.DBus.Properties" dbusPropsInterface = "org.freedesktop.DBus.Properties"
@@ -72,6 +73,8 @@ type NetworkManagerBackend struct {
pendingVPNSaveMu sync.Mutex pendingVPNSaveMu sync.Mutex
cachedVPNCreds *cachedVPNCredentials cachedVPNCreds *cachedVPNCredentials
cachedVPNCredsMu sync.Mutex cachedVPNCredsMu sync.Mutex
cachedPKCS11PIN *cachedPKCS11PIN
cachedPKCS11Mu sync.Mutex
onStateChange func() onStateChange func()
} }
@@ -89,6 +92,11 @@ type cachedVPNCredentials struct {
SavePassword bool SavePassword bool
} }
type cachedPKCS11PIN struct {
ConnectionUUID string
PIN string
}
func NewNetworkManagerBackend(nmConn ...gonetworkmanager.NetworkManager) (*NetworkManagerBackend, error) { func NewNetworkManagerBackend(nmConn ...gonetworkmanager.NetworkManager) (*NetworkManagerBackend, error) {
var nm gonetworkmanager.NetworkManager var nm gonetworkmanager.NetworkManager
var err error var err error

View File

@@ -81,44 +81,24 @@ func (b *NetworkManagerBackend) startSignalPump() error {
return err return err
} }
if b.wifiDevice != nil { for _, info := range b.wifiDevices {
dev := b.wifiDevice.(gonetworkmanager.Device)
if err := conn.AddMatchSignal( if err := conn.AddMatchSignal(
dbus.WithMatchObjectPath(dbus.ObjectPath(dev.GetPath())), dbus.WithMatchObjectPath(dbus.ObjectPath(info.device.GetPath())),
dbus.WithMatchInterface(dbusPropsInterface), dbus.WithMatchInterface(dbusPropsInterface),
dbus.WithMatchMember("PropertiesChanged"), dbus.WithMatchMember("PropertiesChanged"),
); err != nil { ); err != nil {
conn.RemoveMatchSignal(
dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMPath)),
dbus.WithMatchInterface(dbusPropsInterface),
dbus.WithMatchMember("PropertiesChanged"),
)
conn.RemoveSignal(signals) conn.RemoveSignal(signals)
conn.Close() conn.Close()
return err return err
} }
} }
if b.ethernetDevice != nil { for _, info := range b.ethernetDevices {
dev := b.ethernetDevice.(gonetworkmanager.Device)
if err := conn.AddMatchSignal( if err := conn.AddMatchSignal(
dbus.WithMatchObjectPath(dbus.ObjectPath(dev.GetPath())), dbus.WithMatchObjectPath(dbus.ObjectPath(info.device.GetPath())),
dbus.WithMatchInterface(dbusPropsInterface), dbus.WithMatchInterface(dbusPropsInterface),
dbus.WithMatchMember("PropertiesChanged"), dbus.WithMatchMember("PropertiesChanged"),
); err != nil { ); err != nil {
conn.RemoveMatchSignal(
dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMPath)),
dbus.WithMatchInterface(dbusPropsInterface),
dbus.WithMatchMember("PropertiesChanged"),
)
if b.wifiDevice != nil {
dev := b.wifiDevice.(gonetworkmanager.Device)
conn.RemoveMatchSignal(
dbus.WithMatchObjectPath(dbus.ObjectPath(dev.GetPath())),
dbus.WithMatchInterface(dbusPropsInterface),
dbus.WithMatchMember("PropertiesChanged"),
)
}
conn.RemoveSignal(signals) conn.RemoveSignal(signals)
conn.Close() conn.Close()
return err return err
@@ -157,19 +137,17 @@ func (b *NetworkManagerBackend) stopSignalPump() {
dbus.WithMatchMember("PropertiesChanged"), dbus.WithMatchMember("PropertiesChanged"),
) )
if b.wifiDevice != nil { for _, info := range b.wifiDevices {
dev := b.wifiDevice.(gonetworkmanager.Device)
b.dbusConn.RemoveMatchSignal( b.dbusConn.RemoveMatchSignal(
dbus.WithMatchObjectPath(dbus.ObjectPath(dev.GetPath())), dbus.WithMatchObjectPath(dbus.ObjectPath(info.device.GetPath())),
dbus.WithMatchInterface(dbusPropsInterface), dbus.WithMatchInterface(dbusPropsInterface),
dbus.WithMatchMember("PropertiesChanged"), dbus.WithMatchMember("PropertiesChanged"),
) )
} }
if b.ethernetDevice != nil { for _, info := range b.ethernetDevices {
dev := b.ethernetDevice.(gonetworkmanager.Device)
b.dbusConn.RemoveMatchSignal( b.dbusConn.RemoveMatchSignal(
dbus.WithMatchObjectPath(dbus.ObjectPath(dev.GetPath())), dbus.WithMatchObjectPath(dbus.ObjectPath(info.device.GetPath())),
dbus.WithMatchInterface(dbusPropsInterface), dbus.WithMatchInterface(dbusPropsInterface),
dbus.WithMatchMember("PropertiesChanged"), dbus.WithMatchMember("PropertiesChanged"),
) )
@@ -232,7 +210,10 @@ func (b *NetworkManagerBackend) handleDBusSignal(sig *dbus.Signal) {
b.handleNetworkManagerChange(changes) b.handleNetworkManagerChange(changes)
case dbusNMDeviceInterface: case dbusNMDeviceInterface:
b.handleDeviceChange(changes) b.handleDeviceChange(sig.Path, changes)
case dbusNMWiredInterface:
b.handleWiredChange(changes)
case dbusNMWirelessInterface: case dbusNMWirelessInterface:
b.handleWiFiChange(changes) b.handleWiFiChange(changes)
@@ -278,9 +259,10 @@ func (b *NetworkManagerBackend) handleNetworkManagerChange(changes map[string]db
} }
} }
func (b *NetworkManagerBackend) handleDeviceChange(changes map[string]dbus.Variant) { func (b *NetworkManagerBackend) handleDeviceChange(devicePath dbus.ObjectPath, changes map[string]dbus.Variant) {
var needsUpdate bool var needsUpdate bool
var stateChanged bool var stateChanged bool
var managedChanged bool
for key := range changes { for key := range changes {
switch key { switch key {
@@ -289,21 +271,61 @@ func (b *NetworkManagerBackend) handleDeviceChange(changes map[string]dbus.Varia
needsUpdate = true needsUpdate = true
case "Ip4Config": case "Ip4Config":
needsUpdate = true needsUpdate = true
case "Managed":
managedChanged = true
default: default:
continue continue
} }
} }
if needsUpdate { if managedChanged {
b.updateEthernetState() if managedVariant, ok := changes["Managed"]; ok {
b.updateWiFiState() if managed, ok := managedVariant.Value().(bool); ok && managed {
if stateChanged { b.handleDeviceAdded(devicePath)
b.updatePrimaryConnection() return
}
} }
if b.onStateChange != nil { }
b.onStateChange()
if !needsUpdate {
return
}
b.updateAllEthernetDevices()
b.updateEthernetState()
b.updateAllWiFiDevices()
b.updateWiFiState()
if stateChanged {
b.listEthernetConnections()
b.updatePrimaryConnection()
}
if b.onStateChange != nil {
b.onStateChange()
}
}
func (b *NetworkManagerBackend) handleWiredChange(changes map[string]dbus.Variant) {
var needsUpdate bool
for key := range changes {
switch key {
case "Carrier", "Speed", "HwAddress":
needsUpdate = true
default:
continue
} }
} }
if !needsUpdate {
return
}
b.updateAllEthernetDevices()
b.updateEthernetState()
b.updatePrimaryConnection()
if b.onStateChange != nil {
b.onStateChange()
}
} }
func (b *NetworkManagerBackend) handleWiFiChange(changes map[string]dbus.Variant) { func (b *NetworkManagerBackend) handleWiFiChange(changes map[string]dbus.Variant) {
@@ -369,6 +391,18 @@ func (b *NetworkManagerBackend) handleDeviceAdded(devicePath dbus.ObjectPath) {
return return
} }
if devType != gonetworkmanager.NmDeviceTypeEthernet && devType != gonetworkmanager.NmDeviceTypeWifi {
return
}
if b.dbusConn != nil {
b.dbusConn.AddMatchSignal(
dbus.WithMatchObjectPath(devicePath),
dbus.WithMatchInterface(dbusPropsInterface),
dbus.WithMatchMember("PropertiesChanged"),
)
}
managed, _ := dev.GetPropertyManaged() managed, _ := dev.GetPropertyManaged()
if !managed { if !managed {
return return
@@ -398,14 +432,6 @@ func (b *NetworkManagerBackend) handleDeviceAdded(devicePath dbus.ObjectPath) {
b.ethernetDevice = dev b.ethernetDevice = dev
} }
if b.dbusConn != nil {
b.dbusConn.AddMatchSignal(
dbus.WithMatchObjectPath(devicePath),
dbus.WithMatchInterface(dbusPropsInterface),
dbus.WithMatchMember("PropertiesChanged"),
)
}
b.updateAllEthernetDevices() b.updateAllEthernetDevices()
b.updateEthernetState() b.updateEthernetState()
b.listEthernetConnections() b.listEthernetConnections()
@@ -430,14 +456,6 @@ func (b *NetworkManagerBackend) handleDeviceAdded(devicePath dbus.ObjectPath) {
b.wifiDev = w b.wifiDev = w
} }
if b.dbusConn != nil {
b.dbusConn.AddMatchSignal(
dbus.WithMatchObjectPath(devicePath),
dbus.WithMatchInterface(dbusPropsInterface),
dbus.WithMatchMember("PropertiesChanged"),
)
}
b.updateAllWiFiDevices() b.updateAllWiFiDevices()
b.updateWiFiState() b.updateWiFiState()
} }

View File

@@ -159,7 +159,7 @@ func TestNetworkManagerBackend_HandleDeviceChange(t *testing.T) {
} }
assert.NotPanics(t, func() { assert.NotPanics(t, func() {
backend.handleDeviceChange(changes) backend.handleDeviceChange("/org/freedesktop/NetworkManager/Devices/1", changes)
}) })
} }
@@ -174,7 +174,7 @@ func TestNetworkManagerBackend_HandleDeviceChange_Ip4Config(t *testing.T) {
} }
assert.NotPanics(t, func() { assert.NotPanics(t, func() {
backend.handleDeviceChange(changes) backend.handleDeviceChange("/org/freedesktop/NetworkManager/Devices/1", changes)
}) })
} }

View File

@@ -3,6 +3,7 @@ package network
import ( import (
"bufio" "bufio"
"context" "context"
"errors"
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
@@ -282,111 +283,26 @@ func (b *NetworkManagerBackend) ConnectVPN(uuidOrName string, singleActive bool)
} }
} }
needsUsernamePrePrompt := false
var vpnServiceType string var vpnServiceType string
var vpnData map[string]string
if vpnSettings, ok := targetSettings["vpn"]; ok { if vpnSettings, ok := targetSettings["vpn"]; ok {
if svc, ok := vpnSettings["service-type"].(string); ok { if svc, ok := vpnSettings["service-type"].(string); ok {
vpnServiceType = svc vpnServiceType = svc
} }
if data, ok := vpnSettings["data"].(map[string]string); ok { if data, ok := vpnSettings["data"].(map[string]string); ok {
connType := data["connection-type"] vpnData = data
username := data["username"]
// OpenVPN password auth needs username in vpn.data
if strings.Contains(vpnServiceType, "openvpn") &&
(connType == "password" || connType == "password-tls") &&
username == "" {
needsUsernamePrePrompt = true
}
} }
} }
// If username is needed but missing, prompt for it before activating authAction := detectVPNAuthAction(vpnServiceType, vpnData)
if needsUsernamePrePrompt && b.promptBroker != nil {
log.Infof("[ConnectVPN] OpenVPN requires username in vpn.data - prompting before activation")
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) switch authAction {
defer cancel() case "openvpn_username":
if b.promptBroker == nil {
token, err := b.promptBroker.Ask(ctx, PromptRequest{ return fmt.Errorf("OpenVPN password authentication requires interactive prompt")
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)
} }
if err := b.handleOpenVPNUsernameAuth(targetConn, connName, targetUUID, vpnServiceType); err != nil {
reply, err := b.promptBroker.Wait(ctx, token) return err
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")
}
} }
} }
@@ -417,6 +333,119 @@ func (b *NetworkManagerBackend) ConnectVPN(uuidOrName string, singleActive bool)
return nil 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 { func (b *NetworkManagerBackend) DisconnectVPN(uuidOrName string) error {
nm := b.nmConn.(gonetworkmanager.NetworkManager) nm := b.nmConn.(gonetworkmanager.NetworkManager)
@@ -655,6 +684,11 @@ func (b *NetworkManagerBackend) updateVPNConnectionState() {
b.state.LastError = "" b.state.LastError = ""
b.stateMutex.Unlock() b.stateMutex.Unlock()
// Clear cached PKCS11 PIN on success
b.cachedPKCS11Mu.Lock()
b.cachedPKCS11PIN = nil
b.cachedPKCS11Mu.Unlock()
b.pendingVPNSaveMu.Lock() b.pendingVPNSaveMu.Lock()
pending := b.pendingVPNSave pending := b.pendingVPNSave
b.pendingVPNSave = nil b.pendingVPNSave = nil
@@ -671,6 +705,11 @@ func (b *NetworkManagerBackend) updateVPNConnectionState() {
b.state.ConnectingVPNUUID = "" b.state.ConnectingVPNUUID = ""
b.state.LastError = "VPN connection failed" b.state.LastError = "VPN connection failed"
b.stateMutex.Unlock() b.stateMutex.Unlock()
// Clear cached PKCS11 PIN on failure
b.cachedPKCS11Mu.Lock()
b.cachedPKCS11PIN = nil
b.cachedPKCS11Mu.Unlock()
return return
} }
} }
@@ -683,6 +722,11 @@ func (b *NetworkManagerBackend) updateVPNConnectionState() {
b.state.ConnectingVPNUUID = "" b.state.ConnectingVPNUUID = ""
b.state.LastError = "VPN connection failed" b.state.LastError = "VPN connection failed"
b.stateMutex.Unlock() b.stateMutex.Unlock()
// Clear cached PKCS11 PIN
b.cachedPKCS11Mu.Lock()
b.cachedPKCS11PIN = nil
b.cachedPKCS11Mu.Unlock()
} }
} }
@@ -882,25 +926,24 @@ func (b *NetworkManagerBackend) ImportVPN(filePath string, name string) (*VPNImp
func (b *NetworkManagerBackend) importVPNWithNmcli(filePath string, name string) (*VPNImportResult, error) { func (b *NetworkManagerBackend) importVPNWithNmcli(filePath string, name string) (*VPNImportResult, error) {
vpnTypes := []string{"openvpn", "wireguard", "vpnc", "pptp", "l2tp", "openconnect", "strongswan"} vpnTypes := []string{"openvpn", "wireguard", "vpnc", "pptp", "l2tp", "openconnect", "strongswan"}
var output []byte var allErrors []error
var err error var outputStr string
for _, vpnType := range vpnTypes { for _, vpnType := range vpnTypes {
args := []string{"connection", "import", "type", vpnType, "file", filePath} cmd := exec.Command("nmcli", "connection", "import", "type", vpnType, "file", filePath)
cmd := exec.Command("nmcli", args...) output, err := cmd.CombinedOutput()
output, err = cmd.CombinedOutput()
if err == nil { if err == nil {
outputStr = string(output)
break break
} }
allErrors = append(allErrors, fmt.Errorf("%s: %s", vpnType, strings.TrimSpace(string(output))))
} }
if err != nil { if len(allErrors) == len(vpnTypes) {
return &VPNImportResult{ return &VPNImportResult{
Success: false, Success: false,
Error: fmt.Sprintf("import failed: %s", strings.TrimSpace(string(output))), Error: errors.Join(allErrors...).Error(),
}, nil }, nil
} }
outputStr := string(output)
var connUUID, connName string var connUUID, connName string
lines := strings.Split(outputStr, "\n") lines := strings.Split(outputStr, "\n")

View File

@@ -357,31 +357,51 @@ func (b *NetworkManagerBackend) updateWiFiNetworks() ([]WiFiNetwork, error) {
savedSSIDs := make(map[string]bool) savedSSIDs := make(map[string]bool)
autoconnectMap := make(map[string]bool) autoconnectMap := make(map[string]bool)
hiddenSSIDs := make(map[string]bool)
for _, conn := range connections { for _, conn := range connections {
connSettings, err := conn.GetSettings() connSettings, err := conn.GetSettings()
if err != nil { if err != nil {
continue continue
} }
if connMeta, ok := connSettings["connection"]; ok { connMeta, ok := connSettings["connection"]
if connType, ok := connMeta["type"].(string); ok && connType == "802-11-wireless" { if !ok {
if wifiSettings, ok := connSettings["802-11-wireless"]; ok { continue
if ssidBytes, ok := wifiSettings["ssid"].([]byte); ok { }
ssid := string(ssidBytes)
savedSSIDs[ssid] = true connType, ok := connMeta["type"].(string)
autoconnect := true if !ok || connType != "802-11-wireless" {
if ac, ok := connMeta["autoconnect"].(bool); ok { continue
autoconnect = ac }
}
autoconnectMap[ssid] = autoconnect wifiSettings, ok := connSettings["802-11-wireless"]
} if !ok {
} continue
} }
ssidBytes, ok := wifiSettings["ssid"].([]byte)
if !ok {
continue
}
ssid := string(ssidBytes)
savedSSIDs[ssid] = true
autoconnect := true
if ac, ok := connMeta["autoconnect"].(bool); ok {
autoconnect = ac
}
autoconnectMap[ssid] = autoconnect
if hidden, ok := wifiSettings["hidden"].(bool); ok && hidden {
hiddenSSIDs[ssid] = true
} }
} }
b.stateMutex.RLock() b.stateMutex.RLock()
currentSSID := b.state.WiFiSSID currentSSID := b.state.WiFiSSID
wifiConnected := b.state.WiFiConnected
wifiSignal := b.state.WiFiSignal
wifiBSSID := b.state.WiFiBSSID
b.stateMutex.RUnlock() b.stateMutex.RUnlock()
seenSSIDs := make(map[string]*WiFiNetwork) seenSSIDs := make(map[string]*WiFiNetwork)
@@ -444,6 +464,7 @@ func (b *NetworkManagerBackend) updateWiFiNetworks() ([]WiFiNetwork, error) {
Connected: ssid == currentSSID, Connected: ssid == currentSSID,
Saved: savedSSIDs[ssid], Saved: savedSSIDs[ssid],
Autoconnect: autoconnectMap[ssid], Autoconnect: autoconnectMap[ssid],
Hidden: hiddenSSIDs[ssid],
Frequency: freq, Frequency: freq,
Mode: modeStr, Mode: modeStr,
Rate: maxBitrate / 1000, Rate: maxBitrate / 1000,
@@ -454,6 +475,23 @@ func (b *NetworkManagerBackend) updateWiFiNetworks() ([]WiFiNetwork, error) {
networks = append(networks, network) networks = append(networks, network)
} }
if wifiConnected && currentSSID != "" {
if _, exists := seenSSIDs[currentSSID]; !exists {
hiddenNetwork := WiFiNetwork{
SSID: currentSSID,
BSSID: wifiBSSID,
Signal: wifiSignal,
Secured: true,
Connected: true,
Saved: savedSSIDs[currentSSID],
Autoconnect: autoconnectMap[currentSSID],
Hidden: true,
Mode: "infrastructure",
}
networks = append(networks, hiddenNetwork)
}
}
sortWiFiNetworks(networks) sortWiFiNetworks(networks)
b.stateMutex.Lock() b.stateMutex.Lock()
@@ -515,40 +553,53 @@ func (b *NetworkManagerBackend) createAndConnectWiFiOnDevice(req ConnectionReque
nm := b.nmConn.(gonetworkmanager.NetworkManager) nm := b.nmConn.(gonetworkmanager.NetworkManager)
dev := devInfo.device dev := devInfo.device
w := devInfo.wireless w := devInfo.wireless
apPaths, err := w.GetAccessPoints()
if err != nil {
return fmt.Errorf("failed to get access points: %w", err)
}
var targetAP gonetworkmanager.AccessPoint var targetAP gonetworkmanager.AccessPoint
for _, ap := range apPaths { var flags, wpaFlags, rsnFlags uint32
ssid, err := ap.GetPropertySSID()
if err != nil || ssid != req.SSID { if !req.Hidden {
continue apPaths, err := w.GetAccessPoints()
if err != nil {
return fmt.Errorf("failed to get access points: %w", err)
} }
targetAP = ap
break
}
if targetAP == nil { for _, ap := range apPaths {
return fmt.Errorf("access point not found: %s", req.SSID) ssid, err := ap.GetPropertySSID()
} if err != nil || ssid != req.SSID {
continue
}
targetAP = ap
break
}
flags, _ := targetAP.GetPropertyFlags() if targetAP == nil {
wpaFlags, _ := targetAP.GetPropertyWPAFlags() return fmt.Errorf("access point not found: %s", req.SSID)
rsnFlags, _ := targetAP.GetPropertyRSNFlags() }
flags, _ = targetAP.GetPropertyFlags()
wpaFlags, _ = targetAP.GetPropertyWPAFlags()
rsnFlags, _ = targetAP.GetPropertyRSNFlags()
}
const KeyMgmt8021x = uint32(512) const KeyMgmt8021x = uint32(512)
const KeyMgmtPsk = uint32(256) const KeyMgmtPsk = uint32(256)
const KeyMgmtSae = uint32(1024) const KeyMgmtSae = uint32(1024)
isEnterprise := (wpaFlags&KeyMgmt8021x) != 0 || (rsnFlags&KeyMgmt8021x) != 0 var isEnterprise, isPsk, isSae, secured bool
isPsk := (wpaFlags&KeyMgmtPsk) != 0 || (rsnFlags&KeyMgmtPsk) != 0
isSae := (wpaFlags&KeyMgmtSae) != 0 || (rsnFlags&KeyMgmtSae) != 0
secured := flags != uint32(gonetworkmanager.Nm80211APFlagsNone) || switch {
wpaFlags != uint32(gonetworkmanager.Nm80211APSecNone) || case req.Hidden:
rsnFlags != uint32(gonetworkmanager.Nm80211APSecNone) secured = req.Password != "" || req.Username != ""
isEnterprise = req.Username != ""
isPsk = req.Password != "" && !isEnterprise
default:
isEnterprise = (wpaFlags&KeyMgmt8021x) != 0 || (rsnFlags&KeyMgmt8021x) != 0
isPsk = (wpaFlags&KeyMgmtPsk) != 0 || (rsnFlags&KeyMgmtPsk) != 0
isSae = (wpaFlags&KeyMgmtSae) != 0 || (rsnFlags&KeyMgmtSae) != 0
secured = flags != uint32(gonetworkmanager.Nm80211APFlagsNone) ||
wpaFlags != uint32(gonetworkmanager.Nm80211APSecNone) ||
rsnFlags != uint32(gonetworkmanager.Nm80211APSecNone)
}
if isEnterprise { if isEnterprise {
log.Infof("[createAndConnectWiFi] Enterprise network detected (802.1x) - SSID: %s, interactive: %v", log.Infof("[createAndConnectWiFi] Enterprise network detected (802.1x) - SSID: %s, interactive: %v",
@@ -567,11 +618,15 @@ func (b *NetworkManagerBackend) createAndConnectWiFiOnDevice(req ConnectionReque
settings["ipv6"] = map[string]any{"method": "auto"} settings["ipv6"] = map[string]any{"method": "auto"}
if secured { if secured {
settings["802-11-wireless"] = map[string]any{ wifiSettings := map[string]any{
"ssid": []byte(req.SSID), "ssid": []byte(req.SSID),
"mode": "infrastructure", "mode": "infrastructure",
"security": "802-11-wireless-security", "security": "802-11-wireless-security",
} }
if req.Hidden {
wifiSettings["hidden"] = true
}
settings["802-11-wireless"] = wifiSettings
switch { switch {
case isEnterprise || req.Username != "": case isEnterprise || req.Username != "":
@@ -658,10 +713,14 @@ func (b *NetworkManagerBackend) createAndConnectWiFiOnDevice(req ConnectionReque
return fmt.Errorf("secured network but not SAE/PSK/802.1X (rsn=0x%x wpa=0x%x)", rsnFlags, wpaFlags) return fmt.Errorf("secured network but not SAE/PSK/802.1X (rsn=0x%x wpa=0x%x)", rsnFlags, wpaFlags)
} }
} else { } else {
settings["802-11-wireless"] = map[string]any{ wifiSettings := map[string]any{
"ssid": []byte(req.SSID), "ssid": []byte(req.SSID),
"mode": "infrastructure", "mode": "infrastructure",
} }
if req.Hidden {
wifiSettings["hidden"] = true
}
settings["802-11-wireless"] = wifiSettings
} }
if req.Interactive { if req.Interactive {
@@ -685,14 +744,23 @@ func (b *NetworkManagerBackend) createAndConnectWiFiOnDevice(req ConnectionReque
log.Infof("[createAndConnectWiFi] Enterprise connection added, activating (secret agent will be called)") log.Infof("[createAndConnectWiFi] Enterprise connection added, activating (secret agent will be called)")
} }
_, err = nm.ActivateWirelessConnection(conn, dev, targetAP) if req.Hidden {
_, err = nm.ActivateConnection(conn, dev, nil)
} else {
_, err = nm.ActivateWirelessConnection(conn, dev, targetAP)
}
if err != nil { if err != nil {
return fmt.Errorf("failed to activate connection: %w", err) return fmt.Errorf("failed to activate connection: %w", err)
} }
log.Infof("[createAndConnectWiFi] Connection activation initiated, waiting for NetworkManager state changes...") log.Infof("[createAndConnectWiFi] Connection activation initiated, waiting for NetworkManager state changes...")
} else { } else {
_, err = nm.AddAndActivateWirelessConnection(settings, dev, targetAP) var err error
if req.Hidden {
_, err = nm.AddAndActivateConnection(settings, dev)
} else {
_, err = nm.AddAndActivateWirelessConnection(settings, dev, targetAP)
}
if err != nil { if err != nil {
return fmt.Errorf("failed to connect: %w", err) return fmt.Errorf("failed to connect: %w", err)
} }
@@ -813,6 +881,7 @@ func (b *NetworkManagerBackend) updateAllWiFiDevices() {
savedSSIDs := make(map[string]bool) savedSSIDs := make(map[string]bool)
autoconnectMap := make(map[string]bool) autoconnectMap := make(map[string]bool)
hiddenSSIDs := make(map[string]bool)
for _, conn := range connections { for _, conn := range connections {
connSettings, err := conn.GetSettings() connSettings, err := conn.GetSettings()
if err != nil { if err != nil {
@@ -846,6 +915,10 @@ func (b *NetworkManagerBackend) updateAllWiFiDevices() {
autoconnect = ac autoconnect = ac
} }
autoconnectMap[ssid] = autoconnect autoconnectMap[ssid] = autoconnect
if hidden, ok := wifiSettings["hidden"].(bool); ok && hidden {
hiddenSSIDs[ssid] = true
}
} }
var devices []WiFiDevice var devices []WiFiDevice
@@ -939,6 +1012,7 @@ func (b *NetworkManagerBackend) updateAllWiFiDevices() {
Connected: connected && apSSID == ssid, Connected: connected && apSSID == ssid,
Saved: savedSSIDs[apSSID], Saved: savedSSIDs[apSSID],
Autoconnect: autoconnectMap[apSSID], Autoconnect: autoconnectMap[apSSID],
Hidden: hiddenSSIDs[apSSID],
Frequency: freq, Frequency: freq,
Mode: modeStr, Mode: modeStr,
Rate: maxBitrate / 1000, Rate: maxBitrate / 1000,
@@ -949,6 +1023,25 @@ func (b *NetworkManagerBackend) updateAllWiFiDevices() {
seenSSIDs[apSSID] = &network seenSSIDs[apSSID] = &network
networks = append(networks, network) networks = append(networks, network)
} }
if connected && ssid != "" {
if _, exists := seenSSIDs[ssid]; !exists {
hiddenNetwork := WiFiNetwork{
SSID: ssid,
BSSID: bssid,
Signal: signal,
Secured: true,
Connected: true,
Saved: savedSSIDs[ssid],
Autoconnect: autoconnectMap[ssid],
Hidden: true,
Mode: "infrastructure",
Device: name,
}
networks = append(networks, hiddenNetwork)
}
}
sortWiFiNetworks(networks) sortWiFiNetworks(networks)
} }

View File

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

View File

@@ -2,9 +2,21 @@ package network
import ( import (
"fmt" "fmt"
"os/exec"
"time" "time"
"github.com/Wifx/gonetworkmanager/v2" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/godbus/dbus/v5"
)
const (
priorityHigh = int32(100)
priorityLow = int32(10)
priorityDefault = int32(0)
metricPreferred = int64(100)
metricNonPreferred = int64(300)
metricDefault = int64(100)
) )
func (m *Manager) SetConnectionPreference(pref ConnectionPreference) error { func (m *Manager) SetConnectionPreference(pref ConnectionPreference) error {
@@ -36,83 +48,124 @@ func (m *Manager) SetConnectionPreference(pref ConnectionPreference) error {
} }
func (m *Manager) prioritizeWiFi() error { func (m *Manager) prioritizeWiFi() error {
if err := m.setConnectionMetrics("802-11-wireless", 50); err != nil { if err := m.setConnectionPriority("802-11-wireless", priorityHigh, metricPreferred); err != nil {
return err log.Warnf("Failed to set WiFi priority: %v", err)
} }
if err := m.setConnectionMetrics("802-3-ethernet", 100); err != nil { if err := m.setConnectionPriority("802-3-ethernet", priorityLow, metricNonPreferred); err != nil {
return err log.Warnf("Failed to set Ethernet priority: %v", err)
} }
m.reapplyActiveConnections()
m.notifySubscribers() m.notifySubscribers()
return nil return nil
} }
func (m *Manager) prioritizeEthernet() error { func (m *Manager) prioritizeEthernet() error {
if err := m.setConnectionMetrics("802-3-ethernet", 50); err != nil { if err := m.setConnectionPriority("802-3-ethernet", priorityHigh, metricPreferred); err != nil {
return err log.Warnf("Failed to set Ethernet priority: %v", err)
} }
if err := m.setConnectionMetrics("802-11-wireless", 100); err != nil { if err := m.setConnectionPriority("802-11-wireless", priorityLow, metricNonPreferred); err != nil {
return err log.Warnf("Failed to set WiFi priority: %v", err)
} }
m.reapplyActiveConnections()
m.notifySubscribers() m.notifySubscribers()
return nil return nil
} }
func (m *Manager) balancePriorities() error { func (m *Manager) balancePriorities() error {
if err := m.setConnectionMetrics("802-3-ethernet", 50); err != nil { if err := m.setConnectionPriority("802-3-ethernet", priorityDefault, metricDefault); err != nil {
return err log.Warnf("Failed to reset Ethernet priority: %v", err)
} }
if err := m.setConnectionMetrics("802-11-wireless", 50); err != nil { if err := m.setConnectionPriority("802-11-wireless", priorityDefault, metricDefault); err != nil {
return err log.Warnf("Failed to reset WiFi priority: %v", err)
} }
m.reapplyActiveConnections()
m.notifySubscribers() m.notifySubscribers()
return nil return nil
} }
func (m *Manager) setConnectionMetrics(connType string, metric uint32) error { func (m *Manager) reapplyActiveConnections() {
settingsMgr, err := gonetworkmanager.NewSettings() m.stateMutex.RLock()
ethDev := m.state.EthernetDevice
wifiDev := m.state.WiFiDevice
m.stateMutex.RUnlock()
if ethDev != "" {
exec.Command("nmcli", "dev", "reapply", ethDev).Run()
}
if wifiDev != "" {
exec.Command("nmcli", "dev", "reapply", wifiDev).Run()
}
}
func (m *Manager) setConnectionPriority(connType string, autoconnectPriority int32, routeMetric int64) error {
conn, err := dbus.ConnectSystemBus()
if err != nil { if err != nil {
return fmt.Errorf("failed to get settings: %w", err) return fmt.Errorf("failed to connect to system bus: %w", err)
}
defer conn.Close()
settingsObj := conn.Object("org.freedesktop.NetworkManager", "/org/freedesktop/NetworkManager/Settings")
var connPaths []dbus.ObjectPath
if err := settingsObj.Call("org.freedesktop.NetworkManager.Settings.ListConnections", 0).Store(&connPaths); err != nil {
return fmt.Errorf("failed to list connections: %w", err)
} }
connections, err := settingsMgr.ListConnections() for _, connPath := range connPaths {
if err != nil { connObj := conn.Object("org.freedesktop.NetworkManager", connPath)
return fmt.Errorf("failed to get connections: %w", err)
}
for _, conn := range connections { var settings map[string]map[string]dbus.Variant
connSettings, err := conn.GetSettings() if err := connObj.Call("org.freedesktop.NetworkManager.Settings.Connection.GetSettings", 0).Store(&settings); err != nil {
if err != nil {
continue continue
} }
if connMeta, ok := connSettings["connection"]; ok { connSection, ok := settings["connection"]
if cType, ok := connMeta["type"].(string); ok && cType == connType { if !ok {
if connSettings["ipv4"] == nil { continue
connSettings["ipv4"] = make(map[string]any)
}
if ipv4Map := connSettings["ipv4"]; ipv4Map != nil {
ipv4Map["route-metric"] = int64(metric)
}
if connSettings["ipv6"] == nil {
connSettings["ipv6"] = make(map[string]any)
}
if ipv6Map := connSettings["ipv6"]; ipv6Map != nil {
ipv6Map["route-metric"] = int64(metric)
}
err = conn.Update(connSettings)
if err != nil {
continue
}
}
} }
typeVariant, ok := connSection["type"]
if !ok {
continue
}
cType, ok := typeVariant.Value().(string)
if !ok || cType != connType {
continue
}
connName := ""
if idVariant, ok := connSection["id"]; ok {
connName, _ = idVariant.Value().(string)
}
if connName == "" {
continue
}
if err := exec.Command("nmcli", "con", "mod", connName,
"connection.autoconnect-priority", fmt.Sprintf("%d", autoconnectPriority)).Run(); err != nil {
log.Warnf("Failed to set autoconnect-priority for %v: %v", connName, err)
continue
}
if err := exec.Command("nmcli", "con", "mod", connName,
"ipv4.route-metric", fmt.Sprintf("%d", routeMetric)).Run(); err != nil {
log.Warnf("Failed to set ipv4.route-metric for %v: %v", connName, err)
}
if err := exec.Command("nmcli", "con", "mod", connName,
"ipv6.route-metric", fmt.Sprintf("%d", routeMetric)).Run(); err != nil {
log.Warnf("Failed to set ipv6.route-metric for %v: %v", connName, err)
}
log.Infof("Updated %v: autoconnect-priority=%d, route-metric=%d", connName, autoconnectPriority, routeMetric)
} }
return nil return nil
@@ -125,14 +178,18 @@ func (m *Manager) GetConnectionPreference() ConnectionPreference {
} }
func (m *Manager) WasRecentlyFailed(ssid string) bool { func (m *Manager) WasRecentlyFailed(ssid string) bool {
if nm, ok := m.backend.(*NetworkManagerBackend); ok { nm, ok := m.backend.(*NetworkManagerBackend)
nm.failedMutex.RLock() if !ok {
defer nm.failedMutex.RUnlock() return false
if nm.lastFailedSSID == ssid {
elapsed := time.Now().Unix() - nm.lastFailedTime
return elapsed < 10
}
} }
return false
nm.failedMutex.RLock()
defer nm.failedMutex.RUnlock()
if nm.lastFailedSSID != ssid {
return false
}
elapsed := time.Now().Unix() - nm.lastFailedTime
return elapsed < 10
} }

View File

@@ -33,6 +33,7 @@ type WiFiNetwork struct {
Connected bool `json:"connected"` Connected bool `json:"connected"`
Saved bool `json:"saved"` Saved bool `json:"saved"`
Autoconnect bool `json:"autoconnect"` Autoconnect bool `json:"autoconnect"`
Hidden bool `json:"hidden"`
Frequency uint32 `json:"frequency"` Frequency uint32 `json:"frequency"`
Mode string `json:"mode"` Mode string `json:"mode"`
Rate uint32 `json:"rate"` Rate uint32 `json:"rate"`
@@ -127,6 +128,7 @@ type ConnectionRequest struct {
AnonymousIdentity string `json:"anonymousIdentity,omitempty"` AnonymousIdentity string `json:"anonymousIdentity,omitempty"`
DomainSuffixMatch string `json:"domainSuffixMatch,omitempty"` DomainSuffixMatch string `json:"domainSuffixMatch,omitempty"`
Interactive bool `json:"interactive,omitempty"` Interactive bool `json:"interactive,omitempty"`
Hidden bool `json:"hidden,omitempty"`
Device string `json:"device,omitempty"` Device string `json:"device,omitempty"`
EAPMethod string `json:"eapMethod,omitempty"` EAPMethod string `json:"eapMethod,omitempty"`
Phase2Auth string `json:"phase2Auth,omitempty"` Phase2Auth string `json:"phase2Auth,omitempty"`

View File

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

View File

@@ -44,6 +44,7 @@ func HandleList(conn net.Conn, req models.Request) {
Dependencies: p.Dependencies, Dependencies: p.Dependencies,
Installed: installed, Installed: installed,
FirstParty: strings.HasPrefix(p.Repo, "https://github.com/AvengeMedia"), FirstParty: strings.HasPrefix(p.Repo, "https://github.com/AvengeMedia"),
RequiresDMS: p.RequiresDMS,
} }
} }

View File

@@ -60,6 +60,7 @@ func HandleListInstalled(conn net.Conn, req models.Request) {
Dependencies: plugin.Dependencies, Dependencies: plugin.Dependencies,
FirstParty: strings.HasPrefix(plugin.Repo, "https://github.com/AvengeMedia"), FirstParty: strings.HasPrefix(plugin.Repo, "https://github.com/AvengeMedia"),
HasUpdate: hasUpdate, HasUpdate: hasUpdate,
RequiresDMS: plugin.RequiresDMS,
}) })
} else { } else {
result = append(result, PluginInfo{ result = append(result, PluginInfo{

View File

@@ -10,7 +10,7 @@ import (
) )
func HandleSearch(conn net.Conn, req models.Request) { func HandleSearch(conn net.Conn, req models.Request) {
query, ok := req.Params["query"].(string) query, ok := models.Get[string](req, "query")
if !ok { if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'query' parameter") models.RespondError(conn, req.ID, "missing or invalid 'query' parameter")
return return
@@ -30,15 +30,15 @@ func HandleSearch(conn net.Conn, req models.Request) {
searchResults := plugins.FuzzySearch(query, pluginList) 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) 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) 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) searchResults = plugins.FilterByCapability(capability, searchResults)
} }
@@ -66,6 +66,7 @@ func HandleSearch(conn net.Conn, req models.Request) {
Dependencies: p.Dependencies, Dependencies: p.Dependencies,
Installed: installed, Installed: installed,
FirstParty: strings.HasPrefix(p.Repo, "https://github.com/AvengeMedia"), FirstParty: strings.HasPrefix(p.Repo, "https://github.com/AvengeMedia"),
RequiresDMS: p.RequiresDMS,
} }
} }

View File

@@ -15,6 +15,7 @@ type PluginInfo struct {
FirstParty bool `json:"firstParty,omitempty"` FirstParty bool `json:"firstParty,omitempty"`
Note string `json:"note,omitempty"` Note string `json:"note,omitempty"`
HasUpdate bool `json:"hasUpdate,omitempty"` HasUpdate bool `json:"hasUpdate,omitempty"`
RequiresDMS string `json:"requires_dms,omitempty"`
} }
type SuccessResult struct { type SuccessResult struct {

View File

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

View File

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

View File

@@ -192,24 +192,21 @@ func RouteRequest(conn net.Conn, req models.Request) {
func handleClipboardSetConfig(conn net.Conn, req models.Request) { func handleClipboardSetConfig(conn net.Conn, req models.Request) {
cfg := clipboard.LoadConfig() cfg := clipboard.LoadConfig()
if v, ok := req.Params["maxHistory"].(float64); ok { if v, ok := models.Get[float64](req, "maxHistory"); ok {
cfg.MaxHistory = int(v) 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) 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) cfg.AutoClearDays = int(v)
} }
if v, ok := req.Params["clearAtStartup"].(bool); ok { if v, ok := models.Get[bool](req, "clearAtStartup"); ok {
cfg.ClearAtStartup = v cfg.ClearAtStartup = v
} }
if v, ok := req.Params["disabled"].(bool); ok { if v, ok := models.Get[bool](req, "disabled"); ok {
cfg.Disabled = v cfg.Disabled = v
} }
if v, ok := req.Params["disableHistory"].(bool); ok {
cfg.DisableHistory = v
}
if err := clipboard.SaveConfig(cfg); err != nil { if err := clipboard.SaveConfig(cfg); err != nil {
models.RespondError(conn, req.ID, err.Error()) 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) clientID := fmt.Sprintf("meta-client-%p", conn)
var services []string var services []string
if servicesParam, ok := req.Params["services"].([]any); ok { if servicesParam, ok := models.Get[[]any](req, "services"); ok {
for _, s := range servicesParam { for _, s := range servicesParam {
if str, ok := s.(string); ok { if str, ok := s.(string); ok {
services = append(services, str) services = append(services, str)

View File

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

View File

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

View File

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

View File

@@ -9,7 +9,7 @@ import (
) )
func HandleUpdate(conn net.Conn, req models.Request) { func HandleUpdate(conn net.Conn, req models.Request) {
idOrName, ok := req.Params["name"].(string) idOrName, ok := models.Get[string](req, "name")
if !ok { if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'name' parameter") models.RespondError(conn, req.ID, "missing or invalid 'name' parameter")
return 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) { func handleSetTemperature(conn net.Conn, req models.Request, manager *Manager) {
var lowTemp, highTemp int var lowTemp, highTemp int
if temp, ok := req.Params["temp"].(float64); ok { if temp, ok := models.Get[float64](req, "temp"); ok {
lowTemp = int(temp) lowTemp = int(temp)
highTemp = int(temp) highTemp = int(temp)
} else { } 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) { func handleSetManualTimes(conn net.Conn, req models.Request, manager *Manager) {
sunriseParam := req.Params["sunrise"] sunriseStr, sunriseOK := models.Get[string](req, "sunrise")
sunsetParam := req.Params["sunset"] sunsetStr, sunsetOK := models.Get[string](req, "sunset")
if sunriseParam == nil || sunsetParam == nil { if !sunriseOK || !sunsetOK || sunriseStr == "" || sunsetStr == "" {
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 == "" {
manager.ClearManualTimes() manager.ClearManualTimes()
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "manual times cleared"}) models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "manual times cleared"})
return return

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