1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-05-03 02:52:07 -04:00

Compare commits

...

276 Commits

Author SHA1 Message Date
bbedward
4d316007af lock: add lock at startup action, not sure how to handle it in crash
scenarios
launcher v2: fix state reset in section changes
fixes #1648
2026-02-10 23:25:54 -05:00
purian23
3c2d60d8e1 fix: QT notifs warning 2026-02-10 21:38:25 -05:00
purian23
9c4f4cbd0d notifications: Add Left/Right Keyboard Nav to Current/History tabs 2026-02-10 20:51:13 -05:00
bbedward
a337585b00 core/server/dbus: suppress unsubscribe warnings 2026-02-10 17:52:39 -05:00
bbedward
1cdec5d687 launcher v2: add visibility guards 2026-02-10 17:40:41 -05:00
bbedward
081b15e24c dock: fix intelligent auto hide on hyprland
fixes #1535
2026-02-10 17:29:39 -05:00
purian23
b04cb7b3cc guide: Include Fedora paths in the Contributing guide 2026-02-10 16:07:33 -05:00
ArijanJ
e2c3ff00fb feat(ipc): add player-specific mpris volume control (#1645)
* feat: add mpris volume control through ipc

* feat: add mpris volume action and default binds
2026-02-10 15:44:56 -05:00
bbedward
c783ff3dcf core: add DL helper, apply to TrackArt OSD, DankLocationSearch
- unrelated change to add gsettingsOrDconf helpers
2026-02-10 15:42:40 -05:00
bbedward
2c360dc3e8 mautgen: post-hook reload GTK4 and qt6ct
fixes #1643
2026-02-10 15:06:44 -05:00
bbedward
5342647bfb launcher v2: performance optimizations
- Use ListView in all tab
- use filesystem cache to speed up first launch
- apply highlights to visible models
2026-02-10 14:56:29 -05:00
bbedward
46a2f6f0d8 launcher v2: de-dupe cached entries by ID 2026-02-10 12:59:53 -05:00
bbedward
f8af8fc171 processlist: fix default popout focus 2026-02-10 12:52:11 -05:00
bbedward
3d0ee9d72b animations/ripple: clean up effect and apply more universally 2026-02-10 12:48:12 -05:00
bbedward
5a0bb260b4 popout: only scale texture size on DPR > 1 2026-02-10 09:56:20 -05:00
Erwin Boskma
9a7f1f5f2f feat: configurable volume amount on scroll for Media widget (#1641)
* Add audioWheelScrollAmount setting

* Add UI to configure volume change amount for Media Player widget

* Let Media Player widget use the configured volume change on wheel event
2026-02-10 09:12:03 -05:00
purian23
d88b04fc30 notifications: Update group expansion card animations 2026-02-09 22:48:07 -05:00
bbedward
6fe4cc98b9 popout: fix blurry text 2026-02-09 21:33:12 -05:00
ArijanJ
b9bcfd8d2c Making the new media playback OSD more beautiful (#1638)
* feat: decouple track art downloads into new TrackArtService

* feat: beautify media playback osd with track art

* fix: bug when switching from art to no art
2026-02-09 21:13:23 -05:00
bbedward
e3bd31bb52 clipboard: fix row layout overflow 2026-02-09 21:09:15 -05:00
purian23
0922e3e459 clipboard: Fix pinned entry logic
- Add keyboard nav to pinned entries
- Fix wrong copied selection upon Enter
2026-02-09 20:53:48 -05:00
purian23
a168b12bb2 dankbar: Fix widget context focus w/Autohide enabled 2026-02-09 19:42:27 -05:00
bbedward
8c01deba86 anims: revise ListView animations 2026-02-09 13:21:35 -05:00
bbedward
b645487e79 cc: expand mouse areas of scroll/click targets in bar 2026-02-09 13:15:43 -05:00
bbedward
91569affd7 displays: update mango display config syntax
fixes #1629
2026-02-09 09:49:31 -05:00
bbedward
1ed44ee6f3 audio: add per-device max volume limit setting 2026-02-09 09:26:34 -05:00
bbedward
fce120fa31 system monitor: disable anims until list is stable 2026-02-08 22:16:45 -05:00
bbedward
a02b0c0c3c animations: tweak list view transitions to not animate X 2026-02-08 22:10:51 -05:00
bbedward
c86999f389 popout: move layer to content wrapper 2026-02-08 22:06:35 -05:00
bbedward
2b546967d2 weather: fix anim on open 2026-02-08 21:43:56 -05:00
purian23
591d2ba4d4 settings: DankCollapsible 2026-02-08 20:26:06 -05:00
purian23
37cc4ab197 dms: Material Animation Refactor
- Thanks Google for Material 3 Expressive stuffs
- Thanks Caelestia shell for pushing qml limits to showcase the blueprint
2026-02-08 20:24:37 -05:00
Jhannes Reimann
d775974a90 i18n: wrap missing user-facing strings in I18n.tr() (#1624)
* fix: reverse rotation direction of sync icon in plugin browser

* i18n: wrap missing user-facing strings in I18n.tr()
2026-02-08 19:18:17 -05:00
Vladimir Kosteley
cc62aa4a9e fix: VpnPopout and Vpn widget tooltip positions (#1623)
* fix(vpn-widget): correct tooltip positioning for bottom bar alignment

- Calculate tooltip Y position based on bar edge (bottom vs top)
- Position tooltip above bar when edge is bottom to prevent overflow
- Account for tooltip height when positioning on bottom edge
- Use proper screen reference for consistent positioning across displays

* fix(VpnPopout): use correct screen height for popup sizing

- Fix popup height calculation to use the assigned screen property
  instead of the global Screen object
- Prevents incorrect positioning when multiple screens are present
- Fallback to Screen.height if screen property is not set

* fix(widgets): close DankPopout when screen is removed

- Add Connections handler to monitor Quickshell.onScreensChanged
- Automatically close popout if its assigned screen no longer exists
- Prevent orphaned popouts when displays are disconnected
2026-02-08 19:17:53 -05:00
bbedward
5b8b7b04be niri: replace github ref 2026-02-08 10:21:34 -05:00
Tulip Blossom
b4a8853591 fix(greeter): use 0755 permissions for greeter directories (#1619)
* fix(greeter): use 0755 permissions for greeter directories

Directories need to be executable to read/write to them (weirdly enough)

Fixes: https://github.com/AvengeMedia/DankMaterialShell/issues/1618

Signed-off-by: Tulip Blossom <tulilirockz@outlook.com>

* fix: also need 4 for world writeable perms on DMS greeter directory

---------

Signed-off-by: Tulip Blossom <tulilirockz@outlook.com>
2026-02-07 22:25:39 -05:00
Jhannes Reimann
4220dfe2a5 fix: reverse rotation direction of sync icon in plugin browser (#1617) 2026-02-07 18:53:00 -05:00
bbedward
4557426c28 notifications: fix kb navigation breaking on history tab close 2026-02-07 17:42:20 -05:00
purian23
8ee7fe8e66 notifications: Refactor Animations 2026-02-07 17:25:26 -05:00
bri
c4a41f994a feat(dms-greeter): add Niri override kdl includes (#1616)
This will optionally include one or two files into the Niri conf for
dms-greeter:
  - `/usr/share/greetd/niri_overrides.kdl` (for building into a system image)
  - `/etc/greetd/niri_overrides.kdl` (for local overrides)

This enables a distro or a system administrator to easily add their own
customizations to the dms-greeter Niri configuration.

Note: Niri next includes https://github.com/YaLTeR/niri/pull/3022 which
can make this a whole lot simpler: we're testing for the existence of
files and optionally adding `include` lines within the dms-greeter shell
script, but in the future it will be possible to simply use `include
optional=true` which we can just append unconditionally.

Lastly, I deduplicated the line that spawns the actual greeter by
appending that unconditionally at the end.
2026-02-07 12:45:13 -05:00
bbedward
fa639424f5 core/config: update default steam window rules
fixes #1615
2026-02-07 11:43:34 -05:00
bbedward
e618a8390c launcher v2: fix hover effect and view mode pref in all tab 2026-02-06 11:53:59 -05:00
bbedward
393e9ed2e4 greeter: try to fix random nixos issue 2026-02-06 10:56:44 -05:00
bbedward
e1ea441215 cava: use input source pipwire and auto 2026-02-06 10:12:16 -05:00
bbedward
654661fd66 cli/setup: add subcommands for individual includes 2026-02-06 09:53:41 -05:00
bbedward
c5a21f8da0 niri: ensure other configs too 2026-02-06 08:20:17 -05:00
bbedward
ca5b168117 niri: add ensure colors.kdl existence
fixes #1606
2026-02-06 08:18:37 -05:00
bbedward
aa88eb42ee cava: remove input config 2026-02-06 08:10:48 -05:00
purian23
ac84cadd77 fix: Truncate Media Playback OSD 2026-02-05 22:21:45 -05:00
ArijanJ
81d5235b9f feat: media playback OSD (#1602)
* feat: media playback OSD

* Update code style improvements
- Removes resetTimer to reuse event driven code

---------

Co-authored-by: purian23 <purian23@gmail.com>
2026-02-05 19:18:52 -05:00
bbedward
8944762c76 notifications: cap max anim speed in popout 2026-02-05 15:17:17 -05:00
bbedward
3d05c34673 i18n: sync terms 2026-02-05 14:38:17 -05:00
bbedward
c2ee41c844 running apps: make settings bar-specific 2026-02-05 14:37:05 -05:00
bbedward
6b537f30a5 matugen: sync adwaita accent color by visual similarity 2026-02-05 14:16:30 -05:00
bbedward
a3ae95df09 launcher v2: general performance improvements 2026-02-05 13:22:49 -05:00
bbedward
4349d68f87 greeter: block loading of memory file 2026-02-05 12:18:44 -05:00
bbedward
7d5c20125a animations: fine-grained anim settings for modals and popouts 2026-02-05 12:10:17 -05:00
bbedward
2583dbd3f2 niri/keybinds: expose when-locked, inhibitied, repeat through GUI editor
fixes #1437
2026-02-05 11:55:25 -05:00
bbedward
a103b93583 greeter: add connection on session load 2026-02-05 09:40:27 -05:00
claymorwan
fff018eafb fix(layers): change layers namespace for desktop widgets and plugin popouts (#1594) 2026-02-05 09:18:47 -05:00
bbedward
60b824e7a4 i18n: update settings search index 2026-02-05 09:12:08 -05:00
bbedward
e27e904157 doctor: add --copy option for github issue reporting 2026-02-04 19:33:22 -05:00
bbedward
fe15667986 clipboard: add watch -m for mime-types 2026-02-04 11:36:41 -05:00
bbedward
bd9029e533 niri: support any screenshot editor tool 2026-02-04 11:05:52 -05:00
bbedward
fa71d563ea vpn: uncheck "save password" by defaul 2026-02-04 10:55:06 -05:00
bbedward
143918bc5e plugins: fix reload IPC on failure 2026-02-04 09:46:10 -05:00
purian23
961680af8c feat: Alias for Audio Devices
- New custom audio UI to set custom names for input/output devices
2026-02-04 07:09:55 -05:00
purian23
6e3b3ce888 fix: Notepad Transparency override 2026-02-03 21:19:57 -05:00
bbedward
44292c3b55 theme: fix popup transparency setting 2026-02-03 21:06:15 -05:00
bbedward
c024c1b8e4 matugen: fix emacs template
fixes #1580
2026-02-03 16:15:13 -05:00
bbedward
13adfdec11 Merge branch 'master' of github.com:AvengeMedia/DankMaterialShell 2026-02-03 16:04:11 -05:00
bbedward
e9ec28aab7 core/greeter: embed base config, tie enable into sync, sync config
argument always, preserve existing args
2026-02-03 16:02:42 -05:00
purian23
0af4d1d6e3 greeter: Restore baseline configs 2026-02-03 15:52:07 -05:00
purian23
3ef0e63533 feat: DMS Greeter Sync w/niri include settings
- cursor, debug, input & options
- This lets the greeter inherit your niri display/output layout, input device behavior, cursor settings & debug flags overrides
2026-02-03 14:57:29 -05:00
purian23
f4dad69ccd General agent files 2026-02-03 14:56:56 -05:00
bbedward
b811316d0c DankPopout: make bg and content siblings 2026-02-03 14:31:31 -05:00
bbedward
f59aeb2782 dankbar: fix centering of numerous bar widgets 2026-02-03 13:44:57 -05:00
bbedward
24ce41935e bluetooth: improve performance of details 2026-02-03 11:30:38 -05:00
bbedward
3c4749ead0 widgets: add a button color setting 2026-02-03 11:03:33 -05:00
xxyangyoulin
22ab5b9660 feat(ipc): add tray icon control commands (#1576)
Add IPC commands to interact with system tray icons:
  - `dms ipc call tray list` - list all tray items
  - `dms ipc call tray activate <id>` - activate (left-click) a tray item
  - `dms ipc call tray status <id>` - show tray item details
2026-02-03 09:36:35 -05:00
bbedward
22f16f1da3 widgets: theme text field selection color 2026-02-03 09:35:35 -05:00
bbedward
a97409dfd7 window: freeze mask geometry in popout 2026-02-02 16:29:59 -05:00
grokXcopilot
eaa6a664c8 feat(niri): Add drag-and-drop workspace reordering (#1569)
* feat(niri): Add drag-and-drop workspace reordering

Add interactive drag-and-drop reordering for Niri workspace indicators
with smooth animations matching the system tray behavior.

- Add moveWorkspaceToIndex() to NiriService for workspace reordering
- Implement drag detection with 5px threshold
- Add shift animation for items between source and target
- Clamp drag offset to stay within workspace row bounds
- Reset drag state when workspace list changes during drag
- Visual feedback: opacity change, border highlight on drag/drop target

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(settings): Add workspace drag reorder toggle

Add workspaceDragReorder setting to enable/disable workspace
drag-and-drop reordering. Enabled by default, only visible on Niri.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 12:58:05 -05:00
bbedward
d934b3b3b4 launcher v2: improve search result responsiveness, highlight matches 2026-02-02 12:49:20 -05:00
Mmmattias
546cbfb3ca wallpaper: Only pause cycling when screen is locked or active window is fullscreen (#1553) 2026-02-01 20:47:13 -05:00
bbedward
39b70a53a0 cursor: more intelligent Xresources editing 2026-02-01 20:44:21 -05:00
bbedward
795f84adce notifications: handle material icons 2026-02-01 20:39:15 -05:00
purian23
3d80a9dd0f appsDock: Update Size & Color options 2026-02-01 00:22:08 -05:00
purian23
9669e9bc87 fix: Extend Blur Overview edge to edge 2026-01-31 22:16:13 -05:00
purian23
5f2a5a5d7d distro: Update DMS/OBS versioning 2026-01-31 20:20:19 -05:00
johngalt
ecfd721fc0 Zen Browser Theme: fixing background color in template (#1557) 2026-01-31 15:58:03 -05:00
purian23
07242a00b3 fix: Update DankDropdown & Clipboard Pins 2026-01-31 13:40:15 -05:00
Rin
4602442feb feat: add ipc handlers for color picker modal (#1554)
* dankcolorpickermodal: add ipc handlers

* add ipc docs for color picker modal
2026-01-30 22:35:02 -05:00
Higor Prado
a90717b20c Fix Process List popout crash from AppSearch (#1552) 2026-01-30 13:45:33 -05:00
bbedward
02edce2999 plugins: represent featured plugins in built-in browsers 2026-01-30 13:31:12 -05:00
bbedward
f2d9066f90 clipboard: add popout variant 2026-01-30 13:24:05 -05:00
bbedward
f6f7b1ed72 polkit: allow empty passwords 2026-01-30 09:19:10 -05:00
bbedward
803bc1cb7f system tray: allow re-ordering tray items 2026-01-30 09:17:01 -05:00
bbedward
67d3aa9da3 system tray: use id+title as identifier
fixes #1542
2026-01-29 22:01:33 -05:00
purian23
9fbff5e833 feat: Notepad widget quick context menu 2026-01-29 18:51:49 -05:00
purian23
c371140a97 feat: Clipboard widget context quick menu 2026-01-29 18:12:56 -05:00
bbedward
c755a3719d core/windowrules: disable hyprland from CLI 2026-01-29 13:12:17 -05:00
bbedward
4f153f3026 settings: remove bad text 2026-01-29 13:07:06 -05:00
bbedward
f2b1dbd256 greeter: pass --unsupported-gpu to sway 2026-01-29 12:39:06 -05:00
bbedward
be0ca993ff clipboard: add raw image mime-type to offers in CopyFile 2026-01-29 09:46:01 -05:00
bbedward
ed87e1b00b i18n: add Dutch 2026-01-29 09:35:23 -05:00
bbedward
ac509933d7 i18n: fix sound missing erorr message 2026-01-28 23:35:53 -05:00
bbedward
f49f98ff85 settings: undo mono font filtering 2026-01-28 22:07:08 -05:00
bbedward
10923346d7 clipboard: fix watch command 2026-01-28 21:36:16 -05:00
bbedward
f27bffc387 displays: add disable snap option in settings
fixes #1438
2026-01-28 21:08:16 -05:00
bbedward
36b43f93a3 displays: support for multiple output profiles
- add support for deleting unplugged configs
- Option to hide disconnected displays
fixes #1453
2026-01-28 20:51:29 -05:00
bbedward
2deeab9d08 clipboard: touch copied history entry
- makes it appear at the top of the history
2026-01-28 16:09:32 -05:00
bbedward
f00854879c workspaces: fix overflow with grouped apps + icons
fixes #1530
2026-01-28 16:00:44 -05:00
bbedward
75fd62865b core/dbus: fix arg types in calls 2026-01-28 15:25:16 -05:00
bbedward
757054e140 core/dbus: support Normalize for more dbus types 2026-01-28 13:28:13 -05:00
bbedward
eda59b348c clipboard: react to changes 2026-01-27 22:50:28 -05:00
bbedward
d19e81ffac clipboard: fix duplicate clear dialog 2026-01-27 22:41:01 -05:00
purian23
60c6872aec workflow: Update dms-git run times 2026-01-27 22:38:09 -05:00
bbedward
a9cb2fe912 clipboard: fix hash duplication check, set isOwner for CopyFile 2026-01-27 22:35:20 -05:00
purian23
a168a8160c feat: appsDock Widget Overflow & Config Options 2026-01-27 21:15:33 -05:00
bbedward
78662f9613 window-rules: fix checkbox alignment 2026-01-27 19:44:17 -05:00
bbedward
d9d7bb8dcc i18n: update settings search index 2026-01-27 19:39:29 -05:00
bbedward
3136f48b30 settings: make dock position match dankbar
fixes #1527
2026-01-27 19:33:32 -05:00
sin-1337
0c46711b01 Update Makefile (#1524)
Stop assuming the user's primary group matches their username.
2026-01-27 19:29:17 -05:00
bbedward
68159b5c41 niri: add window-rule management
- settings UI for creating, editing, deleting window ruels
- IPC to create a window rule for the currently focused toplevel

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

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

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

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

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

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

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

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

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

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

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

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

* clock: made pad 12 hour formats optional

---------

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

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

* refactor: wrap WorkspaceRenameModal in LazyLoader

Reduces memory footprint when the modal is not in use.
2026-01-23 13:46:34 -05:00
bbedward
775b381987 lock: add disable media player option
fixes #1470
2026-01-23 13:34:25 -05:00
bbedward
3a41f2f1ed greeter+lock: remove random facts
fixes #1475
2026-01-23 13:25:42 -05:00
bbedward
972fc534a4 meta: support async launcher plugins, cached GIFs, paste on launcher v2
action
- Preparations for DankGifSearch plugin
2026-01-23 12:03:05 -05:00
purian23
808ee66e11 feat: AppsDock Widget on the Dankbar
- Pinnable apps independent from the main dock
- Drag & Drop support
2026-01-23 11:49:45 -05:00
bbedward
3936a516f8 lock: fix loginctl lock integration disabled setting
fixes #1471
2026-01-23 09:56:43 -05:00
purian23
15dc91f779 dock: Fix dock launcher button persistence 2026-01-22 18:15:00 -05:00
bbedward
dd3d2908a2 prek format 2026-01-22 17:57:12 -05:00
bbedward
0857023dba core: ipc fill in help, remove management tui 2026-01-22 17:51:38 -05:00
purian23
1edc8f468e feat: Pinnable DMS coreApps w/Color options 2026-01-22 17:45:38 -05:00
purian23
2681fe87bb feat: Implement Dank Launcher button on the Dock
- Configurable with custom icons/logos
- Respects light/dark theme
- Drag & Drop in place
2026-01-22 16:52:38 -05:00
bbedward
3f0d0f4d95 launcher v2: remove dupe launch on dGPU 2026-01-22 14:52:36 -05:00
bbedward
f24ecf1b99 weather: m/s wind units and feels like
fixes #1463
fixes #1456
2026-01-22 14:44:40 -05:00
bbedward
acdd1d2ec4 settings: fix theme flavor buttons 2026-01-22 13:58:44 -05:00
bbedward
d08496f237 launcher v2: add micro size 2026-01-22 10:10:19 -05:00
bbedward
27b4e0221b settings: fix emacs syntax err 2026-01-22 09:35:23 -05:00
Sunny
496ace0cd4 add dank emacs template (#1460)
* add dank emacs template

* prek

* prek ws

---------

Co-authored-by: bbedward <bbedward@gmail.com>
2026-01-22 09:25:02 -05:00
bbedward
f61ed8b8a6 launcher v2: reduce debounce 2026-01-22 09:20:00 -05:00
bbedward
41ee88a3cf launcher v2: keep old namesapce 2026-01-22 09:06:04 -05:00
purian23
6bf1438ef1 fix: dms chroma hang on print 2026-01-21 22:47:53 -05:00
bbedward
b819306ab6 launcher v2: use Top layer by default 2026-01-21 21:59:38 -05:00
bbedward
b140afca8e launcher v2: retire spotlight launcher in favor of dank launcher 2026-01-21 21:34:31 -05:00
bbedward
6735989455 launcher v2: reset visibility on screen change 2026-01-21 19:29:03 -05:00
bbedward
db37ac24c7 launcher v2: support CachingImage in icon renderer 2026-01-21 17:54:36 -05:00
bbedward
0231270f9e launcher v2: use AppIconRenderer from legacy launcha 2026-01-21 17:51:24 -05:00
bbedward
b5194aa9e1 notifications: update dimensions and text expansion logic 2026-01-21 16:51:39 -05:00
bbedward
ea0ffaacb0 launcher v2: fix some plugin icon handling 2026-01-21 16:09:52 -05:00
bbedward
3b1f084a13 notepad: fix unsave changed dialog height 2026-01-21 16:01:59 -05:00
bbedward
39a9e3a89f add dms doctor to issue template 2026-01-21 14:25:41 -05:00
bbedward
7a7af775c2 launcher v2: some optims on meta performance
- limit plugin results to 10
- longer debounce
- search plugins when chars > 1
2026-01-21 14:20:12 -05:00
bbedward
6ac2a305f7 launcher v2: sort order preference for plugin results 2026-01-21 14:08:40 -05:00
bbedward
3507c6cec3 i18n: RTL fixes in about tab and dank bar settings 2026-01-21 11:57:46 -05:00
purian23
3ff00768ac core: dms chroma notepad updates 2026-01-21 11:48:08 -05:00
bbedward
556d253ea8 launcher v2: fix view mode persistence 2026-01-21 11:43:02 -05:00
bbedward
3922070488 launcher v2: meta improvements
- Allow disabling each plugin from "all" mode
- add IPCs for toggling specific modes
- niri: overview respect size & default to apps mode
- fix unicode icon handling
2026-01-21 11:38:48 -05:00
Eggrror404
eebb4827c4 feat(bar): enlarge bar icons if widget background is off (#1425)
* use iconSizeLarge if noBackground is on

* widgets: pass noBackground to barIconSize in param
2026-01-21 10:44:08 -05:00
Kamil Chmielewski
fd2c6a0784 Feat/niri workspace names (#1396)
* dankbar: show niri workspace names

Keep labels aligned with niri indices and live renames.

* dankbar: prefix named workspaces with index

Use workspace index toggle to show index: name labels.

* workspaces: change size conditions for workspace names

---------

Co-authored-by: bbedward <bbedward@gmail.com>
2026-01-21 10:43:55 -05:00
bbedward
417bf37515 clipboard: fix header GUI and add tooltips 2026-01-21 10:19:52 -05:00
bbedward
132e799265 Revert "settings: fix modal not opening on latest quickshell (#1357)"
This reverts commit bdd01e335d.
2026-01-21 09:19:12 -05:00
dms-ci[bot]
bdc864781b nix: update vendorHash for go.mod changes 2026-01-21 14:18:47 +00:00
purian23
a343bc7562 feat: DMS Core Chroma Syntax Highlighter
- Thanks alecthomas for the project
2026-01-21 09:16:58 -05:00
bbedward
1f2e231386 launcher v2: fix context switch back on empty text field 2026-01-20 21:57:50 -05:00
bbedward
0e7f628c4a launcher v2: improve danksearch context switching behavior 2026-01-20 21:55:05 -05:00
bbedward
553f5257b3 launcher v2: general padding improvements, to more than just launcher v2
but yea
2026-01-20 21:46:02 -05:00
bbedward
80ce6aa19c launcher v2: spacing adjustments 2026-01-20 18:10:55 -05:00
bbedward
2b2977de4a launcher v2: smarter right/left arrow key handler 2026-01-20 18:02:23 -05:00
bbedward
1d5d876e16 launcher: Dank Launcher V2 (beta)
- Aggregate plugins/extensions in new "all" tab
- Quick tab actions
- New tile mode for results
- Plugins can enforce/require view mode, or set preferred default
- Danksearch under "files" category
2026-01-20 17:59:13 -05:00
Body
3c39162016 remove hardcoded width and padding fixing overlap (#1446) 2026-01-20 16:19:59 -05:00
bbedward
d38767fb5a settings: fix power&sleep tab button groups
fixes #1442
2026-01-20 11:41:39 -05:00
purian23
f2be6cfeb1 notepad: Update cursor color & activity 2026-01-19 00:34:10 -05:00
purian23
65486ed3cf notepad: QOL updates 2026-01-18 23:49:38 -05:00
bbedward
cc30e2a9e4 workspaces: fix occupied color overridworkspacs: fix occupied color
overridee
2026-01-18 22:44:54 -05:00
bbedward
ac68451cdf processlist: add full keyboard navigation 2026-01-18 21:03:34 -05:00
bbedward
0f6ae11c3d launcher: add name, icon, description overrides + hide/unhide options
- convenient helpers without needing to make .desktop overrides
fixes #1329
2026-01-18 20:30:50 -05:00
bbedward
7cb39f00ad i18n: add french 2026-01-18 14:24:20 -05:00
bbedward
f313d03348 dankbar: add click-through option 2026-01-18 14:22:50 -05:00
Eggrror404
1adbf3937b add option to change occupied workspace color (#1427) 2026-01-18 13:25:37 -05:00
Kamil Chmielewski
a685d9da52 feat: power off monitors when lock screen activates (#1402)
Add ability to immediately power off monitors when the lock screen
activates, controlled by a new setting "Power off monitors on lock".
Uses a 100ms polling timer to detect when the session lock actually
becomes active, then invokes compositor-specific DPMS commands.

For niri, uses the new power-off-monitors action via niri msg CLI
with socket fallback.

Wake on input: first input after lock arms wake, second input
actually powers monitors back on while keeping the lock screen visible.

Closes #1157
2026-01-18 13:08:58 -05:00
bbedward
13dededcc9 Makefile: don't overwrite VERSION file 2026-01-17 23:21:11 -05:00
bbedward
3bed2d9feb plugins: give popout customizable header actions 2026-01-17 22:43:10 -05:00
purian23
7241877995 feat: Intelligent Dock Auto-hide 2026-01-17 22:20:15 -05:00
dms-ci[bot]
340d79000c nix: update vendorHash for go.mod changes 2026-01-18 03:08:19 +00:00
bbedward
162ec909da core/server: add generic dbus service
- Add QML client with subscribe/introspect/getprop/setprop/call
- Add CLI helper `dms notify` that allows async calls with action
  handlers.
2026-01-17 22:04:58 -05:00
purian23
53f5240d41 notepad: Fix open/save modals 2026-01-17 15:23:57 -05:00
bbedward
27f0df07af widgets: refresh layout on plugin load
fixes #1414
2026-01-17 12:27:24 -05:00
Jon Rogers
ad940b5884 feat(plugins): Add toggle support with lazy daemon instantiation (#1407)
Add togglePlugin() function and IPC command to toggle plugin visibility,
particularly for slideout-capable daemon plugins like AI Assistant.

Implementation uses lazy instantiation for daemon plugins:
- Daemons remain uninstantiated on load (respecting lifecycle)
- First toggle() call instantiates the daemon on-demand
- Subsequent toggles use the existing instance
- Prevents duplicate instantiation while supporting toggle functionality

This approach preserves the fix from f9b9d986 (ensure daemon plugins
not instantiated twice) while enabling new toggle capabilities.

Changes:
- Add PluginService.togglePlugin() with lazy instantiation
- Add DMSShellIPC plugin.toggle() command
- Maintains compatibility with existing daemon plugins
2026-01-17 12:05:04 -05:00
purian23
ec8ab47462 distros: Deprecate Cliphist dependencies 2026-01-17 01:06:28 -05:00
purian23
35cbfeb008 feat: Save Pinned Clipboard entries 2026-01-17 00:52:47 -05:00
bbedward
7036362b9b dgop: fix default sort direction 2026-01-16 21:04:44 -05:00
bbedward
2bcb33e85c system monitor: update gauge sizes 2026-01-16 20:28:57 -05:00
bbedward
76ac036f85 system monitor: overhaul popout and app with new design 2026-01-16 20:20:03 -05:00
bbedward
581073394a dank16: update algorithm overall
- More similarities to primary, smoother gradient of
  cyan->purple->magenta->white, keep gray
- Make purple slot near-match for primaryContainer
2026-01-16 18:10:15 -05:00
Flux
d7b7086b21 labwc patch (#1391) 2026-01-16 09:50:01 -05:00
bbedward
59be179821 i18n: more RTL fixes across settings 2026-01-16 09:45:15 -05:00
bbedward
1cf2f6b946 popout: fix cross-monitor handling of widgets
fixes #1364
2026-01-16 09:45:15 -05:00
bbedward
a57a9c2121 doctor: add mango and labwc to compositors
fixes #1394
2026-01-16 09:45:15 -05:00
bbedward
67568c3746 greeter: remove WLR_DRM_DEVICES setting
fixes #1393
2026-01-16 09:45:15 -05:00
bbedward
afce792b80 dankbar: fix property preservation in widgets
fixes #1392
2026-01-16 09:45:15 -05:00
bbedward
f5c7493dbb weather: fix precipitationw weekly propability
fixes #1395
2026-01-16 09:45:15 -05:00
bbedward
f9b9d98638 plugins: ensure daemon plugins not instantiated twice 2026-01-16 09:45:15 -05:00
bbedward
2a97e03fa6 cc: fixed width column, remove anchoring from individual icons on vbar
maybe #1376
2026-01-16 09:45:15 -05:00
Lucas
d6dacc2975 nix: fix home module (#1387) 2026-01-16 08:46:26 +01:00
Bailey
aab4b6765d nix: Support specifying systemd target (#1385) 2026-01-16 02:01:51 -03:00
bbedward
3539aca1f7 cc: wrap icons in fixed size containers
maybe #1376
2026-01-15 23:05:21 -05:00
bbedward
81fbe9eaba controlcenter: fix visibility condition of no icons
fixes #1377
2026-01-15 23:00:43 -05:00
purian23
f9dc6de485 Fix fedora version format 2026-01-15 23:00:08 -05:00
bbedward
012022d370 plugins: fix plugin confirm third part repo window 2026-01-15 22:55:11 -05:00
purian23
993216e157 distro: Update Fedora dynamic versioning 2026-01-15 22:30:20 -05:00
purian23
c992f2b582 feat: Allow more pinned services in Control Center/Settings 2026-01-15 21:51:17 -05:00
purian23
3243adebca core: Update ghostty on dankinstall 2026-01-15 21:26:31 -05:00
Abhinav Chalise
baccef57d4 fix volume osd sliding ui update for vertical layout (#1382) 2026-01-15 21:10:43 -05:00
bbedward
a823095372 widgets: add fallback for steam apps 2026-01-15 21:07:57 -05:00
Lucas
172a743de4 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 20:59:47 -05:00
Ivan Molodetskikh
623eec3689 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-15 00:55:21 -05:00
bbedward
53a033fe35 dankdash: fix weather open IPC
fixes #1367
2026-01-14 22:29:29 -05:00
bbedward
c490ee24f4 matugen: fix nvim ID in skipTemplates 2026-01-14 22:27:07 -05:00
bbedward
cc1e49294e i18n: update terms 2026-01-14 22:22:27 -05:00
purian23
e6fa46ae26 dankdash: Center Media Art & Controls 2026-01-14 18:03:16 -05:00
purian23
35fe774a1b Update OBS Choice selection 2026-01-13 17:51:55 -05:00
purian23
1e6a0f9423 Update OBS DMS Stable workflow 2026-01-13 17:31:01 -05:00
bbedward
cc1877aadb modals: fix wifi passowrd, polkit, and VPN import 2026-01-13 17:21:16 -05:00
bbedward
f1eb1fa9ba settings: fix child windows on newer quickshell-git 2026-01-13 16:57:23 -05:00
Lucas
bdd01e335d settings: fix modal not opening on latest quickshell (#1357) 2026-01-13 16:41:54 -05:00
Lucas
4b7baf82cd nix: escape version string (#1353) 2026-01-13 11:24:51 -05:00
purian23
15c88ce1d2 quickshell: Despace Versioning 2026-01-13 11:07:12 -05:00
bbedward
8891c388d0 bump to v1.4-unstable 2026-01-13 08:40:56 -05:00
440 changed files with 73143 additions and 17265 deletions

View File

@@ -0,0 +1,57 @@
{
"permissions": {
"allow": [
"Bash(cat:*)",
"Bash(git -C /home/purian23/dms diff --stat .github/workflows/)",
"Bash(git -C /home/purian23/projects/danklinux diff --stat .github/workflows/)",
"Bash(git -C /home/purian23/dms diff .github/workflows/)",
"Bash(git -C /home/purian23/dms diff .github/workflows/run-ppa.yml)",
"Bash(osc cat:*)",
"Bash(ls:*)",
"Bash(find:*)",
"Bash(git show-ref:*)",
"Bash(git tag:*)",
"Bash(bash -c 'ALL_PATHS=$(grep -A 5 \"\"<service name=\\\"\"download_url\\\"\">\"\" distro/debian/dms/_service | grep \"\"<param name=\\\"\"path\\\"\">\"\" | sed \"\"s/.*<param name=\\\"\"path\\\"\">\\(.*\\)<\\/param>.*/\\1/\"\"); SOURCE_PATH=\"\"\"\"; for path in $ALL_PATHS; do if echo \"\"$path\"\" | grep -qE \"\"(source|archive|\\.tar\\.(gz|xz|bz2))\"\" && ! echo \"\"$path\"\" | grep -qE \"\"(distropkg|binary)\"\"; then SOURCE_PATH=\"\"$path\"\"; break; fi; done; echo \"\"Selected path: $SOURCE_PATH\"\"')",
"Bash(curl:*)",
"Bash(tar:*)",
"Bash(git -C /home/purian23/dms log:*)",
"Bash(osc status:*)",
"Bash(osc commit:*)",
"Bash(osc up:*)",
"Bash(osc results:*)",
"Bash(osc api:*)",
"Bash(systemctl:*)",
"Bash(dms version:*)",
"Bash(git describe:*)",
"Bash(qmlsc:*)",
"Bash(qmllint-qt6:*)",
"Bash(make fmt:*)",
"Bash(make test:*)",
"Bash(dms chroma list-styles:*)",
"Bash(python3:*)",
"Bash(time dms chroma:*)",
"Bash(dms chroma:*)",
"Bash(make build:*)",
"Bash(pgrep:*)",
"Bash(go build:*)",
"Bash(/tmp/dms-test chroma:*)",
"Bash(1)",
"Bash(go install:*)",
"Bash(grep:*)",
"Bash(journalctl:*)",
"Bash(qdbus:*)",
"Bash(TZ='Asia/Tokyo' date:*)",
"Bash(dms --help:*)",
"Bash(dms run:*)",
"Bash(dms status:*)",
"Bash(dms kill:*)",
"Bash(tee:*)",
"Bash(qmlscene:*)",
"Bash(quickshell --version:*)",
"WebFetch(domain:forum.qt.io)",
"Bash(gh api:*)",
"WebFetch(domain:github.com)",
"WebFetch(domain:raw.githubusercontent.com)"
]
}
}

View File

@@ -42,12 +42,12 @@ body:
placeholder: e.g., PikaOS, Void Linux, etc.
validations:
required: false
- type: input
id: dms_version
- type: textarea
id: dms_doctor
attributes:
label: dms version
description: Output of dms version command
placeholder: e.g., 1.2.3
label: dms doctor -v
description: Output of `dms doctor -v` command
placeholder: Paste the output of `dms doctor -v` here
validations:
required: true
- type: textarea

View File

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

View File

@@ -27,12 +27,12 @@ body:
placeholder: Your Linux distribution
validations:
required: false
- type: input
id: dms_version
- type: textarea
id: dms_doctor
attributes:
label: dms version
description: Output of dms version command
placeholder: e.g., 1.2.3
label: dms doctor -v
description: Output of `dms doctor -v` command
placeholder: Paste the output of `dms doctor -v` here
validations:
required: false
- type: textarea

View File

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

View File

@@ -4,19 +4,20 @@ on:
workflow_dispatch:
inputs:
package:
description: "Package to update (dms, dms-git, or all)"
required: false
default: "all"
tag_version:
description: "Specific tag version for dms stable (e.g., v1.0.2). Leave empty to auto-detect latest release."
required: false
default: ""
description: "Package to update"
required: true
type: choice
options:
- dms
- dms-git
- all
default: "dms"
rebuild_release:
description: "Release number for rebuilds (e.g., 2, 3, 4 to increment spec Release)"
required: false
default: ""
schedule:
- cron: "0 */3 * * *" # Every 3 hours for dms-git builds
- cron: "0 2,5,14,17,20,23 * * *" # 9am, 12pm, 3pm, 6pm, 9pm, 12am EST (UTC times shown)
jobs:
check-updates:
@@ -56,8 +57,9 @@ jobs:
}
# Helper function to check dms stable tag
# Sets LATEST_TAG variable in parent scope if update needed
check_dms_stable() {
local LATEST_TAG=$(curl -s https://api.github.com/repos/AvengeMedia/DankMaterialShell/releases/latest | grep '"tag_name"' | sed 's/.*"tag_name": "v\?\([^"]*\)".*/\1/' || echo "")
LATEST_TAG=$(curl -s https://api.github.com/repos/AvengeMedia/DankMaterialShell/releases/latest | grep '"tag_name"' | sed 's/.*"tag_name": "\([^"]*\)".*/\1/' || echo "")
local OBS_SPEC=$(curl -s -u "$OBS_USERNAME:$OBS_PASSWORD" "https://api.opensuse.org/source/home:AvengeMedia:dms/dms/dms.spec" 2>/dev/null || echo "")
local OBS_VERSION=$(echo "$OBS_SPEC" | grep "^Version:" | awk '{print $2}' | xargs || echo "")
@@ -73,8 +75,8 @@ jobs:
# Main logic
REBUILD="${{ github.event.inputs.rebuild_release }}"
if [[ "${{ github.event_name }}" == "push" && "${{ github.ref }}" =~ ^refs/tags/ ]]; then
# Tag push - always update stable package
if [[ "${{ github.ref }}" =~ ^refs/tags/ ]]; then
# Tag selected or pushed - always update stable package
echo "packages=dms" >> $GITHUB_OUTPUT
VERSION="${GITHUB_REF#refs/tags/}"
echo "version=$VERSION" >> $GITHUB_OUTPUT
@@ -104,7 +106,12 @@ jobs:
# Check each package and build list of those needing updates
PACKAGES_TO_UPDATE=()
check_dms_git && PACKAGES_TO_UPDATE+=("dms-git")
check_dms_stable && PACKAGES_TO_UPDATE+=("dms")
if check_dms_stable; then
PACKAGES_TO_UPDATE+=("dms")
if [[ -n "$LATEST_TAG" ]]; then
echo "version=$LATEST_TAG" >> $GITHUB_OUTPUT
fi
fi
if [[ ${#PACKAGES_TO_UPDATE[@]} -gt 0 ]]; then
echo "packages=${PACKAGES_TO_UPDATE[*]}" >> $GITHUB_OUTPUT
@@ -129,6 +136,9 @@ jobs:
if check_dms_stable; then
echo "packages=$PKG" >> $GITHUB_OUTPUT
echo "has_updates=true" >> $GITHUB_OUTPUT
if [[ -n "$LATEST_TAG" ]]; then
echo "version=$LATEST_TAG" >> $GITHUB_OUTPUT
fi
else
echo "packages=" >> $GITHUB_OUTPUT
echo "has_updates=false" >> $GITHUB_OUTPUT
@@ -161,12 +171,19 @@ jobs:
- name: Determine packages to update
id: packages
run: |
if [[ "${{ github.event_name }}" == "push" && "${{ github.ref }}" =~ ^refs/tags/ ]]; then
# Tag push event - use the pushed tag
# Check if GITHUB_REF points to a tag (works for both push events and workflow_dispatch with tag selected)
if [[ "${{ github.ref }}" =~ ^refs/tags/ ]]; then
# Tag selected or pushed - use the tag from GITHUB_REF
echo "packages=dms" >> $GITHUB_OUTPUT
VERSION="${GITHUB_REF#refs/tags/}"
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Triggered by tag: $VERSION"
echo "Using tag from GITHUB_REF: $VERSION"
# Check if check-updates already determined a version (from auto-detection)
elif [[ -n "${{ needs.check-updates.outputs.version }}" ]]; then
# Use version from check-updates job
echo "packages=${{ needs.check-updates.outputs.packages }}" >> $GITHUB_OUTPUT
echo "version=${{ needs.check-updates.outputs.version }}" >> $GITHUB_OUTPUT
echo "Using version from check-updates: ${{ needs.check-updates.outputs.version }}"
elif [[ "${{ github.event_name }}" == "schedule" ]]; then
# Scheduled run - dms-git only
echo "packages=${{ needs.check-updates.outputs.packages }}" >> $GITHUB_OUTPUT
@@ -176,22 +193,28 @@ jobs:
# Determine version for dms stable
if [[ "${{ github.event.inputs.package }}" == "dms" ]]; then
# For explicit dms selection, require tag_version
if [[ -n "${{ github.event.inputs.tag_version }}" ]]; then
VERSION="${{ github.event.inputs.tag_version }}"
# Use github.ref if tag selected, otherwise auto-detect latest
if [[ "${{ github.ref }}" =~ ^refs/tags/ ]]; then
VERSION="${GITHUB_REF#refs/tags/}"
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Using specified tag: $VERSION"
echo "Using tag from GITHUB_REF: $VERSION"
else
echo "ERROR: tag_version is required when package=dms"
echo "Please specify a tag version (e.g., v1.0.2) or use package=all for auto-detection"
exit 1
# Auto-detect latest release for dms
LATEST_TAG=$(curl -s https://api.github.com/repos/AvengeMedia/DankMaterialShell/releases/latest | grep '"tag_name"' | sed 's/.*"tag_name": "\([^"]*\)".*/\1/' || echo "")
if [[ -n "$LATEST_TAG" ]]; then
echo "version=$LATEST_TAG" >> $GITHUB_OUTPUT
echo "Auto-detected latest release: $LATEST_TAG"
else
echo "ERROR: Could not auto-detect latest release"
exit 1
fi
fi
elif [[ "${{ github.event.inputs.package }}" == "all" ]]; then
# For "all", auto-detect if tag_version not specified
if [[ -n "${{ github.event.inputs.tag_version }}" ]]; then
VERSION="${{ github.event.inputs.tag_version }}"
# Use github.ref if tag selected, otherwise auto-detect latest
if [[ "${{ github.ref }}" =~ ^refs/tags/ ]]; then
VERSION="${GITHUB_REF#refs/tags/}"
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Using specified tag: $VERSION"
echo "Using tag from GITHUB_REF: $VERSION"
else
# Auto-detect latest release for "all"
LATEST_TAG=$(curl -s https://api.github.com/repos/AvengeMedia/DankMaterialShell/releases/latest | grep '"tag_name"' | sed 's/.*"tag_name": "\([^"]*\)".*/\1/' || echo "")
@@ -206,7 +229,7 @@ jobs:
fi
# Use filtered packages from check-updates when package="all" and no rebuild/tag specified
if [[ "${{ github.event.inputs.package }}" == "all" ]] && [[ -z "${{ github.event.inputs.rebuild_release }}" ]] && [[ -z "${{ github.event.inputs.tag_version }}" ]]; then
if [[ "${{ github.event.inputs.package }}" == "all" ]] && [[ -z "${{ github.event.inputs.rebuild_release }}" ]] && [[ ! "${{ github.ref }}" =~ ^refs/tags/ ]]; then
echo "packages=${{ needs.check-updates.outputs.packages }}" >> $GITHUB_OUTPUT
echo "Manual trigger: all (filtered to: ${{ needs.check-updates.outputs.packages }})"
else
@@ -215,6 +238,9 @@ jobs:
fi
else
echo "packages=${{ needs.check-updates.outputs.packages }}" >> $GITHUB_OUTPUT
if [[ -n "${{ needs.check-updates.outputs.version }}" ]]; then
echo "version=${{ needs.check-updates.outputs.version }}" >> $GITHUB_OUTPUT
fi
fi
- name: Update dms-git spec version

View File

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

2
.gitignore vendored
View File

@@ -56,6 +56,8 @@ UNUSED
CLAUDE-activeContext.md
CLAUDE-temp.md
AGENTS-activeContext.md
AGENTS-temp.md
# Auto-generated theme files
*.generated.*

View File

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

View File

@@ -1,5 +1,15 @@
This file is more of a quick reference so I know what to account for before next releases.
# 1.4.0
- Overhauled system monitor, graphs, styling
- dbus API for plugins, KDEConnect
- new dank16 algorithm
- launcher actions, customize env, args, name, icon
- launcher v2 - omega stuff, GIF search, supa powerful
- dock on bar
- window rule manager, with IPC - #TODO verify RTL layout (niri only)
# 1.2.0
- Added clipboard and clipboard history integration

View File

@@ -37,10 +37,43 @@ This is a monorepo, the easiest thing to do is to open an editor in either `quic
1. Install the [QML Extension](https://doc.qt.io/vscodeext/)
2. Configure `ctrl+shift+p` -> user preferences (json) with qmlls path
**Note:** Paths may vary by distribution. Below are examples for Arch Linux and Fedora.
**Arch Linux:**
```json
{
"[qml]": {
"editor.defaultFormatter": "qt-project.qmlls",
"editor.formatOnSave": true
},
"qt-qml.doNotAskForQmllsDownload": true,
"qt-qml.qmlls.customExePath": "/usr/lib/qt6/bin/qmlls"
"qt-qml.qmlls.customExePath": "/usr/lib/qt6/bin/qmlls",
"qt-core.additionalQtPaths": [
{
"name": "Qt-6.x-linux-g++",
"path": "/usr/bin/qmake"
}
]
}
```
**Fedora:**
```json
{
"[qml]": {
"editor.defaultFormatter": "qt-project.qmlls",
"editor.formatOnSave": true
},
"qt-qml.doNotAskForQmllsDownload": true,
"qt-qml.qmlls.customExePath": "/usr/bin/qmlls",
"qt-core.additionalQtPaths": [
{
"name": "Qt-6.x-Fedora-linux-g++",
"path": "/usr/bin/qmake6"
}
]
}
```

View File

@@ -43,7 +43,6 @@ install-shell:
@mkdir -p $(SHELL_INSTALL_DIR)
@cp -r $(SHELL_DIR)/* $(SHELL_INSTALL_DIR)/
@rm -rf $(SHELL_INSTALL_DIR)/.git* $(SHELL_INSTALL_DIR)/.github
@$(MAKE) --no-print-directory -C $(CORE_DIR) print-version > $(SHELL_INSTALL_DIR)/VERSION
@echo "Shell files installed"
install-completions:
@@ -59,10 +58,10 @@ install-completions:
install-systemd:
@echo "Installing systemd user service..."
@mkdir -p $(SYSTEMD_USER_DIR)
@if [ -n "$(SUDO_USER)" ]; then chown -R $(SUDO_USER):$(SUDO_USER) $(SYSTEMD_USER_DIR); fi
@if [ -n "$(SUDO_USER)" ]; then chown -R $(SUDO_USER):"$(id -gn $SUDO_USER)" $(SYSTEMD_USER_DIR); fi
@sed 's|/usr/bin/dms|$(INSTALL_DIR)/dms|g' $(ASSETS_DIR)/systemd/dms.service > $(SYSTEMD_USER_DIR)/dms.service
@chmod 644 $(SYSTEMD_USER_DIR)/dms.service
@if [ -n "$(SUDO_USER)" ]; then chown $(SUDO_USER):$(SUDO_USER) $(SYSTEMD_USER_DIR)/dms.service; fi
@if [ -n "$(SUDO_USER)" ]; then chown $(SUDO_USER):"$(id -gn $SUDO_USER)" $(SYSTEMD_USER_DIR)/dms.service; fi
@echo "Systemd service installed to $(SYSTEMD_USER_DIR)/dms.service"
install-icon:

View File

@@ -0,0 +1,300 @@
package main
import (
"bytes"
"fmt"
"io"
"os"
"strings"
"sync"
"github.com/alecthomas/chroma/v2"
"github.com/alecthomas/chroma/v2/formatters/html"
"github.com/alecthomas/chroma/v2/lexers"
"github.com/alecthomas/chroma/v2/styles"
"github.com/spf13/cobra"
"github.com/yuin/goldmark"
highlighting "github.com/yuin/goldmark-highlighting/v2"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
ghtml "github.com/yuin/goldmark/renderer/html"
)
var (
chromaLanguage string
chromaStyle string
chromaInline bool
chromaMarkdown bool
chromaLineNumbers bool
// Caching layer for performance
lexerCache = make(map[string]chroma.Lexer)
styleCache = make(map[string]*chroma.Style)
formatterCache = make(map[string]*html.Formatter)
cacheMutex sync.RWMutex
maxFileSize = int64(5 * 1024 * 1024) // 5MB default
)
var chromaCmd = &cobra.Command{
Use: "chroma [file]",
Short: "Syntax highlight source code",
Long: `Generate syntax-highlighted HTML from source code.
Reads from file or stdin, outputs HTML with syntax highlighting.
Language is auto-detected from filename or can be specified with --language.
Examples:
dms chroma main.go
dms chroma --language python script.py
echo "def foo(): pass" | dms chroma -l python
cat code.rs | dms chroma -l rust --style dracula
dms chroma --markdown README.md
dms chroma --markdown --style github-dark notes.md
dms chroma list-languages
dms chroma list-styles`,
Args: cobra.MaximumNArgs(1),
Run: runChroma,
}
var chromaListLanguagesCmd = &cobra.Command{
Use: "list-languages",
Short: "List all supported languages",
Run: func(cmd *cobra.Command, args []string) {
for _, name := range lexers.Names(true) {
fmt.Println(name)
}
},
}
var chromaListStylesCmd = &cobra.Command{
Use: "list-styles",
Short: "List all available color styles",
Run: func(cmd *cobra.Command, args []string) {
for _, name := range styles.Names() {
fmt.Println(name)
}
},
}
func init() {
chromaCmd.Flags().StringVarP(&chromaLanguage, "language", "l", "", "Language for highlighting (auto-detect if not specified)")
chromaCmd.Flags().StringVarP(&chromaStyle, "style", "s", "monokai", "Color style (monokai, dracula, github, etc.)")
chromaCmd.Flags().BoolVar(&chromaInline, "inline", false, "Output inline styles instead of CSS classes")
chromaCmd.Flags().BoolVar(&chromaLineNumbers, "line-numbers", false, "Show line numbers in output")
chromaCmd.Flags().BoolVarP(&chromaMarkdown, "markdown", "m", false, "Render markdown with syntax-highlighted code blocks")
chromaCmd.Flags().Int64Var(&maxFileSize, "max-size", 5*1024*1024, "Maximum file size to process without warning (bytes)")
chromaCmd.AddCommand(chromaListLanguagesCmd)
chromaCmd.AddCommand(chromaListStylesCmd)
}
func getCachedLexer(key string, fallbackFunc func() chroma.Lexer) chroma.Lexer {
cacheMutex.RLock()
if lexer, ok := lexerCache[key]; ok {
cacheMutex.RUnlock()
return lexer
}
cacheMutex.RUnlock()
lexer := fallbackFunc()
if lexer != nil {
cacheMutex.Lock()
lexerCache[key] = lexer
cacheMutex.Unlock()
}
return lexer
}
func getCachedStyle(name string) *chroma.Style {
cacheMutex.RLock()
if style, ok := styleCache[name]; ok {
cacheMutex.RUnlock()
return style
}
cacheMutex.RUnlock()
style := styles.Get(name)
if style == nil {
fmt.Fprintf(os.Stderr, "Warning: Style '%s' not found, using fallback\n", name)
style = styles.Fallback
}
cacheMutex.Lock()
styleCache[name] = style
cacheMutex.Unlock()
return style
}
func getCachedFormatter(inline bool, lineNumbers bool) *html.Formatter {
key := fmt.Sprintf("inline=%t,lineNumbers=%t", inline, lineNumbers)
cacheMutex.RLock()
if formatter, ok := formatterCache[key]; ok {
cacheMutex.RUnlock()
return formatter
}
cacheMutex.RUnlock()
var opts []html.Option
if inline {
opts = append(opts, html.WithClasses(false))
} else {
opts = append(opts, html.WithClasses(true))
}
opts = append(opts, html.TabWidth(4))
if lineNumbers {
opts = append(opts, html.WithLineNumbers(true))
opts = append(opts, html.LineNumbersInTable(false))
opts = append(opts, html.WithLinkableLineNumbers(false, ""))
}
formatter := html.New(opts...)
cacheMutex.Lock()
formatterCache[key] = formatter
cacheMutex.Unlock()
return formatter
}
func runChroma(cmd *cobra.Command, args []string) {
var source string
var filename string
// Read from file or stdin
if len(args) > 0 {
filename = args[0]
// Check file size before reading
fileInfo, err := os.Stat(filename)
if err != nil {
fmt.Fprintf(os.Stderr, "Error reading file info: %v\n", err)
os.Exit(1)
}
if fileInfo.Size() > maxFileSize {
fmt.Fprintf(os.Stderr, "Warning: File size (%d bytes) exceeds recommended limit (%d bytes)\n",
fileInfo.Size(), maxFileSize)
fmt.Fprintf(os.Stderr, "Processing may be slow. Consider using smaller files.\n")
}
content, err := os.ReadFile(filename)
if err != nil {
fmt.Fprintf(os.Stderr, "Error reading file: %v\n", err)
os.Exit(1)
}
source = string(content)
} else {
stat, _ := os.Stdin.Stat()
if (stat.Mode() & os.ModeCharDevice) != 0 {
_ = cmd.Help()
os.Exit(0)
}
content, err := io.ReadAll(os.Stdin)
if err != nil {
fmt.Fprintf(os.Stderr, "Error reading stdin: %v\n", err)
os.Exit(1)
}
source = string(content)
}
// Handle empty input
if strings.TrimSpace(source) == "" {
return
}
// Handle Markdown rendering
if chromaMarkdown {
md := goldmark.New(
goldmark.WithExtensions(
extension.GFM,
highlighting.NewHighlighting(
highlighting.WithStyle(chromaStyle),
highlighting.WithFormatOptions(
html.WithClasses(!chromaInline),
),
),
),
goldmark.WithParserOptions(
parser.WithAutoHeadingID(),
),
goldmark.WithRendererOptions(
ghtml.WithHardWraps(),
ghtml.WithXHTML(),
),
)
var buf bytes.Buffer
if err := md.Convert([]byte(source), &buf); err != nil {
fmt.Fprintf(os.Stderr, "Markdown rendering error: %v\n", err)
os.Exit(1)
}
fmt.Print(buf.String())
return
}
// Detect or use specified lexer
var lexer chroma.Lexer
if chromaLanguage != "" {
lexer = getCachedLexer(chromaLanguage, func() chroma.Lexer {
l := lexers.Get(chromaLanguage)
if l == nil {
fmt.Fprintf(os.Stderr, "Unknown language: %s\n", chromaLanguage)
os.Exit(1)
}
return l
})
} else if filename != "" {
lexer = getCachedLexer("file:"+filename, func() chroma.Lexer {
return lexers.Match(filename)
})
}
// Try content analysis if no lexer found (limit to first 1KB for performance)
if lexer == nil {
analyzeContent := source
if len(source) > 1024 {
analyzeContent = source[:1024]
}
lexer = lexers.Analyse(analyzeContent)
}
// Fallback to plaintext
if lexer == nil {
lexer = lexers.Fallback
}
lexer = chroma.Coalesce(lexer)
// Get cached style
style := getCachedStyle(chromaStyle)
// Get cached formatter
formatter := getCachedFormatter(chromaInline, chromaLineNumbers)
// Tokenize
iterator, err := lexer.Tokenise(nil, source)
if err != nil {
fmt.Fprintf(os.Stderr, "Tokenization error: %v\n", err)
os.Exit(1)
}
// Format and output
if chromaLineNumbers {
var buf bytes.Buffer
if err := formatter.Format(&buf, style, iterator); err != nil {
fmt.Fprintf(os.Stderr, "Formatting error: %v\n", err)
os.Exit(1)
}
// Add spacing between line numbers
output := buf.String()
output = strings.ReplaceAll(output, "</span><span>", "</span>\u00A0\u00A0<span>")
fmt.Print(output)
} else {
if err := formatter.Format(os.Stdout, style, iterator); err != nil {
fmt.Fprintf(os.Stderr, "Formatting error: %v\n", err)
os.Exit(1)
}
}
}

View File

@@ -12,17 +12,21 @@ import (
_ "image/jpeg"
_ "image/png"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"os/signal"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
bolt "go.etcd.io/bbolt"
_ "golang.org/x/image/bmp"
_ "golang.org/x/image/tiff"
_ "golang.org/x/image/webp"
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
@@ -48,6 +52,7 @@ var (
clipCopyForeground bool
clipCopyPasteOnce bool
clipCopyType string
clipCopyDownload bool
clipJSONOutput bool
)
@@ -107,6 +112,7 @@ var clipClearCmd = &cobra.Command{
}
var clipWatchStore bool
var clipWatchMimes bool
var clipSearchCmd = &cobra.Command{
Use: "search [query]",
@@ -184,11 +190,12 @@ func init() {
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().StringVarP(&clipCopyType, "type", "t", "text/plain;charset=utf-8", "MIME type")
clipCopyCmd.Flags().BoolVarP(&clipCopyDownload, "download", "d", false, "Download URL as image and copy as file")
clipWatchCmd.Flags().BoolVar(&clipJSONOutput, "json", false, "Output as JSON")
clipHistoryCmd.Flags().BoolVar(&clipJSONOutput, "json", false, "Output as JSON")
clipGetCmd.Flags().BoolVar(&clipJSONOutput, "json", false, "Output as JSON")
clipGetCmd.Flags().BoolVarP(&clipGetCopy, "copy", "c", false, "Copy entry to clipboard")
clipGetCmd.Flags().BoolVarP(&clipGetCopy, "copy", "C", false, "Copy entry to clipboard")
clipSearchCmd.Flags().IntVarP(&clipSearchLimit, "limit", "l", 50, "Max results")
clipSearchCmd.Flags().IntVarP(&clipSearchOffset, "offset", "o", 0, "Result offset")
@@ -205,6 +212,7 @@ func init() {
clipConfigSetCmd.Flags().BoolVar(&clipConfigEnabled, "enable", false, "Enable clipboard tracking")
clipWatchCmd.Flags().BoolVarP(&clipWatchStore, "store", "s", false, "Store clipboard changes to history (no server required)")
clipWatchCmd.Flags().BoolVarP(&clipWatchMimes, "mimes", "m", false, "Show all offered MIME types")
clipMigrateCmd.Flags().BoolVar(&clipMigrateDelete, "delete", false, "Delete cliphist db after successful migration")
@@ -215,9 +223,10 @@ func init() {
func runClipCopy(cmd *cobra.Command, args []string) {
var data []byte
if len(args) > 0 {
switch {
case len(args) > 0:
data = []byte(args[0])
} else {
default:
var err error
data, err = io.ReadAll(os.Stdin)
if err != nil {
@@ -225,11 +234,68 @@ func runClipCopy(cmd *cobra.Command, args []string) {
}
}
if clipCopyDownload {
filePath, err := downloadToTempFile(strings.TrimSpace(string(data)))
if err != nil {
log.Fatalf("download: %v", err)
}
if err := copyFileToClipboard(filePath); err != nil {
log.Fatalf("copy file: %v", err)
}
fmt.Printf("Downloaded and copied: %s\n", filePath)
return
}
if clipCopyType == "__multi__" {
offers, err := parseMultiOffers(data)
if err != nil {
log.Fatalf("parse multi offers: %v", err)
}
if err := clipboard.CopyMulti(offers, true, clipCopyPasteOnce); err != nil {
log.Fatalf("copy multi: %v", err)
}
return
}
if err := clipboard.CopyOpts(data, clipCopyType, clipCopyForeground, clipCopyPasteOnce); err != nil {
log.Fatalf("copy: %v", err)
}
}
func parseMultiOffers(data []byte) ([]clipboard.Offer, error) {
var offers []clipboard.Offer
pos := 0
for pos < len(data) {
mimeEnd := bytes.IndexByte(data[pos:], 0)
if mimeEnd == -1 {
break
}
mimeType := string(data[pos : pos+mimeEnd])
pos += mimeEnd + 1
lenEnd := bytes.IndexByte(data[pos:], 0)
if lenEnd == -1 {
break
}
dataLen, err := strconv.Atoi(string(data[pos : pos+lenEnd]))
if err != nil {
return nil, fmt.Errorf("parse length: %w", err)
}
pos += lenEnd + 1
if pos+dataLen > len(data) {
return nil, fmt.Errorf("data truncated")
}
offerData := data[pos : pos+dataLen]
pos += dataLen
offers = append(offers, clipboard.Offer{MimeType: mimeType, Data: offerData})
}
return offers, nil
}
func runClipPaste(cmd *cobra.Command, args []string) {
data, _, err := clipboard.Paste()
if err != nil {
@@ -264,6 +330,30 @@ func runClipWatch(cmd *cobra.Command, args []string) {
}); err != nil && err != context.Canceled {
log.Fatalf("Watch error: %v", err)
}
case clipWatchMimes:
if err := clipboard.WatchAll(ctx, func(data []byte, mimeType string, allMimes []string) {
if clipJSONOutput {
out := map[string]any{
"data": string(data),
"mimeType": mimeType,
"mimeTypes": allMimes,
"timestamp": time.Now().Format(time.RFC3339),
"size": len(data),
}
j, _ := json.Marshal(out)
fmt.Println(string(j))
return
}
fmt.Printf("=== Clipboard Change ===\n")
fmt.Printf("Selected: %s\n", mimeType)
fmt.Printf("All MIME types:\n")
for _, m := range allMimes {
fmt.Printf(" - %s\n", m)
}
fmt.Printf("Size: %d bytes\n\n", len(data))
}); err != nil && err != context.Canceled {
log.Fatalf("Watch error: %v", err)
}
case clipJSONOutput:
if err := clipboard.Watch(ctx, func(data []byte, mimeType string) {
out := map[string]any{
@@ -386,16 +476,13 @@ func runClipGet(cmd *cobra.Command, args []string) {
req := models.Request{
ID: 1,
Method: "clipboard.copyEntry",
Params: map[string]any{
"id": id,
},
Params: map[string]any{"id": id},
}
resp, err := sendServerRequest(req)
if err != nil {
log.Fatalf("Failed to copy clipboard entry: %v", err)
}
if resp.Error != "" {
log.Fatalf("Error: %s", resp.Error)
}
@@ -672,7 +759,7 @@ func runClipExport(cmd *cobra.Command, args []string) {
return
}
if err := os.WriteFile(args[0], out, 0644); err != nil {
if err := os.WriteFile(args[0], out, 0o644); err != nil {
log.Fatalf("Failed to write file: %v", err)
}
fmt.Printf("Exported to %s\n", args[0])
@@ -727,7 +814,7 @@ func runClipMigrate(cmd *cobra.Command, args []string) {
log.Fatalf("Cliphist db not found: %s", dbPath)
}
db, err := bolt.Open(dbPath, 0644, &bolt.Options{
db, err := bolt.Open(dbPath, 0o644, &bolt.Options{
ReadOnly: true,
Timeout: 1 * time.Second,
})
@@ -795,3 +882,113 @@ func detectMimeType(data []byte) string {
func btoi(v []byte) uint64 {
return binary.BigEndian.Uint64(v)
}
func downloadToTempFile(rawURL string) (string, error) {
if !strings.HasPrefix(rawURL, "http://") && !strings.HasPrefix(rawURL, "https://") {
return "", fmt.Errorf("invalid URL: %s", rawURL)
}
parsedURL, err := url.Parse(rawURL)
if err != nil {
return "", fmt.Errorf("parse URL: %w", err)
}
ext := filepath.Ext(parsedURL.Path)
if ext == "" {
ext = ".png"
}
client := &http.Client{Timeout: 30 * time.Second}
var data []byte
var contentType string
var lastErr error
for attempt := 0; attempt < 3; attempt++ {
if attempt > 0 {
time.Sleep(time.Duration(attempt) * 500 * time.Millisecond)
}
req, err := http.NewRequest("GET", rawURL, nil)
if err != nil {
lastErr = fmt.Errorf("create request: %w", err)
continue
}
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
req.Header.Set("Accept", "image/*,video/*,*/*")
resp, err := client.Do(req)
if err != nil {
lastErr = fmt.Errorf("download (attempt %d): %w", attempt+1, err)
continue
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
lastErr = fmt.Errorf("download failed (attempt %d): status %d", attempt+1, resp.StatusCode)
continue
}
data, err = io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
lastErr = fmt.Errorf("read response (attempt %d): %w", attempt+1, err)
continue
}
contentType = resp.Header.Get("Content-Type")
if idx := strings.Index(contentType, ";"); idx != -1 {
contentType = strings.TrimSpace(contentType[:idx])
}
lastErr = nil
break
}
if lastErr != nil {
return "", lastErr
}
if len(data) == 0 {
return "", fmt.Errorf("downloaded empty file")
}
if !strings.HasPrefix(contentType, "image/") && !strings.HasPrefix(contentType, "video/") {
if _, _, err := image.DecodeConfig(bytes.NewReader(data)); err != nil {
return "", fmt.Errorf("not a valid media file (content-type: %s)", contentType)
}
}
cacheDir, err := os.UserCacheDir()
if err != nil {
cacheDir = "/tmp"
}
clipDir := filepath.Join(cacheDir, "dms", "clipboard")
if err := os.MkdirAll(clipDir, 0o755); err != nil {
return "", fmt.Errorf("create cache dir: %w", err)
}
filePath := filepath.Join(clipDir, fmt.Sprintf("%d%s", time.Now().UnixNano(), ext))
if err := os.WriteFile(filePath, data, 0o644); err != nil {
return "", fmt.Errorf("write file: %w", err)
}
return filePath, nil
}
func copyFileToClipboard(filePath string) error {
req := models.Request{
ID: 1,
Method: "clipboard.copyFile",
Params: map[string]any{"filePath": filePath},
}
resp, err := sendServerRequest(req)
if err != nil {
return fmt.Errorf("server request: %w", err)
}
if resp.Error != "" {
return fmt.Errorf("server error: %s", resp.Error)
}
return nil
}

View File

@@ -64,9 +64,8 @@ var killCmd = &cobra.Command{
}
var ipcCmd = &cobra.Command{
Use: "ipc",
Use: "ipc [target] [function] [args...]",
Short: "Send IPC commands to running DMS shell",
Long: "Send IPC commands to running DMS shell (qs -c dms ipc <args>)",
PreRunE: findConfig,
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
_ = findConfig(cmd, args)
@@ -77,6 +76,13 @@ var ipcCmd = &cobra.Command{
},
}
func init() {
ipcCmd.SetHelpFunc(func(cmd *cobra.Command, args []string) {
_ = findConfig(cmd, args)
printIPCHelp()
})
}
var debugSrvCmd = &cobra.Command{
Use: "debug-srv",
Short: "Start the debug server",
@@ -511,9 +517,13 @@ func getCommonCommands() []*cobra.Command {
colorCmd,
screenshotCmd,
notifyActionCmd,
notifyCmd,
genericNotifyActionCmd,
matugenCmd,
clipboardCmd,
chromaCmd,
doctorCmd,
configCmd,
dlCmd,
}
}

View File

@@ -11,6 +11,7 @@ import (
"slices"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/brightness"
@@ -87,6 +88,8 @@ var (
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{
@@ -99,11 +102,13 @@ var doctorCmd = &cobra.Command{
var (
doctorVerbose bool
doctorJSON bool
doctorCopy 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")
doctorCmd.Flags().BoolVarP(&doctorCopy, "copy", "C", false, "Copy results to clipboard in GitHub-friendly format")
}
type category int
@@ -190,7 +195,7 @@ func (r checkResult) toJSON() checkResultJSON {
}
func runDoctor(cmd *cobra.Command, args []string) {
if !doctorJSON {
if !doctorJSON && !doctorCopy {
printDoctorHeader()
}
@@ -208,9 +213,17 @@ func runDoctor(cmd *cobra.Command, args []string) {
checkEnvironmentVars(),
)
if doctorJSON {
switch {
case doctorCopy:
text := formatResultsPlain(results)
if err := clipboard.CopyOpts([]byte(text), "text/plain;charset=utf-8", false, false); err != nil {
fmt.Fprintf(os.Stderr, "Failed to copy to clipboard: %v\n", err)
os.Exit(1)
}
fmt.Println("Doctor report copied to clipboard")
case doctorJSON:
printResultsJSON(results)
} else {
default:
printResults(results)
printSummary(results, qsMissingFeatures)
}
@@ -448,11 +461,13 @@ func checkWindowManagers() []checkResult {
versionRegex *regexp.Regexp
commands []string
}{
{"Hyprland", "hyprctl", "version", hyprlandVersionRegex, []string{"hyprland", "Hyprland"}},
{"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
@@ -477,7 +492,7 @@ func checkWindowManagers() []checkResult {
results = append(results, checkResult{
catCompositor, c.name, statusOK,
getVersionFromCommand(c.versionCmd, c.versionArg, c.versionRegex), details,
doctorDocsURL + "#compositor",
doctorDocsURL + "#compositor-checks",
})
}
@@ -486,7 +501,7 @@ func checkWindowManagers() []checkResult {
catCompositor, "Compositor", statusError,
"No supported Wayland compositor found",
"Install Hyprland, niri, Sway, River, or Wayfire",
doctorDocsURL + "#compositor",
doctorDocsURL + "#compositor-checks",
})
}
@@ -498,8 +513,8 @@ func checkWindowManagers() []checkResult {
}
func getVersionFromCommand(cmd, arg string, regex *regexp.Regexp) string {
output, err := exec.Command(cmd, arg).Output()
if err != nil {
output, err := exec.Command(cmd, arg).CombinedOutput()
if err != nil && len(output) == 0 {
return "installed"
}
@@ -587,7 +602,7 @@ ShellRoot {
}
`
if err := os.WriteFile(testScript, []byte(qmlContent), 0644); err != nil {
if err := os.WriteFile(testScript, []byte(qmlContent), 0o644); err != nil {
return nil, false
}
@@ -634,19 +649,14 @@ func checkI2CAvailability() checkResult {
return checkResult{catOptionalFeatures, "I2C/DDC", statusOK, fmt.Sprintf("%d monitor(s) detected", len(devices)), "External monitor brightness control", doctorDocsURL + "#optional-features"}
}
func detectNetworkBackend() string {
result, err := network.DetectNetworkStack()
if err != nil {
return ""
}
switch result.Backend {
func detectNetworkBackend(stackResult *network.DetectResult) string {
switch stackResult.Backend {
case network.BackendNetworkManager:
return "NetworkManager"
case network.BackendIwd:
return "iwd"
case network.BackendNetworkd:
if result.HasIwd {
if stackResult.HasIwd {
return "iwd + systemd-networkd"
}
return "systemd-networkd"
@@ -657,75 +667,73 @@ func detectNetworkBackend() string {
}
}
func getOptionalDBusStatus(busName string) (status, string) {
if utils.IsDBusServiceAvailable(busName) {
return statusOK, "Available"
} else {
return statusWarn, "Not available"
}
}
func checkOptionalDependencies() []checkResult {
var results []checkResult
if utils.IsServiceActive("accounts-daemon", false) {
results = append(results, checkResult{catOptionalFeatures, "accountsservice", statusOK, "Running", "User accounts", doctorDocsURL + "#optional-features"})
} else {
results = append(results, checkResult{catOptionalFeatures, "accountsservice", statusWarn, "Not running", "User accounts", doctorDocsURL + "#optional-features"})
}
optionalFeaturesURL := doctorDocsURL + "#optional-features"
if utils.IsServiceActive("power-profiles-daemon", false) {
results = append(results, checkResult{catOptionalFeatures, "power-profiles-daemon", statusOK, "Running", "Power profile management", doctorDocsURL + "#optional-features"})
} else {
results = append(results, checkResult{catOptionalFeatures, "power-profiles-daemon", statusInfo, "Not running", "Power profile management", 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], "", doctorDocsURL + "#optional-features"})
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", doctorDocsURL + "#optional-features"})
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, altCmd, desc string
important bool
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},
{"Network", "nmcli", "iwctl", "Network management", false},
{"danksearch", "dsearch", "", "File search", false},
{"loginctl", "loginctl", "", "Session management", false},
{"fprintd", "fprintd-list", "", "Fingerprint auth", false},
{"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, foundCmd := utils.CommandExists(d.cmd), d.cmd
if !found && d.altCmd != "" && utils.CommandExists(d.altCmd) {
found, foundCmd = true, d.altCmd
}
found := utils.CommandExists(d.cmd)
switch {
case found:
message := "Installed"
details := d.desc
if d.name == "Network" {
result, err := network.DetectNetworkStack()
if err == nil && result.Backend != network.BackendNone {
message = detectNetworkBackend() + " (active)"
if doctorVerbose {
details = result.ChosenReason
}
} else {
switch foundCmd {
case "nmcli":
message = "NetworkManager (installed)"
case "iwctl":
message = "iwd (installed)"
}
}
}
results = append(results, checkResult{catOptionalFeatures, d.name, statusOK, message, details, doctorDocsURL + "#optional-features"})
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, doctorDocsURL + "#optional-features"})
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, doctorDocsURL + "#optional-features"})
results = append(results, checkResult{catOptionalFeatures, d.name, statusInfo, "Not installed", d.desc, optionalFeaturesURL})
}
}
@@ -755,7 +763,7 @@ func checkConfigurationFiles() []checkResult {
status := statusOK
message := "Present"
if info.Mode().Perm()&0200 == 0 {
if info.Mode().Perm()&0o200 == 0 {
status = statusWarn
message += " (read-only)"
}
@@ -893,6 +901,10 @@ func printResultLine(r checkResult, styles tui.Styles) {
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) {
@@ -928,3 +940,36 @@ func printSummary(results []checkResult, qsMissingFeatures bool) {
}
fmt.Println()
}
func formatResultsPlain(results []checkResult) string {
var sb strings.Builder
sb.WriteString("## DMS Doctor Report\n\n")
currentCategory := category(-1)
for _, r := range results {
if r.category != currentCategory {
if currentCategory != -1 {
sb.WriteString("\n")
}
sb.WriteString(fmt.Sprintf("**%s**\n", r.category.String()))
currentCategory = r.category
}
sb.WriteString(fmt.Sprintf("- [%s] %s: %s\n", r.status, r.name, r.message))
if doctorVerbose && r.details != "" {
sb.WriteString(fmt.Sprintf(" - %s\n", r.details))
}
}
var ds DoctorStatus
for _, r := range results {
ds.Add(r)
}
sb.WriteString("\n---\n")
sb.WriteString(fmt.Sprintf("**Summary:** %d error(s), %d warning(s), %d ok\n",
ds.ErrorCount(), ds.WarningCount(), ds.OKCount()))
return sb.String()
}

View File

@@ -0,0 +1,99 @@
package main
import (
"context"
"fmt"
"io"
"net"
"net/http"
"os"
"path/filepath"
"time"
"github.com/spf13/cobra"
)
var dlOutput string
var dlUserAgent string
var dlTimeout int
var dlIPv4Only bool
var dlCmd = &cobra.Command{
Use: "dl <url>",
Short: "Download a URL to stdout or file",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
if err := runDownload(args[0]); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
},
}
func init() {
dlCmd.Flags().StringVarP(&dlOutput, "output", "o", "", "Output file path (default: stdout)")
dlCmd.Flags().StringVar(&dlUserAgent, "user-agent", "", "Custom User-Agent header")
dlCmd.Flags().IntVar(&dlTimeout, "timeout", 10, "Request timeout in seconds")
dlCmd.Flags().BoolVarP(&dlIPv4Only, "ipv4", "4", false, "Force IPv4 only")
}
func runDownload(url string) error {
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(dlTimeout)*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return fmt.Errorf("invalid request: %w", err)
}
switch {
case dlUserAgent != "":
req.Header.Set("User-Agent", dlUserAgent)
default:
req.Header.Set("User-Agent", "DankMaterialShell/1.0 (Linux)")
}
dialer := &net.Dialer{Timeout: 5 * time.Second}
transport := &http.Transport{DialContext: dialer.DialContext}
if dlIPv4Only {
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
return dialer.DialContext(ctx, "tcp4", addr)
}
}
client := &http.Client{Transport: transport}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("download failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("HTTP %d", resp.StatusCode)
}
if dlOutput == "" {
_, err = io.Copy(os.Stdout, resp.Body)
return err
}
if dir := filepath.Dir(dlOutput); dir != "." {
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("mkdir failed: %w", err)
}
}
f, err := os.Create(dlOutput)
if err != nil {
return fmt.Errorf("create failed: %w", err)
}
defer f.Close()
if _, err := io.Copy(f, resp.Body); err != nil {
os.Remove(dlOutput)
return fmt.Errorf("write failed: %w", err)
}
fmt.Println(dlOutput)
return nil
}

View File

@@ -119,7 +119,7 @@ func installGreeter() error {
}
fmt.Println("\nSynchronizing DMS configurations...")
if err := greeter.SyncDMSConfigs(dmsPath, logFunc, ""); err != nil {
if err := greeter.SyncDMSConfigs(dmsPath, selectedCompositor, logFunc, ""); err != nil {
return err
}
@@ -147,6 +147,23 @@ func syncGreeter() error {
}
fmt.Printf("✓ Found DMS at: %s\n", dmsPath)
if !isGreeterEnabled() {
fmt.Println("\n⚠ DMS greeter is not enabled in greetd config.")
fmt.Print("Would you like to enable it now? (Y/n): ")
var response string
fmt.Scanln(&response)
response = strings.ToLower(strings.TrimSpace(response))
if response != "n" && response != "no" {
if err := enableGreeter(); err != nil {
return err
}
} else {
return fmt.Errorf("greeter must be enabled before syncing")
}
}
cacheDir := "/var/cache/dms-greeter"
if _, err := os.Stat(cacheDir); os.IsNotExist(err) {
return fmt.Errorf("greeter cache directory not found at %s\nPlease install the greeter first", cacheDir)
@@ -188,13 +205,34 @@ func syncGreeter() error {
}
}
compositor := detectConfiguredCompositor()
if compositor == "" {
compositors := greeter.DetectCompositors()
switch len(compositors) {
case 0:
return fmt.Errorf("no supported compositors found")
case 1:
compositor = compositors[0]
fmt.Printf("✓ Using compositor: %s\n", compositor)
default:
var err error
compositor, err = promptCompositorChoice(compositors)
if err != nil {
return err
}
fmt.Printf("✓ Selected compositor: %s\n", compositor)
}
} else {
fmt.Printf("✓ Detected compositor from config: %s\n", compositor)
}
fmt.Println("\nSetting up permissions and ACLs...")
if err := greeter.SetupDMSGroup(logFunc, ""); err != nil {
return err
}
fmt.Println("\nSynchronizing DMS configurations...")
if err := greeter.SyncDMSConfigs(dmsPath, logFunc, ""); err != nil {
if err := greeter.SyncDMSConfigs(dmsPath, compositor, logFunc, ""); err != nil {
return err
}
@@ -552,6 +590,39 @@ func enableGreeter() error {
return nil
}
func isGreeterEnabled() bool {
data, err := os.ReadFile("/etc/greetd/config.toml")
if err != nil {
return false
}
return strings.Contains(string(data), "dms-greeter")
}
func detectConfiguredCompositor() string {
data, err := os.ReadFile("/etc/greetd/config.toml")
if err != nil {
return ""
}
for _, line := range strings.Split(string(data), "\n") {
trimmed := strings.TrimSpace(line)
if !strings.HasPrefix(trimmed, "command") || !strings.Contains(trimmed, "dms-greeter") {
continue
}
switch {
case strings.Contains(trimmed, "--command niri"):
return "niri"
case strings.Contains(trimmed, "--command hyprland"):
return "hyprland"
case strings.Contains(trimmed, "--command sway"):
return "sway"
}
}
return ""
}
func promptCompositorChoice(compositors []string) (string, error) {
fmt.Println("\nMultiple compositors detected:")
for i, comp := range compositors {

View File

@@ -63,6 +63,7 @@ func init() {
keybindsSetCmd.Flags().Bool("allow-when-locked", false, "Allow when screen is locked")
keybindsSetCmd.Flags().Int("cooldown-ms", 0, "Cooldown in milliseconds")
keybindsSetCmd.Flags().Bool("no-repeat", false, "Disable key repeat")
keybindsSetCmd.Flags().Bool("no-inhibiting", false, "Keep bind active when shortcuts are inhibited (allow-inhibiting=false)")
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)")
@@ -212,6 +213,9 @@ func runKeybindsSet(cmd *cobra.Command, args []string) {
if v, _ := cmd.Flags().GetBool("no-repeat"); v {
options["repeat"] = false
}
if v, _ := cmd.Flags().GetBool("no-inhibiting"); v {
options["allow-inhibiting"] = false
}
if v, _ := cmd.Flags().GetString("flags"); v != "" {
options["flags"] = v
}

View File

@@ -0,0 +1,68 @@
package main
import (
"fmt"
"os"
"github.com/AvengeMedia/DankMaterialShell/core/internal/notify"
"github.com/spf13/cobra"
)
var (
notifyAppName string
notifyIcon string
notifyFile string
notifyTimeout int
)
var notifyCmd = &cobra.Command{
Use: "notify <summary> [body]",
Short: "Send a desktop notification",
Long: `Send a desktop notification with optional actions.
If --file is provided, the notification will have "Open" and "Open Folder" actions.
Examples:
dms notify "Hello" "World"
dms notify "File received" "photo.jpg" --file ~/Downloads/photo.jpg --icon smartphone
dms notify "Download complete" --file ~/Downloads/file.zip --app "My App"`,
Args: cobra.MinimumNArgs(1),
Run: runNotify,
}
var genericNotifyActionCmd = &cobra.Command{
Use: "notify-action-generic",
Hidden: true,
Run: func(cmd *cobra.Command, args []string) {
notify.RunActionListener(args)
},
}
func init() {
notifyCmd.Flags().StringVar(&notifyAppName, "app", "DMS", "Application name")
notifyCmd.Flags().StringVar(&notifyIcon, "icon", "", "Icon name or path")
notifyCmd.Flags().StringVar(&notifyFile, "file", "", "File path (enables Open/Open Folder actions)")
notifyCmd.Flags().IntVar(&notifyTimeout, "timeout", 5000, "Timeout in milliseconds")
}
func runNotify(cmd *cobra.Command, args []string) {
summary := args[0]
body := ""
if len(args) > 1 {
body = args[1]
}
n := notify.Notification{
AppName: notifyAppName,
Icon: notifyIcon,
Summary: summary,
Body: body,
FilePath: notifyFile,
Timeout: int32(notifyTimeout),
}
if err := notify.Send(n); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}

View File

@@ -7,9 +7,7 @@ import (
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
"github.com/AvengeMedia/DankMaterialShell/core/internal/dms"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
tea "github.com/charmbracelet/bubbletea"
"github.com/spf13/cobra"
)
@@ -20,11 +18,9 @@ var rootCmd = &cobra.Command{
Use: "dms",
Short: "dms CLI",
Long: "dms is the DankMaterialShell management CLI and backend server.",
Run: runInteractiveMode,
}
func init() {
// Add the -c flag
rootCmd.PersistentFlags().StringVarP(&customConfigPath, "config", "c", "", "Specify a custom path to the DMS config directory")
}
@@ -38,7 +34,7 @@ func findConfig(cmd *cobra.Command, args []string) error {
if statErr == nil && !info.IsDir() {
configPath = customConfigPath
log.Debug("Using config from: %s", configPath)
return nil // <-- Guard statement
return nil
}
if statErr != nil {
@@ -76,18 +72,3 @@ func findConfig(cmd *cobra.Command, args []string) error {
log.Debug("Using config from: %s", configPath)
return nil
}
func runInteractiveMode(cmd *cobra.Command, args []string) {
detector, _ := dms.NewDetector()
if !detector.IsDMSInstalled() {
log.Error("DankMaterialShell (DMS) is not detected as installed on this system.")
log.Info("Please install DMS using dankinstall before using this management interface.")
os.Exit(1)
}
model := dms.NewModel(Version)
p := tea.NewProgram(model, tea.WithAltScreen())
if _, err := p.Run(); err != nil {
log.Fatalf("Error running program: %v", err)
}
}

View File

@@ -9,7 +9,9 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/greeter"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
"github.com/spf13/cobra"
)
@@ -24,6 +26,243 @@ var setupCmd = &cobra.Command{
},
}
var setupBindsCmd = &cobra.Command{
Use: "binds",
Short: "Deploy default keybinds config",
Run: func(cmd *cobra.Command, args []string) {
if err := runSetupDmsConfig("binds"); err != nil {
log.Fatalf("Error: %v", err)
}
},
}
var setupLayoutCmd = &cobra.Command{
Use: "layout",
Short: "Deploy default layout config",
Run: func(cmd *cobra.Command, args []string) {
if err := runSetupDmsConfig("layout"); err != nil {
log.Fatalf("Error: %v", err)
}
},
}
var setupColorsCmd = &cobra.Command{
Use: "colors",
Short: "Deploy default colors config",
Run: func(cmd *cobra.Command, args []string) {
if err := runSetupDmsConfig("colors"); err != nil {
log.Fatalf("Error: %v", err)
}
},
}
var setupAlttabCmd = &cobra.Command{
Use: "alttab",
Short: "Deploy default alt-tab config (niri only)",
Run: func(cmd *cobra.Command, args []string) {
if err := runSetupDmsConfig("alttab"); err != nil {
log.Fatalf("Error: %v", err)
}
},
}
var setupOutputsCmd = &cobra.Command{
Use: "outputs",
Short: "Deploy default outputs config",
Run: func(cmd *cobra.Command, args []string) {
if err := runSetupDmsConfig("outputs"); err != nil {
log.Fatalf("Error: %v", err)
}
},
}
var setupCursorCmd = &cobra.Command{
Use: "cursor",
Short: "Deploy default cursor config",
Run: func(cmd *cobra.Command, args []string) {
if err := runSetupDmsConfig("cursor"); err != nil {
log.Fatalf("Error: %v", err)
}
},
}
var setupWindowrulesCmd = &cobra.Command{
Use: "windowrules",
Short: "Deploy default window rules config",
Run: func(cmd *cobra.Command, args []string) {
if err := runSetupDmsConfig("windowrules"); err != nil {
log.Fatalf("Error: %v", err)
}
},
}
type dmsConfigSpec struct {
niriFile string
hyprFile string
niriContent func(terminal string) string
hyprContent func(terminal string) string
}
var dmsConfigSpecs = map[string]dmsConfigSpec{
"binds": {
niriFile: "binds.kdl",
hyprFile: "binds.conf",
niriContent: func(t string) string {
return strings.ReplaceAll(config.NiriBindsConfig, "{{TERMINAL_COMMAND}}", t)
},
hyprContent: func(t string) string {
return strings.ReplaceAll(config.HyprBindsConfig, "{{TERMINAL_COMMAND}}", t)
},
},
"layout": {
niriFile: "layout.kdl",
hyprFile: "layout.conf",
niriContent: func(_ string) string { return config.NiriLayoutConfig },
hyprContent: func(_ string) string { return config.HyprLayoutConfig },
},
"colors": {
niriFile: "colors.kdl",
hyprFile: "colors.conf",
niriContent: func(_ string) string { return config.NiriColorsConfig },
hyprContent: func(_ string) string { return config.HyprColorsConfig },
},
"alttab": {
niriFile: "alttab.kdl",
niriContent: func(_ string) string { return config.NiriAlttabConfig },
},
"outputs": {
niriFile: "outputs.kdl",
hyprFile: "outputs.conf",
niriContent: func(_ string) string { return "" },
hyprContent: func(_ string) string { return "" },
},
"cursor": {
niriFile: "cursor.kdl",
hyprFile: "cursor.conf",
niriContent: func(_ string) string { return "" },
hyprContent: func(_ string) string { return "" },
},
"windowrules": {
niriFile: "windowrules.kdl",
hyprFile: "windowrules.conf",
niriContent: func(_ string) string { return "" },
hyprContent: func(_ string) string { return "" },
},
}
func detectTerminal() (string, error) {
terminals := []string{"ghostty", "foot", "kitty", "alacritty"}
var found []string
for _, t := range terminals {
if utils.CommandExists(t) {
found = append(found, t)
}
}
switch len(found) {
case 0:
return "ghostty", nil
case 1:
return found[0], nil
}
fmt.Println("Multiple terminals detected:")
for i, t := range found {
fmt.Printf("%d) %s\n", i+1, t)
}
fmt.Printf("\nChoice (1-%d): ", len(found))
var response string
fmt.Scanln(&response)
response = strings.TrimSpace(response)
choice := 0
fmt.Sscanf(response, "%d", &choice)
if choice < 1 || choice > len(found) {
return "", fmt.Errorf("invalid choice")
}
return found[choice-1], nil
}
func detectCompositorForSetup() (string, error) {
compositors := greeter.DetectCompositors()
switch len(compositors) {
case 0:
return "", fmt.Errorf("no supported compositors found (niri or Hyprland required)")
case 1:
return strings.ToLower(compositors[0]), nil
}
selected, err := greeter.PromptCompositorChoice(compositors)
if err != nil {
return "", err
}
return strings.ToLower(selected), nil
}
func runSetupDmsConfig(name string) error {
spec, ok := dmsConfigSpecs[name]
if !ok {
return fmt.Errorf("unknown config: %s", name)
}
compositor, err := detectCompositorForSetup()
if err != nil {
return err
}
var filename string
var contentFn func(string) string
switch compositor {
case "niri":
filename = spec.niriFile
contentFn = spec.niriContent
case "hyprland":
filename = spec.hyprFile
contentFn = spec.hyprContent
default:
return fmt.Errorf("unsupported compositor: %s", compositor)
}
if filename == "" {
return fmt.Errorf("%s is not supported for %s", name, compositor)
}
var dmsDir string
switch compositor {
case "niri":
dmsDir = filepath.Join(os.Getenv("HOME"), ".config", "niri", "dms")
case "hyprland":
dmsDir = filepath.Join(os.Getenv("HOME"), ".config", "hypr", "dms")
}
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
return fmt.Errorf("failed to create dms directory: %w", err)
}
path := filepath.Join(dmsDir, filename)
if info, err := os.Stat(path); err == nil && info.Size() > 0 {
return fmt.Errorf("%s already exists and is not empty: %s", name, path)
}
terminal := "ghostty"
if contentFn != nil && name == "binds" {
terminal, err = detectTerminal()
if err != nil {
return err
}
}
content := contentFn(terminal)
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
return fmt.Errorf("failed to write %s: %w", filename, err)
}
fmt.Printf("Deployed %s to %s\n", name, path)
return nil
}
func runSetup() error {
fmt.Println("=== DMS Configuration Setup ===")

View File

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

View File

@@ -19,6 +19,9 @@ func init() {
// Add subcommands to greeter
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd)
// Add subcommands to setup
setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
// Add subcommands to update
updateCmd.AddCommand(updateCheckCmd)

View File

@@ -20,6 +20,9 @@ func init() {
// Add subcommands to greeter
greeterCmd.AddCommand(greeterSyncCmd, greeterEnableCmd, greeterStatusCmd)
// Add subcommands to setup
setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
// Add subcommands to plugins
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)

View File

@@ -618,9 +618,8 @@ func getShellIPCCompletions(args []string, _ string) []string {
func runShellIPCCommand(args []string) {
if len(args) == 0 {
log.Error("IPC command requires arguments")
log.Info("Usage: dms ipc <command> [args...]")
os.Exit(1)
printIPCHelp()
return
}
if args[0] != "call" {
@@ -642,3 +641,45 @@ func runShellIPCCommand(args []string) {
log.Fatalf("Error running IPC command: %v", err)
}
}
func printIPCHelp() {
fmt.Println("Usage: dms ipc <target> <function> [args...]")
fmt.Println()
cmdArgs := []string{"ipc"}
if qsHasAnyDisplay() {
cmdArgs = append(cmdArgs, "--any-display")
}
cmdArgs = append(cmdArgs, "-p", configPath, "show")
cmd := exec.Command("qs", cmdArgs...)
output, err := cmd.Output()
if err != nil {
fmt.Println("Could not retrieve available IPC targets (is DMS running?)")
return
}
targets := parseTargetsFromIPCShowOutput(string(output))
if len(targets) == 0 {
fmt.Println("No IPC targets available")
return
}
fmt.Println("Targets:")
targetNames := make([]string, 0, len(targets))
for name := range targets {
targetNames = append(targetNames, name)
}
slices.Sort(targetNames)
for _, targetName := range targetNames {
funcs := targets[targetName]
funcNames := make([]string, 0, len(funcs))
for fn := range funcs {
funcNames = append(funcNames, fn)
}
slices.Sort(funcNames)
fmt.Printf(" %-16s %s\n", targetName, strings.Join(funcNames, ", "))
}
}

View File

@@ -4,6 +4,7 @@ go 1.24.6
require (
github.com/Wifx/gonetworkmanager/v2 v2.2.0
github.com/alecthomas/chroma/v2 v2.23.1
github.com/charmbracelet/bubbles v0.21.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
@@ -12,25 +13,28 @@ require (
github.com/godbus/dbus/v5 v5.2.2
github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83
github.com/pilebones/go-udev v0.9.1
github.com/sblinch/kdl-go v0.0.0-20251203232544-981d4ecc17c3
github.com/sblinch/kdl-go v0.0.0-20260121213736-8b7053306ca6
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
github.com/yuin/goldmark v1.7.16
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
go.etcd.io/bbolt v1.4.3
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93
golang.org/x/image v0.34.0
golang.org/x/exp v0.0.0-20260112195511-716be5621a96
golang.org/x/image v0.35.0
)
require (
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.3.0 // indirect
github.com/clipperhouse/displaywidth v0.6.2 // indirect
github.com/clipperhouse/displaywidth v0.8.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
github.com/cloudflare/circl v1.6.2 // indirect
github.com/clipperhouse/uax29/v2 v2.4.0 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/go-git/gcfg/v2 v2.0.2 // indirect
github.com/go-git/go-billy/v6 v6.0.0-20251217170237-e9738f50a3cd // indirect
github.com/go-git/go-billy/v6 v6.0.0-20260114122816-19306b749ecc // indirect
github.com/go-logfmt/logfmt v0.6.1 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/kevinburke/ssh_config v1.4.0 // indirect
@@ -38,8 +42,8 @@ require (
github.com/pjbgf/sha1cd v0.5.0 // indirect
github.com/sergi/go-diff v1.4.0 // indirect
github.com/stretchr/objx v0.5.3 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/net v0.49.0 // indirect
)
require (
@@ -47,12 +51,12 @@ require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/charmbracelet/x/ansi v0.11.3 // indirect
github.com/charmbracelet/x/ansi v0.11.4 // indirect
github.com/charmbracelet/x/cellbuf v0.0.14 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/go-git/go-git/v6 v6.0.0-20251231065035-29ae690a9f19
github.com/go-git/go-git/v6 v6.0.0-20260123133532-f99a98e81ce9
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0
github.com/mattn/go-isatty v0.0.20 // indirect
@@ -66,7 +70,7 @@ require (
github.com/spf13/afero v1.15.0
github.com/spf13/pflag v1.0.10 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.39.0
golang.org/x/text v0.32.0
golang.org/x/sys v0.40.0
golang.org/x/text v0.33.0
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View File

@@ -4,6 +4,14 @@ github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBi
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
github.com/Wifx/gonetworkmanager/v2 v2.2.0 h1:kPstgsQtY8CmDOOFZd81ytM9Gi3f6ImzPCKF7nNhQ2U=
github.com/Wifx/gonetworkmanager/v2 v2.2.0/go.mod h1:fMDb//SHsKWxyDUAwXvCqurV3npbIyyaQWenGpZ/uXg=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs=
github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=
github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
@@ -16,8 +24,6 @@ github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
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/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=
@@ -26,34 +32,30 @@ github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoF
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig=
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/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/ansi v0.11.4 h1:6G65PLu6HjmE858CnTUQY1LXT3ZUWwfvqEROLF8vqHI=
github.com/charmbracelet/x/ansi v0.11.4/go.mod h1:/5AZ+UfWExW3int5H5ugnsG/PWjNcSQcwYsHBlPFQN4=
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/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
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/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/displaywidth v0.8.0 h1:/z8v+H+4XLluJKS7rAc7uHZTalT5Z+1430ld3lePSRI=
github.com/clipperhouse/displaywidth v0.8.0/go.mod h1:UpOXiIKep+TohQYwvAAM/VDU8v3Z5rnWTxiwueR0XvQ=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/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/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
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.2 h1:hL7VBpHHKzrV5WTfHCaBsgx/HGbBYlgrwvNXEVDYYsQ=
github.com/cloudflare/circl v1.6.2/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/clipperhouse/uax29/v2 v2.4.0 h1:RXqE/l5EiAbA4u97giimKNlmpvkmz+GrBVTelsoXy9g=
github.com/clipperhouse/uax29/v2 v2.4.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
@@ -64,22 +66,15 @@ github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
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/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-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/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/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-git/go-billy/v6 v6.0.0-20260114122816-19306b749ecc h1:rhkjrnRkamkRC7woapp425E4CAH6RPcqsS9X8LA93IY=
github.com/go-git/go-billy/v6 v6.0.0-20260114122816-19306b749ecc/go.mod h1:X1oe0Z2qMsa9hkar3AAPuL9hu4Mi3ztXEjdqRhr6fcc=
github.com/go-git/go-git-fixtures/v5 v5.1.2-0.20260122163445-0622d7459a67 h1:3hutPZF+/FBjR/9MdsLJ7e1mlt9pwHgwxMW7CrbmWII=
github.com/go-git/go-git-fixtures/v5 v5.1.2-0.20260122163445-0622d7459a67/go.mod h1:xKt0pNHST9tYHvbiLxSY27CQWFwgIxBJuDrOE0JvbZw=
github.com/go-git/go-git/v6 v6.0.0-20260123133532-f99a98e81ce9 h1:VzdR70t+SMjYnBgnbtNpq4ElZAAovLPMG+GFX8OBRtM=
github.com/go-git/go-git/v6 v6.0.0-20260123133532-f99a98e81ce9/go.mod h1:EWlxLBkiFCzXNCadvt05fT9PCAE2sUedgDsvUUIo18s=
github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE=
github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk=
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/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=
@@ -87,6 +82,8 @@ github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUv
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83 h1:B+A58zGFuDrvEZpPN+yS6swJA0nzqgZvDzgl/OPyefU=
github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83/go.mod h1:iHAf8OIncO2gcQ8XOjS7CMJ2aPbX2Bs0wl5pZyanEqk=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
@@ -127,16 +124,12 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
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/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/sblinch/kdl-go v0.0.0-20260121213736-8b7053306ca6 h1:JsjzqC6ymELkN4XlTjZPSahSAem21GySugLbKz6uF5E=
github.com/sblinch/kdl-go v0.0.0-20260121213736-8b7053306ca6/go.mod h1:b3oNGuAKOQzhsCKmuLc/urEOPzgHj6fB8vl8bwTBh28=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
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/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=
@@ -146,45 +139,43 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
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/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/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/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU=
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.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
golang.org/x/image v0.35.0 h1:LKjiHdgMtO8z7Fh18nGY6KDcoEtVfsgLDPeLyguqb7I=
golang.org/x/image v0.35.0/go.mod h1:MwPLTVgvxSASsxdLzKrl8BRFuyqMyGhLwmC+TO1Sybk=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
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/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.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
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/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/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ package clipboard
import (
"context"
"errors"
"fmt"
"io"
"os"
@@ -12,8 +13,9 @@ import (
)
type ClipboardChange struct {
Data []byte
MimeType string
Data []byte
MimeType string
MimeTypes []string
}
func Watch(ctx context.Context, callback func(data []byte, mimeType string)) error {
@@ -130,13 +132,154 @@ func Watch(ctx context.Context, callback func(data []byte, mimeType string)) err
if err := wlCtx.SetReadDeadline(time.Now().Add(100 * time.Millisecond)); err != nil {
return fmt.Errorf("set read deadline: %w", err)
}
if err := wlCtx.Dispatch(); err != nil && err != os.ErrDeadlineExceeded {
if err := wlCtx.Dispatch(); err != nil {
if isTimeoutError(err) {
continue
}
return fmt.Errorf("dispatch: %w", err)
}
}
}
}
func WatchAll(ctx context.Context, callback func(data []byte, mimeType string, allMimeTypes []string)) error {
display, err := wlclient.Connect("")
if err != nil {
return fmt.Errorf("wayland connect: %w", err)
}
defer display.Destroy()
wlCtx := display.Context()
registry, err := display.GetRegistry()
if err != nil {
return fmt.Errorf("get registry: %w", err)
}
defer registry.Destroy()
var dataControlMgr *ext_data_control.ExtDataControlManagerV1
var seat *wlclient.Seat
var bindErr error
registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) {
switch e.Interface {
case "ext_data_control_manager_v1":
dataControlMgr = ext_data_control.NewExtDataControlManagerV1(wlCtx)
if err := registry.Bind(e.Name, e.Interface, e.Version, dataControlMgr); err != nil {
bindErr = err
}
case "wl_seat":
if seat != nil {
return
}
seat = wlclient.NewSeat(wlCtx)
if err := registry.Bind(e.Name, e.Interface, e.Version, seat); err != nil {
bindErr = err
}
}
})
display.Roundtrip()
display.Roundtrip()
if bindErr != nil {
return fmt.Errorf("registry bind: %w", bindErr)
}
if dataControlMgr == nil {
return fmt.Errorf("compositor does not support ext_data_control_manager_v1")
}
defer dataControlMgr.Destroy()
if seat == nil {
return fmt.Errorf("no seat available")
}
device, err := dataControlMgr.GetDataDevice(seat)
if err != nil {
return fmt.Errorf("get data device: %w", err)
}
defer device.Destroy()
offerMimeTypes := make(map[*ext_data_control.ExtDataControlOfferV1][]string)
device.SetDataOfferHandler(func(e ext_data_control.ExtDataControlDeviceV1DataOfferEvent) {
if e.Id == nil {
return
}
offerMimeTypes[e.Id] = nil
e.Id.SetOfferHandler(func(me ext_data_control.ExtDataControlOfferV1OfferEvent) {
offerMimeTypes[e.Id] = append(offerMimeTypes[e.Id], me.MimeType)
})
})
device.SetSelectionHandler(func(e ext_data_control.ExtDataControlDeviceV1SelectionEvent) {
if e.Id == nil {
return
}
mimes := offerMimeTypes[e.Id]
selectedMime := selectPreferredMimeType(mimes)
if selectedMime == "" {
return
}
mimesCopy := make([]string, len(mimes))
copy(mimesCopy, mimes)
r, w, err := os.Pipe()
if err != nil {
return
}
if err := e.Id.Receive(selectedMime, int(w.Fd())); err != nil {
w.Close()
r.Close()
return
}
w.Close()
go func() {
defer r.Close()
data, err := io.ReadAll(r)
if err != nil || len(data) == 0 {
return
}
callback(data, selectedMime, mimesCopy)
}()
})
display.Roundtrip()
display.Roundtrip()
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
if err := wlCtx.SetReadDeadline(time.Now().Add(100 * time.Millisecond)); err != nil {
return fmt.Errorf("set read deadline: %w", err)
}
if err := wlCtx.Dispatch(); err != nil {
if isTimeoutError(err) {
continue
}
return fmt.Errorf("dispatch: %w", err)
}
}
}
}
func isTimeoutError(err error) bool {
if err == nil {
return false
}
if errors.Is(err, os.ErrDeadlineExceeded) {
return true
}
if netErr, ok := err.(interface{ Timeout() bool }); ok && netErr.Timeout() {
return true
}
return false
}
func WatchChan(ctx context.Context) (<-chan ClipboardChange, <-chan error) {
ch := make(chan ClipboardChange, 16)
errCh := make(chan error, 1)

View File

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

View File

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

View File

@@ -27,6 +27,8 @@ bindl = , XF86AudioPause, exec, dms ipc call mpris playPause
bindl = , XF86AudioPlay, exec, dms ipc call mpris playPause
bindl = , XF86AudioPrev, exec, dms ipc call mpris previous
bindl = , XF86AudioNext, exec, dms ipc call mpris next
bindel = CTRL, XF86AudioRaiseVolume, exec, dms ipc call mpris increment 3
bindel = CTRL, XF86AudioLowerVolume, exec, dms ipc call mpris decrement 3
# === Brightness Controls ===
bindel = , XF86MonBrightnessUp, exec, dms ipc call brightness increment 5 ""
@@ -38,6 +40,7 @@ bind = SUPER, F, fullscreen, 1
bind = SUPER SHIFT, F, fullscreen, 0
bind = SUPER SHIFT, T, togglefloating
bind = SUPER, W, togglegroup
bind = SUPER SHIFT, W, exec, dms ipc call window-rules toggle
# === Focus Navigation ===
bind = SUPER, left, movefocus, l
@@ -91,6 +94,9 @@ bind = SUPER CTRL, up, movetoworkspace, e-1
bind = SUPER CTRL, U, movetoworkspace, e+1
bind = SUPER CTRL, I, movetoworkspace, e-1
# === Workspace Management ===
bind = CTRL SHIFT, R, exec, dms ipc call workspace-rename open
# === Move Workspaces ===
bind = SUPER SHIFT, Page_Down, movetoworkspace, e+1
bind = SUPER SHIFT, Page_Up, movetoworkspace, e-1

View File

@@ -81,7 +81,6 @@ master {
misc {
disable_hyprland_logo = true
disable_splash_rendering = true
vrr = 1
}
# ==================
@@ -99,9 +98,11 @@ windowrule = float on, match:class ^(gnome-calculator)$
windowrule = float on, match:class ^(galculator)$
windowrule = float on, match:class ^(blueman-manager)$
windowrule = float on, match:class ^(org\.gnome\.Nautilus)$
windowrule = float on, match:class ^(steam)$
windowrule = float on, match:class ^(xdg-desktop-portal)$
windowrule = noinitialfocus on, match:class ^(steam)$, match:title ^(notificationtoasts)
windowrule = pin on, match:class ^(steam)$, match:title ^(notificationtoasts)
windowrule = float on, match:class ^(firefox)$, match:title ^(Picture-in-Picture)$
windowrule = float on, match:class ^(zoom)$

View File

@@ -60,6 +60,12 @@ binds {
XF86AudioNext allow-when-locked=true {
spawn "dms" "ipc" "call" "mpris" "next";
}
Ctrl+XF86AudioRaiseVolume allow-when-locked=true {
spawn "dms" "ipc" "call" "mpris" "increment" "3";
}
Ctrl+XF86AudioLowerVolume allow-when-locked=true {
spawn "dms" "ipc" "call" "mpris" "decrement" "3";
}
// === Brightness Controls ===
XF86MonBrightnessUp allow-when-locked=true {
@@ -76,6 +82,7 @@ binds {
Mod+Shift+T { toggle-window-floating; }
Mod+Shift+V { switch-focus-between-floating-and-tiling; }
Mod+W { toggle-column-tabbed-display; }
Mod+Shift+W hotkey-overlay-title="Create window rule" { spawn "dms" "ipc" "call" "window-rules" "toggle"; }
// === Focus Navigation ===
Mod+Left { focus-column-left; }
@@ -133,6 +140,11 @@ binds {
Mod+Ctrl+U { move-column-to-workspace-down; }
Mod+Ctrl+I { move-column-to-workspace-up; }
// === Workspace Management ===
Ctrl+Shift+R hotkey-overlay-title="Rename Workspace" {
spawn "dms" "ipc" "call" "workspace-rename" "open";
}
// === Move Workspaces ===
Mod+Shift+Page_Down { move-workspace-down; }
Mod+Shift+Page_Up { move-workspace-up; }

View File

@@ -0,0 +1,17 @@
hotkey-overlay {
skip-at-startup
}
environment {
DMS_RUN_GREETER "1"
}
gestures {
hot-corners {
off
}
}
layout {
background-color "#000000"
}

View File

@@ -228,10 +228,14 @@ window-rule {
match app-id=r#"^galculator$"#
match app-id=r#"^blueman-manager$"#
match app-id=r#"^org\.gnome\.Nautilus$"#
match app-id=r#"^steam$"#
match app-id=r#"^xdg-desktop-portal$"#
open-floating true
}
window-rule {
match app-id=r#"^steam$"# title=r#"^notificationtoasts_\d+_desktop$"#
default-floating-position x=10 y=10 relative-to="bottom-right"
open-focused false
}
window-rule {
match app-id=r#"^org\.wezfurlong\.wezterm$"#
match app-id="Alacritty"

View File

@@ -16,3 +16,6 @@ var NiriAlttabConfig string
//go:embed embedded/niri-binds.kdl
var NiriBindsConfig string
//go:embed embedded/niri-greeter.kdl
var NiriGreeterConfig string

View File

@@ -199,31 +199,6 @@ func labToHex(L, a, b float64) string {
return fmt.Sprintf("#%02x%02x%02x", r, g, b2)
}
// Adjust brightness while keeping the same hue
func retoneToL(hex string, Ltarget float64) string {
rgb := HexToRGB(hex)
col := colorful.Color{R: rgb.R, G: rgb.G, B: rgb.B}
L, a, b := col.Lab()
L100 := L * 100.0
scale := 1.0
if L100 != 0 {
scale = Ltarget / L100
}
a2, b2 := a*scale, b*scale
// Don't let it get too saturated
maxChroma := 0.4
if math.Hypot(a2, b2) > maxChroma {
k := maxChroma / math.Hypot(a2, b2)
a2 *= k
b2 *= k
}
return labToHex(Ltarget, a2, b2)
}
func DeltaPhiStar(hexFg, hexBg string, negativePolarity bool) float64 {
Lf := getLstar(hexFg)
Lb := getLstar(hexBg)
@@ -356,6 +331,59 @@ func EnsureContrastDPSLstar(hexColor, hexBg string, minLc float64, isLightMode b
return hexColor
}
// Bidirectional contrast - tries both lighter and darker, picks closest to original
func EnsureContrastDPSBidirectional(hexColor, hexBg string, minLc float64, isLightMode bool) string {
current := DeltaPhiStarContrast(hexColor, hexBg, isLightMode)
if current >= minLc {
return hexColor
}
fg := HexToRGB(hexColor)
cf := colorful.Color{R: fg.R, G: fg.G, B: fg.B}
origL, af, bf := cf.Lab()
var darkerResult, lighterResult string
darkerL, lighterL := origL, origL
darkerFound, lighterFound := false, false
step := 0.5
for i := range 120 {
if !darkerFound {
darkerL = math.Max(0, origL-float64(i)*step)
cand := labToHex(darkerL, af, bf)
if DeltaPhiStarContrast(cand, hexBg, isLightMode) >= minLc {
darkerResult = cand
darkerFound = true
}
}
if !lighterFound {
lighterL = math.Min(100, origL+float64(i)*step)
cand := labToHex(lighterL, af, bf)
if DeltaPhiStarContrast(cand, hexBg, isLightMode) >= minLc {
lighterResult = cand
lighterFound = true
}
}
if darkerFound && lighterFound {
break
}
}
if darkerFound && lighterFound {
if math.Abs(darkerL-origL) <= math.Abs(lighterL-origL) {
return darkerResult
}
return lighterResult
}
if darkerFound {
return darkerResult
}
if lighterFound {
return lighterResult
}
return hexColor
}
type PaletteOptions struct {
IsLight bool
Background string
@@ -369,6 +397,29 @@ func ensureContrastAuto(hexColor, hexBg string, target float64, opts PaletteOpti
return EnsureContrast(hexColor, hexBg, target, opts.IsLight)
}
func ensureContrastBidirectional(hexColor, hexBg string, target float64, opts PaletteOptions) string {
if opts.UseDPS {
return EnsureContrastDPSBidirectional(hexColor, hexBg, target, opts.IsLight)
}
return EnsureContrast(hexColor, hexBg, target, opts.IsLight)
}
func blendHue(base, target, factor float64) float64 {
diff := target - base
if diff > 0.5 {
diff -= 1.0
} else if diff < -0.5 {
diff += 1.0
}
result := base + diff*factor
if result < 0 {
result += 1.0
} else if result >= 1.0 {
result -= 1.0
}
return result
}
func DeriveContainer(primary string, isLight bool) string {
rgb := HexToRGB(primary)
hsv := RGBToHSV(rgb)
@@ -389,6 +440,9 @@ func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
rgb := HexToRGB(baseColor)
hsv := RGBToHSV(rgb)
pr := HexToRGB(primaryColor)
ph := RGBToHSV(pr)
var palette Palette
var normalTextTarget, secondaryTarget float64
@@ -410,115 +464,136 @@ func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
}
palette.Color0 = NewColorInfo(bgColor)
hueShift := (hsv.H - 0.6) * 0.12
satBoost := 1.15
baseSat := math.Max(ph.S, 0.5)
baseVal := math.Max(ph.V, 0.5)
redH := math.Mod(0.0+hueShift+1.0, 1.0)
var redColor string
if opts.IsLight {
redColor = RGBToHex(HSVToRGB(HSV{H: redH, S: math.Min(0.80*satBoost, 1.0), V: 0.55}))
palette.Color1 = NewColorInfo(ensureContrastAuto(redColor, bgColor, normalTextTarget, opts))
} else {
redColor = RGBToHex(HSVToRGB(HSV{H: redH, S: math.Min(0.65*satBoost, 1.0), V: 0.80}))
palette.Color1 = NewColorInfo(ensureContrastAuto(redColor, bgColor, normalTextTarget, opts))
}
redH := blendHue(0.0, ph.H, 0.12)
greenH := blendHue(0.33, ph.H, 0.10)
yellowH := blendHue(0.14, ph.H, 0.04)
greenH := math.Mod(0.33+hueShift+1.0, 1.0)
var greenColor string
if opts.IsLight {
greenColor = RGBToHex(HSVToRGB(HSV{H: greenH, S: math.Min(math.Max(hsv.S*0.9, 0.80)*satBoost, 1.0), V: 0.45}))
palette.Color2 = NewColorInfo(ensureContrastAuto(greenColor, bgColor, normalTextTarget, opts))
} else {
greenColor = RGBToHex(HSVToRGB(HSV{H: greenH, S: math.Min(0.42*satBoost, 1.0), V: 0.84}))
palette.Color2 = NewColorInfo(ensureContrastAuto(greenColor, bgColor, normalTextTarget, opts))
}
yellowH := math.Mod(0.15+hueShift+1.0, 1.0)
var yellowColor string
if opts.IsLight {
yellowColor = RGBToHex(HSVToRGB(HSV{H: yellowH, S: math.Min(0.75*satBoost, 1.0), V: 0.50}))
palette.Color3 = NewColorInfo(ensureContrastAuto(yellowColor, bgColor, normalTextTarget, opts))
} else {
yellowColor = RGBToHex(HSVToRGB(HSV{H: yellowH, S: math.Min(0.38*satBoost, 1.0), V: 0.86}))
palette.Color3 = NewColorInfo(ensureContrastAuto(yellowColor, bgColor, normalTextTarget, opts))
}
var blueColor string
if opts.IsLight {
blueColor = RGBToHex(HSVToRGB(HSV{H: hsv.H, S: math.Max(hsv.S*0.9, 0.7), V: hsv.V * 1.1}))
palette.Color4 = NewColorInfo(ensureContrastAuto(blueColor, bgColor, normalTextTarget, opts))
} else {
blueColor = RGBToHex(HSVToRGB(HSV{H: hsv.H, S: math.Max(hsv.S*0.8, 0.6), V: math.Min(hsv.V*1.6, 1.0)}))
palette.Color4 = NewColorInfo(ensureContrastAuto(blueColor, bgColor, normalTextTarget, opts))
}
magH := hsv.H - 0.03
if magH < 0 {
magH += 1.0
}
var magColor string
hr := HexToRGB(primaryColor)
hh := RGBToHSV(hr)
if opts.IsLight {
magColor = RGBToHex(HSVToRGB(HSV{H: hh.H, S: math.Max(hh.S*0.9, 0.7), V: hh.V * 0.85}))
palette.Color5 = NewColorInfo(ensureContrastAuto(magColor, bgColor, normalTextTarget, opts))
} else {
magColor = RGBToHex(HSVToRGB(HSV{H: hh.H, S: hh.S * 0.8, V: hh.V * 0.75}))
palette.Color5 = NewColorInfo(ensureContrastAuto(magColor, bgColor, normalTextTarget, opts))
}
cyanH := hsv.H + 0.08
if cyanH > 1.0 {
cyanH -= 1.0
}
palette.Color6 = NewColorInfo(ensureContrastAuto(primaryColor, bgColor, normalTextTarget, opts))
accentTarget := secondaryTarget * 0.7
if opts.IsLight {
palette.Color7 = NewColorInfo("#1a1a1a")
palette.Color8 = NewColorInfo("#2e2e2e")
} else {
palette.Color7 = NewColorInfo("#abb2bf")
palette.Color8 = NewColorInfo("#5c6370")
}
redS := math.Min(baseSat*1.2, 1.0)
redV := baseVal * 0.95
palette.Color1 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: redH, S: redS, V: redV})), bgColor, normalTextTarget, opts))
if opts.IsLight {
brightRed := RGBToHex(HSVToRGB(HSV{H: redH, S: math.Min(0.70*satBoost, 1.0), V: 0.65}))
palette.Color9 = NewColorInfo(ensureContrastAuto(brightRed, bgColor, secondaryTarget, opts))
brightGreen := RGBToHex(HSVToRGB(HSV{H: greenH, S: math.Min(math.Max(hsv.S*0.85, 0.75)*satBoost, 1.0), V: 0.55}))
palette.Color10 = NewColorInfo(ensureContrastAuto(brightGreen, bgColor, secondaryTarget, opts))
brightYellow := RGBToHex(HSVToRGB(HSV{H: yellowH, S: math.Min(0.68*satBoost, 1.0), V: 0.60}))
palette.Color11 = NewColorInfo(ensureContrastAuto(brightYellow, bgColor, secondaryTarget, opts))
hr := HexToRGB(primaryColor)
hh := RGBToHSV(hr)
brightBlue := RGBToHex(HSVToRGB(HSV{H: hh.H, S: math.Min(hh.S*1.1, 1.0), V: math.Min(hh.V*1.2, 1.0)}))
palette.Color12 = NewColorInfo(ensureContrastAuto(brightBlue, bgColor, secondaryTarget, opts))
brightMag := RGBToHex(HSVToRGB(HSV{H: magH, S: math.Max(hsv.S*0.9, 0.75), V: math.Min(hsv.V*1.25, 1.0)}))
palette.Color13 = NewColorInfo(ensureContrastAuto(brightMag, bgColor, secondaryTarget, opts))
brightCyan := RGBToHex(HSVToRGB(HSV{H: cyanH, S: math.Max(hsv.S*0.75, 0.65), V: math.Min(hsv.V*1.25, 1.0)}))
palette.Color14 = NewColorInfo(ensureContrastAuto(brightCyan, bgColor, secondaryTarget, opts))
} else {
brightRed := RGBToHex(HSVToRGB(HSV{H: redH, S: math.Min(0.50*satBoost, 1.0), V: 0.88}))
palette.Color9 = NewColorInfo(ensureContrastAuto(brightRed, bgColor, secondaryTarget, opts))
brightGreen := RGBToHex(HSVToRGB(HSV{H: greenH, S: math.Min(0.35*satBoost, 1.0), V: 0.88}))
palette.Color10 = NewColorInfo(ensureContrastAuto(brightGreen, bgColor, secondaryTarget, opts))
brightYellow := RGBToHex(HSVToRGB(HSV{H: yellowH, S: math.Min(0.30*satBoost, 1.0), V: 0.91}))
palette.Color11 = NewColorInfo(ensureContrastAuto(brightYellow, bgColor, secondaryTarget, opts))
brightBlue := retoneToL(primaryColor, 85.0)
palette.Color12 = NewColorInfo(brightBlue)
brightMag := RGBToHex(HSVToRGB(HSV{H: magH, S: math.Max(hsv.S*0.7, 0.6), V: math.Min(hsv.V*1.3, 0.9)}))
palette.Color13 = NewColorInfo(ensureContrastAuto(brightMag, bgColor, secondaryTarget, opts))
brightCyanH := hsv.H + 0.02
if brightCyanH > 1.0 {
brightCyanH -= 1.0
}
brightCyan := RGBToHex(HSVToRGB(HSV{H: brightCyanH, S: math.Max(hsv.S*0.6, 0.5), V: math.Min(hsv.V*1.2, 0.85)}))
palette.Color14 = NewColorInfo(ensureContrastAuto(brightCyan, bgColor, secondaryTarget, opts))
}
greenS := math.Min(baseSat*1.3, 1.0)
greenV := baseVal * 0.75
palette.Color2 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: greenH, S: greenS, V: greenV})), bgColor, normalTextTarget, opts))
if opts.IsLight {
palette.Color15 = NewColorInfo("#1a1a1a")
yellowS := math.Min(baseSat*1.5, 1.0)
yellowV := math.Min(baseVal*1.2, 1.0)
palette.Color3 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: yellowH, S: yellowS, V: yellowV})), bgColor, accentTarget, opts))
blueS := math.Min(ph.S*1.05, 1.0)
blueV := math.Min(ph.V*1.05, 1.0)
palette.Color4 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: ph.H, S: blueS, V: blueV})), bgColor, normalTextTarget, opts))
// Color5 matches primary_container exactly (light container in light mode)
container5 := DeriveContainer(primaryColor, true)
palette.Color5 = NewColorInfo(container5)
palette.Color6 = NewColorInfo(primaryColor)
gray7S := baseSat * 0.08
gray7V := baseVal * 0.28
palette.Color7 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: hsv.H, S: gray7S, V: gray7V})), bgColor, normalTextTarget, opts))
gray8S := baseSat * 0.05
gray8V := baseVal * 0.85
dimTarget := secondaryTarget * 0.5
palette.Color8 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: hsv.H, S: gray8S, V: gray8V})), bgColor, dimTarget, opts))
brightRedS := math.Min(baseSat*1.0, 1.0)
brightRedV := math.Min(baseVal*1.2, 1.0)
palette.Color9 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: redH, S: brightRedS, V: brightRedV})), bgColor, accentTarget, opts))
brightGreenS := math.Min(baseSat*1.1, 1.0)
brightGreenV := math.Min(baseVal*1.1, 1.0)
palette.Color10 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: greenH, S: brightGreenS, V: brightGreenV})), bgColor, accentTarget, opts))
brightYellowS := math.Min(baseSat*1.4, 1.0)
brightYellowV := math.Min(baseVal*1.3, 1.0)
palette.Color11 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: yellowH, S: brightYellowS, V: brightYellowV})), bgColor, accentTarget, opts))
brightBlueS := math.Min(ph.S*1.1, 1.0)
brightBlueV := math.Min(ph.V*1.15, 1.0)
palette.Color12 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: ph.H, S: brightBlueS, V: brightBlueV})), bgColor, accentTarget, opts))
lightContainer := DeriveContainer(primaryColor, true)
palette.Color13 = NewColorInfo(lightContainer)
brightCyanS := ph.S * 0.5
brightCyanV := math.Min(ph.V*1.3, 1.0)
palette.Color14 = NewColorInfo(RGBToHex(HSVToRGB(HSV{H: ph.H, S: brightCyanS, V: brightCyanV})))
white15S := baseSat * 0.04
white15V := math.Min(baseVal*1.5, 1.0)
palette.Color15 = NewColorInfo(RGBToHex(HSVToRGB(HSV{H: hsv.H, S: white15S, V: white15V})))
} else {
palette.Color15 = NewColorInfo("#ffffff")
redS := math.Min(baseSat*1.1, 1.0)
redV := math.Min(baseVal*1.15, 1.0)
palette.Color1 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: redH, S: redS, V: redV})), bgColor, normalTextTarget, opts))
greenS := math.Min(baseSat*1.0, 1.0)
greenV := math.Min(baseVal*1.0, 1.0)
palette.Color2 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: greenH, S: greenS, V: greenV})), bgColor, normalTextTarget, opts))
yellowS := math.Min(baseSat*1.1, 1.0)
yellowV := math.Min(baseVal*1.25, 1.0)
palette.Color3 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: yellowH, S: yellowS, V: yellowV})), bgColor, normalTextTarget, opts))
// Slightly more saturated variant of primary
blueS := math.Min(ph.S*1.2, 1.0)
blueV := ph.V * 0.95
palette.Color4 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: ph.H, S: blueS, V: blueV})), bgColor, normalTextTarget, opts))
// Color5 matches primary_container exactly (dark container in dark mode)
darkContainer := DeriveContainer(primaryColor, false)
palette.Color5 = NewColorInfo(darkContainer)
palette.Color6 = NewColorInfo(primaryColor)
gray7S := baseSat * 0.12
gray7V := math.Min(baseVal*1.05, 1.0)
palette.Color7 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: hsv.H, S: gray7S, V: gray7V})), bgColor, normalTextTarget, opts))
gray8S := baseSat * 0.15
gray8V := baseVal * 0.65
palette.Color8 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: hsv.H, S: gray8S, V: gray8V})), bgColor, secondaryTarget, opts))
brightRedS := math.Min(baseSat*0.75, 1.0)
brightRedV := math.Min(baseVal*1.35, 1.0)
palette.Color9 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: redH, S: brightRedS, V: brightRedV})), bgColor, accentTarget, opts))
brightGreenS := math.Min(baseSat*0.7, 1.0)
brightGreenV := math.Min(baseVal*1.2, 1.0)
palette.Color10 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: greenH, S: brightGreenS, V: brightGreenV})), bgColor, accentTarget, opts))
brightYellowS := math.Min(baseSat*0.7, 1.0)
brightYellowV := math.Min(baseVal*1.5, 1.0)
palette.Color11 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: yellowH, S: brightYellowS, V: brightYellowV})), bgColor, accentTarget, opts))
// Create a gradient of primary variants: Color12 -> Color13 -> Color14 -> Color15 (near white)
// Color12: Start of the lighter gradient - slightly desaturated
brightBlueS := ph.S * 0.85
brightBlueV := math.Min(ph.V*1.1, 1.0)
palette.Color12 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: ph.H, S: brightBlueS, V: brightBlueV})), bgColor, accentTarget, opts))
// Medium-high saturation pastel primary
color13S := ph.S * 0.7
color13V := math.Min(ph.V*1.3, 1.0)
palette.Color13 = NewColorInfo(RGBToHex(HSVToRGB(HSV{H: ph.H, S: color13S, V: color13V})))
// Lower saturation, lighter variant
color14S := ph.S * 0.45
color14V := math.Min(ph.V*1.4, 1.0)
palette.Color14 = NewColorInfo(RGBToHex(HSVToRGB(HSV{H: ph.H, S: color14S, V: color14V})))
white15S := baseSat * 0.05
white15V := math.Min(baseVal*1.45, 1.0)
palette.Color15 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: hsv.H, S: white15S, V: white15V})), bgColor, normalTextTarget, opts))
}
return palette

View File

@@ -366,10 +366,19 @@ func TestGeneratePalette(t *testing.T) {
t.Errorf("Light mode background = %s, expected #f8f8f8", result.Color0.Hex)
}
if tt.opts.IsLight && result.Color15.Hex != "#1a1a1a" {
t.Errorf("Light mode foreground = %s, expected #1a1a1a", result.Color15.Hex)
} else if !tt.opts.IsLight && result.Color15.Hex != "#ffffff" {
t.Errorf("Dark mode foreground = %s, expected #ffffff", result.Color15.Hex)
// Color15 is now derived from primary, so just verify it's a valid color
// and has appropriate luminance for the mode (now theme-tinted, not pure white/black)
color15Lum := Luminance(result.Color15.Hex)
if tt.opts.IsLight {
// Light mode: Color15 should still be relatively light
if color15Lum < 0.5 {
t.Errorf("Light mode Color15 = %s (lum %.2f) is too dark", result.Color15.Hex, color15Lum)
}
} else {
// Dark mode: Color15 should be light (but may have theme tint, so lower threshold)
if color15Lum < 0.5 {
t.Errorf("Dark mode Color15 = %s (lum %.2f) is too dark", result.Color15.Hex, color15Lum)
}
}
})
}
@@ -579,6 +588,10 @@ func TestGeneratePaletteWithDPS(t *testing.T) {
bgColor := result.Color0.Hex
for i := 1; i < 8; i++ {
// Skip Color5 (container) and Color6 (exact primary) - intentionally not contrast-adjusted
if i == 5 || i == 6 {
continue
}
lc := DeltaPhiStarContrast(colors[i].Hex, bgColor, tt.opts.IsLight)
minLc := 30.0
if lc < minLc && lc > 0 {

View File

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

View File

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

View File

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

View File

@@ -108,7 +108,6 @@ func (o *OpenSUSEDistribution) GetPackageMappingWithVariants(wm deps.WindowManag
packages := map[string]PackageMapping{
// Standard zypper packages
"git": {Name: "git", Repository: RepoTypeSystem},
"ghostty": {Name: "ghostty", Repository: RepoTypeSystem},
"kitty": {Name: "kitty", Repository: RepoTypeSystem},
"alacritty": {Name: "alacritty", 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 (DankMaterialShell)": o.getDmsMapping(variants["dms (DankMaterialShell)"]),
"quickshell": o.getQuickshellMapping(variants["quickshell"]),
"ghostty": {Name: "ghostty", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
"matugen": {Name: "matugen", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
"dgop": {Name: "dgop", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
}
@@ -540,12 +540,12 @@ func (o *OpenSUSEDistribution) installQuickshell(ctx context.Context, variant de
}
cacheDir := filepath.Join(homeDir, ".cache", "dankinstall")
if err := os.MkdirAll(cacheDir, 0755); err != nil {
if err := os.MkdirAll(cacheDir, 0o755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err)
}
tmpDir := filepath.Join(cacheDir, "quickshell-build")
if err := os.MkdirAll(tmpDir, 0755); err != nil {
if err := os.MkdirAll(tmpDir, 0o755); err != nil {
return fmt.Errorf("failed to create temp directory: %w", err)
}
defer os.RemoveAll(tmpDir)
@@ -576,7 +576,7 @@ func (o *OpenSUSEDistribution) installQuickshell(ctx context.Context, variant de
}
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)
}

View File

@@ -1,450 +0,0 @@
//go:build !distro_binary
package dms
import (
"os/exec"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
tea "github.com/charmbracelet/bubbletea"
)
type AppState int
const (
StateMainMenu AppState = iota
StateUpdate
StateUpdatePassword
StateUpdateProgress
StateShell
StatePluginsMenu
StatePluginsBrowse
StatePluginDetail
StatePluginSearch
StatePluginsInstalled
StatePluginInstalledDetail
StateGreeterMenu
StateGreeterCompositorSelect
StateGreeterPassword
StateGreeterInstalling
StateAbout
)
type Model struct {
version string
detector *Detector
dependencies []DependencyInfo
state AppState
selectedItem int
width int
height int
// Menu items
menuItems []MenuItem
updateDeps []DependencyInfo
selectedUpdateDep int
updateToggles map[string]bool
updateProgressChan chan updateProgressMsg
updateProgress updateProgressMsg
updateLogs []string
sudoPassword string
passwordInput string
passwordError string
// Window manager states
hyprlandInstalled bool
niriInstalled bool
selectedGreeterItem int
greeterInstallChan chan greeterProgressMsg
greeterProgress greeterProgressMsg
greeterLogs []string
greeterPasswordInput string
greeterPasswordError string
greeterSudoPassword string
greeterCompositors []string
greeterSelectedComp int
greeterChosenCompositor string
pluginsMenuItems []MenuItem
selectedPluginsMenuItem int
pluginsList []pluginInfo
filteredPluginsList []pluginInfo
selectedPluginIndex int
pluginsLoading bool
pluginsError string
pluginSearchQuery string
installedPluginsList []pluginInfo
selectedInstalledIndex int
installedPluginsLoading bool
installedPluginsError string
pluginInstallStatus map[string]bool
}
type pluginInfo struct {
ID string
Name string
Category string
Author string
Description string
Repo string
Path string
Capabilities []string
Compositors []string
Dependencies []string
FirstParty bool
}
type MenuItem struct {
Label string
Action AppState
}
func NewModel(version string) Model {
detector, _ := NewDetector()
var dependencies []DependencyInfo
var hyprlandInstalled, niriInstalled bool
var err error
if detector != nil {
dependencies = detector.GetInstalledComponents()
// Use the proper detection method for both window managers
hyprlandInstalled, niriInstalled, err = detector.GetWindowManagerStatus()
if err != nil {
// Fallback to false if detection fails
hyprlandInstalled = false
niriInstalled = false
}
}
updateToggles := make(map[string]bool)
for _, dep := range dependencies {
if dep.Name == "dms (DankMaterialShell)" && dep.Status == deps.StatusNeedsUpdate {
updateToggles[dep.Name] = true
break
}
}
m := Model{
version: version,
detector: detector,
dependencies: dependencies,
state: StateMainMenu,
selectedItem: 0,
updateToggles: updateToggles,
updateDeps: dependencies,
updateProgressChan: make(chan updateProgressMsg, 100),
hyprlandInstalled: hyprlandInstalled,
niriInstalled: niriInstalled,
greeterInstallChan: make(chan greeterProgressMsg, 100),
pluginInstallStatus: make(map[string]bool),
}
m.menuItems = m.buildMenuItems()
return m
}
func (m *Model) buildMenuItems() []MenuItem {
items := []MenuItem{
{Label: "Update", Action: StateUpdate},
}
// Shell management
if m.isShellRunning() {
items = append(items, MenuItem{Label: "Terminate Shell", Action: StateShell})
} else {
items = append(items, MenuItem{Label: "Start Shell (Daemon)", Action: StateShell})
}
// Plugins management
items = append(items, MenuItem{Label: "Plugins", Action: StatePluginsMenu})
// Greeter management
items = append(items, MenuItem{Label: "Greeter", Action: StateGreeterMenu})
items = append(items, MenuItem{Label: "About", Action: StateAbout})
return items
}
func (m *Model) buildPluginsMenuItems() []MenuItem {
return []MenuItem{
{Label: "Browse Plugins", Action: StatePluginsBrowse},
{Label: "View Installed", Action: StatePluginsInstalled},
}
}
func (m *Model) isShellRunning() bool {
// Check for both -c and -p flag patterns since quickshell can be started either way
// -c dms: config name mode
// -p <path>/dms: path mode (used when installed via system packages)
cmd := exec.Command("pgrep", "-f", "qs.*dms")
err := cmd.Run()
return err == nil
}
func (m Model) Init() tea.Cmd {
return nil
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
case shellStartedMsg:
m.menuItems = m.buildMenuItems()
if m.selectedItem >= len(m.menuItems) {
m.selectedItem = len(m.menuItems) - 1
}
return m, nil
case updateProgressMsg:
m.updateProgress = msg
if msg.logOutput != "" {
m.updateLogs = append(m.updateLogs, msg.logOutput)
}
return m, m.waitForProgress()
case updateCompleteMsg:
m.updateProgress.complete = true
m.updateProgress.err = msg.err
m.dependencies = m.detector.GetInstalledComponents()
m.updateDeps = m.dependencies
m.menuItems = m.buildMenuItems()
// Restart shell if update was successful and shell is running
if msg.err == nil && m.isShellRunning() {
restartShell()
}
return m, nil
case greeterProgressMsg:
m.greeterProgress = msg
if msg.logOutput != "" {
m.greeterLogs = append(m.greeterLogs, msg.logOutput)
}
return m, m.waitForGreeterProgress()
case pluginsLoadedMsg:
m.pluginsLoading = false
if msg.err != nil {
m.pluginsError = msg.err.Error()
} else {
m.pluginsList = make([]pluginInfo, len(msg.plugins))
for i, p := range msg.plugins {
m.pluginsList[i] = pluginInfo{
ID: p.ID,
Name: p.Name,
Category: p.Category,
Author: p.Author,
Description: p.Description,
Repo: p.Repo,
Path: p.Path,
Capabilities: p.Capabilities,
Compositors: p.Compositors,
Dependencies: p.Dependencies,
FirstParty: strings.HasPrefix(p.Repo, "https://github.com/AvengeMedia"),
}
}
m.filteredPluginsList = m.pluginsList
m.selectedPluginIndex = 0
m.updatePluginInstallStatus()
}
return m, nil
case installedPluginsLoadedMsg:
m.installedPluginsLoading = false
if msg.err != nil {
m.installedPluginsError = msg.err.Error()
} else {
m.installedPluginsList = make([]pluginInfo, len(msg.plugins))
for i, p := range msg.plugins {
m.installedPluginsList[i] = pluginInfo{
ID: p.ID,
Name: p.Name,
Category: p.Category,
Author: p.Author,
Description: p.Description,
Repo: p.Repo,
Path: p.Path,
Capabilities: p.Capabilities,
Compositors: p.Compositors,
Dependencies: p.Dependencies,
FirstParty: strings.HasPrefix(p.Repo, "https://github.com/AvengeMedia"),
}
}
m.selectedInstalledIndex = 0
}
return m, nil
case pluginUninstalledMsg:
if msg.err != nil {
m.installedPluginsError = msg.err.Error()
m.state = StatePluginInstalledDetail
} else {
m.state = StatePluginsInstalled
m.installedPluginsLoading = true
m.installedPluginsError = ""
return m, loadInstalledPlugins
}
return m, nil
case pluginUpdatedMsg:
if msg.err != nil {
m.installedPluginsError = msg.err.Error()
} else {
m.installedPluginsError = ""
}
return m, nil
case pluginInstalledMsg:
if msg.err != nil {
m.pluginsError = msg.err.Error()
} else {
m.pluginInstallStatus[msg.pluginName] = true
m.pluginsError = ""
}
return m, nil
case greeterPasswordValidMsg:
if msg.valid {
m.greeterSudoPassword = msg.password
m.greeterPasswordInput = ""
m.greeterPasswordError = ""
m.state = StateGreeterInstalling
m.greeterProgress = greeterProgressMsg{step: "Starting greeter installation..."}
m.greeterLogs = []string{}
return m, tea.Batch(m.performGreeterInstall(), m.waitForGreeterProgress())
} else {
m.greeterPasswordError = "Incorrect password. Please try again."
m.greeterPasswordInput = ""
}
return m, nil
case passwordValidMsg:
if msg.valid {
m.sudoPassword = msg.password
m.passwordInput = ""
m.passwordError = ""
m.state = StateUpdateProgress
m.updateProgress = updateProgressMsg{progress: 0.0, step: "Starting update..."}
m.updateLogs = []string{}
return m, tea.Batch(m.performUpdate(), m.waitForProgress())
} else {
m.passwordError = "Incorrect password. Please try again."
m.passwordInput = ""
}
return m, nil
case tea.KeyMsg:
switch m.state {
case StateMainMenu:
return m.updateMainMenu(msg)
case StateUpdate:
return m.updateUpdateView(msg)
case StateUpdatePassword:
return m.updatePasswordView(msg)
case StateUpdateProgress:
return m.updateProgressView(msg)
case StateShell:
return m.updateShellView(msg)
case StatePluginsMenu:
return m.updatePluginsMenu(msg)
case StatePluginsBrowse:
return m.updatePluginsBrowse(msg)
case StatePluginDetail:
return m.updatePluginDetail(msg)
case StatePluginSearch:
return m.updatePluginSearch(msg)
case StatePluginsInstalled:
return m.updatePluginsInstalled(msg)
case StatePluginInstalledDetail:
return m.updatePluginInstalledDetail(msg)
case StateGreeterMenu:
return m.updateGreeterMenu(msg)
case StateGreeterCompositorSelect:
return m.updateGreeterCompositorSelect(msg)
case StateGreeterPassword:
return m.updateGreeterPasswordView(msg)
case StateGreeterInstalling:
return m.updateGreeterInstalling(msg)
case StateAbout:
return m.updateAboutView(msg)
}
}
return m, nil
}
type updateProgressMsg struct {
progress float64
step string
complete bool
err error
logOutput string
}
type updateCompleteMsg struct {
err error
}
type passwordValidMsg struct {
password string
valid bool
}
type greeterProgressMsg struct {
step string
complete bool
err error
logOutput string
}
type greeterPasswordValidMsg struct {
password string
valid bool
}
func (m Model) waitForProgress() tea.Cmd {
return func() tea.Msg {
return <-m.updateProgressChan
}
}
func (m Model) waitForGreeterProgress() tea.Cmd {
return func() tea.Msg {
return <-m.greeterInstallChan
}
}
func (m Model) View() string {
switch m.state {
case StateMainMenu:
return m.renderMainMenu()
case StateUpdate:
return m.renderUpdateView()
case StateUpdatePassword:
return m.renderPasswordView()
case StateUpdateProgress:
return m.renderProgressView()
case StateShell:
return m.renderShellView()
case StatePluginsMenu:
return m.renderPluginsMenu()
case StatePluginsBrowse:
return m.renderPluginsBrowse()
case StatePluginDetail:
return m.renderPluginDetail()
case StatePluginSearch:
return m.renderPluginSearch()
case StatePluginsInstalled:
return m.renderPluginsInstalled()
case StatePluginInstalledDetail:
return m.renderPluginInstalledDetail()
case StateGreeterMenu:
return m.renderGreeterMenu()
case StateGreeterCompositorSelect:
return m.renderGreeterCompositorSelect()
case StateGreeterPassword:
return m.renderGreeterPasswordView()
case StateGreeterInstalling:
return m.renderGreeterInstalling()
case StateAbout:
return m.renderAboutView()
default:
return m.renderMainMenu()
}
}

View File

@@ -1,267 +0,0 @@
//go:build distro_binary
package dms
import (
"os/exec"
"strings"
tea "github.com/charmbracelet/bubbletea"
)
type AppState int
const (
StateMainMenu AppState = iota
StateShell
StatePluginsMenu
StatePluginsBrowse
StatePluginDetail
StatePluginSearch
StatePluginsInstalled
StatePluginInstalledDetail
StateAbout
)
type Model struct {
version string
detector *Detector
dependencies []DependencyInfo
state AppState
selectedItem int
width int
height int
// Menu items
menuItems []MenuItem
// Window manager states
hyprlandInstalled bool
niriInstalled bool
pluginsMenuItems []MenuItem
selectedPluginsMenuItem int
pluginsList []pluginInfo
filteredPluginsList []pluginInfo
selectedPluginIndex int
pluginsLoading bool
pluginsError string
pluginSearchQuery string
installedPluginsList []pluginInfo
selectedInstalledIndex int
installedPluginsLoading bool
installedPluginsError string
pluginInstallStatus map[string]bool
}
type pluginInfo struct {
ID string
Name string
Category string
Author string
Description string
Repo string
Path string
Capabilities []string
Compositors []string
Dependencies []string
FirstParty bool
}
type MenuItem struct {
Label string
Action AppState
}
func NewModel(version string) Model {
detector, _ := NewDetector()
var dependencies []DependencyInfo
var hyprlandInstalled, niriInstalled bool
if detector != nil {
dependencies = detector.GetInstalledComponents()
hyprlandInstalled, niriInstalled, _ = detector.GetWindowManagerStatus()
}
m := Model{
version: version,
detector: detector,
dependencies: dependencies,
state: StateMainMenu,
selectedItem: 0,
hyprlandInstalled: hyprlandInstalled,
niriInstalled: niriInstalled,
pluginInstallStatus: make(map[string]bool),
}
m.menuItems = m.buildMenuItems()
return m
}
func (m *Model) buildMenuItems() []MenuItem {
items := []MenuItem{}
// Shell management
if m.isShellRunning() {
items = append(items, MenuItem{Label: "Terminate Shell", Action: StateShell})
} else {
items = append(items, MenuItem{Label: "Start Shell (Daemon)", Action: StateShell})
}
// Plugins management
items = append(items, MenuItem{Label: "Plugins", Action: StatePluginsMenu})
items = append(items, MenuItem{Label: "About", Action: StateAbout})
return items
}
func (m *Model) buildPluginsMenuItems() []MenuItem {
return []MenuItem{
{Label: "Browse Plugins", Action: StatePluginsBrowse},
{Label: "View Installed", Action: StatePluginsInstalled},
}
}
func (m *Model) isShellRunning() bool {
cmd := exec.Command("pgrep", "-f", "qs -c dms")
err := cmd.Run()
return err == nil
}
func (m Model) Init() tea.Cmd {
return nil
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
case pluginsLoadedMsg:
m.pluginsLoading = false
if msg.err != nil {
m.pluginsError = msg.err.Error()
} else {
m.pluginsList = make([]pluginInfo, len(msg.plugins))
for i, p := range msg.plugins {
m.pluginsList[i] = pluginInfo{
ID: p.ID,
Name: p.Name,
Category: p.Category,
Author: p.Author,
Description: p.Description,
Repo: p.Repo,
Path: p.Path,
Capabilities: p.Capabilities,
Compositors: p.Compositors,
Dependencies: p.Dependencies,
FirstParty: strings.HasPrefix(p.Repo, "https://github.com/AvengeMedia"),
}
}
m.filteredPluginsList = m.pluginsList
m.selectedPluginIndex = 0
m.updatePluginInstallStatus()
}
return m, nil
case installedPluginsLoadedMsg:
m.installedPluginsLoading = false
if msg.err != nil {
m.installedPluginsError = msg.err.Error()
} else {
m.installedPluginsList = make([]pluginInfo, len(msg.plugins))
for i, p := range msg.plugins {
m.installedPluginsList[i] = pluginInfo{
ID: p.ID,
Name: p.Name,
Category: p.Category,
Author: p.Author,
Description: p.Description,
Repo: p.Repo,
Path: p.Path,
Capabilities: p.Capabilities,
Compositors: p.Compositors,
Dependencies: p.Dependencies,
FirstParty: strings.HasPrefix(p.Repo, "https://github.com/AvengeMedia"),
}
}
m.selectedInstalledIndex = 0
}
return m, nil
case pluginUninstalledMsg:
if msg.err != nil {
m.installedPluginsError = msg.err.Error()
m.state = StatePluginInstalledDetail
} else {
m.state = StatePluginsInstalled
m.installedPluginsLoading = true
m.installedPluginsError = ""
return m, loadInstalledPlugins
}
return m, nil
case pluginUpdatedMsg:
if msg.err != nil {
m.installedPluginsError = msg.err.Error()
} else {
m.installedPluginsError = ""
}
return m, nil
case pluginInstalledMsg:
if msg.err != nil {
m.pluginsError = msg.err.Error()
} else {
m.pluginInstallStatus[msg.pluginName] = true
m.pluginsError = ""
}
return m, nil
case tea.KeyMsg:
switch m.state {
case StateMainMenu:
return m.updateMainMenu(msg)
case StateShell:
return m.updateShellView(msg)
case StatePluginsMenu:
return m.updatePluginsMenu(msg)
case StatePluginsBrowse:
return m.updatePluginsBrowse(msg)
case StatePluginDetail:
return m.updatePluginDetail(msg)
case StatePluginSearch:
return m.updatePluginSearch(msg)
case StatePluginsInstalled:
return m.updatePluginsInstalled(msg)
case StatePluginInstalledDetail:
return m.updatePluginInstalledDetail(msg)
case StateAbout:
return m.updateAboutView(msg)
}
}
return m, nil
}
func (m Model) View() string {
switch m.state {
case StateMainMenu:
return m.renderMainMenu()
case StateShell:
return m.renderShellView()
case StatePluginsMenu:
return m.renderPluginsMenu()
case StatePluginsBrowse:
return m.renderPluginsBrowse()
case StatePluginDetail:
return m.renderPluginDetail()
case StatePluginSearch:
return m.renderPluginSearch()
case StatePluginsInstalled:
return m.renderPluginsInstalled()
case StatePluginInstalledDetail:
return m.renderPluginInstalledDetail()
case StateAbout:
return m.renderAboutView()
default:
return m.renderMainMenu()
}
}

View File

@@ -1,143 +0,0 @@
package dms
import (
"context"
"os"
"os/exec"
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
)
type Detector struct {
homeDir string
distribution distros.Distribution
}
func (d *Detector) GetDistribution() distros.Distribution {
return d.distribution
}
func NewDetector() (*Detector, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return nil, err
}
logChan := make(chan string, 100)
go func() {
for range logChan {
}
}()
osInfo, err := distros.GetOSInfo()
if err != nil {
return nil, err
}
dist, err := distros.NewDistribution(osInfo.Distribution.ID, logChan)
if err != nil {
return nil, err
}
return &Detector{
homeDir: homeDir,
distribution: dist,
}, nil
}
func (d *Detector) IsDMSInstalled() bool {
_, err := config.LocateDMSConfig()
return err == nil
}
func (d *Detector) GetDependencyStatus() ([]deps.Dependency, error) {
hyprlandDeps, err := d.distribution.DetectDependencies(context.Background(), deps.WindowManagerHyprland)
if err != nil {
return nil, err
}
niriDeps, err := d.distribution.DetectDependencies(context.Background(), deps.WindowManagerNiri)
if err != nil {
return nil, err
}
// Combine dependencies and deduplicate
depMap := make(map[string]deps.Dependency)
for _, dep := range hyprlandDeps {
depMap[dep.Name] = dep
}
for _, dep := range niriDeps {
// If dependency already exists, keep the one that's installed or needs update
if existing, exists := depMap[dep.Name]; exists {
if dep.Status > existing.Status {
depMap[dep.Name] = dep
}
} else {
depMap[dep.Name] = dep
}
}
// Convert map back to slice
var allDeps []deps.Dependency
for _, dep := range depMap {
allDeps = append(allDeps, dep)
}
return allDeps, nil
}
func (d *Detector) GetWindowManagerStatus() (bool, bool, error) {
// Reuse the existing command detection logic from BaseDistribution
// Since all distros embed BaseDistribution, we can access it via interface
type CommandChecker interface {
CommandExists(string) bool
}
checker, ok := d.distribution.(CommandChecker)
if !ok {
// Fallback to direct command check if interface not available
hyprlandInstalled := d.commandExists("hyprland") || d.commandExists("Hyprland")
niriInstalled := d.commandExists("niri")
return hyprlandInstalled, niriInstalled, nil
}
hyprlandInstalled := checker.CommandExists("hyprland") || checker.CommandExists("Hyprland")
niriInstalled := checker.CommandExists("niri")
return hyprlandInstalled, niriInstalled, nil
}
func (d *Detector) commandExists(cmd string) bool {
_, err := exec.LookPath(cmd)
return err == nil
}
func (d *Detector) GetInstalledComponents() []DependencyInfo {
dependencies, err := d.GetDependencyStatus()
if err != nil {
return []DependencyInfo{}
}
var components []DependencyInfo
for _, dep := range dependencies {
components = append(components, DependencyInfo{
Name: dep.Name,
Status: dep.Status,
Description: dep.Description,
Required: dep.Required,
})
}
return components
}
type DependencyInfo struct {
Name string
Status deps.DependencyStatus
Description string
Required bool
}

View File

@@ -1,54 +0,0 @@
package dms
import (
"os/exec"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
tea "github.com/charmbracelet/bubbletea"
)
func (m Model) updateShellView(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "esc":
m.state = StateMainMenu
default:
return m, tea.Quit
}
return m, nil
}
func (m Model) updateAboutView(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c", "q", "esc":
if msg.String() == "esc" {
m.state = StateMainMenu
} else {
return m, tea.Quit
}
}
return m, nil
}
func terminateShell() {
patterns := []string{"dms run", "qs -c dms"}
for _, pattern := range patterns {
cmd := exec.Command("pkill", "-f", pattern)
cmd.Run()
}
}
func startShellDaemon() {
cmd := exec.Command("dms", "run", "-d")
if err := cmd.Start(); err != nil {
log.Errorf("Error starting daemon: %v", err)
}
}
func restartShell() {
terminateShell()
time.Sleep(500 * time.Millisecond)
startShellDaemon()
}

View File

@@ -1,392 +0,0 @@
//go:build !distro_binary
package dms
import (
"context"
"fmt"
"os/exec"
"strings"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
"github.com/AvengeMedia/DankMaterialShell/core/internal/greeter"
tea "github.com/charmbracelet/bubbletea"
)
func (m Model) updateUpdateView(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
filteredDeps := m.getFilteredDeps()
maxIndex := len(filteredDeps) - 1
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "esc":
m.state = StateMainMenu
case "up", "k":
if m.selectedUpdateDep > 0 {
m.selectedUpdateDep--
}
case "down", "j":
if m.selectedUpdateDep < maxIndex {
m.selectedUpdateDep++
}
case " ":
if dep := m.getDepAtVisualIndex(m.selectedUpdateDep); dep != nil {
m.updateToggles[dep.Name] = !m.updateToggles[dep.Name]
}
case "enter":
hasSelected := false
for _, toggle := range m.updateToggles {
if toggle {
hasSelected = true
break
}
}
if !hasSelected {
m.state = StateMainMenu
return m, nil
}
m.state = StateUpdatePassword
m.passwordInput = ""
m.passwordError = ""
return m, nil
}
return m, nil
}
func (m Model) updatePasswordView(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c":
return m, tea.Quit
case "esc":
m.state = StateUpdate
m.passwordInput = ""
m.passwordError = ""
return m, nil
case "enter":
if m.passwordInput == "" {
return m, nil
}
return m, m.validatePassword(m.passwordInput)
case "backspace":
if len(m.passwordInput) > 0 {
m.passwordInput = m.passwordInput[:len(m.passwordInput)-1]
}
default:
if len(msg.String()) == 1 && msg.String()[0] >= 32 && msg.String()[0] <= 126 {
m.passwordInput += msg.String()
}
}
return m, nil
}
func (m Model) updateProgressView(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "esc":
if m.updateProgress.complete {
m.state = StateMainMenu
m.updateProgress = updateProgressMsg{}
m.updateLogs = []string{}
}
}
return m, nil
}
func (m Model) validatePassword(password string) tea.Cmd {
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "sudo", "-S", "-v")
stdin, err := cmd.StdinPipe()
if err != nil {
return passwordValidMsg{password: "", valid: false}
}
go func() {
defer stdin.Close()
fmt.Fprintf(stdin, "%s\n", password)
}()
output, err := cmd.CombinedOutput()
outputStr := string(output)
if err != nil {
if strings.Contains(outputStr, "Sorry, try again") ||
strings.Contains(outputStr, "incorrect password") ||
strings.Contains(outputStr, "authentication failure") {
return passwordValidMsg{password: "", valid: false}
}
return passwordValidMsg{password: "", valid: false}
}
return passwordValidMsg{password: password, valid: true}
}
}
func (m Model) performUpdate() tea.Cmd {
var depsToUpdate []deps.Dependency
for _, depInfo := range m.updateDeps {
if m.updateToggles[depInfo.Name] {
depsToUpdate = append(depsToUpdate, deps.Dependency{
Name: depInfo.Name,
Status: depInfo.Status,
Description: depInfo.Description,
Required: depInfo.Required,
})
}
}
if len(depsToUpdate) == 0 {
return func() tea.Msg {
return updateCompleteMsg{err: nil}
}
}
wm := deps.WindowManagerHyprland
if m.niriInstalled {
wm = deps.WindowManagerNiri
}
sudoPassword := m.sudoPassword
reinstallFlags := make(map[string]bool)
for name, toggled := range m.updateToggles {
if toggled {
reinstallFlags[name] = true
}
}
distribution := m.detector.GetDistribution()
progressChan := m.updateProgressChan
return func() tea.Msg {
installerChan := make(chan distros.InstallProgressMsg, 100)
go func() {
ctx := context.Background()
disabledFlags := make(map[string]bool)
err := distribution.InstallPackages(ctx, depsToUpdate, wm, sudoPassword, reinstallFlags, disabledFlags, false, installerChan)
close(installerChan)
if err != nil {
progressChan <- updateProgressMsg{complete: true, err: err}
} else {
progressChan <- updateProgressMsg{complete: true}
}
}()
go func() {
for msg := range installerChan {
progressChan <- updateProgressMsg{
progress: msg.Progress,
step: msg.Step,
complete: msg.IsComplete,
err: msg.Error,
logOutput: msg.LogOutput,
}
}
}()
return nil
}
}
func (m Model) updateGreeterMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
greeterMenuItems := []string{"Install Greeter"}
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "esc":
m.state = StateMainMenu
case "up", "k":
if m.selectedGreeterItem > 0 {
m.selectedGreeterItem--
}
case "down", "j":
if m.selectedGreeterItem < len(greeterMenuItems)-1 {
m.selectedGreeterItem++
}
case "enter", " ":
if m.selectedGreeterItem == 0 {
compositors := greeter.DetectCompositors()
if len(compositors) == 0 {
return m, nil
}
m.greeterCompositors = compositors
if len(compositors) > 1 {
m.state = StateGreeterCompositorSelect
m.greeterSelectedComp = 0
return m, nil
} else {
m.greeterChosenCompositor = compositors[0]
m.state = StateGreeterPassword
m.greeterPasswordInput = ""
m.greeterPasswordError = ""
return m, nil
}
}
}
return m, nil
}
func (m Model) updateGreeterCompositorSelect(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "esc":
m.state = StateGreeterMenu
return m, nil
case "up", "k":
if m.greeterSelectedComp > 0 {
m.greeterSelectedComp--
}
case "down", "j":
if m.greeterSelectedComp < len(m.greeterCompositors)-1 {
m.greeterSelectedComp++
}
case "enter", " ":
m.greeterChosenCompositor = m.greeterCompositors[m.greeterSelectedComp]
m.state = StateGreeterPassword
m.greeterPasswordInput = ""
m.greeterPasswordError = ""
return m, nil
}
return m, nil
}
func (m Model) updateGreeterPasswordView(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c":
return m, tea.Quit
case "esc":
m.state = StateGreeterMenu
m.greeterPasswordInput = ""
m.greeterPasswordError = ""
return m, nil
case "enter":
if m.greeterPasswordInput == "" {
return m, nil
}
return m, m.validateGreeterPassword(m.greeterPasswordInput)
case "backspace":
if len(m.greeterPasswordInput) > 0 {
m.greeterPasswordInput = m.greeterPasswordInput[:len(m.greeterPasswordInput)-1]
}
default:
if len(msg.String()) == 1 && msg.String()[0] >= 32 && msg.String()[0] <= 126 {
m.greeterPasswordInput += msg.String()
}
}
return m, nil
}
func (m Model) updateGreeterInstalling(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "esc":
if m.greeterProgress.complete {
m.state = StateMainMenu
m.greeterProgress = greeterProgressMsg{}
m.greeterLogs = []string{}
}
}
return m, nil
}
func (m Model) performGreeterInstall() tea.Cmd {
progressChan := m.greeterInstallChan
sudoPassword := m.greeterSudoPassword
compositor := m.greeterChosenCompositor
return func() tea.Msg {
go func() {
logFunc := func(msg string) {
progressChan <- greeterProgressMsg{step: msg, logOutput: msg}
}
progressChan <- greeterProgressMsg{step: "Checking greetd installation..."}
if err := performGreeterInstallSteps(progressChan, logFunc, sudoPassword, compositor); err != nil {
progressChan <- greeterProgressMsg{step: "Installation failed", complete: true, err: err}
return
}
progressChan <- greeterProgressMsg{step: "Installation complete", complete: true}
}()
return nil
}
}
func (m Model) validateGreeterPassword(password string) tea.Cmd {
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "sudo", "-S", "-v")
stdin, err := cmd.StdinPipe()
if err != nil {
return greeterPasswordValidMsg{password: "", valid: false}
}
go func() {
defer stdin.Close()
fmt.Fprintf(stdin, "%s\n", password)
}()
output, err := cmd.CombinedOutput()
outputStr := string(output)
if err != nil {
if strings.Contains(outputStr, "Sorry, try again") ||
strings.Contains(outputStr, "incorrect password") ||
strings.Contains(outputStr, "authentication failure") {
return greeterPasswordValidMsg{password: "", valid: false}
}
return greeterPasswordValidMsg{password: "", valid: false}
}
return greeterPasswordValidMsg{password: password, valid: true}
}
}
func performGreeterInstallSteps(progressChan chan greeterProgressMsg, logFunc func(string), sudoPassword string, compositor string) error {
if err := greeter.EnsureGreetdInstalled(logFunc, sudoPassword); err != nil {
return err
}
progressChan <- greeterProgressMsg{step: "Detecting DMS installation..."}
dmsPath, err := greeter.DetectDMSPath()
if err != nil {
return err
}
logFunc(fmt.Sprintf("✓ Found DMS at: %s", dmsPath))
logFunc(fmt.Sprintf("✓ Selected compositor: %s", compositor))
progressChan <- greeterProgressMsg{step: "Copying greeter files..."}
if err := greeter.CopyGreeterFiles(dmsPath, compositor, logFunc, sudoPassword); err != nil {
return err
}
progressChan <- greeterProgressMsg{step: "Configuring greetd..."}
if err := greeter.ConfigureGreetd(dmsPath, compositor, logFunc, sudoPassword); err != nil {
return err
}
progressChan <- greeterProgressMsg{step: "Synchronizing DMS configurations..."}
if err := greeter.SyncDMSConfigs(dmsPath, logFunc, sudoPassword); err != nil {
return err
}
return nil
}

View File

@@ -1,61 +0,0 @@
//go:build !distro_binary
package dms
import (
"time"
tea "github.com/charmbracelet/bubbletea"
)
type shellStartedMsg struct{}
func (m Model) updateMainMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c", "q", "esc":
return m, tea.Quit
case "up", "k":
if m.selectedItem > 0 {
m.selectedItem--
}
case "down", "j":
if m.selectedItem < len(m.menuItems)-1 {
m.selectedItem++
}
case "enter", " ":
if m.selectedItem < len(m.menuItems) {
selectedAction := m.menuItems[m.selectedItem].Action
selectedLabel := m.menuItems[m.selectedItem].Label
switch selectedAction {
case StateUpdate:
m.state = StateUpdate
m.selectedUpdateDep = 0
case StateShell:
if selectedLabel == "Terminate Shell" {
terminateShell()
m.menuItems = m.buildMenuItems()
if m.selectedItem >= len(m.menuItems) {
m.selectedItem = len(m.menuItems) - 1
}
} else {
startShellDaemon()
// Wait a moment for the daemon to actually start before checking status
return m, tea.Tick(300*time.Millisecond, func(t time.Time) tea.Msg {
return shellStartedMsg{}
})
}
case StatePluginsMenu:
m.state = StatePluginsMenu
m.selectedPluginsMenuItem = 0
m.pluginsMenuItems = m.buildPluginsMenuItems()
case StateGreeterMenu:
m.state = StateGreeterMenu
m.selectedGreeterItem = 0
case StateAbout:
m.state = StateAbout
}
}
}
return m, nil
}

View File

@@ -1,55 +0,0 @@
//go:build distro_binary
package dms
import (
"time"
tea "github.com/charmbracelet/bubbletea"
)
type shellStartedMsg struct{}
func (m Model) updateMainMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c", "q", "esc":
return m, tea.Quit
case "up", "k":
if m.selectedItem > 0 {
m.selectedItem--
}
case "down", "j":
if m.selectedItem < len(m.menuItems)-1 {
m.selectedItem++
}
case "enter", " ":
if m.selectedItem < len(m.menuItems) {
selectedAction := m.menuItems[m.selectedItem].Action
selectedLabel := m.menuItems[m.selectedItem].Label
switch selectedAction {
case StateShell:
if selectedLabel == "Terminate Shell" {
terminateShell()
m.menuItems = m.buildMenuItems()
if m.selectedItem >= len(m.menuItems) {
m.selectedItem = len(m.menuItems) - 1
}
} else {
startShellDaemon()
// Wait a moment for the daemon to actually start before checking status
return m, tea.Tick(300*time.Millisecond, func(t time.Time) tea.Msg {
return shellStartedMsg{}
})
}
case StatePluginsMenu:
m.state = StatePluginsMenu
m.selectedPluginsMenuItem = 0
m.pluginsMenuItems = m.buildPluginsMenuItems()
case StateAbout:
m.state = StateAbout
}
}
}
return m, nil
}

View File

@@ -1,377 +0,0 @@
package dms
import (
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/plugins"
tea "github.com/charmbracelet/bubbletea"
)
func (m Model) updatePluginsMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "esc":
m.state = StateMainMenu
case "up", "k":
if m.selectedPluginsMenuItem > 0 {
m.selectedPluginsMenuItem--
}
case "down", "j":
if m.selectedPluginsMenuItem < len(m.pluginsMenuItems)-1 {
m.selectedPluginsMenuItem++
}
case "enter", " ":
if m.selectedPluginsMenuItem < len(m.pluginsMenuItems) {
selectedAction := m.pluginsMenuItems[m.selectedPluginsMenuItem].Action
switch selectedAction {
case StatePluginsBrowse:
m.state = StatePluginsBrowse
m.pluginsLoading = true
m.pluginsError = ""
m.pluginsList = nil
return m, loadPlugins
case StatePluginsInstalled:
m.state = StatePluginsInstalled
m.installedPluginsLoading = true
m.installedPluginsError = ""
m.installedPluginsList = nil
return m, loadInstalledPlugins
}
}
}
return m, nil
}
func (m Model) updatePluginsBrowse(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "esc":
m.state = StatePluginsMenu
m.pluginSearchQuery = ""
m.filteredPluginsList = m.pluginsList
m.selectedPluginIndex = 0
case "up", "k":
if m.selectedPluginIndex > 0 {
m.selectedPluginIndex--
}
case "down", "j":
if m.selectedPluginIndex < len(m.filteredPluginsList)-1 {
m.selectedPluginIndex++
}
case "enter", " ":
if m.selectedPluginIndex < len(m.filteredPluginsList) {
m.state = StatePluginDetail
}
case "/":
m.state = StatePluginSearch
m.pluginSearchQuery = ""
}
return m, nil
}
func (m Model) updatePluginDetail(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "esc":
m.state = StatePluginsBrowse
case "i":
if m.selectedPluginIndex < len(m.filteredPluginsList) {
plugin := m.filteredPluginsList[m.selectedPluginIndex]
installed := m.pluginInstallStatus[plugin.Name]
if !installed {
return m, installPlugin(plugin)
}
}
}
return m, nil
}
func (m Model) updatePluginSearch(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c":
return m, tea.Quit
case "esc":
m.state = StatePluginsBrowse
m.pluginSearchQuery = ""
m.filteredPluginsList = m.pluginsList
m.selectedPluginIndex = 0
case "enter":
m.state = StatePluginsBrowse
m.filterPlugins()
case "backspace":
if len(m.pluginSearchQuery) > 0 {
m.pluginSearchQuery = m.pluginSearchQuery[:len(m.pluginSearchQuery)-1]
}
default:
if len(msg.String()) == 1 {
m.pluginSearchQuery += msg.String()
}
}
return m, nil
}
func (m *Model) filterPlugins() {
if m.pluginSearchQuery == "" {
m.filteredPluginsList = m.pluginsList
m.selectedPluginIndex = 0
return
}
rawPlugins := make([]plugins.Plugin, len(m.pluginsList))
for i, p := range m.pluginsList {
rawPlugins[i] = plugins.Plugin{
ID: p.ID,
Name: p.Name,
Category: p.Category,
Author: p.Author,
Description: p.Description,
Repo: p.Repo,
Path: p.Path,
Capabilities: p.Capabilities,
Compositors: p.Compositors,
Dependencies: p.Dependencies,
}
}
searchResults := plugins.FuzzySearch(m.pluginSearchQuery, rawPlugins)
searchResults = plugins.SortByFirstParty(searchResults)
filtered := make([]pluginInfo, len(searchResults))
for i, p := range searchResults {
filtered[i] = pluginInfo{
ID: p.ID,
Name: p.Name,
Category: p.Category,
Author: p.Author,
Description: p.Description,
Repo: p.Repo,
Path: p.Path,
Capabilities: p.Capabilities,
Compositors: p.Compositors,
Dependencies: p.Dependencies,
FirstParty: strings.HasPrefix(p.Repo, "https://github.com/AvengeMedia"),
}
}
m.filteredPluginsList = filtered
m.selectedPluginIndex = 0
}
type pluginsLoadedMsg struct {
plugins []plugins.Plugin
err error
}
func loadPlugins() tea.Msg {
registry, err := plugins.NewRegistry()
if err != nil {
return pluginsLoadedMsg{err: err}
}
pluginList, err := registry.List()
if err != nil {
return pluginsLoadedMsg{err: err}
}
return pluginsLoadedMsg{plugins: pluginList}
}
func (m *Model) updatePluginInstallStatus() {
manager, err := plugins.NewManager()
if err != nil {
return
}
for _, plugin := range m.pluginsList {
p := plugins.Plugin{ID: plugin.ID}
installed, err := manager.IsInstalled(p)
if err == nil {
m.pluginInstallStatus[plugin.Name] = installed
}
}
}
func (m Model) updatePluginsInstalled(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "esc":
m.state = StatePluginsMenu
case "up", "k":
if m.selectedInstalledIndex > 0 {
m.selectedInstalledIndex--
}
case "down", "j":
if m.selectedInstalledIndex < len(m.installedPluginsList)-1 {
m.selectedInstalledIndex++
}
case "enter", " ":
if m.selectedInstalledIndex < len(m.installedPluginsList) {
m.state = StatePluginInstalledDetail
}
}
return m, nil
}
func (m Model) updatePluginInstalledDetail(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "esc":
m.state = StatePluginsInstalled
case "u":
if m.selectedInstalledIndex < len(m.installedPluginsList) {
plugin := m.installedPluginsList[m.selectedInstalledIndex]
return m, uninstallPlugin(plugin)
}
case "p":
if m.selectedInstalledIndex < len(m.installedPluginsList) {
plugin := m.installedPluginsList[m.selectedInstalledIndex]
return m, updatePlugin(plugin)
}
}
return m, nil
}
type installedPluginsLoadedMsg struct {
plugins []plugins.Plugin
err error
}
type pluginUninstalledMsg struct {
pluginName string
err error
}
type pluginInstalledMsg struct {
pluginName string
err error
}
type pluginUpdatedMsg struct {
pluginName string
err error
}
func loadInstalledPlugins() tea.Msg {
manager, err := plugins.NewManager()
if err != nil {
return installedPluginsLoadedMsg{err: err}
}
registry, err := plugins.NewRegistry()
if err != nil {
return installedPluginsLoadedMsg{err: err}
}
installedNames, err := manager.ListInstalled()
if err != nil {
return installedPluginsLoadedMsg{err: err}
}
allPlugins, err := registry.List()
if err != nil {
return installedPluginsLoadedMsg{err: err}
}
var installed []plugins.Plugin
for _, id := range installedNames {
for _, p := range allPlugins {
if p.ID == id {
installed = append(installed, p)
break
}
}
}
installed = plugins.SortByFirstParty(installed)
return installedPluginsLoadedMsg{plugins: installed}
}
func installPlugin(plugin pluginInfo) tea.Cmd {
return func() tea.Msg {
manager, err := plugins.NewManager()
if err != nil {
return pluginInstalledMsg{pluginName: plugin.Name, err: err}
}
p := plugins.Plugin{
ID: plugin.ID,
Name: plugin.Name,
Category: plugin.Category,
Author: plugin.Author,
Description: plugin.Description,
Repo: plugin.Repo,
Path: plugin.Path,
Capabilities: plugin.Capabilities,
Compositors: plugin.Compositors,
Dependencies: plugin.Dependencies,
}
if err := manager.Install(p); err != nil {
return pluginInstalledMsg{pluginName: plugin.Name, err: err}
}
return pluginInstalledMsg{pluginName: plugin.Name}
}
}
func uninstallPlugin(plugin pluginInfo) tea.Cmd {
return func() tea.Msg {
manager, err := plugins.NewManager()
if err != nil {
return pluginUninstalledMsg{pluginName: plugin.Name, err: err}
}
p := plugins.Plugin{
ID: plugin.ID,
Name: plugin.Name,
Category: plugin.Category,
Author: plugin.Author,
Description: plugin.Description,
Repo: plugin.Repo,
Path: plugin.Path,
Capabilities: plugin.Capabilities,
Compositors: plugin.Compositors,
Dependencies: plugin.Dependencies,
}
if err := manager.Uninstall(p); err != nil {
return pluginUninstalledMsg{pluginName: plugin.Name, err: err}
}
return pluginUninstalledMsg{pluginName: plugin.Name}
}
}
func updatePlugin(plugin pluginInfo) tea.Cmd {
return func() tea.Msg {
manager, err := plugins.NewManager()
if err != nil {
return pluginUpdatedMsg{pluginName: plugin.Name, err: err}
}
p := plugins.Plugin{
ID: plugin.ID,
Name: plugin.Name,
Category: plugin.Category,
Author: plugin.Author,
Description: plugin.Description,
Repo: plugin.Repo,
Path: plugin.Path,
Capabilities: plugin.Capabilities,
Compositors: plugin.Compositors,
Dependencies: plugin.Dependencies,
}
if err := manager.Update(p); err != nil {
return pluginUpdatedMsg{pluginName: plugin.Name, err: err}
}
return pluginUpdatedMsg{pluginName: plugin.Name}
}
}

View File

@@ -1,367 +0,0 @@
package dms
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
)
func (m Model) renderPluginsMenu() string {
var b strings.Builder
titleStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#00D4AA"))
normalStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF"))
selectedStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#00D4AA")).
Bold(true)
b.WriteString(titleStyle.Render("Plugins"))
b.WriteString("\n\n")
for i, item := range m.pluginsMenuItems {
if i == m.selectedPluginsMenuItem {
b.WriteString(selectedStyle.Render(fmt.Sprintf("→ %s", item.Label)))
} else {
b.WriteString(normalStyle.Render(fmt.Sprintf(" %s", item.Label)))
}
b.WriteString("\n")
}
b.WriteString("\n")
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888"))
b.WriteString(instructionStyle.Render("↑/↓: Navigate | Enter: Select | Esc: Back | q: Quit"))
return b.String()
}
func (m Model) renderPluginsBrowse() string {
var b strings.Builder
titleStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#00D4AA"))
normalStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF"))
errorStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF0000"))
selectedStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#00D4AA")).
Bold(true)
b.WriteString(titleStyle.Render("Browse Plugins"))
b.WriteString("\n\n")
if m.pluginsLoading {
b.WriteString(normalStyle.Render("Fetching plugins from registry..."))
} else if m.pluginsError != "" {
b.WriteString(errorStyle.Render(fmt.Sprintf("Error: %s", m.pluginsError)))
} else if len(m.filteredPluginsList) == 0 {
if m.pluginSearchQuery != "" {
b.WriteString(normalStyle.Render(fmt.Sprintf("No plugins match '%s'", m.pluginSearchQuery)))
} else {
b.WriteString(normalStyle.Render("No plugins found in registry."))
}
} else {
installedStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888"))
for i, plugin := range m.filteredPluginsList {
installed := m.pluginInstallStatus[plugin.Name]
installMarker := ""
if installed {
installMarker = " [Installed]"
}
if i == m.selectedPluginIndex {
b.WriteString(selectedStyle.Render(fmt.Sprintf("→ %s", plugin.Name)))
if installed {
b.WriteString(installedStyle.Render(installMarker))
}
} else {
b.WriteString(normalStyle.Render(fmt.Sprintf(" %s", plugin.Name)))
if installed {
b.WriteString(installedStyle.Render(installMarker))
}
}
b.WriteString("\n")
}
}
b.WriteString("\n")
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888"))
if m.pluginsLoading || m.pluginsError != "" {
b.WriteString(instructionStyle.Render("Esc: Back | q: Quit"))
} else {
b.WriteString(instructionStyle.Render("↑/↓: Navigate | Enter: View/Install | /: Search | Esc: Back | q: Quit"))
}
return b.String()
}
func (m Model) renderPluginDetail() string {
var b strings.Builder
titleStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#00D4AA"))
normalStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF"))
labelStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888"))
if m.selectedPluginIndex >= len(m.filteredPluginsList) {
return "No plugin selected"
}
plugin := m.filteredPluginsList[m.selectedPluginIndex]
b.WriteString(titleStyle.Render(plugin.Name))
b.WriteString("\n\n")
b.WriteString(labelStyle.Render("ID: "))
b.WriteString(normalStyle.Render(plugin.ID))
b.WriteString("\n\n")
b.WriteString(labelStyle.Render("Category: "))
b.WriteString(normalStyle.Render(plugin.Category))
b.WriteString("\n\n")
b.WriteString(labelStyle.Render("Author: "))
b.WriteString(normalStyle.Render(plugin.Author))
b.WriteString("\n\n")
b.WriteString(labelStyle.Render("Description:"))
b.WriteString("\n")
wrapped := wrapText(plugin.Description, 60)
b.WriteString(normalStyle.Render(wrapped))
b.WriteString("\n\n")
b.WriteString(labelStyle.Render("Repository: "))
b.WriteString(normalStyle.Render(plugin.Repo))
b.WriteString("\n\n")
if len(plugin.Capabilities) > 0 {
b.WriteString(labelStyle.Render("Capabilities: "))
b.WriteString(normalStyle.Render(strings.Join(plugin.Capabilities, ", ")))
b.WriteString("\n\n")
}
if len(plugin.Compositors) > 0 {
b.WriteString(labelStyle.Render("Compositors: "))
b.WriteString(normalStyle.Render(strings.Join(plugin.Compositors, ", ")))
b.WriteString("\n\n")
}
if len(plugin.Dependencies) > 0 {
b.WriteString(labelStyle.Render("Dependencies: "))
b.WriteString(normalStyle.Render(strings.Join(plugin.Dependencies, ", ")))
b.WriteString("\n\n")
}
installed := m.pluginInstallStatus[plugin.Name]
if installed {
b.WriteString(labelStyle.Render("Status: "))
installedStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#00D4AA"))
b.WriteString(installedStyle.Render("Installed"))
b.WriteString("\n\n")
}
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888"))
if installed {
b.WriteString(instructionStyle.Render("Esc: Back | q: Quit"))
} else {
b.WriteString(instructionStyle.Render("i: Install | Esc: Back | q: Quit"))
}
return b.String()
}
func (m Model) renderPluginSearch() string {
var b strings.Builder
titleStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#00D4AA"))
normalStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF"))
b.WriteString(titleStyle.Render("Search Plugins"))
b.WriteString("\n\n")
b.WriteString(normalStyle.Render("Query: "))
b.WriteString(titleStyle.Render(m.pluginSearchQuery + "▌"))
b.WriteString("\n\n")
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888"))
b.WriteString(instructionStyle.Render("Enter: Search | Esc: Cancel"))
return b.String()
}
func (m Model) renderPluginsInstalled() string {
var b strings.Builder
titleStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#00D4AA"))
normalStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF"))
errorStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF0000"))
selectedStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#00D4AA")).
Bold(true)
b.WriteString(titleStyle.Render("Installed Plugins"))
b.WriteString("\n\n")
if m.installedPluginsLoading {
b.WriteString(normalStyle.Render("Loading installed plugins..."))
} else if m.installedPluginsError != "" {
b.WriteString(errorStyle.Render(fmt.Sprintf("Error: %s", m.installedPluginsError)))
} else if len(m.installedPluginsList) == 0 {
b.WriteString(normalStyle.Render("No plugins installed."))
} else {
for i, plugin := range m.installedPluginsList {
if i == m.selectedInstalledIndex {
b.WriteString(selectedStyle.Render(fmt.Sprintf("→ %s", plugin.Name)))
} else {
b.WriteString(normalStyle.Render(fmt.Sprintf(" %s", plugin.Name)))
}
b.WriteString("\n")
}
}
b.WriteString("\n")
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888"))
if m.installedPluginsLoading || m.installedPluginsError != "" {
b.WriteString(instructionStyle.Render("Esc: Back | q: Quit"))
} else {
b.WriteString(instructionStyle.Render("↑/↓: Navigate | Enter: Details | Esc: Back | q: Quit"))
}
return b.String()
}
func (m Model) renderPluginInstalledDetail() string {
var b strings.Builder
titleStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#00D4AA"))
normalStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF"))
labelStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888"))
errorStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF0000"))
if m.selectedInstalledIndex >= len(m.installedPluginsList) {
return "No plugin selected"
}
plugin := m.installedPluginsList[m.selectedInstalledIndex]
b.WriteString(titleStyle.Render(plugin.Name))
b.WriteString("\n\n")
b.WriteString(labelStyle.Render("ID: "))
b.WriteString(normalStyle.Render(plugin.ID))
b.WriteString("\n\n")
b.WriteString(labelStyle.Render("Category: "))
b.WriteString(normalStyle.Render(plugin.Category))
b.WriteString("\n\n")
b.WriteString(labelStyle.Render("Author: "))
b.WriteString(normalStyle.Render(plugin.Author))
b.WriteString("\n\n")
b.WriteString(labelStyle.Render("Description:"))
b.WriteString("\n")
wrapped := wrapText(plugin.Description, 60)
b.WriteString(normalStyle.Render(wrapped))
b.WriteString("\n\n")
b.WriteString(labelStyle.Render("Repository: "))
b.WriteString(normalStyle.Render(plugin.Repo))
b.WriteString("\n\n")
if len(plugin.Capabilities) > 0 {
b.WriteString(labelStyle.Render("Capabilities: "))
b.WriteString(normalStyle.Render(strings.Join(plugin.Capabilities, ", ")))
b.WriteString("\n\n")
}
if len(plugin.Compositors) > 0 {
b.WriteString(labelStyle.Render("Compositors: "))
b.WriteString(normalStyle.Render(strings.Join(plugin.Compositors, ", ")))
b.WriteString("\n\n")
}
if len(plugin.Dependencies) > 0 {
b.WriteString(labelStyle.Render("Dependencies: "))
b.WriteString(normalStyle.Render(strings.Join(plugin.Dependencies, ", ")))
b.WriteString("\n\n")
}
if m.installedPluginsError != "" {
b.WriteString(errorStyle.Render(fmt.Sprintf("Error: %s", m.installedPluginsError)))
b.WriteString("\n\n")
}
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888"))
b.WriteString(instructionStyle.Render("u: Uninstall | Esc: Back | q: Quit"))
return b.String()
}
func wrapText(text string, width int) string {
words := strings.Fields(text)
if len(words) == 0 {
return text
}
var lines []string
currentLine := words[0]
for _, word := range words[1:] {
if len(currentLine)+1+len(word) <= width {
currentLine += " " + word
} else {
lines = append(lines, currentLine)
currentLine = word
}
}
lines = append(lines, currentLine)
return strings.Join(lines, "\n")
}

View File

@@ -1,152 +0,0 @@
package dms
import (
"fmt"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/tui"
"github.com/charmbracelet/lipgloss"
)
func (m Model) renderMainMenu() string {
var b strings.Builder
b.WriteString(m.renderBanner())
b.WriteString("\n")
headerStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Bold(true).
MarginBottom(1)
b.WriteString(headerStyle.Render("dms"))
b.WriteString("\n")
selectedStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#00D4AA")).
Bold(true)
normalStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF"))
for i, item := range m.menuItems {
if i == m.selectedItem {
b.WriteString(selectedStyle.Render(fmt.Sprintf("▶ %s", item.Label)))
} else {
b.WriteString(normalStyle.Render(fmt.Sprintf(" %s", item.Label)))
}
b.WriteString("\n")
}
b.WriteString("\n")
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888")).
MarginTop(1)
instructions := "↑/↓: Navigate, Enter: Select, q/Esc: Exit"
b.WriteString(instructionStyle.Render(instructions))
return b.String()
}
func (m Model) renderShellView() string {
var b strings.Builder
b.WriteString(m.renderBanner())
b.WriteString("\n")
headerStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Bold(true).
MarginBottom(1)
b.WriteString(headerStyle.Render("Shell"))
b.WriteString("\n\n")
normalStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF"))
b.WriteString(normalStyle.Render("Opening interactive shell..."))
b.WriteString("\n")
b.WriteString(normalStyle.Render("This will launch a shell with DMS environment loaded."))
b.WriteString("\n\n")
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888")).
MarginTop(1)
instructions := "Press any key to launch shell, Esc: Back"
b.WriteString(instructionStyle.Render(instructions))
return b.String()
}
func (m Model) renderAboutView() string {
var b strings.Builder
b.WriteString(m.renderBanner())
b.WriteString("\n")
headerStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Bold(true).
MarginBottom(1)
b.WriteString(headerStyle.Render("About DankMaterialShell"))
b.WriteString("\n\n")
normalStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF"))
b.WriteString(normalStyle.Render(fmt.Sprintf("DMS Management Interface %s", m.version)))
b.WriteString("\n\n")
b.WriteString(normalStyle.Render("DankMaterialShell is a comprehensive desktop environment"))
b.WriteString("\n")
b.WriteString(normalStyle.Render("built around Quickshell, providing a modern Material Design"))
b.WriteString("\n")
b.WriteString(normalStyle.Render("experience for Wayland compositors."))
b.WriteString("\n\n")
b.WriteString(normalStyle.Render("Components:"))
b.WriteString("\n")
if len(m.dependencies) == 0 {
b.WriteString(normalStyle.Render("\n Component detection not supported on this platform."))
}
for _, dep := range m.dependencies {
status := "✗"
if dep.Status == 1 {
status = "✓"
}
b.WriteString(normalStyle.Render(fmt.Sprintf(" %s %s", status, dep.Name)))
b.WriteString("\n")
}
b.WriteString("\n")
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888")).
MarginTop(1)
instructions := "Esc: Back to main menu"
b.WriteString(instructionStyle.Render(instructions))
return b.String()
}
func (m Model) renderBanner() string {
theme := tui.TerminalTheme()
logo := `
██████╗ █████╗ ███╗ ██╗██╗ ██╗
██╔══██╗██╔══██╗████╗ ██║██║ ██╔╝
██║ ██║███████║██╔██╗ ██║█████╔╝
██║ ██║██╔══██║██║╚██╗██║██╔═██╗
██████╔╝██║ ██║██║ ╚████║██║ ██╗
╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝`
titleStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color(theme.Primary)).
Bold(true).
MarginBottom(1)
return titleStyle.Render(logo)
}

View File

@@ -1,529 +0,0 @@
//go:build !distro_binary
package dms
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
)
func (m Model) renderUpdateView() string {
var b strings.Builder
b.WriteString(m.renderBanner())
b.WriteString("\n")
headerStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Bold(true).
MarginBottom(1)
b.WriteString(headerStyle.Render("Update Dependencies"))
b.WriteString("\n")
if len(m.updateDeps) == 0 {
b.WriteString("Loading dependencies...\n")
return b.String()
}
categories := m.categorizeDependencies()
currentIndex := 0
for _, category := range []string{"Shell", "Shared Components", "Hyprland Components", "Niri Components"} {
deps, exists := categories[category]
if !exists || len(deps) == 0 {
continue
}
categoryStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#7060ac")).
Bold(true).
MarginTop(1)
b.WriteString(categoryStyle.Render(category + ":"))
b.WriteString("\n")
for _, dep := range deps {
var statusText, icon, reinstallMarker string
var style lipgloss.Style
if m.updateToggles[dep.Name] {
reinstallMarker = "🔄 "
if dep.Status == 0 {
statusText = "Will be installed"
} else {
statusText = "Will be upgraded"
}
style = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFA500"))
} else {
switch dep.Status {
case 1:
icon = "✓"
statusText = "Installed"
style = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF"))
case 0:
icon = "○"
statusText = "Not installed"
style = lipgloss.NewStyle().Foreground(lipgloss.Color("#888888"))
case 2:
icon = "△"
statusText = "Needs update"
style = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFA500"))
case 3:
icon = "!"
statusText = "Needs reinstall"
style = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFA500"))
}
}
line := fmt.Sprintf("%s%s%-25s %s", reinstallMarker, icon, dep.Name, statusText)
if currentIndex == m.selectedUpdateDep {
line = "▶ " + line
selectedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#7060ac")).Bold(true)
b.WriteString(selectedStyle.Render(line))
} else {
line = " " + line
b.WriteString(style.Render(line))
}
b.WriteString("\n")
currentIndex++
}
}
b.WriteString("\n")
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888")).
MarginTop(1)
instructions := "↑/↓: Navigate, Space: Toggle, Enter: Update Selected, Esc: Back"
b.WriteString(instructionStyle.Render(instructions))
return b.String()
}
func (m Model) renderPasswordView() string {
var b strings.Builder
b.WriteString(m.renderBanner())
b.WriteString("\n")
headerStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Bold(true).
MarginBottom(1)
b.WriteString(headerStyle.Render("Sudo Authentication"))
b.WriteString("\n\n")
normalStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF"))
b.WriteString(normalStyle.Render("Package installation requires sudo privileges."))
b.WriteString("\n")
b.WriteString(normalStyle.Render("Please enter your password to continue:"))
b.WriteString("\n\n")
inputStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#00D4AA"))
maskedPassword := strings.Repeat("*", len(m.passwordInput))
b.WriteString(inputStyle.Render("Password: " + maskedPassword))
b.WriteString("\n")
if m.passwordError != "" {
errorStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF0000"))
b.WriteString(errorStyle.Render("✗ " + m.passwordError))
b.WriteString("\n")
}
b.WriteString("\n")
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888")).
MarginTop(1)
instructions := "Enter: Continue, Esc: Back, Ctrl+C: Cancel"
b.WriteString(instructionStyle.Render(instructions))
return b.String()
}
func (m Model) renderProgressView() string {
var b strings.Builder
b.WriteString(m.renderBanner())
b.WriteString("\n")
headerStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Bold(true).
MarginBottom(1)
b.WriteString(headerStyle.Render("Updating Packages"))
b.WriteString("\n\n")
if !m.updateProgress.complete {
progressStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#00D4AA"))
b.WriteString(progressStyle.Render(m.updateProgress.step))
b.WriteString("\n\n")
progressBar := fmt.Sprintf("[%s%s] %.0f%%",
strings.Repeat("█", int(m.updateProgress.progress*30)),
strings.Repeat("░", 30-int(m.updateProgress.progress*30)),
m.updateProgress.progress*100)
b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Render(progressBar))
b.WriteString("\n")
if len(m.updateLogs) > 0 {
b.WriteString("\n")
logHeader := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")).Render("Live Output:")
b.WriteString(logHeader)
b.WriteString("\n")
maxLines := 8
startIdx := 0
if len(m.updateLogs) > maxLines {
startIdx = len(m.updateLogs) - maxLines
}
logStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888"))
for i := startIdx; i < len(m.updateLogs); i++ {
if m.updateLogs[i] != "" {
b.WriteString(logStyle.Render(" " + m.updateLogs[i]))
b.WriteString("\n")
}
}
}
}
if m.updateProgress.err != nil {
errorStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF0000"))
b.WriteString("\n")
b.WriteString(errorStyle.Render(fmt.Sprintf("✗ Update failed: %v", m.updateProgress.err)))
b.WriteString("\n")
if len(m.updateLogs) > 0 {
b.WriteString("\n")
logHeader := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")).Render("Error Logs:")
b.WriteString(logHeader)
b.WriteString("\n")
maxLines := 15
startIdx := 0
if len(m.updateLogs) > maxLines {
startIdx = len(m.updateLogs) - maxLines
}
logStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888"))
for i := startIdx; i < len(m.updateLogs); i++ {
if m.updateLogs[i] != "" {
b.WriteString(logStyle.Render(" " + m.updateLogs[i]))
b.WriteString("\n")
}
}
}
b.WriteString("\n")
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888"))
b.WriteString(instructionStyle.Render("Press Esc to go back"))
} else if m.updateProgress.complete {
successStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#00D4AA"))
b.WriteString("\n")
b.WriteString(successStyle.Render("✓ Update complete!"))
b.WriteString("\n\n")
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888"))
b.WriteString(instructionStyle.Render("Press Esc to return to main menu"))
}
return b.String()
}
func (m Model) getFilteredDeps() []DependencyInfo {
categories := m.categorizeDependencies()
var filtered []DependencyInfo
for _, category := range []string{"Shell", "Shared Components", "Hyprland Components", "Niri Components"} {
deps, exists := categories[category]
if exists {
filtered = append(filtered, deps...)
}
}
return filtered
}
func (m Model) getDepAtVisualIndex(index int) *DependencyInfo {
filtered := m.getFilteredDeps()
if index >= 0 && index < len(filtered) {
return &filtered[index]
}
return nil
}
func (m Model) renderGreeterPasswordView() string {
var b strings.Builder
b.WriteString(m.renderBanner())
b.WriteString("\n")
headerStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Bold(true).
MarginBottom(1)
b.WriteString(headerStyle.Render("Sudo Authentication"))
b.WriteString("\n\n")
normalStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF"))
b.WriteString(normalStyle.Render("Greeter installation requires sudo privileges."))
b.WriteString("\n")
b.WriteString(normalStyle.Render("Please enter your password to continue:"))
b.WriteString("\n\n")
inputStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#00D4AA"))
maskedPassword := strings.Repeat("*", len(m.greeterPasswordInput))
b.WriteString(inputStyle.Render("Password: " + maskedPassword))
b.WriteString("\n")
if m.greeterPasswordError != "" {
errorStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF0000"))
b.WriteString(errorStyle.Render("✗ " + m.greeterPasswordError))
b.WriteString("\n")
}
b.WriteString("\n")
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888")).
MarginTop(1)
instructions := "Enter: Continue, Esc: Back, Ctrl+C: Cancel"
b.WriteString(instructionStyle.Render(instructions))
return b.String()
}
func (m Model) renderGreeterCompositorSelect() string {
var b strings.Builder
b.WriteString(m.renderBanner())
b.WriteString("\n")
headerStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Bold(true).
MarginBottom(1)
b.WriteString(headerStyle.Render("Select Compositor"))
b.WriteString("\n\n")
normalStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF"))
b.WriteString(normalStyle.Render("Multiple compositors detected. Choose which one to use for the greeter:"))
b.WriteString("\n\n")
selectedStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#00D4AA")).
Bold(true)
for i, comp := range m.greeterCompositors {
if i == m.greeterSelectedComp {
b.WriteString(selectedStyle.Render(fmt.Sprintf("▶ %s", comp)))
} else {
b.WriteString(normalStyle.Render(fmt.Sprintf(" %s", comp)))
}
b.WriteString("\n")
}
b.WriteString("\n")
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888")).
MarginTop(1)
instructions := "↑/↓: Navigate, Enter: Select, Esc: Back"
b.WriteString(instructionStyle.Render(instructions))
return b.String()
}
func (m Model) renderGreeterMenu() string {
var b strings.Builder
b.WriteString(m.renderBanner())
b.WriteString("\n")
headerStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Bold(true).
MarginBottom(1)
b.WriteString(headerStyle.Render("Greeter Management"))
b.WriteString("\n")
greeterMenuItems := []string{"Install Greeter"}
selectedStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#00D4AA")).
Bold(true)
normalStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF"))
for i, item := range greeterMenuItems {
if i == m.selectedGreeterItem {
b.WriteString(selectedStyle.Render(fmt.Sprintf("▶ %s", item)))
} else {
b.WriteString(normalStyle.Render(fmt.Sprintf(" %s", item)))
}
b.WriteString("\n")
}
b.WriteString("\n")
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888")).
MarginTop(1)
instructions := "↑/↓: Navigate, Enter: Select, Esc: Back"
b.WriteString(instructionStyle.Render(instructions))
return b.String()
}
func (m Model) renderGreeterInstalling() string {
var b strings.Builder
b.WriteString(m.renderBanner())
b.WriteString("\n")
headerStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Bold(true).
MarginBottom(1)
b.WriteString(headerStyle.Render("Installing Greeter"))
b.WriteString("\n\n")
if !m.greeterProgress.complete {
progressStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#00D4AA"))
b.WriteString(progressStyle.Render(m.greeterProgress.step))
b.WriteString("\n\n")
if len(m.greeterLogs) > 0 {
b.WriteString("\n")
logHeader := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")).Render("Output:")
b.WriteString(logHeader)
b.WriteString("\n")
maxLines := 10
startIdx := 0
if len(m.greeterLogs) > maxLines {
startIdx = len(m.greeterLogs) - maxLines
}
logStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888"))
for i := startIdx; i < len(m.greeterLogs); i++ {
if m.greeterLogs[i] != "" {
b.WriteString(logStyle.Render(" " + m.greeterLogs[i]))
b.WriteString("\n")
}
}
}
}
if m.greeterProgress.err != nil {
errorStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF0000"))
b.WriteString("\n")
b.WriteString(errorStyle.Render(fmt.Sprintf("✗ Installation failed: %v", m.greeterProgress.err)))
b.WriteString("\n\n")
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888"))
b.WriteString(instructionStyle.Render("Press Esc to go back"))
} else if m.greeterProgress.complete {
successStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#00D4AA"))
b.WriteString("\n")
b.WriteString(successStyle.Render("✓ Greeter installation complete!"))
b.WriteString("\n\n")
normalStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF"))
b.WriteString(normalStyle.Render("To test the greeter, run:"))
b.WriteString("\n")
b.WriteString(normalStyle.Render(" sudo systemctl start greetd"))
b.WriteString("\n\n")
b.WriteString(normalStyle.Render("To enable on boot, run:"))
b.WriteString("\n")
b.WriteString(normalStyle.Render(" sudo systemctl enable --now greetd"))
b.WriteString("\n\n")
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888"))
b.WriteString(instructionStyle.Render("Press Esc to return to main menu"))
}
return b.String()
}
func (m Model) categorizeDependencies() map[string][]DependencyInfo {
categories := map[string][]DependencyInfo{
"Shell": {},
"Shared Components": {},
"Hyprland Components": {},
"Niri Components": {},
}
excludeList := map[string]bool{
"git": true,
"polkit-agent": true,
"jq": true,
"xdg-desktop-portal": true,
"xdg-desktop-portal-wlr": true,
"xdg-desktop-portal-hyprland": true,
"xdg-desktop-portal-gtk": true,
}
for _, dep := range m.updateDeps {
if excludeList[dep.Name] {
continue
}
switch dep.Name {
case "dms (DankMaterialShell)", "quickshell":
categories["Shell"] = append(categories["Shell"], dep)
case "hyprland", "hyprctl":
categories["Hyprland Components"] = append(categories["Hyprland Components"], dep)
case "niri":
categories["Niri Components"] = append(categories["Niri Components"], dep)
case "kitty", "alacritty", "ghostty":
categories["Shared Components"] = append(categories["Shared Components"], dep)
default:
categories["Shared Components"] = append(categories["Shared Components"], dep)
}
}
return categories
}

View File

@@ -8,10 +8,13 @@ import (
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
"github.com/sblinch/kdl-go"
"github.com/sblinch/kdl-go/document"
)
// DetectDMSPath checks for DMS installation following XDG Base Directory specification
@@ -195,10 +198,10 @@ func CopyGreeterFiles(dmsPath, compositor string, logFunc func(string), sudoPass
return fmt.Errorf("failed to set cache directory owner: %w", err)
}
if err := runSudoCmd(sudoPassword, "chmod", "750", cacheDir); err != nil {
if err := runSudoCmd(sudoPassword, "chmod", "755", cacheDir); err != nil {
return fmt.Errorf("failed to set cache directory permissions: %w", err)
}
logFunc(fmt.Sprintf("✓ Created cache directory %s (owner: greeter:greeter, permissions: 750)", cacheDir))
logFunc(fmt.Sprintf("✓ Created cache directory %s (owner: greeter:greeter, permissions: 755)", cacheDir))
return nil
}
@@ -235,7 +238,7 @@ func SetupParentDirectoryACLs(logFunc func(string), sudoPassword string) error {
for _, dir := range parentDirs {
if _, err := os.Stat(dir.path); os.IsNotExist(err) {
if err := os.MkdirAll(dir.path, 0755); err != nil {
if err := os.MkdirAll(dir.path, 0o755); err != nil {
logFunc(fmt.Sprintf("⚠ Warning: Could not create %s: %v", dir.desc, err))
continue
}
@@ -295,7 +298,7 @@ func SetupDMSGroup(logFunc func(string), sudoPassword string) error {
for _, dir := range configDirs {
if _, err := os.Stat(dir.path); os.IsNotExist(err) {
if err := os.MkdirAll(dir.path, 0755); err != nil {
if err := os.MkdirAll(dir.path, 0o755); err != nil {
logFunc(fmt.Sprintf("⚠ Warning: Could not create %s: %v", dir.path, err))
continue
}
@@ -322,7 +325,7 @@ func SetupDMSGroup(logFunc func(string), sudoPassword string) error {
return nil
}
func SyncDMSConfigs(dmsPath string, logFunc func(string), sudoPassword string) error {
func SyncDMSConfigs(dmsPath, compositor string, logFunc func(string), sudoPassword string) error {
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get user home directory: %w", err)
@@ -355,14 +358,14 @@ func SyncDMSConfigs(dmsPath string, logFunc func(string), sudoPassword string) e
for _, link := range symlinks {
sourceDir := filepath.Dir(link.source)
if _, err := os.Stat(sourceDir); os.IsNotExist(err) {
if err := os.MkdirAll(sourceDir, 0755); err != nil {
if err := os.MkdirAll(sourceDir, 0o755); err != nil {
logFunc(fmt.Sprintf("⚠ Warning: Could not create directory %s: %v", sourceDir, err))
continue
}
}
if _, err := os.Stat(link.source); os.IsNotExist(err) {
if err := os.WriteFile(link.source, []byte("{}"), 0644); err != nil {
if err := os.WriteFile(link.source, []byte("{}"), 0o644); err != nil {
logFunc(fmt.Sprintf("⚠ Warning: Could not create %s: %v", link.source, err))
continue
}
@@ -378,9 +381,350 @@ func SyncDMSConfigs(dmsPath string, logFunc func(string), sudoPassword string) e
logFunc(fmt.Sprintf("✓ Synced %s", link.desc))
}
if strings.ToLower(compositor) != "niri" {
return nil
}
if err := syncNiriGreeterConfig(logFunc, sudoPassword); err != nil {
logFunc(fmt.Sprintf("⚠ Warning: Failed to sync niri greeter config: %v", err))
}
return nil
}
type niriGreeterSync struct {
processed map[string]bool
nodes []*document.Node
inputCount int
outputCount int
cursorCount int
debugCount int
cursorNode *document.Node
}
func syncNiriGreeterConfig(logFunc func(string), sudoPassword string) error {
configDir, err := os.UserConfigDir()
if err != nil {
return fmt.Errorf("failed to resolve user config directory: %w", err)
}
configPath := filepath.Join(configDir, "niri", "config.kdl")
if _, err := os.Stat(configPath); os.IsNotExist(err) {
logFunc(" Niri config not found; skipping greeter niri sync")
return nil
} else if err != nil {
return fmt.Errorf("failed to stat niri config: %w", err)
}
extractor := &niriGreeterSync{
processed: make(map[string]bool),
}
if err := extractor.processFile(configPath); err != nil {
return err
}
if len(extractor.nodes) == 0 {
logFunc(" No niri input/output sections found; skipping greeter niri sync")
return nil
}
content := extractor.render()
if strings.TrimSpace(content) == "" {
logFunc(" No niri input/output content to sync; skipping greeter niri sync")
return nil
}
greeterDir := "/etc/greetd/niri"
if err := runSudoCmd(sudoPassword, "mkdir", "-p", greeterDir); err != nil {
return fmt.Errorf("failed to create greetd niri directory: %w", err)
}
if err := runSudoCmd(sudoPassword, "chown", "root:greeter", greeterDir); err != nil {
return fmt.Errorf("failed to set greetd niri directory ownership: %w", err)
}
if err := runSudoCmd(sudoPassword, "chmod", "755", greeterDir); err != nil {
return fmt.Errorf("failed to set greetd niri directory permissions: %w", err)
}
dmsTemp, err := os.CreateTemp("", "dms-greeter-niri-dms-*.kdl")
if err != nil {
return fmt.Errorf("failed to create temp file: %w", err)
}
defer os.Remove(dmsTemp.Name())
if _, err := dmsTemp.WriteString(content); err != nil {
_ = dmsTemp.Close()
return fmt.Errorf("failed to write temp niri config: %w", err)
}
if err := dmsTemp.Close(); err != nil {
return fmt.Errorf("failed to close temp niri config: %w", err)
}
dmsPath := filepath.Join(greeterDir, "dms.kdl")
if err := backupFileIfExists(sudoPassword, dmsPath, ".backup"); err != nil {
return fmt.Errorf("failed to backup %s: %w", dmsPath, err)
}
if err := runSudoCmd(sudoPassword, "install", "-o", "root", "-g", "greeter", "-m", "0644", dmsTemp.Name(), dmsPath); err != nil {
return fmt.Errorf("failed to install greetd niri dms config: %w", err)
}
mainContent := fmt.Sprintf("%s\ninclude \"%s\"\n", config.NiriGreeterConfig, dmsPath)
mainTemp, err := os.CreateTemp("", "dms-greeter-niri-main-*.kdl")
if err != nil {
return fmt.Errorf("failed to create temp file: %w", err)
}
defer os.Remove(mainTemp.Name())
if _, err := mainTemp.WriteString(mainContent); err != nil {
_ = mainTemp.Close()
return fmt.Errorf("failed to write temp niri main config: %w", err)
}
if err := mainTemp.Close(); err != nil {
return fmt.Errorf("failed to close temp niri main config: %w", err)
}
mainPath := filepath.Join(greeterDir, "config.kdl")
if err := backupFileIfExists(sudoPassword, mainPath, ".backup"); err != nil {
return fmt.Errorf("failed to backup %s: %w", mainPath, err)
}
if err := runSudoCmd(sudoPassword, "install", "-o", "root", "-g", "greeter", "-m", "0644", mainTemp.Name(), mainPath); err != nil {
return fmt.Errorf("failed to install greetd niri main config: %w", err)
}
if err := ensureGreetdNiriConfig(logFunc, sudoPassword, mainPath); err != nil {
logFunc(fmt.Sprintf("⚠ Warning: Failed to update greetd config for niri: %v", err))
}
logFunc(fmt.Sprintf("✓ Synced niri greeter config (%d input, %d output, %d cursor, %d debug) to %s", extractor.inputCount, extractor.outputCount, extractor.cursorCount, extractor.debugCount, dmsPath))
return nil
}
func ensureGreetdNiriConfig(logFunc func(string), sudoPassword string, niriConfigPath string) error {
configPath := "/etc/greetd/config.toml"
data, err := os.ReadFile(configPath)
if os.IsNotExist(err) {
logFunc(" greetd config not found; skipping niri config wiring")
return nil
}
if err != nil {
return fmt.Errorf("failed to read greetd config: %w", err)
}
lines := strings.Split(string(data), "\n")
updated := false
for i, line := range lines {
trimmed := strings.TrimSpace(line)
if !strings.HasPrefix(trimmed, "command") {
continue
}
parts := strings.SplitN(trimmed, "=", 2)
if len(parts) != 2 {
continue
}
command := strings.Trim(strings.TrimSpace(parts[1]), "\"")
if !strings.Contains(command, "dms-greeter") {
continue
}
if !strings.Contains(command, "--command niri") {
continue
}
// Strip existing -C or --config and their arguments
command = stripConfigFlag(command)
newCommand := fmt.Sprintf("%s -C %s", command, niriConfigPath)
idx := strings.Index(line, "command")
leading := ""
if idx > 0 {
leading = line[:idx]
}
lines[i] = fmt.Sprintf("%scommand = \"%s\"", leading, newCommand)
updated = true
break
}
if !updated {
return nil
}
if err := backupFileIfExists(sudoPassword, configPath, ".backup"); err != nil {
return fmt.Errorf("failed to backup greetd config: %w", err)
}
tmpFile, err := os.CreateTemp("", "greetd-config-*.toml")
if err != nil {
return fmt.Errorf("failed to create temp greetd config: %w", err)
}
defer os.Remove(tmpFile.Name())
if _, err := tmpFile.WriteString(strings.Join(lines, "\n")); err != nil {
_ = tmpFile.Close()
return fmt.Errorf("failed to write temp greetd config: %w", err)
}
if err := tmpFile.Close(); err != nil {
return fmt.Errorf("failed to close temp greetd config: %w", err)
}
if err := runSudoCmd(sudoPassword, "mv", tmpFile.Name(), configPath); err != nil {
return fmt.Errorf("failed to update greetd config: %w", err)
}
logFunc(fmt.Sprintf("✓ Updated greetd config to use niri config %s", niriConfigPath))
return nil
}
func backupFileIfExists(sudoPassword string, path string, suffix string) error {
if _, err := os.Stat(path); os.IsNotExist(err) {
return nil
} else if err != nil {
return err
}
backupPath := fmt.Sprintf("%s%s-%s", path, suffix, time.Now().Format("20060102-150405"))
return runSudoCmd(sudoPassword, "cp", "-p", path, backupPath)
}
func (s *niriGreeterSync) processFile(filePath string) error {
absPath, err := filepath.Abs(filePath)
if err != nil {
return fmt.Errorf("failed to resolve path %s: %w", filePath, err)
}
if s.processed[absPath] {
return nil
}
s.processed[absPath] = true
data, err := os.ReadFile(absPath)
if err != nil {
return fmt.Errorf("failed to read %s: %w", absPath, err)
}
doc, err := kdl.Parse(strings.NewReader(string(data)))
if err != nil {
return fmt.Errorf("failed to parse KDL in %s: %w", absPath, err)
}
baseDir := filepath.Dir(absPath)
for _, node := range doc.Nodes {
name := node.Name.String()
switch name {
case "include":
if err := s.handleInclude(node, baseDir); err != nil {
return err
}
case "input":
s.nodes = append(s.nodes, node)
s.inputCount++
case "output":
s.nodes = append(s.nodes, node)
s.outputCount++
case "cursor":
if s.cursorNode == nil {
s.cursorNode = node
s.cursorNode.Children = dedupeCursorChildren(s.cursorNode.Children)
s.nodes = append(s.nodes, node)
s.cursorCount++
} else if len(node.Children) > 0 {
s.cursorNode.Children = mergeCursorChildren(s.cursorNode.Children, node.Children)
}
case "debug":
s.nodes = append(s.nodes, node)
s.debugCount++
}
}
return nil
}
func mergeCursorChildren(existing []*document.Node, incoming []*document.Node) []*document.Node {
if len(incoming) == 0 {
return existing
}
indexByName := make(map[string]int, len(existing))
for i, child := range existing {
indexByName[child.Name.String()] = i
}
for _, child := range incoming {
name := child.Name.String()
if idx, ok := indexByName[name]; ok {
existing[idx] = child
continue
}
indexByName[name] = len(existing)
existing = append(existing, child)
}
return existing
}
func dedupeCursorChildren(children []*document.Node) []*document.Node {
if len(children) == 0 {
return children
}
var result []*document.Node
indexByName := make(map[string]int, len(children))
for _, child := range children {
name := child.Name.String()
if idx, ok := indexByName[name]; ok {
result[idx] = child
continue
}
indexByName[name] = len(result)
result = append(result, child)
}
return result
}
func (s *niriGreeterSync) handleInclude(node *document.Node, baseDir string) error {
if len(node.Arguments) == 0 {
return nil
}
includePath := strings.Trim(node.Arguments[0].String(), "\"")
if includePath == "" {
return nil
}
fullPath := includePath
if !filepath.IsAbs(includePath) {
fullPath = filepath.Join(baseDir, includePath)
}
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
return nil
} else if err != nil {
return fmt.Errorf("failed to stat include %s: %w", fullPath, err)
}
return s.processFile(fullPath)
}
func (s *niriGreeterSync) render() string {
if len(s.nodes) == 0 {
return ""
}
var builder strings.Builder
for _, node := range s.nodes {
_, _ = node.WriteToOptions(&builder, document.NodeWriteOptions{
LeadingTrailingSpace: true,
NameAndType: true,
Depth: 0,
Indent: []byte(" "),
IgnoreFlags: false,
})
builder.WriteString("\n")
}
return builder.String()
}
func ConfigureGreetd(dmsPath, compositor string, logFunc func(string), sudoPassword string) error {
configPath := "/etc/greetd/config.toml"
@@ -455,7 +799,7 @@ user = "greeter"
newConfig := strings.Join(finalLines, "\n")
tmpFile := "/tmp/greetd-config.toml"
if err := os.WriteFile(tmpFile, []byte(newConfig), 0644); err != nil {
if err := os.WriteFile(tmpFile, []byte(newConfig), 0o644); err != nil {
return fmt.Errorf("failed to write temp config: %w", err)
}
@@ -467,6 +811,37 @@ user = "greeter"
return nil
}
func stripConfigFlag(command string) string {
for _, flag := range []string{" -C ", " --config "} {
idx := strings.Index(command, flag)
if idx == -1 {
continue
}
before := command[:idx]
after := command[idx+len(flag):]
switch {
case strings.HasPrefix(after, `"`):
if end := strings.Index(after[1:], `"`); end != -1 {
after = after[end+2:]
} else {
after = ""
}
default:
if space := strings.Index(after, " "); space != -1 {
after = after[space:]
} else {
after = ""
}
}
command = strings.TrimSpace(before + after)
}
return command
}
func runSudoCmd(sudoPassword string, command string, args ...string) error {
var cmd *exec.Cmd

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -502,17 +502,17 @@ func (p *MangoWCParser) handleSource(line, baseDir string, keybinds *[]MangoWCKe
p.dmsProcessed = true
}
fullPath := sourcePath
if !filepath.IsAbs(sourcePath) {
fullPath = filepath.Join(baseDir, sourcePath)
}
expanded, err := utils.ExpandPath(fullPath)
expanded, err := utils.ExpandPath(sourcePath)
if err != nil {
return
}
includedBinds, err := p.parseFileWithSource(expanded)
fullPath := expanded
if !filepath.IsAbs(expanded) {
fullPath = filepath.Join(baseDir, expanded)
}
includedBinds, err := p.parseFileWithSource(fullPath)
if err != nil {
return
}
@@ -521,33 +521,10 @@ func (p *MangoWCParser) handleSource(line, baseDir string, keybinds *[]MangoWCKe
}
func (p *MangoWCParser) parseDMSBindsDirectly(dmsBindsPath string) []MangoWCKeyBinding {
data, err := os.ReadFile(dmsBindsPath)
keybinds, err := p.parseFileWithSource(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
}

View File

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

View File

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

View File

@@ -118,6 +118,9 @@ func (n *NiriProvider) categorizeByAction(action string) string {
return "Overview"
case action == "quit" ||
action == "power-off-monitors" ||
action == "power-on-monitors" ||
action == "suspend" ||
action == "do-screen-transition" ||
action == "toggle-keyboard-shortcuts-inhibit" ||
strings.Contains(action, "dpms"):
return "System"
@@ -151,13 +154,16 @@ func (n *NiriProvider) convertKeybind(kb *NiriKeyBinding, subcategory string, co
}
bind := keybinds.Keybind{
Key: keyStr,
Description: kb.Description,
Action: rawAction,
Subcategory: subcategory,
Source: source,
HideOnOverlay: kb.HideOnOverlay,
CooldownMs: kb.CooldownMs,
Key: keyStr,
Description: kb.Description,
Action: rawAction,
Subcategory: subcategory,
Source: source,
HideOnOverlay: kb.HideOnOverlay,
CooldownMs: kb.CooldownMs,
AllowWhenLocked: kb.AllowWhenLocked,
AllowInhibiting: kb.AllowInhibiting,
Repeat: kb.Repeat,
}
if source == "dms" && conflicts != nil {
@@ -235,7 +241,7 @@ func (n *NiriProvider) SetBind(key, action, description string, options map[stri
overridePath := n.GetOverridePath()
if err := os.MkdirAll(filepath.Dir(overridePath), 0755); err != nil {
if err := os.MkdirAll(filepath.Dir(overridePath), 0o755); err != nil {
return fmt.Errorf("failed to create dms directory: %w", err)
}
@@ -341,14 +347,10 @@ func (n *NiriProvider) buildActionFromNode(bindNode *document.Node) string {
}
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())
for _, propName := range []string{"focus", "show-pointer", "write-to-disk", "skip-confirmation", "delay-ms"} {
if val, ok := actionNode.Properties.Get(propName); ok {
parts = append(parts, propName+"="+val.String())
}
}
}
@@ -372,6 +374,9 @@ func (n *NiriProvider) extractOptions(node *document.Node) map[string]any {
if val, ok := node.Properties.Get("allow-when-locked"); ok {
opts["allow-when-locked"] = val.String() == "true"
}
if val, ok := node.Properties.Get("allow-inhibiting"); ok {
opts["allow-inhibiting"] = val.String() == "true"
}
return opts
}
@@ -405,6 +410,9 @@ func (n *NiriProvider) buildBindNode(bind *overrideBind) *document.Node {
if v, ok := bind.Options["allow-when-locked"]; ok && v == true {
node.AddProperty("allow-when-locked", true, "")
}
if v, ok := bind.Options["allow-inhibiting"]; ok && v == false {
node.AddProperty("allow-inhibiting", false, "")
}
}
if bind.Description != "" {
@@ -485,7 +493,7 @@ func (n *NiriProvider) writeOverrideBinds(binds map[string]*overrideBind) error
return err
}
return os.WriteFile(overridePath, []byte(content), 0644)
return os.WriteFile(overridePath, []byte(content), 0o644)
}
func (n *NiriProvider) getBindSortPriority(action string) int {

View File

@@ -12,14 +12,17 @@ import (
)
type NiriKeyBinding struct {
Mods []string
Key string
Action string
Args []string
Description string
HideOnOverlay bool
CooldownMs int
Source string
Mods []string
Key string
Action string
Args []string
Description string
HideOnOverlay bool
CooldownMs int
AllowWhenLocked bool
AllowInhibiting *bool
Repeat *bool
Source string
}
type NiriSection struct {
@@ -269,8 +272,10 @@ func (p *NiriParser) parseKeybindNode(node *document.Node, _ string) *NiriKeyBin
args = append(args, arg.ValueString())
}
if actionNode.Properties != nil {
if val, ok := actionNode.Properties.Get("focus"); ok {
args = append(args, "focus="+val.String())
for _, propName := range []string{"focus", "show-pointer", "write-to-disk", "skip-confirmation", "delay-ms"} {
if val, ok := actionNode.Properties.Get(propName); ok {
args = append(args, propName+"="+val.String())
}
}
}
}
@@ -278,6 +283,9 @@ func (p *NiriParser) parseKeybindNode(node *document.Node, _ string) *NiriKeyBin
var description string
var hideOnOverlay bool
var cooldownMs int
var allowWhenLocked bool
var allowInhibiting *bool
var repeat *bool
if node.Properties != nil {
if val, ok := node.Properties.Get("hotkey-overlay-title"); ok {
switch val.ValueString() {
@@ -290,17 +298,31 @@ func (p *NiriParser) parseKeybindNode(node *document.Node, _ string) *NiriKeyBin
if val, ok := node.Properties.Get("cooldown-ms"); ok {
cooldownMs, _ = strconv.Atoi(val.String())
}
if val, ok := node.Properties.Get("allow-when-locked"); ok {
allowWhenLocked = val.String() == "true"
}
if val, ok := node.Properties.Get("allow-inhibiting"); ok {
v := val.String() == "true"
allowInhibiting = &v
}
if val, ok := node.Properties.Get("repeat"); ok {
v := val.String() == "true"
repeat = &v
}
}
return &NiriKeyBinding{
Mods: mods,
Key: key,
Action: action,
Args: args,
Description: description,
HideOnOverlay: hideOnOverlay,
CooldownMs: cooldownMs,
Source: p.currentSource,
Mods: mods,
Key: key,
Action: action,
Args: args,
Description: description,
HideOnOverlay: hideOnOverlay,
CooldownMs: cooldownMs,
AllowWhenLocked: allowWhenLocked,
AllowInhibiting: allowInhibiting,
Repeat: repeat,
Source: p.currentSource,
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,15 +1,18 @@
package keybinds
type Keybind struct {
Key string `json:"key"`
Description string `json:"desc"`
Action string `json:"action,omitempty"`
Subcategory string `json:"subcat,omitempty"`
Source string `json:"source,omitempty"`
HideOnOverlay bool `json:"hideOnOverlay,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"`
Key string `json:"key"`
Description string `json:"desc"`
Action string `json:"action,omitempty"`
Subcategory string `json:"subcat,omitempty"`
Source string `json:"source,omitempty"`
HideOnOverlay bool `json:"hideOnOverlay,omitempty"`
CooldownMs int `json:"cooldownMs,omitempty"`
Flags string `json:"flags,omitempty"` // Hyprland bind flags: e=repeat, l=locked, r=release, o=long-press
AllowWhenLocked bool `json:"allowWhenLocked,omitempty"`
AllowInhibiting *bool `json:"allowInhibiting,omitempty"` // nil=default(true), false=explicitly disabled
Repeat *bool `json:"repeat,omitempty"` // nil=default(true), false=explicitly disabled
Conflict *Keybind `json:"conflict,omitempty"`
}
type DMSBindsStatus struct {

View File

@@ -3,6 +3,7 @@ package matugen
import (
"encoding/json"
"fmt"
"math"
"os"
"os/exec"
"path/filepath"
@@ -10,10 +11,12 @@ import (
"strings"
"sync"
"syscall"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/dank16"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
"github.com/lucasb-eyer/go-colorful"
)
type ColorMode string
@@ -62,6 +65,7 @@ var templateRegistry = []TemplateDef{
{ID: "dgop", Commands: []string{"dgop"}, ConfigFile: "dgop.toml"},
{ID: "kcolorscheme", ConfigFile: "kcolorscheme.toml", RunUnconditionally: true},
{ID: "vscode", Kind: TemplateKindVSCode},
{ID: "emacs", Commands: []string{"emacs"}, ConfigFile: "emacs.toml"},
}
func (c *ColorMode) GTKTheme() string {
@@ -147,7 +151,7 @@ func Run(opts Options) error {
opts.AppChecker = utils.DefaultAppChecker{}
}
if err := os.MkdirAll(opts.StateDir, 0755); err != nil {
if err := os.MkdirAll(opts.StateDir, 0o755); err != nil {
return fmt.Errorf("failed to create state dir: %w", err)
}
@@ -249,7 +253,21 @@ func buildOnce(opts *Options) error {
}
}
refreshGTK(opts.ConfigDir, opts.Mode)
if isDMSGTKActive(opts.ConfigDir) {
switch opts.Mode {
case ColorModeLight:
syncAccentColor(primaryLight)
default:
syncAccentColor(primaryDark)
}
refreshGTK(opts.Mode)
refreshGTK4()
}
if !opts.ShouldSkipTemplate("qt6ct") && appExists(opts.AppChecker, []string{"qt6ct"}, nil) {
refreshQt6ct()
}
signalTerminals()
return nil
@@ -413,7 +431,7 @@ func appendTerminalConfig(opts *Options, cfgFile *os.File, tmpDir string, checkC
modified := strings.ReplaceAll(string(origData), ".default.", ".dark.")
tmpPath := filepath.Join(tmpDir, templateName)
if err := os.WriteFile(tmpPath, []byte(modified), 0644); err != nil {
if err := os.WriteFile(tmpPath, []byte(modified), 0o644); err != nil {
continue
}
@@ -616,33 +634,62 @@ func generateDank16Variants(primaryDark, primaryLight, surface string, mode Colo
return dank16.GenerateVariantJSON(variantColors)
}
func refreshGTK(configDir string, mode ColorMode) {
func isDMSGTKActive(configDir string) bool {
gtkCSS := filepath.Join(configDir, "gtk-3.0", "gtk.css")
info, err := os.Lstat(gtkCSS)
if err != nil {
return
return false
}
shouldRun := false
if info.Mode()&os.ModeSymlink != 0 {
target, err := os.Readlink(gtkCSS)
if err == nil && strings.Contains(target, "dank-colors.css") {
shouldRun = true
}
} else {
data, err := os.ReadFile(gtkCSS)
if err == nil && strings.Contains(string(data), "dank-colors.css") {
shouldRun = true
}
return err == nil && strings.Contains(target, "dank-colors.css")
}
if !shouldRun {
data, err := os.ReadFile(gtkCSS)
return err == nil && strings.Contains(string(data), "dank-colors.css")
}
func refreshGTK(mode ColorMode) {
if err := utils.GsettingsSet("org.gnome.desktop.interface", "gtk-theme", ""); err != nil {
log.Warnf("Failed to reset gtk-theme: %v", err)
}
if err := utils.GsettingsSet("org.gnome.desktop.interface", "gtk-theme", mode.GTKTheme()); err != nil {
log.Warnf("Failed to set gtk-theme: %v", err)
}
}
func refreshGTK4() {
output, err := utils.GsettingsGet("org.gnome.desktop.interface", "color-scheme")
if err != nil {
return
}
current := strings.Trim(output, "'")
exec.Command("gsettings", "set", "org.gnome.desktop.interface", "gtk-theme", "").Run()
exec.Command("gsettings", "set", "org.gnome.desktop.interface", "gtk-theme", mode.GTKTheme()).Run()
var toggle string
if current == "prefer-dark" {
toggle = "default"
} else {
toggle = "prefer-dark"
}
if err := utils.GsettingsSet("org.gnome.desktop.interface", "color-scheme", toggle); err != nil {
log.Warnf("Failed to toggle color-scheme for GTK4 refresh: %v", err)
return
}
time.Sleep(50 * time.Millisecond)
if err := utils.GsettingsSet("org.gnome.desktop.interface", "color-scheme", current); err != nil {
log.Warnf("Failed to restore color-scheme for GTK4 refresh: %v", err)
}
}
func refreshQt6ct() {
confPath := filepath.Join(utils.XDGConfigHome(), "qt6ct", "qt6ct.conf")
now := time.Now()
if err := os.Chtimes(confPath, now, now); err != nil {
log.Warnf("Failed to touch qt6ct.conf: %v", err)
}
}
func signalTerminals() {
@@ -678,8 +725,59 @@ func syncColorScheme(mode ColorMode) {
scheme = "default"
}
if err := exec.Command("gsettings", "set", "org.gnome.desktop.interface", "color-scheme", scheme).Run(); err != nil {
exec.Command("dconf", "write", "/org/gnome/desktop/interface/color-scheme", "'"+scheme+"'").Run()
if err := utils.GsettingsSet("org.gnome.desktop.interface", "color-scheme", scheme); err != nil {
log.Warnf("Failed to sync color-scheme: %v", err)
}
}
var adwaitaAccents = []struct {
name string
colors []colorful.Color
}{
{"blue", hexColors("#3f8ae5", "#438de6", "#a4caee")},
{"green", hexColors("#26a269", "#39ac76", "#81d5ad")},
{"orange", hexColors("#f17738", "#ff7800", "#ffc994")},
{"pink", hexColors("#e4358a", "#e64392", "#f9b3d5")},
{"purple", hexColors("#954ab5", "#9c46b9", "#d099d6")},
{"red", hexColors("#e84053", "#e01b24", "#f2a1a5")},
{"slate", hexColors("#557b9f", "#6a8daf", "#b4c6d6")},
{"teal", hexColors("#129eb0", "#2190a4", "#7bdff4")},
{"yellow", hexColors("#cbac10", "#d4b411", "#f5c211")},
}
func hexColors(hexes ...string) []colorful.Color {
out := make([]colorful.Color, len(hexes))
for i, h := range hexes {
out[i], _ = colorful.Hex(h)
}
return out
}
func closestAdwaitaAccent(primaryHex string) string {
c, err := colorful.Hex(primaryHex)
if err != nil {
return "blue"
}
best := "blue"
bestDist := math.MaxFloat64
for _, a := range adwaitaAccents {
for _, ref := range a.colors {
d := c.DistanceCIEDE2000(ref)
if d < bestDist {
bestDist = d
best = a.name
}
}
}
return best
}
func syncAccentColor(primaryHex string) {
accent := closestAdwaitaAccent(primaryHex)
log.Infof("Setting GNOME accent color: %s", accent)
if err := utils.GsettingsSet("org.gnome.desktop.interface", "accent-color", accent); err != nil {
log.Warnf("Failed to set accent-color: %v", err)
}
}

View File

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

View File

@@ -0,0 +1,170 @@
package notify
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"syscall"
"github.com/godbus/dbus/v5"
)
const (
notifyDest = "org.freedesktop.Notifications"
notifyPath = "/org/freedesktop/Notifications"
notifyInterface = "org.freedesktop.Notifications"
)
type Notification struct {
AppName string
Icon string
Summary string
Body string
FilePath string
Timeout int32
}
func Send(n Notification) error {
conn, err := dbus.SessionBus()
if err != nil {
return fmt.Errorf("dbus session failed: %w", err)
}
if n.AppName == "" {
n.AppName = "DMS"
}
if n.Timeout == 0 {
n.Timeout = 5000
}
var actions []string
if n.FilePath != "" {
actions = []string{
"open", "Open",
"folder", "Open Folder",
}
}
hints := map[string]dbus.Variant{}
if n.FilePath != "" {
hints["image_path"] = dbus.MakeVariant(n.FilePath)
}
obj := conn.Object(notifyDest, notifyPath)
call := obj.Call(
notifyInterface+".Notify",
0,
n.AppName,
uint32(0),
n.Icon,
n.Summary,
n.Body,
actions,
hints,
n.Timeout,
)
if call.Err != nil {
return fmt.Errorf("notify call failed: %w", call.Err)
}
var notificationID uint32
if err := call.Store(&notificationID); err != nil {
return fmt.Errorf("failed to get notification id: %w", err)
}
if len(actions) > 0 && n.FilePath != "" {
spawnActionListener(notificationID, n.FilePath)
}
return nil
}
func spawnActionListener(notificationID uint32, filePath string) {
exe, err := os.Executable()
if err != nil {
return
}
cmd := exec.Command(exe, "notify-action-generic", fmt.Sprintf("%d", notificationID), filePath)
cmd.SysProcAttr = &syscall.SysProcAttr{
Setsid: true,
}
cmd.Start()
}
func RunActionListener(args []string) {
if len(args) < 2 {
return
}
notificationID, err := strconv.ParseUint(args[0], 10, 32)
if err != nil {
return
}
filePath := args[1]
conn, err := dbus.SessionBus()
if err != nil {
return
}
if err := conn.AddMatchSignal(
dbus.WithMatchObjectPath(notifyPath),
dbus.WithMatchInterface(notifyInterface),
); err != nil {
return
}
signals := make(chan *dbus.Signal, 10)
conn.Signal(signals)
for sig := range signals {
switch sig.Name {
case notifyInterface + ".ActionInvoked":
if len(sig.Body) < 2 {
continue
}
id, ok := sig.Body[0].(uint32)
if !ok || id != uint32(notificationID) {
continue
}
action, ok := sig.Body[1].(string)
if !ok {
continue
}
handleAction(action, filePath)
return
case notifyInterface + ".NotificationClosed":
if len(sig.Body) < 1 {
continue
}
id, ok := sig.Body[0].(uint32)
if !ok || id != uint32(notificationID) {
continue
}
return
}
}
}
func handleAction(action, filePath string) {
switch action {
case "open", "default":
openPath(filePath)
case "folder":
openPath(filepath.Dir(filePath))
}
}
func openPath(path string) {
cmd := exec.Command("xdg-open", path)
cmd.SysProcAttr = &syscall.SysProcAttr{
Setsid: true,
}
cmd.Start()
}

View File

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

View File

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

View File

@@ -27,6 +27,7 @@ type Plugin struct {
Distro []string `json:"distro"`
Screenshot string `json:"screenshot,omitempty"`
RequiresDMS string `json:"requires_dms,omitempty"`
Featured bool `json:"featured,omitempty"`
}
type GitClient interface {
@@ -147,7 +148,7 @@ func (r *Registry) Update() error {
}
if !exists {
if err := r.fs.MkdirAll(filepath.Dir(r.cacheDir), 0755); err != nil {
if err := r.fs.MkdirAll(filepath.Dir(r.cacheDir), 0o755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err)
}
@@ -162,7 +163,7 @@ func (r *Registry) Update() error {
return fmt.Errorf("failed to remove corrupted registry: %w", err)
}
if err := r.fs.MkdirAll(filepath.Dir(r.cacheDir), 0755); err != nil {
if err := r.fs.MkdirAll(filepath.Dir(r.cacheDir), 0o755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err)
}

View File

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

View File

@@ -67,6 +67,9 @@ func FilterByCapability(capability string, plugins []Plugin) []Plugin {
func SortByFirstParty(plugins []Plugin) []Plugin {
sort.SliceStable(plugins, func(i, j int) bool {
if plugins[i].Featured != plugins[j].Featured {
return plugins[i].Featured
}
isFirstPartyI := strings.HasPrefix(plugins[i].Repo, "https://github.com/AvengeMedia")
isFirstPartyJ := strings.HasPrefix(plugins[j].Repo, "https://github.com/AvengeMedia")
if isFirstPartyI != isFirstPartyJ {

View File

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

View File

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

View File

@@ -3,11 +3,11 @@ package screenshot
import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
)
type ThemeColors struct {
@@ -83,12 +83,11 @@ func getColorsFilePath() string {
}
func isLightMode() bool {
out, err := exec.Command("gsettings", "get", "org.gnome.desktop.interface", "color-scheme").Output()
scheme, err := utils.GsettingsGet("org.gnome.desktop.interface", "color-scheme")
if err != nil {
return false
}
scheme := strings.TrimSpace(string(out))
switch scheme {
case "'prefer-light'", "'default'":
return true

View File

@@ -7,6 +7,7 @@ import (
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/pkg/dbusutil"
"github.com/godbus/dbus/v5"
)
@@ -110,17 +111,15 @@ func (m *Manager) updateAdapterState() error {
if err != nil {
return err
}
powered, _ := poweredVar.Value().(bool)
discoveringVar, err := obj.GetProperty(adapter1Iface + ".Discovering")
if err != nil {
return err
}
discovering, _ := discoveringVar.Value().(bool)
m.stateMutex.Lock()
m.state.Powered = powered
m.state.Discovering = discovering
m.state.Powered = dbusutil.AsOr(poweredVar, false)
m.state.Discovering = dbusutil.AsOr(discoveringVar, false)
m.stateMutex.Unlock()
return nil
@@ -169,65 +168,20 @@ func (m *Manager) updateDevices() error {
}
func (m *Manager) deviceFromProps(path string, props map[string]dbus.Variant) Device {
dev := Device{Path: path}
if v, ok := props["Address"]; ok {
if addr, ok := v.Value().(string); ok {
dev.Address = addr
}
return Device{
Path: path,
Address: dbusutil.GetOr(props, "Address", ""),
Name: dbusutil.GetOr(props, "Name", ""),
Alias: dbusutil.GetOr(props, "Alias", ""),
Paired: dbusutil.GetOr(props, "Paired", false),
Trusted: dbusutil.GetOr(props, "Trusted", false),
Blocked: dbusutil.GetOr(props, "Blocked", false),
Connected: dbusutil.GetOr(props, "Connected", false),
Class: dbusutil.GetOr(props, "Class", uint32(0)),
Icon: dbusutil.GetOr(props, "Icon", ""),
RSSI: dbusutil.GetOr(props, "RSSI", int16(0)),
LegacyPairing: dbusutil.GetOr(props, "LegacyPairing", false),
}
if v, ok := props["Name"]; ok {
if name, ok := v.Value().(string); ok {
dev.Name = name
}
}
if v, ok := props["Alias"]; ok {
if alias, ok := v.Value().(string); ok {
dev.Alias = alias
}
}
if v, ok := props["Paired"]; ok {
if paired, ok := v.Value().(bool); ok {
dev.Paired = paired
}
}
if v, ok := props["Trusted"]; ok {
if trusted, ok := v.Value().(bool); ok {
dev.Trusted = trusted
}
}
if v, ok := props["Blocked"]; ok {
if blocked, ok := v.Value().(bool); ok {
dev.Blocked = blocked
}
}
if v, ok := props["Connected"]; ok {
if connected, ok := v.Value().(bool); ok {
dev.Connected = connected
}
}
if v, ok := props["Class"]; ok {
if class, ok := v.Value().(uint32); ok {
dev.Class = class
}
}
if v, ok := props["Icon"]; ok {
if icon, ok := v.Value().(string); ok {
dev.Icon = icon
}
}
if v, ok := props["RSSI"]; ok {
if rssi, ok := v.Value().(int16); ok {
dev.RSSI = rssi
}
}
if v, ok := props["LegacyPairing"]; ok {
if legacy, ok := v.Value().(bool); ok {
dev.LegacyPairing = legacy
}
}
return dev
}
func (m *Manager) startAgent() error {
@@ -328,17 +282,13 @@ func (m *Manager) handleAdapterPropertiesChanged(changed map[string]dbus.Variant
m.stateMutex.Lock()
dirty := false
if v, ok := changed["Powered"]; ok {
if powered, ok := v.Value().(bool); ok {
m.state.Powered = powered
dirty = true
}
if powered, ok := dbusutil.Get[bool](changed, "Powered"); ok {
m.state.Powered = powered
dirty = true
}
if v, ok := changed["Discovering"]; ok {
if discovering, ok := v.Value().(bool); ok {
m.state.Discovering = discovering
dirty = true
}
if discovering, ok := dbusutil.Get[bool](changed, "Discovering"); ok {
m.state.Discovering = discovering
dirty = true
}
m.stateMutex.Unlock()
@@ -349,31 +299,28 @@ func (m *Manager) handleAdapterPropertiesChanged(changed map[string]dbus.Variant
}
func (m *Manager) handleDevicePropertiesChanged(path dbus.ObjectPath, changed map[string]dbus.Variant) {
pairedVar, hasPaired := changed["Paired"]
paired, hasPaired := dbusutil.Get[bool](changed, "Paired")
_, hasConnected := changed["Connected"]
_, hasTrusted := changed["Trusted"]
if hasPaired {
devicePath := string(path)
if paired, ok := pairedVar.Value().(bool); ok {
if paired {
_, wasPending := m.pendingPairings.LoadAndDelete(devicePath)
if wasPending {
select {
case m.eventQueue <- func() {
time.Sleep(300 * time.Millisecond)
log.Infof("[Bluetooth] Auto-connecting newly paired device: %s", devicePath)
if err := m.ConnectDevice(devicePath); err != nil {
log.Warnf("[Bluetooth] Auto-connect failed: %v", err)
}
}:
default:
if paired {
_, wasPending := m.pendingPairings.LoadAndDelete(devicePath)
if wasPending {
select {
case m.eventQueue <- func() {
time.Sleep(300 * time.Millisecond)
log.Infof("[Bluetooth] Auto-connecting newly paired device: %s", devicePath)
if err := m.ConnectDevice(devicePath); err != nil {
log.Warnf("[Bluetooth] Auto-connect failed: %v", err)
}
}:
default:
}
} else {
m.pendingPairings.Delete(devicePath)
}
} else {
m.pendingPairings.Delete(devicePath)
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -37,6 +37,16 @@ func HandleRequest(conn net.Conn, req models.Request, m *Manager) {
handleSetConfig(conn, req, m)
case "clipboard.store":
handleStore(conn, req, m)
case "clipboard.pinEntry":
handlePinEntry(conn, req, m)
case "clipboard.unpinEntry":
handleUnpinEntry(conn, req, m)
case "clipboard.getPinnedEntries":
handleGetPinnedEntries(conn, req, m)
case "clipboard.getPinnedCount":
handleGetPinnedCount(conn, req, m)
case "clipboard.copyFile":
handleCopyFile(conn, req, m)
default:
models.RespondError(conn, req.ID, "unknown method: "+req.Method)
}
@@ -118,11 +128,36 @@ func handleCopyEntry(conn net.Conn, req models.Request, m *Manager) {
return
}
filePath := m.EntryToFile(entry)
if filePath != "" {
if err := m.CopyFile(filePath); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, map[string]any{
"success": true,
"filePath": filePath,
})
return
}
if err := m.SetClipboard(entry.Data, entry.MimeType); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
if entry.Pinned {
if err := m.CreateHistoryEntryFromPinned(entry); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
} else {
if err := m.TouchEntry(uint64(id)); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
}
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "copied to clipboard"})
}
@@ -205,6 +240,9 @@ func handleSetConfig(conn net.Conn, req models.Request, m *Manager) {
if v, ok := models.Get[bool](req, "disabled"); ok {
cfg.Disabled = v
}
if v, ok := models.Get[float64](req, "maxPinned"); ok {
cfg.MaxPinned = int(v)
}
if err := m.SetConfig(cfg); err != nil {
models.RespondError(conn, req.ID, err.Error())
@@ -230,3 +268,58 @@ func handleStore(conn net.Conn, req models.Request, m *Manager) {
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "stored"})
}
func handlePinEntry(conn net.Conn, req models.Request, m *Manager) {
id, err := params.Int(req.Params, "id")
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
if err := m.PinEntry(uint64(id)); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "entry pinned"})
}
func handleUnpinEntry(conn net.Conn, req models.Request, m *Manager) {
id, err := params.Int(req.Params, "id")
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
if err := m.UnpinEntry(uint64(id)); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "entry unpinned"})
}
func handleGetPinnedEntries(conn net.Conn, req models.Request, m *Manager) {
pinned := m.GetPinnedEntries()
models.Respond(conn, req.ID, pinned)
}
func handleGetPinnedCount(conn net.Conn, req models.Request, m *Manager) {
count := m.GetPinnedCount()
models.Respond(conn, req.ID, map[string]int{"count": count})
}
func handleCopyFile(conn net.Conn, req models.Request, m *Manager) {
filePath, err := params.String(req.Params, "filePath")
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
if err := m.CopyFile(filePath); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "copied"})
}

View File

@@ -10,6 +10,7 @@ import (
_ "image/png"
"io"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
@@ -19,8 +20,10 @@ import (
"hash/fnv"
"github.com/fsnotify/fsnotify"
"github.com/godbus/dbus/v5"
_ "golang.org/x/image/bmp"
_ "golang.org/x/image/tiff"
_ "golang.org/x/image/webp"
bolt "go.etcd.io/bbolt"
@@ -104,7 +107,7 @@ func NewManager(wlCtx wlcontext.WaylandContext, config Config) (*Manager, error)
}
func openDB(path string) (*bolt.DB, error) {
db, err := bolt.Open(path, 0644, &bolt.Options{
db, err := bolt.Open(path, 0o644, &bolt.Options{
Timeout: 1 * time.Second,
})
if err != nil {
@@ -316,6 +319,13 @@ func (m *Manager) readAndStore(r *os.File, mimeType string) {
}
func (m *Manager) storeClipboardEntry(data []byte, mimeType string) {
if mimeType == "text/uri-list" {
if imgData, imgMime, ok := m.tryReadImageFromURI(data); ok {
data = imgData
mimeType = imgMime
}
}
entry := Entry{
Data: data,
MimeType: mimeType,
@@ -327,6 +337,8 @@ func (m *Manager) storeClipboardEntry(data []byte, mimeType string) {
switch {
case entry.IsImage:
entry.Preview = m.imagePreview(data, mimeType)
case mimeType == "text/uri-list":
entry.Preview, entry.IsImage = m.uriListPreview(data)
default:
entry.Preview = m.textPreview(data)
}
@@ -376,6 +388,10 @@ func (m *Manager) deduplicateInTx(b *bolt.Bucket, hash uint64) error {
if extractHash(v) != hash {
continue
}
entry, err := decodeEntry(v)
if err == nil && entry.Pinned {
continue
}
if err := b.Delete(k); err != nil {
return err
}
@@ -389,7 +405,11 @@ func (m *Manager) trimLengthInTx(b *bolt.Bucket) error {
}
c := b.Cursor()
var count int
for k, _ := c.Last(); k != nil; k, _ = c.Prev() {
for k, v := c.Last(); k != nil; k, v = c.Prev() {
entry, err := decodeEntry(v)
if err == nil && entry.Pinned {
continue
}
if count < m.config.MaxHistory {
count++
continue
@@ -419,6 +439,11 @@ func encodeEntry(e Entry) ([]byte, error) {
buf.WriteByte(0)
}
binary.Write(buf, binary.BigEndian, e.Hash)
if e.Pinned {
buf.WriteByte(1)
} else {
buf.WriteByte(0)
}
return buf.Bytes(), nil
}
@@ -462,6 +487,12 @@ func decodeEntry(data []byte) (Entry, error) {
binary.Read(buf, binary.BigEndian, &e.Hash)
}
if buf.Len() >= 1 {
var pinnedByte byte
binary.Read(buf, binary.BigEndian, &pinnedByte)
e.Pinned = pinnedByte == 1
}
return e, nil
}
@@ -478,10 +509,10 @@ func computeHash(data []byte) uint64 {
}
func extractHash(data []byte) uint64 {
if len(data) < 8 {
if len(data) < 9 {
return 0
}
return binary.BigEndian.Uint64(data[len(data)-8:])
return binary.BigEndian.Uint64(data[len(data)-9 : len(data)-1])
}
func (m *Manager) hasSensitiveMimeType(mimes []string) bool {
@@ -492,6 +523,7 @@ func (m *Manager) hasSensitiveMimeType(mimes []string) bool {
func (m *Manager) selectMimeType(mimes []string) string {
preferredTypes := []string{
"text/uri-list",
"text/plain;charset=utf-8",
"text/plain",
"UTF8_STRING",
@@ -512,8 +544,14 @@ func (m *Manager) selectMimeType(mimes []string) string {
}
}
if len(mimes) > 0 {
return mimes[0]
// Skip useless MIME types when falling back
for _, mime := range mimes {
switch mime {
case "application/vnd.portal.filetransfer":
continue
default:
return mime
}
}
return ""
@@ -542,6 +580,62 @@ func (m *Manager) imagePreview(data []byte, format string) string {
return fmt.Sprintf("[[ image %s %s %dx%d ]]", sizeStr(len(data)), imgFmt, config.Width, config.Height)
}
func (m *Manager) uriListPreview(data []byte) (string, bool) {
text := strings.TrimSpace(string(data))
uris := strings.Split(text, "\r\n")
if len(uris) == 0 {
uris = strings.Split(text, "\n")
}
if len(uris) == 1 && strings.HasPrefix(uris[0], "file://") {
filePath := strings.TrimPrefix(uris[0], "file://")
if info, err := os.Stat(filePath); err == nil && !info.IsDir() {
if imgData, err := os.ReadFile(filePath); err == nil {
if config, imgFmt, err := image.DecodeConfig(bytes.NewReader(imgData)); err == nil {
return fmt.Sprintf("[[ file %s %s %dx%d ]]", filepath.Base(filePath), imgFmt, config.Width, config.Height), true
}
}
return fmt.Sprintf("[[ file %s ]]", filepath.Base(filePath)), false
}
}
if len(uris) > 1 {
return fmt.Sprintf("[[ %d files ]]", len(uris)), false
}
return m.textPreview(data), false
}
func (m *Manager) tryReadImageFromURI(data []byte) ([]byte, string, bool) {
text := strings.TrimSpace(string(data))
uris := strings.Split(text, "\r\n")
if len(uris) == 0 {
uris = strings.Split(text, "\n")
}
if len(uris) != 1 || !strings.HasPrefix(uris[0], "file://") {
return nil, "", false
}
filePath := strings.TrimPrefix(uris[0], "file://")
info, err := os.Stat(filePath)
if err != nil || info.IsDir() {
return nil, "", false
}
imgData, err := os.ReadFile(filePath)
if err != nil {
return nil, "", false
}
_, imgFmt, err := image.DecodeConfig(bytes.NewReader(imgData))
if err != nil {
return nil, "", false
}
return imgData, "image/" + imgFmt, true
}
func sizeStr(size int) string {
units := []string{"B", "KiB", "MiB"}
var i int
@@ -730,24 +824,137 @@ func (m *Manager) DeleteEntry(id uint64) error {
return err
}
func (m *Manager) TouchEntry(id uint64) error {
if m.db == nil {
return fmt.Errorf("database not available")
}
entry, err := m.GetEntry(id)
if err != nil {
return err
}
entry.Timestamp = time.Now()
if err := m.storeEntry(*entry); err != nil {
return err
}
m.updateState()
m.notifySubscribers()
return nil
}
func (m *Manager) CreateHistoryEntryFromPinned(pinnedEntry *Entry) error {
if m.db == nil {
return fmt.Errorf("database not available")
}
// Create a new unpinned entry with the same data
newEntry := Entry{
Data: pinnedEntry.Data,
MimeType: pinnedEntry.MimeType,
Size: pinnedEntry.Size,
Timestamp: time.Now(),
IsImage: pinnedEntry.IsImage,
Preview: pinnedEntry.Preview,
Pinned: false,
}
if err := m.storeEntryWithoutDedup(newEntry); err != nil {
return err
}
m.updateState()
m.notifySubscribers()
return nil
}
func (m *Manager) storeEntryWithoutDedup(entry Entry) error {
if m.db == nil {
return fmt.Errorf("database not available")
}
entry.Hash = computeHash(entry.Data)
return m.db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("clipboard"))
id, err := b.NextSequence()
if err != nil {
return err
}
entry.ID = id
encoded, err := encodeEntry(entry)
if err != nil {
return err
}
if err := b.Put(itob(id), encoded); err != nil {
return err
}
return m.trimLengthInTx(b)
})
}
func (m *Manager) ClearHistory() {
if m.db == nil {
return
}
// Delete only non-pinned entries
if err := m.db.Update(func(tx *bolt.Tx) error {
if err := tx.DeleteBucket([]byte("clipboard")); err != nil {
return err
b := tx.Bucket([]byte("clipboard"))
if b == nil {
return nil
}
_, err := tx.CreateBucket([]byte("clipboard"))
return err
var toDelete [][]byte
c := b.Cursor()
for k, v := c.First(); k != nil; k, v = c.Next() {
entry, err := decodeEntry(v)
if err != nil || !entry.Pinned {
toDelete = append(toDelete, k)
}
}
for _, k := range toDelete {
if err := b.Delete(k); err != nil {
return err
}
}
return nil
}); err != nil {
log.Errorf("Failed to clear clipboard history: %v", err)
return
}
if err := m.compactDB(); err != nil {
log.Errorf("Failed to compact database: %v", err)
pinnedCount := 0
if err := m.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("clipboard"))
if b != nil {
c := b.Cursor()
for k, v := c.First(); k != nil; k, v = c.Next() {
entry, _ := decodeEntry(v)
if entry.Pinned {
pinnedCount++
}
}
}
return nil
}); err != nil {
log.Errorf("Failed to count pinned entries: %v", err)
}
if pinnedCount == 0 {
if err := m.compactDB(); err != nil {
log.Errorf("Failed to compact database: %v", err)
}
}
m.updateState()
@@ -760,23 +967,23 @@ func (m *Manager) compactDB() error {
tmpPath := m.dbPath + ".compact"
defer os.Remove(tmpPath)
srcDB, err := bolt.Open(m.dbPath, 0644, &bolt.Options{ReadOnly: true, Timeout: time.Second})
srcDB, err := bolt.Open(m.dbPath, 0o644, &bolt.Options{ReadOnly: true, Timeout: time.Second})
if err != nil {
m.db, _ = bolt.Open(m.dbPath, 0644, &bolt.Options{Timeout: time.Second})
m.db, _ = bolt.Open(m.dbPath, 0o644, &bolt.Options{Timeout: time.Second})
return fmt.Errorf("open source: %w", err)
}
dstDB, err := bolt.Open(tmpPath, 0644, &bolt.Options{Timeout: time.Second})
dstDB, err := bolt.Open(tmpPath, 0o644, &bolt.Options{Timeout: time.Second})
if err != nil {
srcDB.Close()
m.db, _ = bolt.Open(m.dbPath, 0644, &bolt.Options{Timeout: time.Second})
m.db, _ = bolt.Open(m.dbPath, 0o644, &bolt.Options{Timeout: time.Second})
return fmt.Errorf("open destination: %w", err)
}
if err := bolt.Compact(dstDB, srcDB, 0); err != nil {
srcDB.Close()
dstDB.Close()
m.db, _ = bolt.Open(m.dbPath, 0644, &bolt.Options{Timeout: time.Second})
m.db, _ = bolt.Open(m.dbPath, 0o644, &bolt.Options{Timeout: time.Second})
return fmt.Errorf("compact: %w", err)
}
@@ -784,11 +991,11 @@ func (m *Manager) compactDB() error {
dstDB.Close()
if err := os.Rename(tmpPath, m.dbPath); err != nil {
m.db, _ = bolt.Open(m.dbPath, 0644, &bolt.Options{Timeout: time.Second})
m.db, _ = bolt.Open(m.dbPath, 0o644, &bolt.Options{Timeout: time.Second})
return fmt.Errorf("rename: %w", err)
}
m.db, err = bolt.Open(m.dbPath, 0644, &bolt.Options{Timeout: time.Second})
m.db, err = bolt.Open(m.dbPath, 0o644, &bolt.Options{Timeout: time.Second})
if err != nil {
return fmt.Errorf("reopen: %w", err)
}
@@ -835,11 +1042,21 @@ func (m *Manager) SetClipboard(data []byte, mimeType string) error {
}
})
source.SetCancelledHandler(func(e ext_data_control.ExtDataControlSourceV1CancelledEvent) {
m.ownerLock.Lock()
m.isOwner = false
m.ownerLock.Unlock()
})
m.currentSource = source
m.sourceMutex.Lock()
m.sourceMimeTypes = []string{mimeType}
m.sourceMutex.Unlock()
m.ownerLock.Lock()
m.isOwner = true
m.ownerLock.Unlock()
device := m.dataDevice.(*ext_data_control.ExtDataControlDeviceV1)
if err := device.SetSelection(source); err != nil {
log.Errorf("Failed to set selection: %v", err)
@@ -960,6 +1177,10 @@ func (m *Manager) clearOldEntries(days int) error {
if err != nil {
continue
}
// Skip pinned entries
if entry.Pinned {
continue
}
if entry.Timestamp.Before(cutoff) {
toDelete = append(toDelete, k)
}
@@ -1237,6 +1458,8 @@ func (m *Manager) StoreData(data []byte, mimeType string) error {
switch {
case entry.IsImage:
entry.Preview = m.imagePreview(data, mimeType)
case mimeType == "text/uri-list":
entry.Preview, entry.IsImage = m.uriListPreview(data)
default:
entry.Preview = m.textPreview(data)
}
@@ -1250,3 +1473,421 @@ func (m *Manager) StoreData(data []byte, mimeType string) error {
return nil
}
func (m *Manager) PinEntry(id uint64) error {
if m.db == nil {
return fmt.Errorf("database not available")
}
entryToPin, err := m.GetEntry(id)
if err != nil {
return err
}
var hashExists bool
if err := m.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("clipboard"))
if b == nil {
return nil
}
c := b.Cursor()
for k, v := c.First(); k != nil; k, v = c.Next() {
entry, err := decodeEntry(v)
if err != nil || !entry.Pinned {
continue
}
if entry.Hash == entryToPin.Hash {
hashExists = true
return nil
}
}
return nil
}); err != nil {
return err
}
if hashExists {
return nil
}
// Check pinned count
cfg := m.getConfig()
pinnedCount := 0
if err := m.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("clipboard"))
if b == nil {
return nil
}
c := b.Cursor()
for k, v := c.First(); k != nil; k, v = c.Next() {
entry, err := decodeEntry(v)
if err == nil && entry.Pinned {
pinnedCount++
}
}
return nil
}); err != nil {
log.Errorf("Failed to count pinned entries: %v", err)
}
if pinnedCount >= cfg.MaxPinned {
return fmt.Errorf("maximum pinned entries reached (%d)", cfg.MaxPinned)
}
err = m.db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("clipboard"))
v := b.Get(itob(id))
if v == nil {
return fmt.Errorf("entry not found")
}
entry, err := decodeEntry(v)
if err != nil {
return err
}
entry.Pinned = true
encoded, err := encodeEntry(entry)
if err != nil {
return err
}
return b.Put(itob(id), encoded)
})
if err == nil {
m.updateState()
m.notifySubscribers()
}
return err
}
func (m *Manager) UnpinEntry(id uint64) error {
if m.db == nil {
return fmt.Errorf("database not available")
}
err := m.db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("clipboard"))
v := b.Get(itob(id))
if v == nil {
return fmt.Errorf("entry not found")
}
entry, err := decodeEntry(v)
if err != nil {
return err
}
entry.Pinned = false
encoded, err := encodeEntry(entry)
if err != nil {
return err
}
return b.Put(itob(id), encoded)
})
if err == nil {
m.updateState()
m.notifySubscribers()
}
return err
}
func (m *Manager) GetPinnedEntries() []Entry {
if m.db == nil {
return nil
}
var pinned []Entry
if err := m.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("clipboard"))
if b == nil {
return nil
}
c := b.Cursor()
for k, v := c.Last(); k != nil; k, v = c.Prev() {
entry, err := decodeEntry(v)
if err != nil {
continue
}
if entry.Pinned {
entry.Data = nil
pinned = append(pinned, entry)
}
}
return nil
}); err != nil {
log.Errorf("Failed to get pinned entries: %v", err)
}
return pinned
}
func (m *Manager) GetPinnedCount() int {
if m.db == nil {
return 0
}
count := 0
if err := m.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("clipboard"))
if b == nil {
return nil
}
c := b.Cursor()
for k, v := c.First(); k != nil; k, v = c.Next() {
entry, err := decodeEntry(v)
if err == nil && entry.Pinned {
count++
}
}
return nil
}); err != nil {
log.Errorf("Failed to count pinned entries: %v", err)
}
return count
}
func (m *Manager) CopyFile(filePath string) error {
fileInfo, err := os.Stat(filePath)
if err != nil {
return fmt.Errorf("file not found: %w", err)
}
cfg := m.getConfig()
if fileInfo.Size() > cfg.MaxEntrySize {
return fmt.Errorf("file too large: %d > %d", fileInfo.Size(), cfg.MaxEntrySize)
}
fileData, err := os.ReadFile(filePath)
if err != nil {
return fmt.Errorf("read file: %w", err)
}
exportedPath, err := m.ExportFileForFlatpak(filePath)
if err != nil {
exportedPath = filePath
}
fileURI := "file://" + exportedPath
if imgData, imgMime, ok := m.tryReadImageFromURI([]byte("file://" + filePath)); ok {
entry := Entry{
Data: imgData,
MimeType: imgMime,
Size: len(imgData),
Timestamp: time.Now(),
IsImage: true,
Preview: m.imagePreview(imgData, imgMime),
}
if err := m.storeEntry(entry); err != nil {
log.Errorf("Failed to store file entry: %v", err)
}
} else {
entry := Entry{
Data: fileData,
MimeType: "text/uri-list",
Size: len(fileData),
Timestamp: time.Now(),
IsImage: false,
Preview: fmt.Sprintf("[[ file %s ]]", filepath.Base(filePath)),
}
if err := m.storeEntry(entry); err != nil {
log.Errorf("Failed to store file entry: %v", err)
}
}
m.updateState()
m.notifySubscribers()
_, imgMime, imgErr := image.DecodeConfig(bytes.NewReader(fileData))
m.post(func() {
if m.dataControlMgr == nil || m.dataDevice == nil {
log.Error("Data control manager or device not initialized")
return
}
dataMgr := m.dataControlMgr.(*ext_data_control.ExtDataControlManagerV1)
source, err := dataMgr.CreateDataSource()
if err != nil {
log.Errorf("Failed to create data source: %v", err)
return
}
type offer struct {
mime string
data []byte
}
offers := []offer{
{"x-special/gnome-copied-files", []byte("copy\n" + fileURI)},
{"text/uri-list", []byte(fileURI + "\r\n")},
{"text/plain", []byte(filePath)},
}
if imgErr == nil {
imgMimeType := "image/" + imgMime
offers = append(offers, offer{imgMimeType, fileData})
}
offerData := make(map[string][]byte)
for _, o := range offers {
if err := source.Offer(o.mime); err != nil {
log.Errorf("Failed to offer %s: %v", o.mime, err)
return
}
offerData[o.mime] = o.data
}
source.SetSendHandler(func(e ext_data_control.ExtDataControlSourceV1SendEvent) {
fd := e.Fd
defer syscall.Close(fd)
file := os.NewFile(uintptr(fd), "clipboard-pipe")
defer file.Close()
if data, ok := offerData[e.MimeType]; ok {
file.Write(data)
}
})
source.SetCancelledHandler(func(e ext_data_control.ExtDataControlSourceV1CancelledEvent) {
m.ownerLock.Lock()
m.isOwner = false
m.ownerLock.Unlock()
})
m.currentSource = source
m.ownerLock.Lock()
m.isOwner = true
m.ownerLock.Unlock()
device := m.dataDevice.(*ext_data_control.ExtDataControlDeviceV1)
if err := device.SetSelection(source); err != nil {
log.Errorf("Failed to set selection: %v", err)
}
})
return nil
}
func (m *Manager) EntryToFile(entry *Entry) string {
switch {
case entry.MimeType == "text/uri-list":
data := strings.TrimSpace(string(entry.Data))
lines := strings.Split(data, "\n")
if len(lines) == 0 {
return ""
}
uri := strings.TrimSuffix(strings.TrimSpace(lines[0]), "\r")
if path, ok := strings.CutPrefix(uri, "file://"); ok {
return path
}
case entry.IsImage:
ext := ".png"
if suffix, ok := strings.CutPrefix(entry.MimeType, "image/"); ok {
ext = "." + suffix
}
cacheDir, err := os.UserCacheDir()
if err != nil {
return ""
}
clipDir := filepath.Join(cacheDir, "dms", "clipboard")
if err := os.MkdirAll(clipDir, 0o755); err != nil {
return ""
}
filePath := filepath.Join(clipDir, fmt.Sprintf("%d%s", time.Now().UnixNano(), ext))
if os.WriteFile(filePath, entry.Data, 0o644) != nil {
return ""
}
return filePath
}
return ""
}
func (m *Manager) ExportFileForFlatpak(filePath string) (string, error) {
if _, err := os.Stat(filePath); err != nil {
return "", fmt.Errorf("file not found: %w", err)
}
if m.dbusConn == nil {
conn, err := dbus.ConnectSessionBus()
if err != nil {
return "", fmt.Errorf("connect session bus: %w", err)
}
if !conn.SupportsUnixFDs() {
conn.Close()
return "", fmt.Errorf("D-Bus connection does not support Unix FD passing")
}
m.dbusConn = conn
}
file, err := os.Open(filePath)
if err != nil {
return "", fmt.Errorf("open file: %w", err)
}
fd := int(file.Fd())
portal := m.dbusConn.Object("org.freedesktop.portal.Documents", "/org/freedesktop/portal/documents")
var docIds []string
var extra map[string]dbus.Variant
flags := uint32(0)
err = portal.Call(
"org.freedesktop.portal.Documents.AddFull",
0,
[]dbus.UnixFD{dbus.UnixFD(fd)},
flags,
"",
[]string{},
).Store(&docIds, &extra)
file.Close()
if err != nil {
return "", fmt.Errorf("AddFull: %w", err)
}
if len(docIds) == 0 {
return "", fmt.Errorf("no doc IDs returned")
}
docId := docIds[0]
for _, app := range getInstalledFlatpaks() {
_ = portal.Call(
"org.freedesktop.portal.Documents.GrantPermissions",
0,
docId,
app,
[]string{"read"},
).Err
}
uid := os.Getuid()
basename := filepath.Base(filePath)
exportedPath := fmt.Sprintf("/run/user/%d/doc/%s/%s", uid, docId, basename)
return exportedPath, nil
}
func getInstalledFlatpaks() []string {
out, err := exec.Command("flatpak", "list", "--app", "--columns=application").Output()
if err != nil {
return nil
}
var apps []string
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if app := strings.TrimSpace(line); app != "" {
apps = append(apps, app)
}
}
return apps
}

View File

@@ -7,6 +7,7 @@ import (
"sync"
"time"
"github.com/godbus/dbus/v5"
bolt "go.etcd.io/bbolt"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext"
@@ -19,6 +20,7 @@ type Config struct {
AutoClearDays int `json:"autoClearDays"`
ClearAtStartup bool `json:"clearAtStartup"`
Disabled bool `json:"disabled"`
MaxPinned int `json:"maxPinned"`
}
func DefaultConfig() Config {
@@ -27,6 +29,7 @@ func DefaultConfig() Config {
MaxEntrySize: 5 * 1024 * 1024,
AutoClearDays: 0,
ClearAtStartup: false,
MaxPinned: 25,
}
}
@@ -63,7 +66,7 @@ func SaveConfig(cfg Config) error {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
@@ -72,7 +75,7 @@ func SaveConfig(cfg Config) error {
return err
}
return os.WriteFile(path, data, 0644)
return os.WriteFile(path, data, 0o644)
}
type SearchParams struct {
@@ -100,6 +103,7 @@ type Entry struct {
Timestamp time.Time `json:"timestamp"`
IsImage bool `json:"isImage"`
Hash uint64 `json:"hash,omitempty"`
Pinned bool `json:"pinned"`
}
type State struct {
@@ -154,6 +158,8 @@ type Manager struct {
dirty chan struct{}
notifierWg sync.WaitGroup
lastState *State
dbusConn *dbus.Conn
}
func (m *Manager) GetState() State {

View File

@@ -0,0 +1,237 @@
package dbus
import (
"fmt"
"net"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/params"
)
type objectParams struct {
bus string
dest string
path string
iface string
}
func extractObjectParams(p map[string]any, requirePath bool) (objectParams, error) {
bus, err := params.String(p, "bus")
if err != nil {
return objectParams{}, err
}
dest, err := params.String(p, "dest")
if err != nil {
return objectParams{}, err
}
var path string
if requirePath {
path, err = params.String(p, "path")
if err != nil {
return objectParams{}, err
}
} else {
path = params.StringOpt(p, "path", "/")
}
iface, err := params.String(p, "interface")
if err != nil {
return objectParams{}, err
}
return objectParams{bus: bus, dest: dest, path: path, iface: iface}, nil
}
func HandleRequest(conn net.Conn, req models.Request, m *Manager, clientID string) {
switch req.Method {
case "dbus.call":
handleCall(conn, req, m)
case "dbus.getProperty":
handleGetProperty(conn, req, m)
case "dbus.setProperty":
handleSetProperty(conn, req, m)
case "dbus.getAllProperties":
handleGetAllProperties(conn, req, m)
case "dbus.introspect":
handleIntrospect(conn, req, m)
case "dbus.listNames":
handleListNames(conn, req, m)
case "dbus.subscribe":
handleSubscribe(conn, req, m, clientID)
case "dbus.unsubscribe":
handleUnsubscribe(conn, req, m)
default:
models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
}
}
func handleCall(conn net.Conn, req models.Request, m *Manager) {
op, err := extractObjectParams(req.Params, true)
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
method, err := params.String(req.Params, "method")
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
var args []any
if argsRaw, ok := params.Any(req.Params, "args"); ok {
if argsSlice, ok := argsRaw.([]any); ok {
args = argsSlice
}
}
result, err := m.Call(op.bus, op.dest, op.path, op.iface, method, args)
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, result)
}
func handleGetProperty(conn net.Conn, req models.Request, m *Manager) {
op, err := extractObjectParams(req.Params, true)
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
property, err := params.String(req.Params, "property")
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
result, err := m.GetProperty(op.bus, op.dest, op.path, op.iface, property)
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, result)
}
func handleSetProperty(conn net.Conn, req models.Request, m *Manager) {
op, err := extractObjectParams(req.Params, true)
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
property, err := params.String(req.Params, "property")
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
value, ok := params.Any(req.Params, "value")
if !ok {
models.RespondError(conn, req.ID, "missing 'value' parameter")
return
}
if err := m.SetProperty(op.bus, op.dest, op.path, op.iface, property, value); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, models.SuccessResult{Success: true})
}
func handleGetAllProperties(conn net.Conn, req models.Request, m *Manager) {
op, err := extractObjectParams(req.Params, true)
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
result, err := m.GetAllProperties(op.bus, op.dest, op.path, op.iface)
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, result)
}
func handleIntrospect(conn net.Conn, req models.Request, m *Manager) {
bus, err := params.String(req.Params, "bus")
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
dest, err := params.String(req.Params, "dest")
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
path := params.StringOpt(req.Params, "path", "/")
result, err := m.Introspect(bus, dest, path)
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, result)
}
func handleListNames(conn net.Conn, req models.Request, m *Manager) {
bus, err := params.String(req.Params, "bus")
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
result, err := m.ListNames(bus)
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, result)
}
func handleSubscribe(conn net.Conn, req models.Request, m *Manager, clientID string) {
bus, err := params.String(req.Params, "bus")
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
sender := params.StringOpt(req.Params, "sender", "")
path := params.StringOpt(req.Params, "path", "")
iface := params.StringOpt(req.Params, "interface", "")
member := params.StringOpt(req.Params, "member", "")
result, err := m.Subscribe(clientID, bus, sender, path, iface, member)
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, result)
}
func handleUnsubscribe(conn net.Conn, req models.Request, m *Manager) {
subID, err := params.String(req.Params, "subscriptionId")
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
if err := m.Unsubscribe(subID); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, models.SuccessResult{Success: true})
}

View File

@@ -0,0 +1,392 @@
package dbus
import (
"crypto/rand"
"encoding/hex"
"fmt"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/pkg/dbusutil"
"github.com/godbus/dbus/v5"
)
func NewManager() (*Manager, error) {
systemConn, err := dbus.ConnectSystemBus()
if err != nil {
return nil, fmt.Errorf("failed to connect to system bus: %w", err)
}
sessionConn, err := dbus.ConnectSessionBus()
if err != nil {
systemConn.Close()
return nil, fmt.Errorf("failed to connect to session bus: %w", err)
}
m := &Manager{
systemConn: systemConn,
sessionConn: sessionConn,
}
go m.processSystemSignals()
go m.processSessionSignals()
return m, nil
}
func (m *Manager) getConn(bus string) (*dbus.Conn, error) {
switch bus {
case "system":
if m.systemConn == nil {
return nil, fmt.Errorf("system bus not connected")
}
return m.systemConn, nil
case "session":
if m.sessionConn == nil {
return nil, fmt.Errorf("session bus not connected")
}
return m.sessionConn, nil
default:
return nil, fmt.Errorf("invalid bus: %s (must be 'system' or 'session')", bus)
}
}
func (m *Manager) Call(bus, dest, path, iface, method string, args []any) (*CallResult, error) {
conn, err := m.getConn(bus)
if err != nil {
return nil, err
}
obj := conn.Object(dest, dbus.ObjectPath(path))
fullMethod := iface + "." + method
convertedArgs := convertArgs(args)
call := obj.Call(fullMethod, 0, convertedArgs...)
if call.Err != nil {
return nil, fmt.Errorf("dbus call failed: %w", call.Err)
}
return &CallResult{Values: dbusutil.NormalizeSlice(call.Body)}, nil
}
func convertArgs(args []any) []any {
result := make([]any, len(args))
for i, arg := range args {
result[i] = convertArg(arg)
}
return result
}
func convertArg(arg any) any {
switch v := arg.(type) {
case float64:
if v == float64(uint32(v)) && v >= 0 && v <= float64(^uint32(0)) {
return uint32(v)
}
if v == float64(int32(v)) {
return int32(v)
}
return v
case []any:
return convertArgs(v)
case map[string]any:
result := make(map[string]any)
for k, val := range v {
result[k] = convertArg(val)
}
return result
default:
return arg
}
}
func (m *Manager) GetProperty(bus, dest, path, iface, property string) (*PropertyResult, error) {
conn, err := m.getConn(bus)
if err != nil {
return nil, err
}
obj := conn.Object(dest, dbus.ObjectPath(path))
var variant dbus.Variant
err = obj.Call("org.freedesktop.DBus.Properties.Get", 0, iface, property).Store(&variant)
if err != nil {
return nil, fmt.Errorf("failed to get property: %w", err)
}
return &PropertyResult{Value: dbusutil.Normalize(variant.Value())}, nil
}
func (m *Manager) SetProperty(bus, dest, path, iface, property string, value any) error {
conn, err := m.getConn(bus)
if err != nil {
return err
}
obj := conn.Object(dest, dbus.ObjectPath(path))
call := obj.Call("org.freedesktop.DBus.Properties.Set", 0, iface, property, dbus.MakeVariant(value))
if call.Err != nil {
return fmt.Errorf("failed to set property: %w", call.Err)
}
return nil
}
func (m *Manager) GetAllProperties(bus, dest, path, iface string) (map[string]any, error) {
conn, err := m.getConn(bus)
if err != nil {
return nil, err
}
obj := conn.Object(dest, dbus.ObjectPath(path))
var props map[string]dbus.Variant
err = obj.Call("org.freedesktop.DBus.Properties.GetAll", 0, iface).Store(&props)
if err != nil {
return nil, fmt.Errorf("failed to get properties: %w", err)
}
result := make(map[string]any)
for k, v := range props {
result[k] = dbusutil.Normalize(v.Value())
}
return result, nil
}
func (m *Manager) Introspect(bus, dest, path string) (*IntrospectResult, error) {
conn, err := m.getConn(bus)
if err != nil {
return nil, err
}
obj := conn.Object(dest, dbus.ObjectPath(path))
var xml string
err = obj.Call("org.freedesktop.DBus.Introspectable.Introspect", 0).Store(&xml)
if err != nil {
return nil, fmt.Errorf("failed to introspect: %w", err)
}
return &IntrospectResult{XML: xml}, nil
}
func (m *Manager) ListNames(bus string) (*ListNamesResult, error) {
conn, err := m.getConn(bus)
if err != nil {
return nil, err
}
var names []string
err = conn.BusObject().Call("org.freedesktop.DBus.ListNames", 0).Store(&names)
if err != nil {
return nil, fmt.Errorf("failed to list names: %w", err)
}
return &ListNamesResult{Names: names}, nil
}
func (m *Manager) Subscribe(clientID, bus, sender, path, iface, member string) (*SubscribeResult, error) {
conn, err := m.getConn(bus)
if err != nil {
return nil, err
}
subID := generateSubscriptionID()
parts := []string{"type='signal'"}
if sender != "" {
parts = append(parts, fmt.Sprintf("sender='%s'", sender))
}
if path != "" {
parts = append(parts, fmt.Sprintf("path='%s'", path))
}
if iface != "" {
parts = append(parts, fmt.Sprintf("interface='%s'", iface))
}
if member != "" {
parts = append(parts, fmt.Sprintf("member='%s'", member))
}
matchRule := strings.Join(parts, ",")
call := conn.BusObject().Call("org.freedesktop.DBus.AddMatch", 0, matchRule)
if call.Err != nil {
return nil, fmt.Errorf("failed to add match rule: %w", call.Err)
}
sub := &signalSubscription{
Bus: bus,
Sender: sender,
Path: path,
Interface: iface,
Member: member,
ClientID: clientID,
}
m.subscriptions.Store(subID, sub)
log.Debugf("dbus: subscribed %s to %s", subID, matchRule)
return &SubscribeResult{SubscriptionID: subID}, nil
}
func (m *Manager) Unsubscribe(subID string) error {
sub, ok := m.subscriptions.LoadAndDelete(subID)
if !ok {
return fmt.Errorf("subscription not found: %s", subID)
}
conn, err := m.getConn(sub.Bus)
if err != nil {
return err
}
parts := []string{"type='signal'"}
if sub.Sender != "" {
parts = append(parts, fmt.Sprintf("sender='%s'", sub.Sender))
}
if sub.Path != "" {
parts = append(parts, fmt.Sprintf("path='%s'", sub.Path))
}
if sub.Interface != "" {
parts = append(parts, fmt.Sprintf("interface='%s'", sub.Interface))
}
if sub.Member != "" {
parts = append(parts, fmt.Sprintf("member='%s'", sub.Member))
}
matchRule := strings.Join(parts, ",")
call := conn.BusObject().Call("org.freedesktop.DBus.RemoveMatch", 0, matchRule)
if call.Err != nil {
log.Warnf("dbus: failed to remove match rule: %v", call.Err)
}
log.Debugf("dbus: unsubscribed %s", subID)
return nil
}
func (m *Manager) UnsubscribeClient(clientID string) {
var toDelete []string
m.subscriptions.Range(func(subID string, sub *signalSubscription) bool {
if sub.ClientID == clientID {
toDelete = append(toDelete, subID)
}
return true
})
for _, subID := range toDelete {
_ = m.Unsubscribe(subID)
}
}
func (m *Manager) SubscribeSignals(clientID string) chan SignalEvent {
ch := make(chan SignalEvent, 64)
existing, loaded := m.signalSubscribers.LoadOrStore(clientID, ch)
if loaded {
return existing
}
return ch
}
func (m *Manager) UnsubscribeSignals(clientID string) {
if ch, ok := m.signalSubscribers.LoadAndDelete(clientID); ok {
close(ch)
}
m.UnsubscribeClient(clientID)
}
func (m *Manager) processSystemSignals() {
if m.systemConn == nil {
return
}
ch := make(chan *dbus.Signal, 256)
m.systemConn.Signal(ch)
for sig := range ch {
m.dispatchSignal("system", sig)
}
}
func (m *Manager) processSessionSignals() {
if m.sessionConn == nil {
return
}
ch := make(chan *dbus.Signal, 256)
m.sessionConn.Signal(ch)
for sig := range ch {
m.dispatchSignal("session", sig)
}
}
func (m *Manager) dispatchSignal(bus string, sig *dbus.Signal) {
path := string(sig.Path)
iface := ""
member := sig.Name
if idx := strings.LastIndex(sig.Name, "."); idx != -1 {
iface = sig.Name[:idx]
member = sig.Name[idx+1:]
}
m.subscriptions.Range(func(subID string, sub *signalSubscription) bool {
if sub.Bus != bus {
return true
}
if sub.Path != "" && sub.Path != path && !strings.HasPrefix(path, sub.Path) {
return true
}
if sub.Interface != "" && sub.Interface != iface {
return true
}
if sub.Member != "" && sub.Member != member {
return true
}
event := SignalEvent{
SubscriptionID: subID,
Sender: sig.Sender,
Path: path,
Interface: iface,
Member: member,
Body: dbusutil.NormalizeSlice(sig.Body),
}
ch, ok := m.signalSubscribers.Load(sub.ClientID)
if !ok {
return true
}
select {
case ch <- event:
default:
log.Warnf("dbus: channel full for %s, dropping signal", subID)
}
return true
})
}
func (m *Manager) Close() {
m.signalSubscribers.Range(func(clientID string, ch chan SignalEvent) bool {
close(ch)
m.signalSubscribers.Delete(clientID)
return true
})
if m.systemConn != nil {
m.systemConn.Close()
}
if m.sessionConn != nil {
m.sessionConn.Close()
}
}
func generateSubscriptionID() string {
b := make([]byte, 8)
if _, err := rand.Read(b); err != nil {
log.Warnf("dbus: failed to generate random subscription ID: %v", err)
}
return hex.EncodeToString(b)
}

View File

@@ -0,0 +1,52 @@
package dbus
import (
"github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
"github.com/godbus/dbus/v5"
)
type Manager struct {
systemConn *dbus.Conn
sessionConn *dbus.Conn
subscriptions syncmap.Map[string, *signalSubscription]
signalSubscribers syncmap.Map[string, chan SignalEvent]
}
type signalSubscription struct {
Bus string
Sender string
Path string
Interface string
Member string
ClientID string
}
type SignalEvent struct {
SubscriptionID string `json:"subscriptionId"`
Sender string `json:"sender"`
Path string `json:"path"`
Interface string `json:"interface"`
Member string `json:"member"`
Body []any `json:"body"`
}
type CallResult struct {
Values []any `json:"values"`
}
type PropertyResult struct {
Value any `json:"value"`
}
type IntrospectResult struct {
XML string `json:"xml"`
}
type ListNamesResult struct {
Names []string `json:"names"`
}
type SubscribeResult struct {
SubscriptionID string `json:"subscriptionId"`
}

View File

@@ -1,11 +1,9 @@
package freedesktop
import (
"context"
"fmt"
"os/exec"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
"github.com/godbus/dbus/v5"
)
@@ -107,22 +105,8 @@ func (m *Manager) GetUserIconFile(username string) (string, error) {
}
func (m *Manager) SetIconTheme(iconTheme string) error {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
check := exec.CommandContext(ctx, "gsettings", "writable", "org.gnome.desktop.interface", "icon-theme")
if err := check.Run(); err == nil {
cmd := exec.CommandContext(ctx, "gsettings", "set", "org.gnome.desktop.interface", "icon-theme", iconTheme)
if err := cmd.Run(); err != nil {
return fmt.Errorf("gsettings set failed: %w", err)
}
return nil
if err := utils.GsettingsSet("org.gnome.desktop.interface", "icon-theme", iconTheme); err != nil {
return fmt.Errorf("failed to set icon theme: %w", err)
}
checkDconf := exec.CommandContext(ctx, "dconf", "write", "/org/gnome/desktop/interface/icon-theme", fmt.Sprintf("'%s'", iconTheme))
if err := checkDconf.Run(); err != nil {
return fmt.Errorf("both gsettings and dconf unavailable or failed: %w", err)
}
return nil
}

View File

@@ -6,6 +6,7 @@ import (
"os"
"sync"
"github.com/AvengeMedia/DankMaterialShell/core/pkg/dbusutil"
"github.com/godbus/dbus/v5"
)
@@ -110,61 +111,17 @@ func (m *Manager) updateAccountsState() error {
m.stateMutex.Lock()
defer m.stateMutex.Unlock()
if v, ok := props["IconFile"]; ok {
if val, ok := v.Value().(string); ok {
m.state.Accounts.IconFile = val
}
}
if v, ok := props["RealName"]; ok {
if val, ok := v.Value().(string); ok {
m.state.Accounts.RealName = val
}
}
if v, ok := props["UserName"]; ok {
if val, ok := v.Value().(string); ok {
m.state.Accounts.UserName = val
}
}
if v, ok := props["AccountType"]; ok {
if val, ok := v.Value().(int32); ok {
m.state.Accounts.AccountType = val
}
}
if v, ok := props["HomeDirectory"]; ok {
if val, ok := v.Value().(string); ok {
m.state.Accounts.HomeDirectory = val
}
}
if v, ok := props["Shell"]; ok {
if val, ok := v.Value().(string); ok {
m.state.Accounts.Shell = val
}
}
if v, ok := props["Email"]; ok {
if val, ok := v.Value().(string); ok {
m.state.Accounts.Email = val
}
}
if v, ok := props["Language"]; ok {
if val, ok := v.Value().(string); ok {
m.state.Accounts.Language = val
}
}
if v, ok := props["Location"]; ok {
if val, ok := v.Value().(string); ok {
m.state.Accounts.Location = val
}
}
if v, ok := props["Locked"]; ok {
if val, ok := v.Value().(bool); ok {
m.state.Accounts.Locked = val
}
}
if v, ok := props["PasswordMode"]; ok {
if val, ok := v.Value().(int32); ok {
m.state.Accounts.PasswordMode = val
}
}
m.state.Accounts.IconFile = dbusutil.GetOr(props, "IconFile", "")
m.state.Accounts.RealName = dbusutil.GetOr(props, "RealName", "")
m.state.Accounts.UserName = dbusutil.GetOr(props, "UserName", "")
m.state.Accounts.AccountType = dbusutil.GetOr(props, "AccountType", int32(0))
m.state.Accounts.HomeDirectory = dbusutil.GetOr(props, "HomeDirectory", "")
m.state.Accounts.Shell = dbusutil.GetOr(props, "Shell", "")
m.state.Accounts.Email = dbusutil.GetOr(props, "Email", "")
m.state.Accounts.Language = dbusutil.GetOr(props, "Language", "")
m.state.Accounts.Location = dbusutil.GetOr(props, "Location", "")
m.state.Accounts.Locked = dbusutil.GetOr(props, "Locked", false)
m.state.Accounts.PasswordMode = dbusutil.GetOr(props, "PasswordMode", int32(0))
return nil
}
@@ -180,7 +137,7 @@ func (m *Manager) updateSettingsState() error {
return err
}
if colorScheme, ok := variant.Value().(uint32); ok {
if colorScheme, ok := dbusutil.As[uint32](variant); ok {
m.stateMutex.Lock()
m.state.Settings.ColorScheme = colorScheme
m.stateMutex.Unlock()

View File

@@ -7,6 +7,7 @@ import (
"sync"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/pkg/dbusutil"
"github.com/godbus/dbus/v5"
)
@@ -132,37 +133,15 @@ func (m *Manager) updateSessionState() error {
m.stateMutex.Lock()
defer m.stateMutex.Unlock()
if v, ok := props["Active"]; ok {
if val, ok := v.Value().(bool); ok {
m.state.Active = val
}
}
if v, ok := props["IdleHint"]; ok {
if val, ok := v.Value().(bool); ok {
m.state.IdleHint = val
}
}
if v, ok := props["IdleSinceHint"]; ok {
if val, ok := v.Value().(uint64); ok {
m.state.IdleSinceHint = val
}
}
if v, ok := props["LockedHint"]; ok {
if val, ok := v.Value().(bool); ok {
m.state.LockedHint = val
m.state.Locked = val
}
}
if v, ok := props["Type"]; ok {
if val, ok := v.Value().(string); ok {
m.state.SessionType = val
}
}
if v, ok := props["Class"]; ok {
if val, ok := v.Value().(string); ok {
m.state.SessionClass = val
}
m.state.Active = dbusutil.GetOr(props, "Active", m.state.Active)
m.state.IdleHint = dbusutil.GetOr(props, "IdleHint", m.state.IdleHint)
m.state.IdleSinceHint = dbusutil.GetOr(props, "IdleSinceHint", m.state.IdleSinceHint)
if lockedHint, ok := dbusutil.Get[bool](props, "LockedHint"); ok {
m.state.LockedHint = lockedHint
m.state.Locked = lockedHint
}
m.state.SessionType = dbusutil.GetOr(props, "Type", m.state.SessionType)
m.state.SessionClass = dbusutil.GetOr(props, "Class", m.state.SessionClass)
if v, ok := props["User"]; ok {
if userArr, ok := v.Value().([]any); ok && len(userArr) >= 1 {
if uid, ok := userArr[0].(uint32); ok {
@@ -170,36 +149,12 @@ func (m *Manager) updateSessionState() error {
}
}
}
if v, ok := props["Name"]; ok {
if val, ok := v.Value().(string); ok {
m.state.UserName = val
}
}
if v, ok := props["RemoteHost"]; ok {
if val, ok := v.Value().(string); ok {
m.state.RemoteHost = val
}
}
if v, ok := props["Service"]; ok {
if val, ok := v.Value().(string); ok {
m.state.Service = val
}
}
if v, ok := props["TTY"]; ok {
if val, ok := v.Value().(string); ok {
m.state.TTY = val
}
}
if v, ok := props["Display"]; ok {
if val, ok := v.Value().(string); ok {
m.state.Display = val
}
}
if v, ok := props["Remote"]; ok {
if val, ok := v.Value().(bool); ok {
m.state.Remote = val
}
}
m.state.UserName = dbusutil.GetOr(props, "Name", m.state.UserName)
m.state.RemoteHost = dbusutil.GetOr(props, "RemoteHost", m.state.RemoteHost)
m.state.Service = dbusutil.GetOr(props, "Service", m.state.Service)
m.state.TTY = dbusutil.GetOr(props, "TTY", m.state.TTY)
m.state.Display = dbusutil.GetOr(props, "Display", m.state.Display)
m.state.Remote = dbusutil.GetOr(props, "Remote", m.state.Remote)
if v, ok := props["Seat"]; ok {
if seatArr, ok := v.Value().([]any); ok && len(seatArr) >= 1 {
if seatID, ok := seatArr[0].(string); ok {
@@ -207,11 +162,7 @@ func (m *Manager) updateSessionState() error {
}
}
}
if v, ok := props["VTNr"]; ok {
if val, ok := v.Value().(uint32); ok {
m.state.VTNr = val
}
}
m.state.VTNr = dbusutil.GetOr(props, "VTNr", m.state.VTNr)
return nil
}

View File

@@ -3,6 +3,7 @@ package loginctl
import (
"time"
"github.com/AvengeMedia/DankMaterialShell/core/pkg/dbusutil"
"github.com/godbus/dbus/v5"
)
@@ -117,31 +118,28 @@ func (m *Manager) handlePropertiesChanged(sig *dbus.Signal) {
for key, variant := range changes {
switch key {
case "Active":
if val, ok := variant.Value().(bool); ok {
if val, ok := dbusutil.As[bool](variant); ok {
m.stateMutex.Lock()
m.state.Active = val
m.stateMutex.Unlock()
needsUpdate = true
}
case "IdleHint":
if val, ok := variant.Value().(bool); ok {
if val, ok := dbusutil.As[bool](variant); ok {
m.stateMutex.Lock()
m.state.IdleHint = val
m.stateMutex.Unlock()
needsUpdate = true
}
case "IdleSinceHint":
if val, ok := variant.Value().(uint64); ok {
if val, ok := dbusutil.As[uint64](variant); ok {
m.stateMutex.Lock()
m.state.IdleSinceHint = val
m.stateMutex.Unlock()
needsUpdate = true
}
case "LockedHint":
if val, ok := variant.Value().(bool); ok {
if val, ok := dbusutil.As[bool](variant); ok {
m.stateMutex.Lock()
m.state.LockedHint = val
m.state.Locked = val

View File

@@ -150,19 +150,11 @@ func (m *Manager) setConnectionPriority(connType string, autoconnectPriority int
}
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,
"connection.autoconnect-priority", fmt.Sprintf("%d", autoconnectPriority),
"ipv4.route-metric", fmt.Sprintf("%d", routeMetric),
"ipv6.route-metric", fmt.Sprintf("%d", routeMetric)).Run(); err != nil {
log.Warnf("Failed to set ipv6.route-metric for %v: %v", connName, err)
log.Warnf("Failed to set priority for %s: %v", connName, err)
continue
}
log.Infof("Updated %v: autoconnect-priority=%d, route-metric=%d", connName, autoconnectPriority, routeMetric)

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