1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2025-12-06 05:25:41 -05:00

Compare commits

...

535 Commits

Author SHA1 Message Date
github-actions[bot]
89a2b5c00b chore: bump version to v0.5.2 2025-11-15 00:31:06 +00:00
bbedward
929b6dae1a widgets: fix some 0-width issues 2025-11-14 19:26:51 -05:00
Pi Home Server
52fe493da9 Feature/privacy widget - Settings to force icons on (#715)
* Update

* Update

* Update

* Update

* Update

* Set default to false

* Update SettingsData.qml

Set default visibility to false

* privacy widget: fix truncated settings menu

---------

Co-authored-by: bbedward <bbedward@gmail.com>
2025-11-14 19:16:17 -05:00
purian23
3e6be3e762 Greet path updates 2025-11-14 17:54:35 -05:00
purian23
7a8cc449b9 Add local ACL greeter permissions to dms core installer 2025-11-14 16:32:06 -05:00
purian23
8f5a9d6e9f Update dms greeter to scan system & local directories 2025-11-14 15:36:14 -05:00
bbedward
1c5e31fea9 greeter: allow mangowc as compositor 2025-11-14 14:51:28 -05:00
claymorwan
fd08ae18ab feat: plugin layer namespace (#717) 2025-11-14 14:50:29 -05:00
bbedward
a7eb3de06e dankbar: configurable auto-hide delay 2025-11-14 14:00:37 -05:00
bbedward
8902dd7c44 launcher: grid re-style and customizable column counts 2025-11-14 13:54:44 -05:00
bbedward
6387d8400c osd: account for bar position when on bottom 2025-11-14 13:47:26 -05:00
bbedward
597cacb9cc matugen: update gtk4/gtk3-dark colors
- also some change to dankinstall to use niri/xwls from system repos,
  too lazy to split the commits
2025-11-14 13:20:59 -05:00
bbedward
3e285ad9ff dankdash: remove useless tint rectangle
part of #716
2025-11-14 13:09:46 -05:00
bbedward
cc1fa89790 clock: use precision minutes instead of seconds, unless needed
part of #716
2025-11-14 12:42:23 -05:00
bbedward
b0ed007751 core/dankinstall: more deb fixes 2025-11-14 12:22:13 -05:00
bbedward
e1e2650d2b core/dankinstall: fix hyprland util manual compile on debian 2025-11-14 12:13:49 -05:00
bbedward
b23f17b633 core/dankinstall: fix hyprpicker build 2025-11-14 12:07:03 -05:00
github-actions[bot]
818e40b2df nix: update vendorHash for go.mod changes 2025-11-14 17:06:06 +00:00
bbedward
5685e39631 core: improve evdev capslock detection, wayland context fixes 2025-11-14 12:04:47 -05:00
kritag
72534b7674 adding tokyonight, everforest, nord and rose-pine themes (#714)
Co-authored-by: Kristian Tagesen <kristian.tagesen@tietoevry.com>
2025-11-14 11:40:26 -05:00
bbedward
328490d23d powermenu: smarter positioning in control center 2025-11-14 10:45:16 -05:00
bbedward
97a0696930 clock: fix overview clock when seconds is on 2025-11-14 10:29:41 -05:00
bbedward
cb4e0660e0 dock: add reveal IPCs 2025-11-14 10:08:16 -05:00
bbedward
67c642de4c keybinds: add toggleWithPath 2025-11-14 09:03:27 -05:00
bbedward
0d7c2e1024 core/cli: fix keybind provider path override 2025-11-14 08:56:16 -05:00
bbedward
16a779a41b powermenu: restore grid as an option
fixes #712
2025-11-14 08:51:15 -05:00
purian23
c4ca3c8644 Add root dms-cli build script 2025-11-14 00:22:49 -05:00
bbedward
aabcbe34f3 Merge branch 'master' of github.com:AvengeMedia/DankMaterialShell 2025-11-14 00:06:50 -05:00
bbedward
f06626e441 dock: use modded app IDs for grouping logic
fixes #710
2025-11-14 00:06:27 -05:00
purian23
c4e1a71776 Relocate notification tests to scripts dir 2025-11-13 23:53:18 -05:00
bbedward
77e6c16bd2 core/extworkspace: fix some thread-safety issues 2025-11-13 23:52:32 -05:00
purian23
9d1fac3570 Relocate Nix dir under distro/nix 2025-11-13 23:47:00 -05:00
bbedward
b7aeaa7fc5 systemtray: better hide/unhide behavioro 2025-11-13 22:49:30 -05:00
bbedward
f6d8c9ff61 Merge branch 'master' of github.com:AvengeMedia/DankMaterialShell 2025-11-13 22:41:47 -05:00
bbedward
0490794d6c dankbar: add caps lock indicator widget 2025-11-13 22:41:33 -05:00
github-actions[bot]
335c83dd3c nix: update vendorHash for go.mod changes 2025-11-14 03:26:50 +00:00
bbedward
91da720c26 i18n:update translations 2025-11-13 22:25:22 -05:00
bbedward
b6ac744a68 Merge branch 'master' of github.com:AvengeMedia/DankMaterialShell 2025-11-13 22:24:51 -05:00
bbedward
526c4092fd evdev: add evdev monitor for caps lock state 2025-11-13 22:24:27 -05:00
github-actions[bot]
ed06dda384 nix: update vendorHash for go.mod changes 2025-11-14 02:54:15 +00:00
bbedward
6465b11e9b core: ensure all NM tests use mock backend + re-orgs + dep updates 2025-11-13 21:44:03 -05:00
purian23
b2879878a1 feat: Priority pinned items in Control Center 2025-11-13 21:23:54 -05:00
bbedward
3e17b086fb ci: add docs to release archive 2025-11-13 20:19:54 -05:00
purian23
0545e6bcda Remove release tags 2025-11-13 20:01:38 -05:00
purian23
27a907433f Test Copr workflow update 2025-11-13 19:40:16 -05:00
purian23
69616800e3 Release update 2025-11-13 18:54:01 -05:00
github-actions[bot]
abf1f53432 chore: bump version to v0.5.1 2025-11-13 23:45:49 +00:00
bbedward
881c5f75cb ci: ensure version on tag 2025-11-13 18:44:03 -05:00
bbedward
4e45796ade ci: no flake version update 2025-11-13 18:38:47 -05:00
bbedward
1ce4ea5230 ci: update 2025-11-13 18:30:34 -05:00
purian23
f2a2437baa fix Copr dms-greeter 2025-11-13 18:00:30 -05:00
bbedward
508dc9db1e weather: imperial switch not just fahrenheit
fixes #699
2025-11-13 17:41:03 -05:00
bbedward
a914e3557f Merge branch 'master' of github.com:AvengeMedia/DankMaterialShell 2025-11-13 17:31:29 -05:00
bbedward
f489dc062f dankinstall: fix variant passing 2025-11-13 17:31:02 -05:00
purian23
a7e09f4850 Update Copr dms-greeter paths 2025-11-13 17:29:22 -05:00
bbedward
8ea97530d4 matugen: add terminals always dark option 2025-11-13 17:19:37 -05:00
bbedward
13ab54e83a matugen: vscode theme repairs 2025-11-13 17:06:04 -05:00
bbedward
4bc40325cb hyprland: re-add special workspace filtering 2025-11-13 16:56:12 -05:00
bbedward
58d9355ea3 matugen: fix multi vscode themes 2025-11-13 16:51:16 -05:00
bbedward
d46b7528e7 systemtray: new tray detail menu 2025-11-13 16:30:07 -05:00
bbedward
1858597fc9 fix sudo usages 2025-11-13 15:41:41 -05:00
bbedward
83cce5afe4 dankinstall: re-simplify installation 2025-11-13 14:34:42 -05:00
bbedward
201bd8dc1f cli: fix greeter enable, and color sync 2025-11-13 13:21:18 -05:00
bbedward
b62ba69060 dankbar: fix hiding widgets that should not be enabled 2025-11-13 12:55:52 -05:00
bbedward
5d2f5557e5 dwl/mangowc: add layout switcher and viewer widget 2025-11-13 12:44:56 -05:00
bbedward
cf75c1aad0 show a power profile OSD 2025-11-13 10:23:14 -05:00
Saurabh
76a60df88b Feat: wezterm theming support (#705)
* implemented logic for wezterm theming

added matugen configs and dank16 functions, updated matugen worked
scripta

* fixed theme dir

fixed path and moved output location to default wezterm dir
2025-11-13 08:54:47 -05:00
bbedward
9322c79b4e nix: fix greeter path 2025-11-13 08:53:02 -05:00
Lucas
12365edcf0 flake: update to new monorepo structure (#701)
* nix: move alejandra.toml to root

* nix: build using local dms cli

* workflow: update update-vendor-hash to new structure
2025-11-13 00:26:03 -05:00
bbedward
5efc1f9dad powermenu: switch back to a list based style 2025-11-12 23:26:56 -05:00
bbedward
ab976cbb24 popout: add separate variable for layer override
fixes #700
2025-11-12 23:20:04 -05:00
bbedward
db584b7897 rename backend to core 2025-11-12 23:12:31 -05:00
bbedward
0fdc0748cf nix: fix flake 2025-11-12 22:44:17 -05:00
bbedward
2e79c21dc2 fedora: fix spec 2025-11-12 22:24:38 -05:00
bbedward
5490a230bd systemtray: fix menu positioning 2025-11-12 22:21:02 -05:00
bbedward
a6b059b30d don't gitignore Makefile 2025-11-12 22:19:08 -05:00
bbedward
712e6011aa fix contributing ref 2025-11-12 22:14:27 -05:00
bbedward
68f6f87410 disable vendor hash update 2025-11-12 22:06:46 -05:00
bbedward
50cdd68b7b un-gitignore dankinstall 2025-11-12 20:36:50 -05:00
bbedward
e8510b925e meta: monorepo updates 2025-11-12 20:34:58 -05:00
bbedward
24e800501a switch hto monorepo structure 2025-11-12 17:18:45 -05:00
github-actions[bot]
6013c994a6 Update VERSION to v0.5.0 (from DMS) 2025-11-12 22:02:27 +00:00
bbedward
46c90628b9 systemtray: fix visibility when all items hidden 2025-11-12 16:52:14 -05:00
BB
d2d2dac5d1 [LICENSE] Relicense from GPL-3.0 to MIT (#686)
* Change license to MIT

* Add RELICENSE.md tracker

* update license and add change document
2025-11-12 16:33:34 -05:00
bbedward
fd3e7470f4 support for Hyprland workspaces 2025-11-12 16:05:50 -05:00
bbedward
b79e9f72ce Revert "feat: add configurable per-monitor workspace filtering and system tray monitor selection (#163)"
This reverts commit 68157ca636.
2025-11-12 15:52:04 -05:00
bbedward
77eb5dd3bf extws: fix animation 2025-11-12 15:32:43 -05:00
bbedward
b17c14a07b powermenu: make customizable + add dms restart 2025-11-12 15:29:39 -05:00
bbedward
494d90be22 powermenu: support keyboard shortcuts 2025-11-12 12:17:07 -05:00
bbedward
da7e599e65 powermenu: more intuitive layout 2025-11-12 12:08:42 -05:00
bbedward
e3b7360f39 system tray: add a way to hide certain icons 2025-11-12 11:14:41 -05:00
bbedward
367130882d notifs: fix inadvertant transparency 2025-11-12 08:19:11 -05:00
bbedward
d8563ba79d Merge branch 'master' of github.com:AvengeMedia/DankMaterialShell 2025-11-12 00:48:57 -05:00
bbedward
e527453964 powermenu: replace with grid style 2025-11-12 00:48:42 -05:00
purian23
88fe3c5fbd Add shell completions to Copr builds 2025-11-12 00:16:47 -05:00
bbedward
748faf92c1 add modal and notification layer overrides 2025-11-11 23:51:01 -05:00
bbedward
0126aded78 workflow: add shell completions to release artifacts 2025-11-11 22:57:24 -05:00
bbedward
695a75ea09 wayland: add wlr-output-management-unstable-v1 service + labwc info 2025-11-11 17:19:45 -05:00
bbedward
80e690f9fc workspaces: support ext-workspace-v1
- If available
- If not niri, hyprland, sway, or dwl
2025-11-11 16:21:08 -05:00
claymorwan
e8770b90ef feat: more layer namespaces (#693) 2025-11-11 14:33:47 -05:00
bbedward
eec9da42bf danktabbar: fix initial animation + respect animation speed
fixes #687
2025-11-11 13:27:26 -05:00
bbedward
1c8f0d6292 dankbar: keep sticky reveal when tray menu is open 2025-11-11 13:18:19 -05:00
bbedward
b753c8840b widgets: stop inertia with mouse wheel completely 2025-11-11 12:35:13 -05:00
bbedward
95589982a5 meta: more shadows, do not use QT 6.9 RectangularShadow 2025-11-11 12:10:42 -05:00
bbedward
37a10bd453 settings: fix escape key 2025-11-10 17:12:37 -05:00
bbedward
7abc76e92c launcher: tiny spacing fix 2025-11-10 16:55:45 -05:00
bbedward
7aa4467bda notifications: improve keyboard navigation with groups 2025-11-10 16:39:23 -05:00
bbedward
471938adb6 meta: replace rectangles with DankRectangle shapes 2025-11-10 16:16:25 -05:00
bbedward
201a7e3b34 icons: update spotify override 2025-11-10 15:23:44 -05:00
bbedward
11ec3723c3 popout: tweak shadow 2025-11-10 14:39:58 -05:00
bbedward
75eb736856 popout: add a shadow 2025-11-10 14:21:53 -05:00
bbedward
8fea126c20 runningapps: fix tooltip positioning
fixes #682
2025-11-10 13:57:03 -05:00
bbedward
cc02d09c4d dock: track hyprland addresses, fix closing, use ScriptModel 2025-11-10 12:26:14 -05:00
bbedward
af95631a1d modals: more focus fixes 2025-11-10 09:40:28 -05:00
bbedward
7b3d2ab85a settings: try to fix focus loss 2025-11-10 09:28:10 -05:00
bbedward
c52df96af9 brightness: fix persistence of exponent values 2025-11-10 08:58:27 -05:00
bbedward
dee5fa60af dankbar: fix some center position edge cases 2025-11-09 21:29:39 -05:00
bbedward
5e99fdd9c9 dankbar: fix even widget position 2025-11-09 21:10:43 -05:00
purian23
eb01fe757b Update dual widget center 2025-11-09 20:54:51 -05:00
purian23
c52483da2c Update Dankbar center widget positioning 2025-11-09 19:46:21 -05:00
github-actions[bot]
2714c0f4ad Update VERSION to v0.4.3 (from DMS) 2025-11-09 21:41:02 +00:00
bbedward
bba21408ea keybinds/cheasheet: support all providers 2025-11-09 16:26:15 -05:00
bbedward
47c5320d67 lock/greeter: keyboard accessibility improvements 2025-11-09 15:28:21 -05:00
bbedward
b5c49573e5 general little UX consistencies and improvements 2025-11-09 15:13:44 -05:00
bbedward
0197961175 Merge branch 'master' of github.com:bbedward/DankMaterialShell 2025-11-09 13:54:02 -05:00
bbedward
f08b98dcba misc spacing improvements 2025-11-09 13:51:57 -05:00
bbedward
3878998080 hyprland: refresh top levels then debounce sort 2025-11-09 13:00:15 -05:00
Massimo Branchini
5fa117db4c PluginComponent: support for right click not defaulted to poput toggle (#677)
* PluginComponent: support for right click not defaulted to poput toggle

* PluginComponent: right click docs
2025-11-09 12:12:47 -05:00
bbedward
caa085a646 hyprland: use raw events to determine window position updates 2025-11-09 12:11:22 -05:00
bbedward
392a1c03c5 hypr: prevent events with bad data 2025-11-09 11:11:08 -05:00
bbedward
1524d27f4c idle: add option to prevent idle when mpris is playing 2025-11-09 11:01:32 -05:00
bbedward
d309957927 add some null safety checks 2025-11-09 10:35:16 -05:00
bbedward
e0f2c03b91 matugen: fix shell path replacement 2025-11-09 10:29:09 -05:00
claymorwan
1e5848e0d5 fix:notification namespace (#675) 2025-11-09 09:23:23 -05:00
معتز
18bb7dc47b themes/docs: added a gruvbox light/dark json theme file (#674)
Co-authored-by: Motaz Shokry <motaz-dawood@tutamail.com>
2025-11-09 09:22:57 -05:00
bbedward
0ea7de12a5 slideout: animate content not loader 2025-11-08 22:39:01 -05:00
purian23
c8e382e2dd Update Notepad Rendering 2025-11-08 22:23:30 -05:00
purian23
84e19f8565 Update Translations 2025-11-08 22:23:17 -05:00
nebu
f597ea9948 HyprKeybindsModal: add scrollability (#668) 2025-11-08 17:33:35 -05:00
bbedward
d43e1a7cbe assets: update mangowc logo 2025-11-08 16:56:51 -05:00
nebu
8131e713cf HyprKeybindsModal: use Theme.secondary for key color instead of diminished opacity (#667) 2025-11-08 16:45:53 -05:00
bbedward
fefa2bd839 matugen: tweak kcolorscheme 2025-11-08 14:49:31 -05:00
bbedward
cc0984db14 settings: fix updater command key 2025-11-08 12:39:12 -05:00
bbedward
f87609417b Merge branch 'master' of github.com:bbedward/DankMaterialShell 2025-11-08 12:37:50 -05:00
bbedward
8d57b55f94 update readme 2025-11-08 12:04:55 -05:00
bbedward
55776fd7cb plugins: add ColorSetting 2025-11-08 11:19:47 -05:00
github-actions[bot]
3963c98689 Update VERSION to v0.4.2 (from DMS) 2025-11-08 14:29:53 +00:00
bbedward
02c59636fc i18n: add polish 2025-11-08 09:12:58 -05:00
bbedward
989f196894 plugins: fix persistence of some settings 2025-11-08 08:27:57 -05:00
bbedward
9314de4772 compossitor: fix scale check 2025-11-08 08:06:42 -05:00
bbedward
44a6cd88cd Merge branch 'master' of github.com:bbedward/DankMaterialShell 2025-11-08 01:04:57 -05:00
bbedward
d8774c4787 dms: fix missing subs 2025-11-08 01:04:43 -05:00
nebu
a56066bac1 Hyprkeybind: minor fixes (#660)
* HyperKeybindsModal: size to screen

* HyperKeybindsModal: close on Esc
2025-11-08 00:42:09 -05:00
bbedward
8a96f71d10 matugen: remove surface shifting option entirely 2025-11-07 23:52:33 -05:00
bbedward
20a684e8f5 settings: fix time & weather settings
- broke after recent refactor
2025-11-07 23:29:02 -05:00
bbedward
c8fcf50095 ignore compositor scales when QT DPI is overwritten 2025-11-07 20:07:48 -05:00
bbedward
58b637bcca dock: add margin option
fixes #658
2025-11-07 16:37:41 -05:00
Bruno Cesar Rocha
86caf92c90 fix: Matugen relative paths (#656)
* fix: Matugen relative paths

The Problem

All matugen config files (matugen/configs/*.toml) used relative paths like:

input_path = './matugen/templates/gtk-colors.css'

However, matugen was interpreting the relative paths ./matugen/templates/ as relative to its current execution context (which could be /tmp or another directory), not relative to $SHELL_DIR. This caused the "template doesn't exist" warnings and the "Failed to get input and output paths from hashmap" errors.

The Fix

Modified scripts/matugen-worker.sh to replace all relative template paths with absolute paths before passing them to matugen:

`sed "s|input_path = '\./matugen/templates/|input_path = '$SHELL_DIR/matugen/templates/|g"`

* matugen: leave user-templates as-is

---------

Co-authored-by: bbedward <bbedward@gmail.com>
2025-11-07 13:58:19 -05:00
bbedward
35ead280d5 rendering: improve rendering of popouts and modals 2025-11-07 12:35:15 -05:00
bbedward
5d6c3e364d matugen: fix vibrant scheme 2025-11-07 09:50:22 -05:00
Massimo Branchini
f006175829 Small improvement: handled expansion content in case of missing print server or printers (#655) 2025-11-07 08:46:27 -05:00
Rishi Vora
3e0f325734 update flake.lock (#652) 2025-11-07 08:43:01 -05:00
bbedward
f8d383cff0 silent pre-commit hook 2025-11-06 23:19:10 -05:00
purian23
f2ec3ae755 fix: Update fully charged battery logic 2025-11-06 21:23:36 -05:00
bbedward
f95e4e016b fedora: restart on USR1 instead of HUP 2025-11-06 20:27:32 -05:00
bbedward
898e9e67d0 logo: use nerd fonts for some distros 2025-11-06 18:48:09 -05:00
bbedward
15983921b0 dankbar: allow overriding goth radius
fixes #648
2025-11-06 17:20:18 -05:00
bbedward
65c2077e30 meta: add disable hot reload option 2025-11-06 16:03:11 -05:00
Aleksandr Lebedev
946a28d3be Fix: missing system logo and app icons on Guix System (#616)
* Fix for Guix logo not being shown

* Fixed icons not being shown in Workspace Switcher. Also added a DesktopService with a function to get the icon path

* Fixed some icons not being shown + Icons in app drawer

* Fixed icons not appearing in Spotlight

* Adapted missing icons in app launcher/spotlight

* Removed (now) useless change
2025-11-06 12:51:22 -05:00
bbedward
69accb5319 Merge branch 'master' of github.com:bbedward/DankMaterialShell 2025-11-06 12:37:42 -05:00
bbedward
4ddcf4391a i18n: move translation checking to pre-commit hook 2025-11-06 12:37:26 -05:00
Aleksandr Lebedev
a0d886009a Someone forgot to rename function calls (#645) 2025-11-06 12:13:09 -05:00
github-actions[bot]
91c37aaa96 i18n: update translations 2025-11-06 15:26:57 +00:00
Moraxyc Xu
7602247558 nix: restart service on dms update (#636) 2025-11-06 10:26:27 -05:00
github-actions[bot]
d5a4035bef Update VERSION to v0.4.1 (from DMS) 2025-11-06 15:19:12 +00:00
bbedward
d9652c7334 brightness: allow overriding exponent 2025-11-06 09:30:47 -05:00
github-actions[bot]
9b4fd7449b i18n: update translations 2025-11-06 13:11:46 +00:00
Massimo Branchini
bc6b568f7e cups plugin: small fix - change state update (#637)
* change state update

* i18n: update source strings from codebase

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-11-06 08:11:18 -05:00
purian23
c9ee856f91 Remove Systemd pre.targets 2025-11-05 23:36:14 -05:00
github-actions[bot]
2355c898f8 Update VERSION to v0.4.0 (from DMS) 2025-11-06 03:13:08 +00:00
bbedward
87d0aed4ad settings: fix plugin settings not being created 2025-11-05 21:09:14 -05:00
github-actions[bot]
3cbc547e0f i18n: update source strings from codebase 2025-11-05 23:11:26 +00:00
bbedward
e883ebe307 cups: only subscribe to cups, if widget is present 2025-11-05 18:10:54 -05:00
purian23
20d797320a Link greeter docs 2025-11-05 18:10:21 -05:00
github-actions[bot]
bc3e043192 i18n: update translations 2025-11-05 21:16:05 +00:00
github-actions[bot]
c0555aa608 i18n: update source strings from codebase 2025-11-05 21:15:53 +00:00
bbedward
e75b47c21a cups: sync with API changes 2025-11-05 16:15:11 -05:00
Massimo Branchini
3702f493f6 CUPS cc integrated widget and service (#632)
* CUPS cc integrated widget and service

* i18n: update source strings from codebase
2025-11-05 16:04:54 -05:00
github-actions[bot]
80d88d4d8f i18n: update source strings from codebase 2025-11-05 18:57:30 +00:00
claymorwan
95c711ce7e feat: layer namespaces (#635) 2025-11-05 13:56:56 -05:00
bbedward
0ee89920fd cc: fix ccWidgetExpanded signal
fixes #633
2025-11-05 12:26:49 -05:00
bbedward
fd49a171c0 errors: generally, handle errors more gracefully with toasts 2025-11-05 12:22:02 -05:00
bbedward
16ad5221eb matugen: update for new dank16 usage 2025-11-05 10:47:40 -05:00
bbedward
8167c432b8 brightness: rename logarithmic to exponential 2025-11-05 10:18:19 -05:00
bbedward
ae5d6c1ba4 brightness: optimistically update OSDs, works better 2025-11-05 10:00:42 -05:00
bbedward
1e6c80bd03 workspace: tweak animations slightly 2025-11-05 09:32:50 -05:00
bbedward
d48cd1acdd brightness: remember max val 2025-11-05 09:28:00 -05:00
github-actions[bot]
a5f92165fb i18n: update translations 2025-11-05 14:24:02 +00:00
bbedward
d834124a71 ddc use raw values not percent 2025-11-05 09:23:14 -05:00
purian23
ce6f3afb39 Remove conflicting dms-cli 2025-11-05 01:16:07 -05:00
purian23
f5462fa1bf Prep global dms greeter sync 2025-11-05 00:29:38 -05:00
bbedward
f75e23158a cc: fix settings usage 2025-11-04 23:25:33 -05:00
github-actions[bot]
253ff71a0a i18n: update source strings from codebase 2025-11-05 04:00:31 +00:00
bbedward
8d7db49cb0 dankbar: add swap option to dankbar
fixes #556
2025-11-04 22:59:47 -05:00
bbedward
315509f7a4 clock: fix settings key mismap 2025-11-04 22:47:24 -05:00
bbedward
a7bd8b810b matugen: add vscodium theme 2025-11-04 22:33:16 -05:00
bbedward
a7c8ba332b spotlight: fix potential binding loop 2025-11-04 20:38:42 -05:00
bbedward
40cadb6a00 spotlight: shrink slightly 2025-11-04 20:31:01 -05:00
purian23
cbf409dffc Remove rate limiting 2025-11-04 17:52:25 -05:00
bbedward
60a791442e niri: allow using satty as the editor 2025-11-04 17:43:23 -05:00
purian23
528e8bf92e Ensure success, optimize stable spec 2025-11-04 17:27:31 -05:00
github-actions[bot]
8a4243e7f8 i18n: update translations 2025-11-04 22:09:14 +00:00
bbedward
1ac95f0d14 displays: allow choosing logarithmic mode for backlight devices 2025-11-04 17:01:50 -05:00
github-actions[bot]
cd51eb25ce i18n: update translations 2025-11-04 21:30:35 +00:00
purian23
4e64a2b2b2 Silence 2025-11-04 16:30:07 -05:00
github-actions[bot]
672c660c41 i18n: update translations 2025-11-04 21:22:34 +00:00
purian23
2a56e57490 Simplify Copr spec 2025-11-04 16:22:04 -05:00
bbedward
f6efd2363a dankbar: allow lower padding levels 2025-11-04 14:09:12 -05:00
bbedward
fa08b39bb0 niri: add screenshot IPCs with swappy 2025-11-04 13:39:51 -05:00
César Sagaert
81c3110d0d disable auto padding for blurred wallpaper (#628)
fixes the vignette around the blurred image
2025-11-04 13:29:07 -05:00
github-actions[bot]
c01e636421 i18n: update translations 2025-11-04 18:06:30 +00:00
github-actions[bot]
fd8d2961bf i18n: update source strings from codebase 2025-11-04 18:06:21 +00:00
bbedward
9e4b53e20b power: replace hibernate with "suspend behavior" opt 2025-11-04 13:05:48 -05:00
bbedward
20116b3933 settings: refactor for maintainability 2025-11-04 12:58:50 -05:00
purian23
bca5ee0c0d Enable SIGHUP non-systemd restart 2025-11-04 11:12:46 -05:00
BB
331bd69021 enable gh sponsors 2025-11-04 10:47:42 -05:00
bbedward
57b11b7699 plugins: keyboard focus to plugin popouts 2025-11-04 10:38:04 -05:00
bbedward
3e9b11c281 popout: keyboard focus fix 2025-11-04 10:29:16 -05:00
github-actions[bot]
bbfd618626 i18n: update translations 2025-11-04 15:23:48 +00:00
bbedward
00abb839f9 dock: add preventStealing 2025-11-04 10:22:50 -05:00
bbedward
1d639d5f5a weather: fix rain chance 2025-11-04 08:42:19 -05:00
github-actions[bot]
c565fc08c3 i18n: update translations 2025-11-04 13:41:42 +00:00
github-actions[bot]
026c71f9fc i18n: update source strings from codebase 2025-11-04 13:41:32 +00:00
bbedward
1eed499151 meta: consistent transparency for all popups/modals 2025-11-04 08:40:26 -05:00
purian23
21f2aabd58 Merge pull request #623 from avktech78/charge-fix
Fix display of the status of multiple batteries
2025-11-04 00:24:59 -05:00
Oleksandr
e1f06b7139 Fix display of the status of multiple batteries
When there are several batteries, one of them is fully charged, and the other is discharging, this leads to the incorrect display of the overall status as “Charging”
2025-11-04 07:22:26 +02:00
purian23
c4be74bce5 Update copr spec to detect systemd upon upgrade 2025-11-04 00:13:50 -05:00
purian23
7c9e9e1cd9 Update systemd dms service 2025-11-03 23:36:37 -05:00
bbedward
797aabc637 matugen: pass -ghostty to dank16 explicitly 2025-11-03 22:33:49 -05:00
bbedward
630a3d4845 matugen: fix vscode light 2025-11-03 21:56:10 -05:00
github-actions[bot]
3b0bb4ea74 i18n: update translations 2025-11-04 02:29:35 +00:00
github-actions[bot]
e36347a4c3 i18n: update source strings from codebase 2025-11-04 02:29:26 +00:00
bbedward
4f59dfc49c namespace tweaks for blur and layer targets 2025-11-03 21:28:40 -05:00
github-actions[bot]
fc0082a470 i18n: update translations 2025-11-04 02:26:22 +00:00
bbedward
712449674f fix hypr workspace right click 2025-11-03 21:25:44 -05:00
github-actions[bot]
a64b4527f2 Update VERSION to v0.3.4 (from DMS) 2025-11-03 23:35:17 +00:00
github-actions[bot]
71a7ebbfe2 Update VERSION to v0.3.3 (from DMS) 2025-11-03 21:43:00 +00:00
ffoebel
d6d701c722 matugen: missing foot.ini colors section (#620) 2025-11-03 16:40:54 -05:00
bbedward
43fbbc07f5 brightness: fix osd suppression 2025-11-03 16:27:41 -05:00
purian23
8504144c32 Jk - no udev rules here 2025-11-03 16:19:11 -05:00
ffoebel
1d3e59b5dd matugen: pywalfox update via post_hook (#619) 2025-11-03 16:00:26 -05:00
bbedward
3f70ca3506 brightness: dont cap to 1 minimum for non-backlight/ddc 2025-11-03 15:39:49 -05:00
purian23
3640d8bd24 Update Copr udev spec source 2025-11-03 15:34:56 -05:00
bbedward
706a99817f wallpaper: small ui tweak 2025-11-03 15:30:55 -05:00
purian23
4645b2dcab Remove brightnessctl dep from Copr specs 2025-11-03 15:22:12 -05:00
bbedward
13f1673371 toast: handle error overflow better 2025-11-03 15:18:06 -05:00
github-actions[bot]
7d374c4c2a i18n: update source strings from codebase 2025-11-03 20:04:07 +00:00
bbedward
5ed449773c lock: prevent sending lockerReady during unlock 2025-11-03 15:03:31 -05:00
github-actions[bot]
aef9c2269a i18n: update translations 2025-11-03 19:57:43 +00:00
github-actions[bot]
daa5a3e821 i18n: update source strings from codebase 2025-11-03 19:57:32 +00:00
bbedward
5cd1167b28 net: add auto connect option for wifi networks
fixes #597
2025-11-03 14:56:49 -05:00
github-actions[bot]
21e7ae3dfd i18n: update translations 2025-11-03 19:17:48 +00:00
bbedward
5d40138585 display: fix brightness OSD suppression 2025-11-03 14:17:10 -05:00
github-actions[bot]
893fd820a3 i18n: update translations 2025-11-03 18:27:17 +00:00
github-actions[bot]
2d536d99e5 i18n: update source strings from codebase 2025-11-03 18:27:06 +00:00
bbedward
a0ee4792b9 greeter: fix cornerRadius and fillmode sync
fixes #609
2025-11-03 13:26:21 -05:00
bbedward
a8f6880840 wallpaper: improve per-mode wallpaper selection interface
- Separate it out so its clear what you're changing
fixes #611
2025-11-03 12:11:33 -05:00
bbedward
51296d1d44 niri: skip wallpaper transition during mode switches
fixes #612
2025-11-03 12:01:00 -05:00
bbedward
0f29149014 dankbar: fix mousearea position for scrolling workspaces
fixes #610
2025-11-03 11:55:41 -05:00
github-actions[bot]
b9f0c277ec i18n: update source strings from codebase 2025-11-03 15:59:32 +00:00
github-actions[bot]
69964c9704 i18n: update translations 2025-11-03 15:59:12 +00:00
github-actions[bot]
ff1d38e34f i18n: update source strings from codebase 2025-11-03 15:59:02 +00:00
Bruno Cesar Rocha
3abee7f2f5 Consolidate launcher (#615)
* refactor: Consolidate Icon Renderer for launcher

Launcher icons are built on 2 places Spotlight and AppDrawer
This duplicates the maintanance effort, every time something
changes on one place must be replicated on the other.

This commit consolidates the Icon renderer in a shared component.

* refactor: Consolidate Launcher list and grid

List and GRid builders were split in 2 components.

this commit adds separate delegates to be reused as shared components.
2025-11-03 10:58:52 -05:00
bbedward
ed0b80008f brightness: use brightness.decrement/increment/refresh APIs 2025-11-03 10:57:16 -05:00
bbedward
976ff108b3 brightness: remove brightnessctl + ddcutil dependencies
- Switches to DMS API v13+ dependency
2025-11-02 20:22:45 -05:00
purian23
66e3cc77c5 Update handling of Systemd 2025-11-02 19:40:47 -05:00
github-actions[bot]
229abba1e4 i18n: update translations 2025-11-03 00:22:59 +00:00
bbedward
8dacaf84cc matugen: color panel border primary 2025-11-02 19:22:20 -05:00
purian23
102b185572 Check initial plugin status 2025-11-02 14:26:24 -05:00
github-actions[bot]
52ac474f7d i18n: update source strings from codebase 2025-11-02 19:21:00 +00:00
purian23
c0064cfcfa Update Notepad initial services 2025-11-02 14:20:33 -05:00
Aziz Hasanain
414ce5610d Remove wallpaper engine support in favor of plugin (#601)
* Remove wallpaper engine support in favor of plugin

* i18n: update source strings from codebase

* Add migration notification for WallpaperEngine support

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-11-02 13:44:06 -05:00
github-actions[bot]
113ac42814 i18n: update translations 2025-11-02 16:08:29 +00:00
github-actions[bot]
a2f2eef326 i18n: update source strings from codebase 2025-11-02 16:08:20 +00:00
bbedward
54a69a6101 gamma: allow setting high temp 2025-11-02 11:07:47 -05:00
bbedward
5e2d3c8d7d update readme 2025-11-02 10:12:31 -05:00
bbedward
5a9950a7c3 matugen: add foot and alacritty 2025-11-02 10:07:20 -05:00
OpetBrebet
2aadbc1a61 Fix dark spot in disc shader after transition (#604) 2025-11-02 09:35:04 -05:00
bbedward
749414ab65 matugen: vscode update 2025-11-02 08:43:48 -05:00
bbedward
baaebcd413 matugen: add vscode theme, switch to dms dank16 2025-11-02 01:42:48 -04:00
purian23
5a8a60b15d Integrate danksearch in DMS Copr 2025-11-01 23:53:33 -04:00
github-actions[bot]
f0ddb8db49 i18n: update translations 2025-11-02 02:39:36 +00:00
bbedward
baa12c0161 matugen: alt version detection 2025-11-01 22:38:56 -04:00
bbedward
ca226e98c2 add contributing docs section 2025-11-01 13:53:07 -04:00
bbedward
453079ef1f update stock wallpaper 2025-11-01 13:28:10 -04:00
github-actions[bot]
074aea2c35 Update VERSION to v0.3.2 (from DMS) 2025-11-01 17:12:54 +00:00
bbedward
9cf5f0b9b3 readme update 2025-11-01 12:30:35 -04:00
bbedward
89e12eea29 readme updoot 2025-11-01 12:26:11 -04:00
bbedward
03d4caff8f matugen: validation 2025-11-01 12:04:59 -04:00
bbedward
89d54dedb7 matugen: more flexible checking 2025-11-01 11:49:12 -04:00
bbedward
9a9e62ccd3 matugen: support newer json format 2025-11-01 11:40:18 -04:00
Massimo Branchini
eca38ae920 base activation for cups capability (#591)
* base activation for cups capability

* i18n: update source strings from codebase

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-11-01 10:55:27 -04:00
bbedward
84e89599bf widgets: fix right click handling 2025-11-01 10:39:25 -04:00
bbedward
e1cdf4ed50 keyboard/hyprland: sync keyboard layout with event 2025-11-01 10:35:22 -04:00
github-actions[bot]
e4371ea4fc i18n: update source strings from codebase 2025-11-01 14:23:09 +00:00
bbedward
9c45d13cbf lock/greeter: don't elide password field
fixes #593
2025-11-01 10:22:27 -04:00
bbedward
5f22347d7a audio: re-create players on default device change 2025-11-01 10:14:30 -04:00
Moraxyc Xu
ca786a3567 nix: replace pkgs.system with pkgs.stdenv.hostPlatform.system (#599) 2025-11-01 09:39:03 -04:00
purian23
60ce662d49 Update Copr path directories 2025-10-31 21:31:45 -04:00
github-actions[bot]
4f9b0d8925 i18n: update translations 2025-11-01 01:09:17 +00:00
purian23
9c2fc570e6 feat: Add Fedora Copr Systemd Support
- Updated distro filestructure
2025-10-31 21:08:47 -04:00
github-actions[bot]
0ba982b271 i18n: update source strings from codebase 2025-10-31 21:10:22 +00:00
Bruno Cesar Rocha
ff3123e387 feat: allow Launcher plugins to set unicode icons. (#594)
Launcher plugins can now set `icon: "unicode:🍉"`
and the symbol is used as the icon.
2025-10-31 17:10:00 -04:00
github-actions[bot]
1548286083 Update VERSION to v0.3.1 (from DMS) 2025-10-31 20:56:24 +00:00
github-actions[bot]
c018d953b8 i18n: update source strings from codebase 2025-10-31 20:32:22 +00:00
bbedward
cf66d28774 about tab: replace ansi art with logo 2025-10-31 16:31:35 -04:00
bbedward
9cec6fd212 update readme 2025-10-31 13:37:42 -04:00
bbedward
92926331b5 layers: up texture quality 2025-10-31 12:06:02 -04:00
github-actions[bot]
f9932ea222 i18n: update translations 2025-10-31 15:51:55 +00:00
github-actions[bot]
a65d6b7630 i18n: update source strings from codebase 2025-10-31 15:51:46 +00:00
bbedward
7252d1e4d7 polkit: simplify service usage 2025-10-31 11:51:11 -04:00
bbedward
3b5a951431 confirm modal: spacing adjustment 2025-10-31 10:08:18 -04:00
bbedward
0b1c331705 power: resize confirmation modals 2025-10-31 09:58:20 -04:00
github-actions[bot]
3c354b71f5 i18n: update translations 2025-10-31 13:40:55 +00:00
github-actions[bot]
1eb5f381ae i18n: update source strings from codebase 2025-10-31 13:40:44 +00:00
bbedward
c5efd28781 polkit: support for polkit escalation prompts 2025-10-31 09:40:05 -04:00
github-actions[bot]
53ae8ac917 i18n: update translations 2025-10-31 04:00:52 +00:00
bbedward
505b6368e6 settings: wrap sidebar in flickable
fixes #581
2025-10-31 00:00:01 -04:00
bbedward
3c20e9e203 dankdash: show mangowc/sway when on one 2025-10-30 22:07:19 -04:00
github-actions[bot]
43427461f5 i18n: update translations 2025-10-31 01:59:35 +00:00
github-actions[bot]
d7740ff6d2 i18n: update source strings from codebase 2025-10-31 01:59:24 +00:00
bbedward
1fb4eb33aa dwl: don't always show tag 1 2025-10-30 21:58:26 -04:00
github-actions[bot]
b27f362b44 Update VERSION to v0.3.0 (from DMS) 2025-10-30 18:11:33 +00:00
bbedward
325e3bc19b fix duplicated qt6ct sections 2025-10-30 13:53:47 -04:00
bbedward
9215985335 ci: try and fix changelog filter 2025-10-30 13:45:39 -04:00
Mattias
293179daa6 fix: Enable "Show on Last Display" for Notepad Slideout and System Tray (#590) 2025-10-30 13:38:57 -04:00
github-actions[bot]
4fe79dbe85 i18n: update source strings from codebase 2025-10-30 17:14:49 +00:00
bbedward
55d738e917 about: fix links 2025-10-30 13:13:29 -04:00
github-actions[bot]
986b07f4a9 i18n: update translations 2025-10-30 17:04:57 +00:00
github-actions[bot]
450c2e91ed i18n: update source strings from codebase 2025-10-30 17:04:47 +00:00
bbedward
4d06333624 about page: update for mango and sway 2025-10-30 13:04:10 -04:00
Tulip Blossom
fbe4122404 fix(dms-greeter,rpm): greeter user is supplied by sysusers and having manual user on the spec breaks it (#585)
* fix(dms-greeter,rpm): greeter user is supplied by sysusers and having manual user on the spec breaks it

This makes it so this RPM works fine on fedora 43, the greeter user
should be created and configured by systemd sysusers anyways

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

* fix(dms-greeter): use systemd-tmpfiles to set up greeter directories

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

* fix(rpm, dms-greeter): require systemd for tmpfiles macro

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

---------

Signed-off-by: Tulip Blossom <tulilirockz@outlook.com>
2025-10-30 10:54:37 -04:00
bbedward
baf9b5e6f3 dwl: dont show empty tags 2025-10-30 10:50:35 -04:00
bbedward
c88fc20701 vpn: fix persistence
fixes #587
2025-10-30 09:33:50 -04:00
github-actions[bot]
b1078d6c73 i18n: update translations 2025-10-30 13:22:02 +00:00
bbedward
5033d10246 dwl: remove wlr-randr dependency 2025-10-30 09:21:20 -04:00
bbedward
986993a890 Merge branch 'master' of github.com:bbedward/DankMaterialShell 2025-10-29 23:53:43 -04:00
bbedward
19b13a1e81 dwl: tag changes 2025-10-29 23:45:40 -04:00
bbedward
76637fab33 dwl: hide empty tags by default 2025-10-29 23:45:40 -04:00
github-actions[bot]
0a79d9a187 i18n: update translations 2025-10-30 03:39:57 +00:00
github-actions[bot]
36b3b3c7ae i18n: update source strings from codebase 2025-10-30 03:39:47 +00:00
bbedward
8caeca0c08 dwl: tag changes 2025-10-29 23:39:12 -04:00
bbedward
1c323f54ee dwl: hide empty tags by default 2025-10-29 23:07:15 -04:00
bbedward
7ed0b752a8 hyprland: some targeted improvements 2025-10-29 17:23:38 -04:00
bbedward
0569906f7c network: strip down legacy network service 2025-10-29 17:07:19 -04:00
bbedward
2a7cf187ad keyboard layout: remove polling on hyprland 2025-10-29 16:58:07 -04:00
github-actions[bot]
cc5b98a5d2 i18n: update source strings from codebase 2025-10-29 19:42:59 +00:00
bbedward
1478c92f49 matugen: fix wallpaperengine color generation 2025-10-29 15:41:10 -04:00
github-actions[bot]
e1785a1738 i18n: update translations 2025-10-29 19:10:16 +00:00
github-actions[bot]
44ebd2918c i18n: update source strings from codebase 2025-10-29 19:10:05 +00:00
bbedward
c87fa0de5e sway: add support for sway 2025-10-29 15:08:11 -04:00
bbedward
7b26692c8e dwl: support display scales 2025-10-29 13:45:22 -04:00
bbedward
b294e391e7 settings: don't overflow screen dimensions 2025-10-29 13:34:11 -04:00
bbedward
85f8e362e6 pam: try to avoid racey unlock states 2025-10-29 12:59:35 -04:00
github-actions[bot]
d68a6a1056 i18n: update translations 2025-10-29 16:42:00 +00:00
github-actions[bot]
3dae9c0639 i18n: update source strings from codebase 2025-10-29 16:41:52 +00:00
bbedward
aede6b064a dwl: add dwl/MangoWC support
- Requires dms api v12
- Tags/Workspace support
- MangoWC launcher logo
- dpms off/on support
- logout support
2025-10-29 12:39:31 -04:00
bbedward
76b168020c dash: fix IPC positioning 2025-10-29 10:42:39 -04:00
bbedward
5e36b1454a wallpaper: transition blurred wallpaper layer fixes #579 2025-10-29 09:26:03 -04:00
github-actions[bot]
bd35fbac4d i18n: update source strings from codebase 2025-10-29 13:19:15 +00:00
bbedward
e081ec19cc dankbar: cooldown timer on scrolling workspaces 2025-10-29 09:18:46 -04:00
github-actions[bot]
d870d8bad6 i18n: update translations 2025-10-29 13:12:54 +00:00
bbedward
20fd13c836 dankbar: scroll wheels to cycle apps and workspaces 2025-10-29 09:12:10 -04:00
github-actions[bot]
59f98b151d i18n: update source strings from codebase 2025-10-28 20:40:45 +00:00
bbedward
4ac1990c12 systray: fix icon fallback 2025-10-28 16:40:13 -04:00
bbedward
0a5105cc62 niri: simple blur rule 2025-10-28 12:49:49 -04:00
bbedward
a9f8b835ee notepad: use a mask over content area 2025-10-28 12:08:18 -04:00
bbedward
0109bd5bda Merge branch 'master' of github.com:bbedward/DankMaterialShell 2025-10-28 11:37:50 -04:00
bbedward
01dad64c6d notepad: fix mousearea width
fixes #569
2025-10-28 11:37:24 -04:00
bbedward
ee38f57f6d filebrowser: use NF icons 2025-10-28 11:36:18 -04:00
github-actions[bot]
6b163dcb5f Update VERSION to v0.2.5 (from DMS) 2025-10-28 15:29:32 +00:00
github-actions[bot]
baadbbc65a i18n: update source strings from codebase 2025-10-28 15:21:25 +00:00
bbedward
13a2813db9 calendar: parse event links from description 2025-10-28 11:20:42 -04:00
bbedward
cfa7d12dd3 matugen: auto re-load kitty config on color changes 2025-10-28 11:01:26 -04:00
bbedward
8bf23d820f sounds: fix search path to include generic data location
fixes #566
2025-10-28 10:42:01 -04:00
github-actions[bot]
3c7e903ace i18n: update source strings from codebase 2025-10-28 14:20:34 +00:00
bbedward
ee0e3aece9 color: hide picker instantly when spawning hyprpicker
fixes #575
2025-10-28 10:19:56 -04:00
bbedward
d7efd1b285 wallpaper: fix auto cycling initialization 2025-10-28 09:42:50 -04:00
github-actions[bot]
34f7a7ab18 i18n: update translations 2025-10-28 13:32:30 +00:00
bbedward
695eb0a401 i18n: add chinese traiditonal 2025-10-28 09:31:45 -04:00
github-actions[bot]
0d44b95a40 i18n: update translations 2025-10-28 12:55:57 +00:00
bbedward
116c421492 net: don't force singleActive on VPNs
fixes #574
2025-10-28 08:54:56 -04:00
github-actions[bot]
53507ef56b i18n: update translations 2025-10-28 00:21:15 +00:00
bbedward
3c049e031f niri: add workspace assignment helper 2025-10-27 20:20:34 -04:00
bbedward
b6688adb35 niri: more blur on overview 2025-10-27 17:54:53 -04:00
bbedward
b46fe28c05 niri: generate wpblur.kdl 2025-10-27 17:21:26 -04:00
bbedward
e7debdcf46 weather: switch to ip-api for auto location 2025-10-27 16:14:07 -04:00
bbedward
2c2930e876 proc: timeout to CLI helper 2025-10-27 16:13:10 -04:00
github-actions[bot]
ca294fc049 i18n: update translations 2025-10-27 19:38:34 +00:00
github-actions[bot]
86d1a40299 i18n: update source strings from codebase 2025-10-27 19:38:27 +00:00
bbedward
7a3884a633 wallpaper: fix per-monitor dankdash tab + allow matugen override
per-monitor

fixes #561
2025-10-27 15:36:07 -04:00
bbedward
7e5c6581c9 dsearch: rewrite to use CLI instead of api 2025-10-27 15:13:29 -04:00
github-actions[bot]
f17bbbd689 i18n: update source strings from codebase 2025-10-27 18:36:38 +00:00
bbedward
24b046e9d7 dankbar: fix app icon scaling fixes #568 2025-10-27 14:36:15 -04:00
github-actions[bot]
48a7d24c11 i18n: update source strings from codebase 2025-10-27 18:18:05 +00:00
bbedward
033f96a4b0 vpn: various state management fixes 2025-10-27 14:17:29 -04:00
bbedward
f0a1cb6525 icon: fix nerd font path 2025-10-27 11:44:17 -04:00
bbedward
db5782783b files: fix more icon map 2025-10-27 11:35:18 -04:00
bbedward
29022e260d file search: fix more icon mappings 2025-10-27 11:32:07 -04:00
bbedward
1e1f58d3ed fix archive map 2025-10-27 11:29:36 -04:00
github-actions[bot]
12389e2856 i18n: update source strings from codebase 2025-10-27 15:27:51 +00:00
bbedward
cde7427449 file search: add a bunch of nerd icon mappings 2025-10-27 11:27:15 -04:00
github-actions[bot]
42e7cb7b5f i18n: update translations 2025-10-27 15:00:04 +00:00
github-actions[bot]
d7992bc1f7 i18n: update source strings from codebase 2025-10-27 14:59:58 +00:00
bbedward
61c8549401 audio: strip file:// prefix for local dir sounds 2025-10-27 10:59:07 -04:00
bbedward
a284dcf61d i18n: add italian 2025-10-27 10:57:48 -04:00
bbedward
2e462b0899 spotlight: fix file search keyboard navi woes 2025-10-27 10:57:12 -04:00
bbedward
b79c66d59a plugins: fix listview scroll issues
fixes #564
2025-10-27 10:53:29 -04:00
Bruno Cesar Rocha
2f2020e7e2 fix: Load launcher plugin no-trigger settings. (#567)
getPluginTrigger() function only loaded the trigger setting but completely ignored the
noTrigger boolean setting.

When noTrigger was enabled and saved as:
- noTrigger: true
- trigger: ""

On reboot, the function would load trigger which was "", but since empty string is falsy
in the fallback expression, it would revert to plugin.trigger || "!" from the
plugin.json manifest, which is "=" for the Calculator plugin.
2025-10-27 09:55:15 -04:00
github-actions[bot]
b7e99c0d2b i18n: update translations 2025-10-27 13:50:04 +00:00
github-actions[bot]
2648848898 i18n: update source strings from codebase 2025-10-27 13:49:59 +00:00
bbedward
79b23ca829 spotlight: danksearch integration (indexed file search) 2025-10-27 09:49:17 -04:00
bbedward
0ac5b7bc87 workspaces: add desktop entries binding 2025-10-26 22:35:45 -04:00
bbedward
1d211e8474 dock: remove un-used mousearea 2025-10-26 22:32:33 -04:00
github-actions[bot]
1981a83e82 i18n: update translations 2025-10-26 14:19:04 +00:00
Massimo Branchini
cac071e7af filebrowser: use xdg paths (#555) 2025-10-26 10:18:28 -04:00
github-actions[bot]
c6efccd61c i18n: update source strings from codebase 2025-10-26 13:07:38 +00:00
ahoyiski
a90b00e5fe Added a Compact mode to the keyboard_layout_name widget. (#554)
* Added a Compact mode to the keyboard_layout_name widget.

* Added another root.currentLayout = "Unknown" that was missing.
2025-10-26 09:07:11 -04:00
github-actions[bot]
7863d03282 i18n: update source strings from codebase 2025-10-26 03:31:46 +00:00
bbedward
968606d781 filebrowser: improved file browser 2025-10-25 23:30:33 -04:00
bbedward
f7e8de2556 fix section 2025-10-25 18:39:29 -04:00
bbedward
17a8edc1ae greetd: disable hypr logo 2025-10-25 18:39:06 -04:00
github-actions[bot]
30dc63c801 Update VERSION to v0.2.4 (from DMS) 2025-10-25 22:33:32 +00:00
bbedward
8db7b8419a remove font check 2025-10-25 18:30:24 -04:00
bbedward
8c626b20e1 dankbar: fix center section spacers 2025-10-25 18:21:22 -04:00
github-actions[bot]
a8929c8046 i18n: update translations 2025-10-25 21:58:03 +00:00
bbedward
f8e4b5e958 remove font deps from copr 2025-10-25 17:57:20 -04:00
bbedward
58cae24157 fonts: bundle Inter + FiraCode Nerd
- remove all font dependencies
2025-10-25 17:53:08 -04:00
bbedward
bb4f5f37cc Merge branch 'master' of github.com:bbedward/DankMaterialShell 2025-10-25 16:16:16 -04:00
bbedward
237333941a font: bundle in material symbols rounded 2025-10-25 16:15:15 -04:00
github-actions[bot]
e1406875aa Update VERSION to v0.2.3 (from DMS) 2025-10-25 18:56:06 +00:00
github-actions[bot]
6ab96898a3 i18n: update translations 2025-10-25 18:35:07 +00:00
github-actions[bot]
7973b2f3da i18n: update source strings from codebase 2025-10-25 18:35:00 +00:00
bbedward
90284625af lock/greetd: fix 12-hour time 2025-10-25 14:34:25 -04:00
bbedward
6b3442512a net: only show eth/wifi on networkmanager backend 2025-10-25 14:23:12 -04:00
github-actions[bot]
f9208136af i18n: update source strings from codebase 2025-10-25 18:03:54 +00:00
bbedward
9895fbde0d dock: dynamic indicator positions 2025-10-25 14:03:20 -04:00
bbedward
30370e4985 lock/greeter: fixed clock widths 2025-10-25 14:00:02 -04:00
github-actions[bot]
7a45f370b5 i18n: update source strings from codebase 2025-10-25 16:43:28 +00:00
Massimo Branchini
38068eeaac better checks before saying SUCCESS (#550) 2025-10-25 12:43:09 -04:00
bbedward
62df30ed6c apps: fix sorting and reactivity 2025-10-25 12:42:54 -04:00
bbedward
7e75c9e510 dankbar: fix widget hover effects 2025-10-25 12:42:54 -04:00
github-actions[bot]
42f9edf566 i18n: update translations 2025-10-25 15:48:30 +00:00
github-actions[bot]
c72b6144a5 i18n: update source strings from codebase 2025-10-25 15:48:23 +00:00
bbedward
032777e32e net: switch to native VPN backend 2025-10-25 11:47:46 -04:00
Jack Grahn
607b5320fd Add clipboard history command to niri spawn-at-startup options (#545) 2025-10-25 10:08:48 -04:00
Massimo Branchini
959766b265 external system update trigger (#546) 2025-10-25 10:07:56 -04:00
github-actions[bot]
9774991b56 i18n: update translations 2025-10-25 06:01:33 +00:00
github-actions[bot]
e1587995d0 i18n: update source strings from codebase 2025-10-25 06:01:26 +00:00
bbedward
08e6e22046 net: lose fail tracking 2025-10-25 02:00:53 -04:00
github-actions[bot]
454d8bdc88 i18n: update source strings from codebase 2025-10-25 03:59:51 +00:00
bbedward
d0ae7431eb net: updates to accomodate iwd + other backends 2025-10-24 23:59:23 -04:00
purian23
d88dc17b21 Format spec 2025-10-24 22:28:27 -04:00
purian23
705f569571 Update dms-greeter colors path 2025-10-24 19:01:58 -04:00
purian23
abc7badfa9 Print the final message 2025-10-24 18:44:28 -04:00
purian23
e8c2469227 Simplify upgrade message 2025-10-24 18:37:30 -04:00
purian23
1e5e8cd246 Fix scope 2025-10-24 18:26:26 -04:00
purian23
adc81cfb95 Moar Copr DMS restart logic 2025-10-24 18:18:21 -04:00
github-actions[bot]
e175fa64cb i18n: update translations 2025-10-24 21:21:52 +00:00
bbedward
f9994d0e42 add turkish 2025-10-24 17:21:15 -04:00
github-actions[bot]
3e5d1c514a i18n: update translations 2025-10-24 21:17:00 +00:00
purian23
6310394034 Add 'dms restart' to Copr upgrades 2025-10-24 17:16:29 -04:00
purian23
f32596053b Update Copr default dir to usr/share 2025-10-24 13:27:48 -04:00
bbedward
cf4a6969d3 plugins: fix set ToggleSetting not saving
fixes #541
2025-10-24 13:12:57 -04:00
github-actions[bot]
0918412916 i18n: update translations 2025-10-24 16:38:47 +00:00
github-actions[bot]
41b1718587 i18n: update source strings from codebase 2025-10-24 16:38:43 +00:00
bbedward
ca2acbc704 niri: ability to blur wallpaper on overview + add a separate layer for
blurred wallpapers
2025-10-24 12:37:57 -04:00
bbedward
1abd3ef8b1 greeter: search /usr/share path for qml files 2025-10-24 09:10:38 -04:00
bbedward
cedba3770c clock: baseline text relative to date
fixes #535
2025-10-24 08:47:09 -04:00
bbedward
f733be1fd1 lock/greeter: seconds precision on clock
fixes #540
2025-10-24 08:38:41 -04:00
github-actions[bot]
01e02232d7 i18n: update source strings from codebase 2025-10-24 03:16:41 +00:00
bbedward
771920b38b dankbar: fix focusedapp & media text clipping
fixes #537
2025-10-23 23:15:53 -04:00
bbedward
0f55bbc148 dankbar: remove hardcoded font weights
fixes #539
2025-10-23 23:11:20 -04:00
bbedward
ab4e9646ad workspace: don't wrap on mousewheel
fixes #538
2025-10-23 23:04:17 -04:00
bbedward
884b73599a dd: add file name to wallpaper tab 2025-10-23 22:56:53 -04:00
bbedward
492c0e7ef7 dankbar: fix separator 2025-10-23 22:44:49 -04:00
purian23
0865ae000b Remove Notepad indicator 2025-10-23 22:13:53 -04:00
purian23
049c9b44e4 Add req accountsservice to Copr 2025-10-23 19:04:49 -04:00
github-actions[bot]
199edd3771 i18n: update translations 2025-10-23 22:17:51 +00:00
bbedward
8806217d25 gh: use workflow_run trigger for copr 2025-10-23 18:17:09 -04:00
github-actions[bot]
cc1588debd Update VERSION to v0.2.2 (from DMS) 2025-10-23 22:11:22 +00:00
github-actions[bot]
d2ba4b32fe i18n: update translations 2025-10-23 20:53:46 +00:00
github-actions[bot]
b3d5054966 i18n: update source strings from codebase 2025-10-23 20:53:42 +00:00
bbedward
57a921425c settings: about About page 2025-10-23 16:53:00 -04:00
github-actions[bot]
061aaeb933 i18n: update source strings from codebase 2025-10-23 20:14:00 +00:00
bbedward
0c7af9c740 meta: log level re-work 2025-10-23 16:13:27 -04:00
bbedward
d5c4b990dc accessibility: widen click targets to bar edge 2025-10-23 15:51:59 -04:00
Aleksandr Lebedev
a650a79dfc Fixed bugs in Workspace Switcher (#534)
* Fixed bugs with workspace switcher:

- fixed bug that, when moving existing windows/moving focus from one
window to another, information about positions and active windows on
workspace switcher was not updated
- fixed bug that hovering/clicking on app icons didn't work, because
of missplaced MouseArea

* Added comment
2025-10-23 14:46:29 -04:00
github-actions[bot]
7ac6e94348 i18n: update translations 2025-10-23 18:31:58 +00:00
github-actions[bot]
b4abdf3d51 i18n: update source strings from codebase 2025-10-23 18:31:54 +00:00
bbedward
b59b87d84e bluetooth+plugins: some repairs for bad references and dialogs plugins: switch to ID-based references 2025-10-23 14:31:12 -04:00
github-actions[bot]
799ae1a20e i18n: update source strings from codebase 2025-10-23 16:58:27 +00:00
bbedward
1e58e69c59 fix dms subscription 2025-10-23 12:57:50 -04:00
github-actions[bot]
c667bab5ca i18n: update source strings from codebase 2025-10-23 16:25:01 +00:00
bbedward
2a744fb174 battery: hide secondary text on no battery 2025-10-23 12:24:20 -04:00
bbedward
a9744a0cad readme: document accountsservice dependency 2025-10-23 12:15:13 -04:00
github-actions[bot]
0aab22f242 i18n: update translations 2025-10-23 15:55:55 +00:00
github-actions[bot]
b0f65225a9 i18n: update source strings from codebase 2025-10-23 15:55:50 +00:00
bbedward
1311da7258 bluetooth: integrate with DMS API v9 - Supports proper pairing with an agent & pin, passcode, etc. 2025-10-23 11:55:07 -04:00
bbedward
61d68b1f76 Merge branch 'master' of github.com:bbedward/DankMaterialShell 2025-10-23 11:54:56 -04:00
github-actions[bot]
5dd1769536 i18n: update translations 2025-10-23 15:19:35 +00:00
Zsolt Donca
a45a9bda9e Fixed path to dms-colors.json in Modules/Greetd/README.md (#532)
Fixed the path to dms-colors.json, as the previous path did not actually exist, resulting in a broken symbolic link and the colors not actually being right in the login screen.
2025-10-23 11:19:02 -04:00
bbedward
4e43c797e2 niri: improve toplevel sorting 2025-10-23 10:33:53 -04:00
github-actions[bot]
beab1a7b01 i18n: update translations 2025-10-23 13:31:18 +00:00
bbedward
85c00a9c4e dankbar: prevent double widget instances for horiz/vertical 2025-10-23 09:30:37 -04:00
Moraxyc Xu
b2e5565110 nix: use standard way to remove option (#529) 2025-10-22 22:46:15 -04:00
github-actions[bot]
95785afec9 i18n: update source strings from codebase 2025-10-23 02:44:56 +00:00
bbedward
d9d16eccfe displays: default show on last display to true, always 2025-10-22 22:44:26 -04:00
github-actions[bot]
26900c9b62 i18n: update translations 2025-10-23 02:40:18 +00:00
github-actions[bot]
8113ddc809 i18n: update source strings from codebase 2025-10-23 02:40:13 +00:00
bbedward
3cd6a1a558 displays: add "show on last display" for some components.
- Lets the components migrate when un-docked to the otehr monitor,
  basically
2025-10-22 22:38:59 -04:00
purian23
9128141be0 Release tag to Copr 2025-10-22 21:35:36 -04:00
bbedward
0b0af20a84 matugen: add dark/light kcolorschemes 2025-10-22 19:02:38 -04:00
github-actions[bot]
225144cb46 i18n: update source strings from codebase 2025-10-22 20:55:17 +00:00
bbedward
bbe802037e lock: send lockerReady after frame rendered 2025-10-22 16:54:34 -04:00
bbedward
1db4e92779 suppress brightness OSD when operating from cc 2025-10-22 16:38:20 -04:00
github-actions[bot]
072883dcd4 i18n: update source strings from codebase 2025-10-22 20:31:27 +00:00
bbedward
a25e929200 cc: allow pinning brightness device per-monitor 2025-10-22 16:30:51 -04:00
bbedward
6c4d27be8a dock: fix indicator positions 2025-10-22 14:39:21 -04:00
github-actions[bot]
8825382502 i18n: update translations 2025-10-22 18:08:52 +00:00
github-actions[bot]
9ce3c5bd73 i18n: update source strings from codebase 2025-10-22 18:08:46 +00:00
bbedward
771346c8fa dock: add dot indicator style 2025-10-22 14:08:13 -04:00
github-actions[bot]
a56b2d6a9f Update VERSION to v0.2.1 (from DMS) 2025-10-22 17:38:31 +00:00
github-actions[bot]
342f980bad i18n: update translations 2025-10-22 17:35:23 +00:00
Roni Laukkarinen
dbc1bdeb3b Fix WorkspaceSwitcher crash: replace undefined parentScreen?.name with screenName (#527) 2025-10-22 13:34:39 -04:00
842 changed files with 124929 additions and 17226 deletions

27
.githooks/pre-commit Executable file
View File

@@ -0,0 +1,27 @@
#!/bin/bash
# DISABLED for now
exit 0
set -euo pipefail
HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$HOOK_DIR/.." && pwd)"
cd "$REPO_ROOT"
if [[ -z "${POEDITOR_API_TOKEN:-}" ]] || [[ -z "${POEDITOR_PROJECT_ID:-}" ]]; then
exit 0
fi
if ! command -v python3 &>/dev/null; then
exit 0
fi
if ! python3 scripts/i18nsync.py check &>/dev/null; then
echo "Translations out of sync"
echo "run python3 scripts/i18nsync.py sync"
exit 1
fi
exit 0

15
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,15 @@
# These are supported funding model platforms
github: [avengemedia]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: danklinux
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

View File

@@ -9,21 +9,15 @@ assignees: ""
<!-- If your issue is related to ICONS
- Purple and black checkerboards are QT's way of signalling an icon doesn't exist
- FIX: Configure a QT6 or Icon Pack in DMS Settings that has the icon you want
- Follow the [THEMING](https://github.com/AvengeMedia/DankMaterialShell/tree/master?tab=readme-ov-file#theming) section to ensure your QT environment variable is configured correctl for themes.
- Follow the [THEMING](https://danklinux.com/docs/dankmaterialshell/icon-theming) section to ensure your QT environment variable is configured correctly for themes.
- Once done, configure an icon theme - either however you normally do with gtk3 or qt6ct, or through the built-in settings modal. -->
<!-- If your issue is related to APP LAUNCHER/DOCK/Running Apps being stale
Quickshell does not ever update its DesktopEntires.
There is an open PR for it, that has been stuck unmerged over there to fix it.
We unfortunately are at the mercy of quickshell to merge it.
Until then, newly installed and removed apps will not react until the
shell is restarted.
-->
## Compositor
- [ ] niri
- [ ] Hyprland
- [ ] dwl (MangoWC)
- [ ] sway
- [ ] Other (specify)
## Distribution

View File

@@ -21,6 +21,8 @@ Is this feature specific to one compositor?
- [ ] All compositors
- [ ] niri
- [ ] Hyprland
- [ ] dwl (MangoWC)
- [ ] sway
## Proposed Solution

View File

@@ -10,6 +10,8 @@ assignees: ""
- [ ] niri
- [ ] Hyprland
- [ ] dwl (MangoWC)
- [ ] sway
- [ ] other
## Distribution

View File

@@ -1,22 +1,21 @@
name: DMS Copr Stable Release
name: DMS Copr Stable Release (Manual)
on:
push:
tags:
- 'v*'
release:
types: [published]
workflow_dispatch:
inputs:
version:
description: 'Versioning (e.g., 0.1.14, leave empty for latest release)'
required: false
default: ''
release:
description: 'Release number (e.g., 1, 2, 3 for hotfixes)'
required: false
default: '1'
jobs:
build-and-upload:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -24,25 +23,23 @@ jobs:
- name: Determine version
id: version
run: |
# Get version from manual input or latest release
if [ -n "${{ github.event.inputs.version }}" ]; then
VERSION="${{ github.event.inputs.version }}"
echo "Using manual version: $VERSION"
elif [ "${{ github.event_name }}" = "release" ]; then
VERSION="${{ github.event.release.tag_name }}"
VERSION="${VERSION#v}"
echo "Using release version: $VERSION"
elif [ "${{ github.event_name }}" = "push" ] && [[ "${{ github.ref }}" == refs/tags/* ]]; then
VERSION="${{ github.ref_name }}"
VERSION="${VERSION#v}"
echo "Using tag version: $VERSION"
else
# Fallback to latest release
VERSION=$(curl -s https://api.github.com/repos/AvengeMedia/DankMaterialShell/releases/latest | jq -r '.tag_name' | sed 's/^v//')
VERSION=$(curl -s https://api.github.com/repos/${{ github.repository }}/releases/latest | jq -r '.tag_name' | sed 's/^v//')
echo "Using latest release version: $VERSION"
fi
RELEASE="${{ github.event.inputs.release }}"
if [ -z "$RELEASE" ]; then
RELEASE="1"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "✅ Building DMS stable version: $VERSION"
echo "release=$RELEASE" >> $GITHUB_OUTPUT
echo "✅ Building DMS hotfix version: $VERSION-$RELEASE"
- name: Setup build environment
run: |
@@ -71,6 +68,7 @@ jobs:
- name: Generate stable spec file
run: |
VERSION="${{ steps.version.outputs.version }}"
RELEASE="${{ steps.version.outputs.release }}"
CHANGELOG_DATE="$(date '+%a %b %d %Y')"
cat > ~/rpmbuild/SPECS/dms.spec <<'SPECEOF'
@@ -82,27 +80,26 @@ jobs:
Name: dms
Version: %{version}
Release: 1%{?dist}
Release: RELEASE_PLACEHOLDER%{?dist}
Summary: %{pkg_summary}
License: GPL-3.0-only
License: MIT
URL: https://github.com/AvengeMedia/DankMaterialShell
Source0: dms-qml.tar.gz
BuildRequires: gzip
BuildRequires: wget
BuildRequires: systemd-rpm-macros
Requires: (quickshell or quickshell-git)
Requires: accountsservice
Requires: dms-cli
Requires: dgop
Requires: fira-code-fonts
Requires: material-symbols-fonts
Requires: rsms-inter-fonts
Recommends: brightnessctl
Recommends: cava
Recommends: cliphist
Recommends: danksearch
Recommends: hyprpicker
Recommends: matugen
Recommends: wl-clipboard
@@ -121,8 +118,8 @@ jobs:
%package -n dms-cli
Summary: DankMaterialShell CLI tool
License: GPL-3.0-only
URL: https://github.com/AvengeMedia/danklinux
License: MIT
URL: https://github.com/AvengeMedia/DankMaterialShell
%description -n dms-cli
Command-line interface for DankMaterialShell configuration and management.
@@ -158,7 +155,7 @@ jobs:
esac
# Download dms-cli for target architecture
wget -O %{_builddir}/dms-cli.gz "https://github.com/AvengeMedia/danklinux/releases/latest/download/dms-distropkg-${ARCH_SUFFIX}.gz" || {
wget -O %{_builddir}/dms-cli.gz "https://github.com/AvengeMedia/DankMaterialShell/releases/latest/download/dms-distropkg-${ARCH_SUFFIX}.gz" || {
echo "Failed to download dms-cli for architecture %{_arch}"
exit 1
}
@@ -179,36 +176,65 @@ jobs:
install -Dm755 %{_builddir}/dms-cli %{buildroot}%{_bindir}/dms
install -Dm755 %{_builddir}/dgop %{buildroot}%{_bindir}/dgop
install -dm755 %{buildroot}%{_sysconfdir}/xdg/quickshell/dms
cp -r %{_builddir}/dms-qml/* %{buildroot}%{_sysconfdir}/xdg/quickshell/dms/
# Shell completions
install -d %{buildroot}%{_datadir}/bash-completion/completions
install -d %{buildroot}%{_datadir}/zsh/site-functions
install -d %{buildroot}%{_datadir}/fish/vendor_completions.d
%{_builddir}/dms-cli completion bash > %{buildroot}%{_datadir}/bash-completion/completions/dms || :
%{_builddir}/dms-cli completion zsh > %{buildroot}%{_datadir}/zsh/site-functions/_dms || :
%{_builddir}/dms-cli completion fish > %{buildroot}%{_datadir}/fish/vendor_completions.d/dms.fish || :
rm -rf %{buildroot}%{_sysconfdir}/xdg/quickshell/dms/.git*
rm -f %{buildroot}%{_sysconfdir}/xdg/quickshell/dms/.gitignore
rm -rf %{buildroot}%{_sysconfdir}/xdg/quickshell/dms/.github
rm -f %{buildroot}%{_sysconfdir}/xdg/quickshell/dms/*.spec
install -Dm644 %{_builddir}/dms-qml/assets/systemd/dms.service %{buildroot}%{_userunitdir}/dms.service
install -dm755 %{buildroot}%{_datadir}/quickshell/dms
cp -r %{_builddir}/dms-qml/* %{buildroot}%{_datadir}/quickshell/dms/
rm -rf %{buildroot}%{_datadir}/quickshell/dms/.git*
rm -f %{buildroot}%{_datadir}/quickshell/dms/.gitignore
rm -rf %{buildroot}%{_datadir}/quickshell/dms/.github
rm -rf %{buildroot}%{_datadir}/quickshell/dms/distro
%posttrans
# Clean up old installation path from previous versions (only if empty)
if [ -d "%{_sysconfdir}/xdg/quickshell/dms" ]; then
# Remove directories only if empty (preserves any user-added files)
rmdir "%{_sysconfdir}/xdg/quickshell/dms" 2>/dev/null || true
rmdir "%{_sysconfdir}/xdg/quickshell" 2>/dev/null || true
rmdir "%{_sysconfdir}/xdg" 2>/dev/null || true
fi
# Restart DMS for active users after upgrade
if [ "$1" -ge 2 ]; then
pkill -USR1 -x dms >/dev/null 2>&1 || true
fi
%files
%license LICENSE
%doc README.md CONTRIBUTING.md
%{_sysconfdir}/xdg/quickshell/dms/
%{_datadir}/quickshell/dms/
%{_userunitdir}/dms.service
%files -n dms-cli
%{_bindir}/dms
%{_datadir}/bash-completion/completions/dms
%{_datadir}/zsh/site-functions/_dms
%{_datadir}/fish/vendor_completions.d/dms.fish
%files -n dgop
%{_bindir}/dgop
%changelog
* CHANGELOG_DATE_PLACEHOLDER AvengeMedia <contact@avengemedia.com> - VERSION_PLACEHOLDER-1
* CHANGELOG_DATE_PLACEHOLDER AvengeMedia <contact@avengemedia.com> - VERSION_PLACEHOLDER-RELEASE_PLACEHOLDER
- Stable release VERSION_PLACEHOLDER
- Built from GitHub release
- Includes latest dms-cli and dgop binaries
SPECEOF
sed -i "s/VERSION_PLACEHOLDER/${VERSION}/g" ~/rpmbuild/SPECS/dms.spec
sed -i "s/RELEASE_PLACEHOLDER/${RELEASE}/g" ~/rpmbuild/SPECS/dms.spec
sed -i "s/CHANGELOG_DATE_PLACEHOLDER/${CHANGELOG_DATE}/g" ~/rpmbuild/SPECS/dms.spec
echo "✅ Spec file generated for v${VERSION}"
echo "✅ Spec file generated for v${VERSION}-${RELEASE}"
echo ""
echo "=== Spec file preview ==="
head -40 ~/rpmbuild/SPECS/dms.spec
@@ -282,7 +308,7 @@ jobs:
run: |
echo "### 🎉 DMS Stable Build Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "- **Version:** ${{ steps.version.outputs.version }}" >> $GITHUB_STEP_SUMMARY
echo "- **Version:** ${{ steps.version.outputs.version }}-${{ steps.version.outputs.release }}" >> $GITHUB_STEP_SUMMARY
echo "- **SRPM:** ${{ steps.build.outputs.srpm_name }}" >> $GITHUB_STEP_SUMMARY
echo "- **Project:** https://copr.fedorainfracloud.org/coprs/avengemedia/dms/" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY

42
.github/workflows/go-ci.yml vendored Normal file
View File

@@ -0,0 +1,42 @@
name: Go CI
on:
push:
branches:
- '**'
paths:
- 'core/**'
- '.github/workflows/go-ci.yml'
jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: core
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: ./core/go.mod
- name: Format check
run: |
if [ "$(gofmt -s -l . | wc -l)" -gt 0 ]; then
echo "The following files are not formatted:"
gofmt -s -l .
exit 1
fi
- name: Test
run: go test -v ./...
- name: Build dms
run: go build -v ./cmd/dms
- name: Build dankinstall
run: go build -v ./cmd/dankinstall

View File

@@ -1,189 +0,0 @@
name: POEditor Diff & Sync
on:
push:
branches: [ master ]
workflow_dispatch: {}
concurrency:
group: poeditor-sync
cancel-in-progress: false
jobs:
sync-translations:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v4
with:
python-version: '3.x'
- name: Install jq
run: sudo apt-get update && sudo apt-get install -y jq
- name: Extract source strings from codebase
env:
API_TOKEN: ${{ secrets.POEDITOR_API_TOKEN }}
PROJECT_ID: ${{ secrets.POEDITOR_PROJECT_ID }}
run: |
set -euo pipefail
echo "::group::Extracting strings from QML files"
python3 translations/extract_translations.py
echo "::endgroup::"
echo "::group::Checking for changes in en.json"
if [[ -f "translations/en.json" ]]; then
jq -S . "translations/en.json" > /tmp/en_new.json
if [[ -f "translations/en.json.orig" ]]; then
jq -S . "translations/en.json.orig" > /tmp/en_old.json
else
git show HEAD:translations/en.json > /tmp/en_old.json 2>/dev/null || echo "[]" > /tmp/en_old.json
jq -S . /tmp/en_old.json > /tmp/en_old.json.tmp && mv /tmp/en_old.json.tmp /tmp/en_old.json
fi
if diff -q /tmp/en_new.json /tmp/en_old.json >/dev/null 2>&1; then
echo "No changes in source strings"
echo "source_changed=false" >> "$GITHUB_OUTPUT"
else
echo "Detected changes in source strings"
echo "source_changed=true" >> "$GITHUB_OUTPUT"
echo "::group::Uploading source strings to POEditor"
RESP=$(curl -sS -X POST https://api.poeditor.com/v2/projects/upload \
-F api_token="$API_TOKEN" \
-F id="$PROJECT_ID" \
-F updating="terms" \
-F file=@"translations/en.json")
STATUS=$(echo "$RESP" | jq -r '.response.status')
if [[ "$STATUS" != "success" ]]; then
echo "::warning::POEditor upload failed: $RESP"
else
TERMS_ADDED=$(echo "$RESP" | jq -r '.result.terms.added // 0')
TERMS_UPDATED=$(echo "$RESP" | jq -r '.result.terms.updated // 0')
TERMS_DELETED=$(echo "$RESP" | jq -r '.result.terms.deleted // 0')
echo "Terms added: $TERMS_ADDED, updated: $TERMS_UPDATED, deleted: $TERMS_DELETED"
fi
echo "::endgroup::"
fi
else
echo "::warning::translations/en.json not found"
echo "source_changed=false" >> "$GITHUB_OUTPUT"
fi
echo "::endgroup::"
id: extract
- name: Commit and push source strings
if: steps.extract.outputs.source_changed == 'true'
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add translations/en.json translations/template.json
git commit -m "i18n: update source strings from codebase"
for attempt in 1 2 3; do
if git push; then
echo "Successfully pushed source string updates"
exit 0
fi
echo "Push attempt $attempt failed, pulling and retrying..."
git pull --rebase
sleep $((attempt*2))
done
echo "Failed to push after retries" >&2
exit 1
- name: Export and update translations from POEditor
env:
API_TOKEN: ${{ secrets.POEDITOR_API_TOKEN }}
PROJECT_ID: ${{ secrets.POEDITOR_PROJECT_ID }}
run: |
set -euo pipefail
LANGUAGES=(
"ja:translations/poexports/ja.json"
"zh-Hans:translations/poexports/zh_CN.json"
"pt-br:translations/poexports/pt.json"
)
ANY_CHANGED=false
for lang_pair in "${LANGUAGES[@]}"; do
IFS=':' read -r PO_LANG REPO_FILE <<< "$lang_pair"
echo "::group::Processing $PO_LANG"
RESP=$(curl -sS -X POST https://api.poeditor.com/v2/projects/export \
-d api_token="$API_TOKEN" \
-d id="$PROJECT_ID" \
-d language="$PO_LANG" \
-d type="key_value_json")
STATUS=$(echo "$RESP" | jq -r '.response.status')
if [[ "$STATUS" != "success" ]]; then
echo "POEditor export request failed for $PO_LANG: $RESP" >&2
continue
fi
URL=$(echo "$RESP" | jq -r '.result.url')
if [[ -z "$URL" || "$URL" == "null" ]]; then
echo "No export URL returned for $PO_LANG" >&2
continue
fi
curl -sS -L "$URL" -o "/tmp/po_export_${PO_LANG}.json"
jq -S . "/tmp/po_export_${PO_LANG}.json" > "/tmp/po_export_${PO_LANG}.norm.json"
if [[ -f "$REPO_FILE" ]]; then
jq -S . "$REPO_FILE" > "/tmp/repo_${PO_LANG}.norm.json" || echo "{}" > "/tmp/repo_${PO_LANG}.norm.json"
else
echo "{}" > "/tmp/repo_${PO_LANG}.norm.json"
fi
if diff -q "/tmp/po_export_${PO_LANG}.norm.json" "/tmp/repo_${PO_LANG}.norm.json" >/dev/null; then
echo "No changes for $PO_LANG"
else
echo "Detected changes for $PO_LANG"
mkdir -p "$(dirname "$REPO_FILE")"
cp "/tmp/po_export_${PO_LANG}.norm.json" "$REPO_FILE"
ANY_CHANGED=true
fi
echo "::endgroup::"
done
echo "any_changed=$ANY_CHANGED" >> "$GITHUB_OUTPUT"
id: export
- name: Commit and push translation updates
if: steps.export.outputs.any_changed == 'true'
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add translations/poexports/*.json
git commit -m "i18n: update translations"
for attempt in 1 2 3; do
if git push; then
echo "Successfully pushed translation updates"
exit 0
fi
echo "Push attempt $attempt failed, pulling and retrying..."
git pull --rebase
sleep $((attempt*2))
done
echo "Failed to push after retries" >&2
exit 1

View File

@@ -1,46 +1,192 @@
# Release from a dispatch event from the danklinux repo
name: Create Release from DMS
name: Release
on:
repository_dispatch:
types: [dms_release]
push:
tags:
- 'v*'
permissions:
contents: write
actions: write
concurrency:
group: release-${{ github.event.client_payload.tag }}
group: release-${{ github.ref_name }}
cancel-in-progress: true
jobs:
create_release_from_dms:
runs-on: ubuntu-24.04
env:
TAG: ${{ github.event.client_payload.tag }}
DMS_REPO: ${{ github.event.client_payload.dms_repo }}
build-core:
runs-on: ubuntu-latest
strategy:
matrix:
arch: [amd64, arm64]
defaults:
run:
working-directory: core
steps:
- uses: actions/checkout@v4
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Ensure VERSION and tag
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: ./core/go.mod
- name: Format check
run: |
set -euxo pipefail
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
echo "${TAG}" > VERSION
git add -A VERSION
if ! git diff --cached --quiet; then
git commit -m "Update VERSION to ${TAG} (from DMS)"
if [ "$(gofmt -s -l . | wc -l)" -gt 0 ]; then
echo "The following files are not formatted:"
gofmt -s -l .
exit 1
fi
git tag -f "${TAG}"
- name: Run tests
run: go test -v ./...
git push origin HEAD
git push -f origin "${TAG}"
- name: Build dankinstall (${{ matrix.arch }})
env:
GOOS: linux
CGO_ENABLED: 0
GOARCH: ${{ matrix.arch }}
run: |
set -eux
cd cmd/dankinstall
go build -trimpath -ldflags "-s -w -X main.Version=${GITHUB_REF#refs/tags/}" \
-o ../../dankinstall-${{ matrix.arch }}
cd ../..
gzip -9 -k dankinstall-${{ matrix.arch }}
sha256sum dankinstall-${{ matrix.arch }}.gz > dankinstall-${{ matrix.arch }}.gz.sha256
- name: Build dms (${{ matrix.arch }})
env:
GOOS: linux
CGO_ENABLED: 0
GOARCH: ${{ matrix.arch }}
run: |
set -eux
cd cmd/dms
go build -trimpath -ldflags "-s -w -X main.Version=${GITHUB_REF#refs/tags/}" \
-o ../../dms-${{ matrix.arch }}
cd ../..
gzip -9 -k dms-${{ matrix.arch }}
sha256sum dms-${{ matrix.arch }}.gz > dms-${{ matrix.arch }}.gz.sha256
- name: Generate shell completions
if: matrix.arch == 'amd64'
run: |
set -eux
chmod +x dms-amd64
./dms-amd64 completion bash > completion.bash
./dms-amd64 completion fish > completion.fish
./dms-amd64 completion zsh > completion.zsh
- name: Build dms-distropkg (${{ matrix.arch }})
env:
GOOS: linux
CGO_ENABLED: 0
GOARCH: ${{ matrix.arch }}
run: |
set -eux
cd cmd/dms
go build -trimpath -tags distro_binary -ldflags "-s -w -X main.Version=${GITHUB_REF#refs/tags/}" \
-o ../../dms-distropkg-${{ matrix.arch }}
cd ../..
gzip -9 -k dms-distropkg-${{ matrix.arch }}
sha256sum dms-distropkg-${{ matrix.arch }}.gz > dms-distropkg-${{ matrix.arch }}.gz.sha256
- name: Upload artifacts (${{ matrix.arch }})
if: matrix.arch == 'arm64'
uses: actions/upload-artifact@v4
with:
name: core-assets-${{ matrix.arch }}
path: |
core/dankinstall-${{ matrix.arch }}.gz
core/dankinstall-${{ matrix.arch }}.gz.sha256
core/dms-${{ matrix.arch }}.gz
core/dms-${{ matrix.arch }}.gz.sha256
core/dms-distropkg-${{ matrix.arch }}.gz
core/dms-distropkg-${{ matrix.arch }}.gz.sha256
if-no-files-found: error
- name: Upload artifacts with completions
if: matrix.arch == 'amd64'
uses: actions/upload-artifact@v4
with:
name: core-assets-${{ matrix.arch }}
path: |
core/dankinstall-${{ matrix.arch }}.gz
core/dankinstall-${{ matrix.arch }}.gz.sha256
core/dms-${{ matrix.arch }}.gz
core/dms-${{ matrix.arch }}.gz.sha256
core/dms-distropkg-${{ matrix.arch }}.gz
core/dms-distropkg-${{ matrix.arch }}.gz.sha256
core/completion.bash
core/completion.fish
core/completion.zsh
if-no-files-found: error
update-versions:
runs-on: ubuntu-latest
needs: build-core
steps:
- name: Checkout
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
- name: Update VERSION
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
version="${GITHUB_REF#refs/tags/}"
version_no_v="${version#v}"
echo "Updating to version: $version"
# Update VERSION file in quickshell/
echo "${version}" > quickshell/VERSION
git add quickshell/VERSION
if ! git diff --cached --quiet; then
git commit -m "chore: bump version to $version"
git push origin HEAD:master || git push origin HEAD:main
echo "Pushed version updates to master"
else
echo "No version changes needed"
fi
# Force-push the tag to point to the commit with updated VERSION
git tag -f "${version}"
git push -f origin "${version}"
release:
runs-on: ubuntu-24.04
needs: [build-core, update-versions]
env:
TAG: ${{ github.ref_name }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Fetch updated tag after version bump
run: |
git fetch origin --force tag ${{ github.ref_name }}
git checkout ${{ github.ref_name }}
- name: Download core artifacts
uses: actions/download-artifact@v4
with:
pattern: core-assets-*
merge-multiple: true
path: ./_core_assets
- name: Generate Changelog
id: changelog
@@ -48,23 +194,31 @@ jobs:
set -e
PREVIOUS_TAG=$(git describe --tags --abbrev=0 "${TAG}^" 2>/dev/null || echo "")
if [ -z "$PREVIOUS_TAG" ]; then
CHANGELOG=$(git log --oneline --pretty=format:"- %s (%h)" | head -50)
CHANGELOG=$(git log --oneline --pretty=format:"%an|%s (%h)" | grep -v "^github-actions\[bot\]|" | sed 's/^[^|]*|/- /' | head -50)
else
CHANGELOG=$(git log --oneline --pretty=format:"- %s (%h)" "${PREVIOUS_TAG}..${TAG}")
CHANGELOG=$(git log --oneline --pretty=format:"%an|%s (%h)" "${PREVIOUS_TAG}..${TAG}" | grep -v "^github-actions\[bot\]|" | sed 's/^[^|]*|/- /')
fi
cat > RELEASE_BODY.md << 'EOF'
## Installation
```bash
curl -fsSL https://install.danklinux.com | sh
```
## Assets
### Complete Packages
- **`dms-full-amd64.tar.gz`** - Complete package for x86_64 systems (CLI binaries + QML source + installation guide)
- **`dms-full-arm64.tar.gz`** - Complete package for ARM64 systems (CLI binaries + QML source + installation guide)
- **`dms-full-amd64.tar.gz`** - Complete package for x86_64 systems (CLI binaries + QML source + shell completions + installation guide)
- **`dms-full-arm64.tar.gz`** - Complete package for ARM64 systems (CLI binaries + QML source + shell completions + installation guide)
### Individual Components
- **`dms-cli-amd64.gz`** - DMS CLI binary for x86_64 systems
- **`dms-cli-arm64.gz`** - DMS CLI binary for ARM64 systems
- **`dms-distropkg-amd64.gz`** - DMS CLI binary built with distro_package tag for AMD64 systems
- **`dms-distropkg-arm64.gz`** - DMS CLI binary built with distro_package tag for ARM64 systems
- **`dankinstall-amd64.gz`** - Installer binary for x86_64 systems
- **`dankinstall-arm64.gz`** - Installer binary for ARM64 systems
- **`dms-qml.tar.gz`** - QML source code only
### Checksums
@@ -88,30 +242,14 @@ jobs:
cat RELEASE_BODY.md >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Create/Update DankMaterialShell Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ env.TAG }}
name: Release ${{ env.TAG }}
body: ${{ steps.changelog.outputs.changelog }}
draft: false
prerelease: ${{ contains(env.TAG, '-') }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Download and prepare release assets
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Prepare release assets
run: |
set -euxo pipefail
mkdir -p _release_assets
# Download DMS CLI binaries from the danklinux repo
gh release download "${TAG}" -R "${DMS_REPO}" --dir ./_dms_assets
# Rename CLI binaries to dms-cli-* format and copy distropkg binaries
for file in _dms_assets/dms-*.gz*; do
# Copy core binaries and rename dms-*.gz to dms-cli-*.gz
for file in _core_assets/dms-*.gz*; do
if [ -f "$file" ]; then
basename=$(basename "$file")
if [[ "$basename" == dms-distropkg-* ]]; then
@@ -123,13 +261,21 @@ jobs:
fi
done
# Create QML source package (exclude .git, .github, build artifacts)
tar --exclude='.git' \
# Copy dankinstall binaries
cp _core_assets/dankinstall-*.gz* _release_assets/
# Copy completions
cp _core_assets/completion.* _release_assets/ 2>/dev/null || true
# Create QML source package (exclude build artifacts and git files)
# Copy root LICENSE and CONTRIBUTING.md to quickshell/ for packaging
cp LICENSE CONTRIBUTING.md quickshell/
# Tar the CONTENTS of quickshell/, not the directory itself
(cd quickshell && tar --exclude='.git' \
--exclude='.github' \
--exclude='_dms_assets' \
--exclude='_release_assets' \
--exclude='*.tar.gz' \
-czf _release_assets/dms-qml.tar.gz .
-czf ../_release_assets/dms-qml.tar.gz .)
# Generate checksum for QML package
(cd _release_assets && sha256sum dms-qml.tar.gz > dms-qml.tar.gz.sha256)
@@ -138,24 +284,36 @@ jobs:
for arch in amd64 arm64; do
mkdir -p _temp_full/dms
mkdir -p _temp_full/bin
mkdir -p _temp_full/completions
# Extract QML source to temp directory
# Extract QML source
tar -xzf _release_assets/dms-qml.tar.gz -C _temp_full/dms
# Copy CLI binary if it exists
if [ -f "_dms_assets/dms-${arch}.gz" ]; then
gunzip -c "_dms_assets/dms-${arch}.gz" > _temp_full/bin/dms
# Add CLI binaries
if [ -f "_core_assets/dms-${arch}.gz" ]; then
gunzip -c "_core_assets/dms-${arch}.gz" > _temp_full/bin/dms
chmod +x _temp_full/bin/dms
fi
# Copy distropkg binary if it exists
if [ -f "_dms_assets/dms-distropkg-${arch}.gz" ]; then
gunzip -c "_dms_assets/dms-distropkg-${arch}.gz" > _temp_full/bin/dms-distropkg
if [ -f "_core_assets/dms-distropkg-${arch}.gz" ]; then
gunzip -c "_core_assets/dms-distropkg-${arch}.gz" > _temp_full/bin/dms-distropkg
chmod +x _temp_full/bin/dms-distropkg
fi
# Create INSTALL.md
cat > _temp_full/INSTALL.md << 'EOF'
# Add shell completions
for completion in _core_assets/completion.*; do
if [ -f "$completion" ]; then
cp "$completion" _temp_full/completions/
fi
done
# Copy docs directory
if [ -d "docs" ]; then
cp -r docs _temp_full/
fi
# Create installation guide
cat > _temp_full/INSTALL.md << 'EOFINSTALL'
# DankMaterialShell Installation
## Requirements
@@ -175,16 +333,23 @@ jobs:
2. **Install the DMS CLI binaries:**
```bash
sudo install -m 755 bin/dms /usr/local/bin/dms
# or install to a local directory:
mkdir -p ~/.local/bin
install -m 755 bin/dms ~/.local/bin/dms
```
3. **Start the shell:**
3. **Install shell completions (optional):**
```bash
# Bash
sudo install -m 644 completions/completion.bash /usr/share/bash-completion/completions/dms
# Fish
sudo install -m 644 completions/completion.fish /usr/share/fish/vendor_completions.d/dms.fish
# Zsh
sudo install -m 644 completions/completion.zsh /usr/share/zsh/site-functions/_dms
```
4. **Start the shell:**
```bash
dms run
# or directly with quickshell (will lack some dbus integrations & plugin management):
quickshell -p ~/.config/quickshell/dms
```
## Configuration
@@ -195,10 +360,9 @@ jobs:
## Troubleshooting
- Run with verbose output: `quickshell -v -p ~/.config/quickshell/dms`
- Check logs in `~/.local/state/DankMaterialShell/`
- Run with verbose output: `DMS_LOG_LEVEL=debug dms run`
- Ensure all dependencies are installed
EOF
EOFINSTALL
# Create the full package
(cd _temp_full && tar -czf "../_release_assets/dms-full-${arch}.tar.gz" .)
@@ -210,10 +374,257 @@ jobs:
rm -rf _temp_full
done
- name: Attach all assets to release
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ env.TAG }}
name: Release ${{ env.TAG }}
body: ${{ steps.changelog.outputs.changelog }}
files: _release_assets/**
draft: false
prerelease: ${{ contains(env.TAG, '-') }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
copr-build:
runs-on: ubuntu-latest
needs: release
env:
TAG: ${{ github.ref_name }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Determine version
id: version
run: |
VERSION="${TAG#v}"
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Building DMS stable version: $VERSION"
- name: Setup build environment
run: |
sudo apt-get update
sudo apt-get install -y rpm wget curl jq gzip
mkdir -p ~/rpmbuild/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS}
- name: Download release assets
run: |
VERSION="${{ steps.version.outputs.version }}"
cd ~/rpmbuild/SOURCES
wget "https://github.com/AvengeMedia/DankMaterialShell/releases/download/v${VERSION}/dms-qml.tar.gz" || {
echo "Failed to download dms-qml.tar.gz for v${VERSION}"
exit 1
}
- name: Generate stable spec file
run: |
VERSION="${{ steps.version.outputs.version }}"
CHANGELOG_DATE="$(date '+%a %b %d %Y')"
cat > ~/rpmbuild/SPECS/dms.spec <<'SPECEOF'
# Spec for DMS stable releases - Generated by GitHub Actions
%global debug_package %{nil}
%global version VERSION_PLACEHOLDER
%global pkg_summary DankMaterialShell - Material 3 inspired shell for Wayland compositors
Name: dms
Version: %{version}
Release: 1%{?dist}
Summary: %{pkg_summary}
License: MIT
URL: https://github.com/AvengeMedia/DankMaterialShell
Source0: dms-qml.tar.gz
BuildRequires: gzip
BuildRequires: wget
BuildRequires: systemd-rpm-macros
Requires: (quickshell or quickshell-git)
Requires: accountsservice
Requires: dms-cli
Requires: dgop
Recommends: cava
Recommends: cliphist
Recommends: danksearch
Recommends: hyprpicker
Recommends: matugen
Recommends: wl-clipboard
Recommends: NetworkManager
Recommends: qt6-qtmultimedia
Suggests: qt6ct
%description
DankMaterialShell (DMS) is a modern Wayland desktop shell built with Quickshell
and optimized for the niri and hyprland compositors. Features notifications,
app launcher, wallpaper customization, and fully customizable with plugins.
Includes auto-theming for GTK/Qt apps with matugen, 20+ customizable widgets,
process monitoring, notification center, clipboard history, dock, control center,
lock screen, and comprehensive plugin system.
%package -n dms-cli
Summary: DankMaterialShell CLI tool
License: MIT
URL: https://github.com/AvengeMedia/DankMaterialShell
%description -n dms-cli
Command-line interface for DankMaterialShell configuration and management.
Provides native DBus bindings, NetworkManager integration, and system utilities.
%package -n dgop
Summary: Stateless CPU/GPU monitor for DankMaterialShell
License: MIT
URL: https://github.com/AvengeMedia/dgop
Provides: dgop
%description -n dgop
DGOP is a stateless system monitoring tool that provides CPU, GPU, memory, and
network statistics. Designed for integration with DankMaterialShell but can be
used standalone. This package always includes the latest stable dgop release.
%prep
%setup -q -c -n dms-qml
# Download architecture-specific binaries during build
case "%{_arch}" in
x86_64)
ARCH_SUFFIX="amd64"
;;
aarch64)
ARCH_SUFFIX="arm64"
;;
*)
echo "Unsupported architecture: %{_arch}"
exit 1
;;
esac
wget -O %{_builddir}/dms-cli.gz "https://github.com/AvengeMedia/DankMaterialShell/releases/latest/download/dms-distropkg-${ARCH_SUFFIX}.gz" || {
echo "Failed to download dms-cli for architecture %{_arch}"
exit 1
}
gunzip -c %{_builddir}/dms-cli.gz > %{_builddir}/dms-cli
chmod +x %{_builddir}/dms-cli
wget -O %{_builddir}/dgop.gz "https://github.com/AvengeMedia/dgop/releases/latest/download/dgop-linux-${ARCH_SUFFIX}.gz" || {
echo "Failed to download dgop for architecture %{_arch}"
exit 1
}
gunzip -c %{_builddir}/dgop.gz > %{_builddir}/dgop
chmod +x %{_builddir}/dgop
%build
%install
install -Dm755 %{_builddir}/dms-cli %{buildroot}%{_bindir}/dms
install -Dm755 %{_builddir}/dgop %{buildroot}%{_bindir}/dgop
install -d %{buildroot}%{_datadir}/bash-completion/completions
install -d %{buildroot}%{_datadir}/zsh/site-functions
install -d %{buildroot}%{_datadir}/fish/vendor_completions.d
%{_builddir}/dms-cli completion bash > %{buildroot}%{_datadir}/bash-completion/completions/dms || :
%{_builddir}/dms-cli completion zsh > %{buildroot}%{_datadir}/zsh/site-functions/_dms || :
%{_builddir}/dms-cli completion fish > %{buildroot}%{_datadir}/fish/vendor_completions.d/dms.fish || :
install -Dm644 %{_builddir}/dms-qml/assets/systemd/dms.service %{buildroot}%{_userunitdir}/dms.service
install -dm755 %{buildroot}%{_datadir}/quickshell/dms
cp -r %{_builddir}/dms-qml/* %{buildroot}%{_datadir}/quickshell/dms/
rm -rf %{buildroot}%{_datadir}/quickshell/dms/.git*
rm -f %{buildroot}%{_datadir}/quickshell/dms/.gitignore
rm -rf %{buildroot}%{_datadir}/quickshell/dms/.github
rm -rf %{buildroot}%{_datadir}/quickshell/dms/distro
%posttrans
if [ -d "%{_sysconfdir}/xdg/quickshell/dms" ]; then
rmdir "%{_sysconfdir}/xdg/quickshell/dms" 2>/dev/null || true
rmdir "%{_sysconfdir}/xdg/quickshell" 2>/dev/null || true
rmdir "%{_sysconfdir}/xdg" 2>/dev/null || true
fi
if [ "$1" -ge 2 ]; then
pkill -USR1 -x dms >/dev/null 2>&1 || true
fi
%files
%license LICENSE
%doc README.md CONTRIBUTING.md
%{_datadir}/quickshell/dms/
%{_userunitdir}/dms.service
%files -n dms-cli
%{_bindir}/dms
%{_datadir}/bash-completion/completions/dms
%{_datadir}/zsh/site-functions/_dms
%{_datadir}/fish/vendor_completions.d/dms.fish
%files -n dgop
%{_bindir}/dgop
%changelog
* CHANGELOG_DATE_PLACEHOLDER AvengeMedia <contact@avengemedia.com> - VERSION_PLACEHOLDER-1
- Stable release VERSION_PLACEHOLDER
- Built from GitHub release
- Includes latest dms-cli and dgop binaries
SPECEOF
sed -i "s/VERSION_PLACEHOLDER/${VERSION}/g" ~/rpmbuild/SPECS/dms.spec
sed -i "s/CHANGELOG_DATE_PLACEHOLDER/${CHANGELOG_DATE}/g" ~/rpmbuild/SPECS/dms.spec
- name: Build SRPM
id: build
run: |
cd ~/rpmbuild/SPECS
rpmbuild -bs dms.spec
SRPM=$(ls ~/rpmbuild/SRPMS/*.src.rpm | tail -n 1)
SRPM_NAME=$(basename "$SRPM")
echo "srpm_path=$SRPM" >> $GITHUB_OUTPUT
echo "srpm_name=$SRPM_NAME" >> $GITHUB_OUTPUT
echo "SRPM built: $SRPM_NAME"
- name: Upload SRPM artifact
uses: actions/upload-artifact@v4
with:
name: dms-stable-srpm-${{ steps.version.outputs.version }}
path: ${{ steps.build.outputs.srpm_path }}
retention-days: 90
- name: Install Copr CLI
run: |
sudo apt-get install -y python3-pip
pip3 install copr-cli
mkdir -p ~/.config
cat > ~/.config/copr << EOF
[copr-cli]
login = ${{ secrets.COPR_LOGIN }}
username = avengemedia
token = ${{ secrets.COPR_TOKEN }}
copr_url = https://copr.fedorainfracloud.org
EOF
chmod 600 ~/.config/copr
- name: Upload to Copr
run: |
SRPM="${{ steps.build.outputs.srpm_path }}"
VERSION="${{ steps.version.outputs.version }}"
echo "Uploading SRPM to avengemedia/dms..."
BUILD_OUTPUT=$(copr-cli build avengemedia/dms "$SRPM" --nowait 2>&1)
echo "$BUILD_OUTPUT"
BUILD_ID=$(echo "$BUILD_OUTPUT" | grep -oP 'Build was added to.*\K[0-9]+' || echo "unknown")
if [ "$BUILD_ID" != "unknown" ]; then
echo "Build submitted: https://copr.fedorainfracloud.org/coprs/avengemedia/dms/build/$BUILD_ID/"
fi

View File

@@ -0,0 +1,90 @@
name: Update Vendor Hash
on:
push:
paths:
- "core/go.mod"
- "core/go.sum"
branches:
- master
jobs:
update-vendor-hash:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install Nix
uses: cachix/install-nix-action@v31
- name: Update vendorHash in flake.nix
run: |
set -euo pipefail
# Try to build and capture the expected hash from error message
echo "Attempting nix build to get new vendorHash..."
if output=$(nix build .#dmsCli 2>&1); then
echo "Build succeeded, no hash update needed"
exit 0
fi
# Extract the expected hash from the error message
new_hash=$(echo "$output" | grep -oP "got:\s+\K\S+" | head -n1)
if [ -z "$new_hash" ]; then
echo "Could not extract new vendorHash from build output"
echo "Build output:"
echo "$output"
exit 1
fi
echo "New vendorHash: $new_hash"
# Get current hash from flake.nix
current_hash=$(grep -oP 'vendorHash = "\K[^"]+' flake.nix)
echo "Current vendorHash: $current_hash"
if [ "$current_hash" = "$new_hash" ]; then
echo "vendorHash is already up to date"
exit 0
fi
# Update the hash in flake.nix
sed -i "s|vendorHash = \"$current_hash\"|vendorHash = \"$new_hash\"|" flake.nix
# Verify the build works with the new hash
echo "Verifying build with new vendorHash..."
nix build .#dmsCli
echo "vendorHash updated successfully!"
- name: Commit and push vendorHash update
run: |
set -euo pipefail
if ! git diff --quiet flake.nix; then
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add flake.nix
git commit -m "nix: update vendorHash for go.mod changes"
for attempt in 1 2 3; do
if git push; then
echo "Successfully pushed vendorHash update"
exit 0
fi
echo "Push attempt $attempt failed, pulling and retrying..."
git pull --rebase
sleep $((attempt*2))
done
echo "Failed to push after retries" >&2
exit 1
else
echo "No changes to flake.nix"
fi

38
.gitignore vendored
View File

@@ -27,7 +27,6 @@ qrc_*.cpp
ui_*.h
*.qmlc
*.jsc
Makefile*
*build-*
*.qm
*.prl
@@ -101,4 +100,39 @@ go.work.sum
# Editor/IDE
# .idea/
# .vscode/
# .vscode/
# If you prefer the allow list template instead of the deny list, see community template:
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
#
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Code coverage profiles and other test artifacts
*.out
coverage.*
*.coverprofile
profile.cov
# Dependency directories (remove the comment below to include it)
# vendor/
# Go workspace file
go.work
go.work.sum
# env file
.env
# Editor/IDE
# .idea/
# .vscode/
bin/

View File

@@ -1,122 +0,0 @@
pragma Singleton
pragma ComponentBehavior: Bound
import QtCore
import QtQuick
import Quickshell
import Quickshell.Io
Singleton {
id: root
readonly property int cacheConfigVersion: 1
readonly property bool isGreeterMode: Quickshell.env("DMS_RUN_GREETER") === "1" || Quickshell.env("DMS_RUN_GREETER") === "true"
readonly property string _stateUrl: StandardPaths.writableLocation(StandardPaths.GenericCacheLocation)
readonly property string _stateDir: Paths.strip(_stateUrl)
property bool _loading: false
property string wallpaperLastPath: ""
property string profileLastPath: ""
Component.onCompleted: {
if (!isGreeterMode) {
loadCache()
}
}
function loadCache() {
_loading = true
parseCache(cacheFile.text())
_loading = false
}
function parseCache(content) {
_loading = true
try {
if (content && content.trim()) {
const cache = JSON.parse(content)
wallpaperLastPath = cache.wallpaperLastPath !== undefined ? cache.wallpaperLastPath : ""
profileLastPath = cache.profileLastPath !== undefined ? cache.profileLastPath : ""
if (cache.configVersion === undefined) {
migrateFromUndefinedToV1(cache)
cleanupUnusedKeys()
saveCache()
}
}
} catch (e) {
console.warn("CacheData: Failed to parse cache:", e.message)
} finally {
_loading = false
}
}
function saveCache() {
if (_loading)
return
cacheFile.setText(JSON.stringify({
"wallpaperLastPath": wallpaperLastPath,
"profileLastPath": profileLastPath,
"configVersion": cacheConfigVersion
}, null, 2))
}
function migrateFromUndefinedToV1(cache) {
console.log("CacheData: Migrating configuration from undefined to version 1")
}
function cleanupUnusedKeys() {
const validKeys = [
"wallpaperLastPath",
"profileLastPath",
"configVersion"
]
try {
const content = cacheFile.text()
if (!content || !content.trim()) return
const cache = JSON.parse(content)
let needsSave = false
for (const key in cache) {
if (!validKeys.includes(key)) {
console.log("CacheData: Removing unused key:", key)
delete cache[key]
needsSave = true
}
}
if (needsSave) {
cacheFile.setText(JSON.stringify(cache, null, 2))
}
} catch (e) {
console.warn("CacheData: Failed to cleanup unused keys:", e.message)
}
}
FileView {
id: cacheFile
path: isGreeterMode ? "" : _stateDir + "/DankMaterialShell/cache.json"
blockLoading: true
blockWrites: true
atomicWrites: true
watchChanges: !isGreeterMode
onLoaded: {
if (!isGreeterMode) {
parseCache(cacheFile.text())
}
}
onLoadFailed: error => {
if (!isGreeterMode) {
console.log("CacheData: No cache file found, starting fresh")
}
}
}
}

File diff suppressed because it is too large Load Diff

695
LICENSE
View File

@@ -1,674 +1,21 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
MIT License
Copyright (c) 2025 Avenge Media LLC
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

File diff suppressed because it is too large Load Diff

View File

@@ -1,266 +0,0 @@
import QtQuick
import QtQuick.Controls
import Quickshell
import qs.Common
import qs.Modals.Common
import qs.Services
import qs.Widgets
DankModal {
id: root
width: 1400
height: 900
onBackgroundClicked: close()
function categorizeKeybinds() {
const categories = {
"Workspace": [],
"Window": [],
"Monitor": [],
"Execute": [],
"System": [],
"Other": []
}
function addKeybind(keybind) {
const dispatcher = keybind.dispatcher || ""
if (dispatcher.includes("workspace")) {
categories["Workspace"].push(keybind)
} else if (dispatcher.includes("monitor")) {
categories["Monitor"].push(keybind)
} else if (dispatcher.includes("window") || dispatcher.includes("focus") || dispatcher.includes("move") || dispatcher.includes("swap") || dispatcher.includes("resize") || dispatcher === "killactive" || dispatcher === "fullscreen" || dispatcher === "togglefloating") {
categories["Window"].push(keybind)
} else if (dispatcher === "exec") {
categories["Execute"].push(keybind)
} else if (dispatcher === "exit" || dispatcher.includes("dpms")) {
categories["System"].push(keybind)
} else {
categories["Other"].push(keybind)
}
}
const allKeybinds = HyprKeybindsService.keybinds.keybinds || []
for (let i = 0; i < allKeybinds.length; i++) {
addKeybind(allKeybinds[i])
}
const children = HyprKeybindsService.keybinds.children || []
for (let i = 0; i < children.length; i++) {
const child = children[i]
const childKeybinds = child.keybinds || []
for (let j = 0; j < childKeybinds.length; j++) {
addKeybind(childKeybinds[j])
}
}
categories["Workspace"].sort((a, b) => {
const dispA = a.dispatcher || ""
const dispB = b.dispatcher || ""
return dispA.localeCompare(dispB)
})
categories["Window"].sort((a, b) => {
const dispA = a.dispatcher || ""
const dispB = b.dispatcher || ""
return dispA.localeCompare(dispB)
})
categories["Monitor"].sort((a, b) => {
const dispA = a.dispatcher || ""
const dispB = b.dispatcher || ""
return dispA.localeCompare(dispB)
})
categories["Execute"].sort((a, b) => {
const modsA = a.mods || []
const keyA = a.key || ""
const bindA = [...modsA, keyA].join("+")
const modsB = b.mods || []
const keyB = b.key || ""
const bindB = [...modsB, keyB].join("+")
return bindA.localeCompare(bindB)
})
return categories
}
content: Component {
Item {
anchors.fill: parent
DankFlickable {
id: mainFlickable
anchors.fill: parent
anchors.margins: Theme.spacingL
contentWidth: rowLayout.implicitWidth
contentHeight: rowLayout.implicitHeight
clip: true
Row {
id: rowLayout
spacing: Theme.spacingM
property var categories: root.categorizeKeybinds()
property real columnWidth: (mainFlickable.width - spacing * 2) / 3
Column {
width: rowLayout.columnWidth
spacing: Theme.spacingXS
StyledText {
text: "Window / Monitor"
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Bold
color: Theme.primary
}
Rectangle {
width: parent.width
height: 1
color: Theme.primary
opacity: 0.3
}
Item { width: 1; height: Theme.spacingXS }
Column {
width: parent.width
spacing: Theme.spacingXS
Repeater {
model: [...(rowLayout.categories["Window"] || []), ...(rowLayout.categories["Monitor"] || [])]
Row {
width: parent.width
spacing: Theme.spacingS
StyledRect {
width: Math.min(140, parent.width * 0.42)
height: 22
radius: 4
opacity: 0.3
StyledText {
anchors.centerIn: parent
anchors.margins: 2
width: parent.width - 4
text: {
const mods = modelData.mods || []
const key = modelData.key || ""
const parts = [...mods, key]
return parts.join("+")
}
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
isMonospace: true
elide: Text.ElideRight
horizontalAlignment: Text.AlignHCenter
}
}
StyledText {
width: parent.width - 150
text: {
const comment = modelData.comment || ""
if (comment) return comment
const dispatcher = modelData.dispatcher || ""
const params = modelData.params || ""
return params ? `${dispatcher} ${params}` : dispatcher
}
font.pixelSize: Theme.fontSizeSmall
opacity: 0.9
elide: Text.ElideRight
anchors.verticalCenter: parent.verticalCenter
}
}
}
}
}
Repeater {
model: ["Workspace", "Execute"]
Column {
width: rowLayout.columnWidth
spacing: Theme.spacingXS
StyledText {
text: modelData
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Bold
color: Theme.primary
}
Rectangle {
width: parent.width
height: 1
color: Theme.primary
opacity: 0.3
}
Item { width: 1; height: Theme.spacingXS }
Column {
width: parent.width
spacing: Theme.spacingXS
Repeater {
model: rowLayout.categories[modelData] || []
Row {
width: parent.width
spacing: Theme.spacingS
StyledRect {
width: Math.min(140, parent.width * 0.42)
height: 22
radius: 4
opacity: 0.3
StyledText {
anchors.centerIn: parent
anchors.margins: 2
width: parent.width - 4
text: {
const mods = modelData.mods || []
const key = modelData.key || ""
const parts = [...mods, key]
return parts.join("+")
}
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
isMonospace: true
elide: Text.ElideRight
horizontalAlignment: Text.AlignHCenter
}
}
StyledText {
width: parent.width - 150
text: {
const comment = modelData.comment || ""
if (comment) return comment
const dispatcher = modelData.dispatcher || ""
const params = modelData.params || ""
return params ? `${dispatcher} ${params}` : dispatcher
}
font.pixelSize: Theme.fontSizeSmall
opacity: 0.9
elide: Text.ElideRight
anchors.verticalCenter: parent.verticalCenter
}
}
}
}
}
}
}
}
}
}
}

View File

@@ -1,454 +0,0 @@
import QtQuick
import qs.Common
import qs.Modals.Common
import qs.Services
import qs.Widgets
DankModal {
id: root
property int selectedIndex: 0
property int optionCount: SessionService.hibernateSupported ? 5 : 4
property rect parentBounds: Qt.rect(0, 0, 0, 0)
property var parentScreen: null
signal powerActionRequested(string action, string title, string message)
function openCentered() {
parentBounds = Qt.rect(0, 0, 0, 0)
parentScreen = null
backgroundOpacity = 0.5
open()
}
function openFromControlCenter(bounds, targetScreen) {
parentBounds = bounds
parentScreen = targetScreen
backgroundOpacity = 0
open()
}
function selectOption(action) {
close();
const actions = {
"logout": {
"title": I18n.tr("Log Out"),
"message": I18n.tr("Are you sure you want to log out?")
},
"suspend": {
"title": I18n.tr("Suspend"),
"message": I18n.tr("Are you sure you want to suspend the system?")
},
"hibernate": {
"title": I18n.tr("Hibernate"),
"message": I18n.tr("Are you sure you want to hibernate the system?")
},
"reboot": {
"title": I18n.tr("Reboot"),
"message": I18n.tr("Are you sure you want to reboot the system?")
},
"poweroff": {
"title": I18n.tr("Power Off"),
"message": I18n.tr("Are you sure you want to power off the system?")
}
}
const selected = actions[action]
if (selected) {
root.powerActionRequested(action, selected.title, selected.message);
}
}
shouldBeVisible: false
width: 320
height: contentLoader.item ? contentLoader.item.implicitHeight : 300
enableShadow: true
screen: parentScreen
positioning: parentBounds.width > 0 ? "custom" : "center"
customPosition: {
if (parentBounds.width > 0) {
const centerX = parentBounds.x + (parentBounds.width - width) / 2
const centerY = parentBounds.y + (parentBounds.height - height) / 2
return Qt.point(centerX, centerY)
}
return Qt.point(0, 0)
}
onBackgroundClicked: () => {
return close();
}
onOpened: () => {
selectedIndex = 0;
Qt.callLater(() => modalFocusScope.forceActiveFocus());
}
modalFocusScope.Keys.onPressed: (event) => {
switch (event.key) {
case Qt.Key_Up:
case Qt.Key_Backtab:
selectedIndex = (selectedIndex - 1 + optionCount) % optionCount;
event.accepted = true;
break;
case Qt.Key_Down:
case Qt.Key_Tab:
selectedIndex = (selectedIndex + 1) % optionCount;
event.accepted = true;
break;
case Qt.Key_Return:
case Qt.Key_Enter:
const actions = ["logout", "suspend"];
if (SessionService.hibernateSupported) actions.push("hibernate");
actions.push("reboot", "poweroff");
if (selectedIndex < actions.length) {
selectOption(actions[selectedIndex]);
}
event.accepted = true;
break;
case Qt.Key_N:
if (event.modifiers & Qt.ControlModifier) {
selectedIndex = (selectedIndex + 1) % optionCount;
event.accepted = true;
}
break;
case Qt.Key_P:
if (event.modifiers & Qt.ControlModifier) {
selectedIndex = (selectedIndex - 1 + optionCount) % optionCount;
event.accepted = true;
}
break;
case Qt.Key_J:
if (event.modifiers & Qt.ControlModifier) {
selectedIndex = (selectedIndex + 1) % optionCount;
event.accepted = true;
}
break;
case Qt.Key_K:
if (event.modifiers & Qt.ControlModifier) {
selectedIndex = (selectedIndex - 1 + optionCount) % optionCount;
event.accepted = true;
}
break;
}
}
content: Component {
Item {
anchors.fill: parent
implicitHeight: mainColumn.implicitHeight + Theme.spacingL * 2
Column {
id: mainColumn
anchors.fill: parent
anchors.margins: Theme.spacingL
spacing: Theme.spacingM
Row {
width: parent.width
StyledText {
text: I18n.tr("Power Options")
font.pixelSize: Theme.fontSizeLarge
color: Theme.surfaceText
font.weight: Font.Medium
anchors.verticalCenter: parent.verticalCenter
}
Item {
width: parent.width - 150
height: 1
}
DankActionButton {
iconName: "close"
iconSize: Theme.iconSize - 4
iconColor: Theme.surfaceText
onClicked: () => {
return close();
}
}
}
Column {
width: parent.width
spacing: Theme.spacingS
Rectangle {
width: parent.width
height: 50
radius: Theme.cornerRadius
color: {
if (selectedIndex === 0) {
return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12);
} else if (logoutArea.containsMouse) {
return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08);
} else {
return Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.08);
}
}
border.color: selectedIndex === 0 ? Theme.primary : "transparent"
border.width: selectedIndex === 0 ? 1 : 0
Row {
anchors.left: parent.left
anchors.leftMargin: Theme.spacingM
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingM
DankIcon {
name: "logout"
size: Theme.iconSize
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: I18n.tr("Log Out")
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
font.weight: Font.Medium
anchors.verticalCenter: parent.verticalCenter
}
}
MouseArea {
id: logoutArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: () => {
selectedIndex = 0;
selectOption("logout");
}
}
}
Rectangle {
width: parent.width
height: 50
radius: Theme.cornerRadius
color: {
if (selectedIndex === 1) {
return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12);
} else if (suspendArea.containsMouse) {
return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08);
} else {
return Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.08);
}
}
border.color: selectedIndex === 1 ? Theme.primary : "transparent"
border.width: selectedIndex === 1 ? 1 : 0
Row {
anchors.left: parent.left
anchors.leftMargin: Theme.spacingM
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingM
DankIcon {
name: "bedtime"
size: Theme.iconSize
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: I18n.tr("Suspend")
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
font.weight: Font.Medium
anchors.verticalCenter: parent.verticalCenter
}
}
MouseArea {
id: suspendArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: () => {
selectedIndex = 1;
selectOption("suspend");
}
}
}
Rectangle {
width: parent.width
height: 50
radius: Theme.cornerRadius
color: {
if (selectedIndex === 2) {
return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12);
} else if (hibernateArea.containsMouse) {
return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08);
} else {
return Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.08);
}
}
border.color: selectedIndex === 2 ? Theme.primary : "transparent"
border.width: selectedIndex === 2 ? 1 : 0
visible: SessionService.hibernateSupported
Row {
anchors.left: parent.left
anchors.leftMargin: Theme.spacingM
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingM
DankIcon {
name: "ac_unit"
size: Theme.iconSize
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: I18n.tr("Hibernate")
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
font.weight: Font.Medium
anchors.verticalCenter: parent.verticalCenter
}
}
MouseArea {
id: hibernateArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: () => {
selectedIndex = 2;
selectOption("hibernate");
}
}
}
Rectangle {
width: parent.width
height: 50
radius: Theme.cornerRadius
color: {
const rebootIndex = SessionService.hibernateSupported ? 3 : 2;
if (selectedIndex === rebootIndex) {
return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12);
} else if (rebootArea.containsMouse) {
return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08);
} else {
return Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.08);
}
}
border.color: selectedIndex === (SessionService.hibernateSupported ? 3 : 2) ? Theme.primary : "transparent"
border.width: selectedIndex === (SessionService.hibernateSupported ? 3 : 2) ? 1 : 0
Row {
anchors.left: parent.left
anchors.leftMargin: Theme.spacingM
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingM
DankIcon {
name: "restart_alt"
size: Theme.iconSize
color: rebootArea.containsMouse ? Theme.warning : Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: I18n.tr("Reboot")
font.pixelSize: Theme.fontSizeMedium
color: rebootArea.containsMouse ? Theme.warning : Theme.surfaceText
font.weight: Font.Medium
anchors.verticalCenter: parent.verticalCenter
}
}
MouseArea {
id: rebootArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: () => {
selectedIndex = SessionService.hibernateSupported ? 3 : 2;
selectOption("reboot");
}
}
}
Rectangle {
width: parent.width
height: 50
radius: Theme.cornerRadius
color: {
const powerOffIndex = SessionService.hibernateSupported ? 4 : 3;
if (selectedIndex === powerOffIndex) {
return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12);
} else if (powerOffArea.containsMouse) {
return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08);
} else {
return Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.08);
}
}
border.color: selectedIndex === (SessionService.hibernateSupported ? 4 : 3) ? Theme.primary : "transparent"
border.width: selectedIndex === (SessionService.hibernateSupported ? 4 : 3) ? 1 : 0
Row {
anchors.left: parent.left
anchors.leftMargin: Theme.spacingM
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingM
DankIcon {
name: "power_settings_new"
size: Theme.iconSize
color: powerOffArea.containsMouse ? Theme.error : Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: I18n.tr("Power Off")
font.pixelSize: Theme.fontSizeMedium
color: powerOffArea.containsMouse ? Theme.error : Theme.surfaceText
font.weight: Font.Medium
anchors.verticalCenter: parent.verticalCenter
}
}
MouseArea {
id: powerOffArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: () => {
selectedIndex = SessionService.hibernateSupported ? 4 : 3;
selectOption("poweroff");
}
}
}
}
Item {
height: Theme.spacingS
}
}
}
}
}

View File

@@ -1,149 +0,0 @@
pragma ComponentBehavior: Bound
import QtQuick
import qs.Common
import qs.Modals.Settings
import qs.Widgets
Rectangle {
id: sidebarContainer
property int currentIndex: 0
property var parentModal: null
readonly property var sidebarItems: [{
"text": I18n.tr("Personalization"),
"icon": "person"
}, {
"text": I18n.tr("Time & Weather"),
"icon": "schedule"
}, {
"text": I18n.tr("Dank Bar"),
"icon": "toolbar"
}, {
"text": I18n.tr("Widgets"),
"icon": "widgets"
}, {
"text": I18n.tr("Dock"),
"icon": "dock_to_bottom"
}, {
"text": I18n.tr("Displays"),
"icon": "monitor"
}, {
"text": I18n.tr("Launcher"),
"icon": "apps"
}, {
"text": I18n.tr("Theme & Colors"),
"icon": "palette"
}, {
"text": I18n.tr("Power & Security"),
"icon": "power"
}, {
"text": I18n.tr("Plugins"),
"icon": "extension"
}, {
"text": I18n.tr("About"),
"icon": "info"
}]
function navigateNext() {
currentIndex = (currentIndex + 1) % sidebarItems.length
}
function navigatePrevious() {
currentIndex = (currentIndex - 1 + sidebarItems.length) % sidebarItems.length
}
width: 270
height: parent.height
color: Theme.surfaceContainer
radius: Theme.cornerRadius
Column {
anchors.fill: parent
anchors.leftMargin: Theme.spacingS
anchors.rightMargin: Theme.spacingS
anchors.bottomMargin: Theme.spacingS
anchors.topMargin: Theme.spacingM + 2
spacing: Theme.spacingXS
ProfileSection {
parentModal: sidebarContainer.parentModal
}
Rectangle {
width: parent.width - Theme.spacingS * 2
height: 1
color: Theme.outline
opacity: 0.2
}
Item {
width: parent.width
height: Theme.spacingL
}
Repeater {
id: sidebarRepeater
model: sidebarContainer.sidebarItems
delegate: Rectangle {
required property int index
required property var modelData
property bool isActive: sidebarContainer.currentIndex === index
width: parent.width - Theme.spacingS * 2
height: 44
radius: Theme.cornerRadius
color: isActive ? Theme.primary : tabMouseArea.containsMouse ? Theme.surfaceHover : "transparent"
Row {
anchors.left: parent.left
anchors.leftMargin: Theme.spacingM
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingM
DankIcon {
name: modelData.icon || ""
size: Theme.iconSize - 2
color: parent.parent.isActive ? Theme.primaryText : Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: modelData.text || ""
font.pixelSize: Theme.fontSizeMedium
color: parent.parent.isActive ? Theme.primaryText : Theme.surfaceText
font.weight: parent.parent.isActive ? Font.Medium : Font.Normal
anchors.verticalCenter: parent.verticalCenter
}
}
MouseArea {
id: tabMouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: () => {
sidebarContainer.currentIndex = index;
}
}
Behavior on color {
ColorAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
}
}
}
}

View File

@@ -1,244 +0,0 @@
import QtQuick
import QtQuick.Controls
import qs.Common
import qs.Modals.Spotlight
import qs.Modules.AppDrawer
import qs.Services
import qs.Widgets
Item {
id: spotlightKeyHandler
property alias appLauncher: appLauncher
property alias searchField: searchField
property var parentModal: null
function resetScroll() {
resultsView.resetScroll()
}
anchors.fill: parent
focus: true
clip: false
Keys.onPressed: event => {
if (event.key === Qt.Key_Escape) {
if (parentModal)
parentModal.hide()
event.accepted = true
} else if (event.key === Qt.Key_Down) {
appLauncher.selectNext()
event.accepted = true
} else if (event.key === Qt.Key_Up) {
appLauncher.selectPrevious()
event.accepted = true
} else if (event.key === Qt.Key_Right && appLauncher.viewMode === "grid") {
appLauncher.selectNextInRow()
event.accepted = true
} else if (event.key === Qt.Key_Left && appLauncher.viewMode === "grid") {
appLauncher.selectPreviousInRow()
event.accepted = true
} else if (event.key == Qt.Key_J && event.modifiers & Qt.ControlModifier) {
appLauncher.selectNext()
event.accepted = true
} else if (event.key == Qt.Key_K && event.modifiers & Qt.ControlModifier) {
appLauncher.selectPrevious()
event.accepted = true
} else if (event.key == Qt.Key_L && event.modifiers & Qt.ControlModifier && appLauncher.viewMode === "grid") {
appLauncher.selectNextInRow()
event.accepted = true
} else if (event.key == Qt.Key_H && event.modifiers & Qt.ControlModifier && appLauncher.viewMode === "grid") {
appLauncher.selectPreviousInRow()
event.accepted = true
} else if (event.key === Qt.Key_Tab) {
if (appLauncher.viewMode === "grid") {
appLauncher.selectNextInRow()
} else {
appLauncher.selectNext()
}
event.accepted = true
} else if (event.key === Qt.Key_Backtab) {
if (appLauncher.viewMode === "grid") {
appLauncher.selectPreviousInRow()
} else {
appLauncher.selectPrevious()
}
event.accepted = true
} else if (event.key === Qt.Key_N && event.modifiers & Qt.ControlModifier) {
if (appLauncher.viewMode === "grid") {
appLauncher.selectNextInRow()
} else {
appLauncher.selectNext()
}
event.accepted = true
} else if (event.key === Qt.Key_P && event.modifiers & Qt.ControlModifier) {
if (appLauncher.viewMode === "grid") {
appLauncher.selectPreviousInRow()
} else {
appLauncher.selectPrevious()
}
event.accepted = true
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
appLauncher.launchSelected()
event.accepted = true
}
}
AppLauncher {
id: appLauncher
viewMode: SettingsData.spotlightModalViewMode
gridColumns: 4
onAppLaunched: () => {
if (parentModal)
parentModal.hide()
}
onViewModeSelected: mode => {
SettingsData.setSpotlightModalViewMode(mode)
}
}
Column {
anchors.fill: parent
anchors.margins: Theme.spacingM
spacing: Theme.spacingM
clip: false
Row {
width: parent.width
spacing: Theme.spacingM
leftPadding: Theme.spacingS
DankTextField {
id: searchField
width: parent.width - 80 - Theme.spacingL
height: 56
cornerRadius: Theme.cornerRadius
backgroundColor: Theme.surfaceContainerHigh
normalBorderColor: Theme.outlineMedium
focusedBorderColor: Theme.primary
leftIconName: "search"
leftIconSize: Theme.iconSize
leftIconColor: Theme.surfaceVariantText
leftIconFocusedColor: Theme.primary
showClearButton: true
textColor: Theme.surfaceText
font.pixelSize: Theme.fontSizeLarge
enabled: parentModal ? parentModal.spotlightOpen : true
placeholderText: ""
ignoreLeftRightKeys: appLauncher.viewMode !== "list"
ignoreTabKeys: true
keyForwardTargets: [spotlightKeyHandler]
text: appLauncher.searchQuery
onTextEdited: () => {
appLauncher.searchQuery = text
}
Keys.onPressed: event => {
if (event.key === Qt.Key_Escape) {
if (parentModal)
parentModal.hide()
event.accepted = true
} else if ((event.key === Qt.Key_Return || event.key === Qt.Key_Enter) && text.length > 0) {
if (appLauncher.keyboardNavigationActive && appLauncher.model.count > 0)
appLauncher.launchSelected()
else if (appLauncher.model.count > 0)
appLauncher.launchApp(appLauncher.model.get(0))
event.accepted = true
} else if (event.key === Qt.Key_Down || event.key === Qt.Key_Up || event.key === Qt.Key_Left || event.key === Qt.Key_Right || event.key === Qt.Key_Tab || event.key === Qt.Key_Backtab || ((event.key === Qt.Key_Return || event.key === Qt.Key_Enter) && text.length === 0)) {
event.accepted = false
}
}
}
Row {
spacing: Theme.spacingXS
visible: appLauncher.model.count > 0
anchors.verticalCenter: parent.verticalCenter
Rectangle {
width: 36
height: 36
radius: Theme.cornerRadius
color: appLauncher.viewMode === "list" ? Theme.primaryHover : listViewArea.containsMouse ? Theme.surfaceHover : "transparent"
DankIcon {
anchors.centerIn: parent
name: "view_list"
size: 18
color: appLauncher.viewMode === "list" ? Theme.primary : Theme.surfaceText
}
MouseArea {
id: listViewArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: () => {
appLauncher.setViewMode("list")
}
}
}
Rectangle {
width: 36
height: 36
radius: Theme.cornerRadius
color: appLauncher.viewMode === "grid" ? Theme.primaryHover : gridViewArea.containsMouse ? Theme.surfaceHover : "transparent"
DankIcon {
anchors.centerIn: parent
name: "grid_view"
size: 18
color: appLauncher.viewMode === "grid" ? Theme.primary : Theme.surfaceText
}
MouseArea {
id: gridViewArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: () => {
appLauncher.setViewMode("grid")
}
}
}
}
}
SpotlightResults {
id: resultsView
appLauncher: spotlightKeyHandler.appLauncher
contextMenu: contextMenu
}
}
SpotlightContextMenu {
id: contextMenu
appLauncher: spotlightKeyHandler.appLauncher
parentHandler: spotlightKeyHandler
}
MouseArea {
anchors.fill: parent
visible: contextMenu.visible
z: 999
onClicked: () => {
contextMenu.hide()
}
MouseArea {
// Prevent closing when clicking on the menu itself
x: contextMenu.x
y: contextMenu.y
width: contextMenu.width
height: contextMenu.height
onClicked: () => {}
}
}
}

View File

@@ -1,358 +0,0 @@
import QtQuick
import Quickshell
import Quickshell.Widgets
import qs.Common
import qs.Widgets
Rectangle {
id: resultsContainer
// DEVELOPER NOTE: This component renders the Spotlight launcher (accessed via Mod+Space).
// Changes to launcher behavior, especially item rendering, filtering, or model structure,
// likely require corresponding updates in Modules/AppDrawer/AppLauncher.qml and vice versa.
property var appLauncher: null
property var contextMenu: null
function resetScroll() {
resultsList.contentY = 0
resultsGrid.contentY = 0
}
width: parent.width
height: parent.height - y
radius: Theme.cornerRadius
color: "transparent"
clip: true
DankListView {
id: resultsList
property int itemHeight: 60
property int iconSize: 40
property bool showDescription: true
property int itemSpacing: Theme.spacingS
property bool hoverUpdatesSelection: false
property bool keyboardNavigationActive: appLauncher ? appLauncher.keyboardNavigationActive : false
signal keyboardNavigationReset
signal itemClicked(int index, var modelData)
signal itemRightClicked(int index, var modelData, real mouseX, real mouseY)
function ensureVisible(index) {
if (index < 0 || index >= count)
return
const itemY = index * (itemHeight + itemSpacing)
const itemBottom = itemY + itemHeight
if (itemY < contentY)
contentY = itemY
else if (itemBottom > contentY + height)
contentY = itemBottom - height
}
anchors.fill: parent
anchors.margins: Theme.spacingS
visible: appLauncher && appLauncher.viewMode === "list"
model: appLauncher ? appLauncher.model : null
currentIndex: appLauncher ? appLauncher.selectedIndex : -1
clip: true
spacing: itemSpacing
focus: true
interactive: true
cacheBuffer: Math.max(0, Math.min(height * 2, 1000))
reuseItems: true
onCurrentIndexChanged: {
if (keyboardNavigationActive)
ensureVisible(currentIndex)
}
onItemClicked: (index, modelData) => {
if (appLauncher)
appLauncher.launchApp(modelData)
}
onItemRightClicked: (index, modelData, mouseX, mouseY) => {
if (contextMenu)
contextMenu.show(mouseX, mouseY, modelData)
}
onKeyboardNavigationReset: () => {
if (appLauncher)
appLauncher.keyboardNavigationActive = false
}
delegate: Rectangle {
width: ListView.view.width
height: resultsList.itemHeight
radius: Theme.cornerRadius
color: ListView.isCurrentItem ? Theme.primaryPressed : listMouseArea.containsMouse ? Theme.primaryHoverLight : Theme.surfaceContainerHigh
Row {
anchors.fill: parent
anchors.margins: Theme.spacingM
spacing: Theme.spacingL
Item {
width: resultsList.iconSize
height: resultsList.iconSize
anchors.verticalCenter: parent.verticalCenter
visible: model.icon !== undefined && model.icon !== ""
property string iconValue: model.icon || ""
property bool isMaterial: iconValue.indexOf("material:") === 0
property string materialName: isMaterial ? iconValue.substring(9) : ""
DankIcon {
anchors.centerIn: parent
name: parent.materialName
size: resultsList.iconSize
color: Theme.surfaceText
visible: parent.isMaterial
}
IconImage {
id: listIconImg
anchors.fill: parent
source: parent.isMaterial ? "" : Quickshell.iconPath(parent.iconValue, true)
asynchronous: true
visible: !parent.isMaterial && status === Image.Ready
}
Rectangle {
anchors.fill: parent
visible: !parent.isMaterial && !listIconImg.visible
color: Theme.surfaceLight
radius: Theme.cornerRadius
border.width: 1
border.color: Theme.primarySelected
StyledText {
anchors.centerIn: parent
text: (model.name && model.name.length > 0) ? model.name.charAt(0).toUpperCase() : "A"
font.pixelSize: resultsList.iconSize * 0.4
color: Theme.primary
font.weight: Font.Bold
}
}
}
Column {
anchors.verticalCenter: parent.verticalCenter
width: (model.icon !== undefined && model.icon !== "") ? (parent.width - resultsList.iconSize - Theme.spacingL) : parent.width
spacing: Theme.spacingXS
StyledText {
width: parent.width
text: model.name || ""
font.pixelSize: Theme.fontSizeLarge
color: Theme.surfaceText
font.weight: Font.Medium
elide: Text.ElideRight
wrapMode: Text.NoWrap
maximumLineCount: 1
}
StyledText {
width: parent.width
text: model.comment || "Application"
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceVariantText
elide: Text.ElideRight
maximumLineCount: 1
visible: resultsList.showDescription && model.comment && model.comment.length > 0
}
}
}
MouseArea {
id: listMouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
acceptedButtons: Qt.LeftButton | Qt.RightButton
z: 10
onEntered: () => {
if (resultsList.hoverUpdatesSelection && !resultsList.keyboardNavigationActive)
resultsList.currentIndex = index
}
onPositionChanged: () => {
resultsList.keyboardNavigationReset()
}
onClicked: mouse => {
if (mouse.button === Qt.LeftButton) {
resultsList.itemClicked(index, model)
} else if (mouse.button === Qt.RightButton && !model.isPlugin) {
const globalPos = mapToItem(null, mouse.x, mouse.y)
const modalPos = resultsContainer.parent.mapFromItem(null, globalPos.x, globalPos.y)
resultsList.itemRightClicked(index, model, modalPos.x, modalPos.y)
}
}
}
}
}
DankGridView {
id: resultsGrid
property int currentIndex: appLauncher ? appLauncher.selectedIndex : -1
property int columns: 4
property bool adaptiveColumns: false
property int minCellWidth: 120
property int maxCellWidth: 160
property int cellPadding: 8
property real iconSizeRatio: 0.55
property int maxIconSize: 48
property int minIconSize: 32
property bool hoverUpdatesSelection: false
property bool keyboardNavigationActive: appLauncher ? appLauncher.keyboardNavigationActive : false
property int baseCellWidth: adaptiveColumns ? Math.max(minCellWidth, Math.min(maxCellWidth, width / columns)) : (width - Theme.spacingS * 2) / columns
property int baseCellHeight: baseCellWidth + 20
property int actualColumns: adaptiveColumns ? Math.floor(width / cellWidth) : columns
property int remainingSpace: width - (actualColumns * cellWidth)
signal keyboardNavigationReset
signal itemClicked(int index, var modelData)
signal itemRightClicked(int index, var modelData, real mouseX, real mouseY)
function ensureVisible(index) {
if (index < 0 || index >= count)
return
const itemY = Math.floor(index / actualColumns) * cellHeight
const itemBottom = itemY + cellHeight
if (itemY < contentY)
contentY = itemY
else if (itemBottom > contentY + height)
contentY = itemBottom - height
}
anchors.fill: parent
anchors.margins: Theme.spacingS
visible: appLauncher && appLauncher.viewMode === "grid"
model: appLauncher ? appLauncher.model : null
clip: true
cellWidth: baseCellWidth
cellHeight: baseCellHeight
leftMargin: Math.max(Theme.spacingS, remainingSpace / 2)
rightMargin: leftMargin
focus: true
interactive: true
cacheBuffer: Math.max(0, Math.min(height * 2, 1000))
reuseItems: true
onCurrentIndexChanged: {
if (keyboardNavigationActive)
ensureVisible(currentIndex)
}
onItemClicked: (index, modelData) => {
if (appLauncher)
appLauncher.launchApp(modelData)
}
onItemRightClicked: (index, modelData, mouseX, mouseY) => {
if (contextMenu)
contextMenu.show(mouseX, mouseY, modelData)
}
onKeyboardNavigationReset: () => {
if (appLauncher)
appLauncher.keyboardNavigationActive = false
}
delegate: Rectangle {
width: resultsGrid.cellWidth - resultsGrid.cellPadding
height: resultsGrid.cellHeight - resultsGrid.cellPadding
radius: Theme.cornerRadius
color: resultsGrid.currentIndex === index ? Theme.primaryPressed : gridMouseArea.containsMouse ? Theme.primaryHoverLight : Theme.surfaceContainerHigh
Column {
anchors.centerIn: parent
spacing: Theme.spacingS
Item {
property int iconSize: Math.min(resultsGrid.maxIconSize, Math.max(resultsGrid.minIconSize, resultsGrid.cellWidth * resultsGrid.iconSizeRatio))
width: iconSize
height: iconSize
anchors.horizontalCenter: parent.horizontalCenter
visible: model.icon !== undefined && model.icon !== ""
property string iconValue: model.icon || ""
property bool isMaterial: iconValue.indexOf("material:") === 0
property string materialName: isMaterial ? iconValue.substring(9) : ""
DankIcon {
anchors.centerIn: parent
name: parent.materialName
size: parent.iconSize
color: Theme.surfaceText
visible: parent.isMaterial
}
IconImage {
id: gridIconImg
anchors.fill: parent
source: parent.isMaterial ? "" : Quickshell.iconPath(parent.iconValue, true)
smooth: true
asynchronous: true
visible: !parent.isMaterial && status === Image.Ready
}
Rectangle {
anchors.fill: parent
visible: !parent.isMaterial && !gridIconImg.visible
color: Theme.surfaceLight
radius: Theme.cornerRadius
border.width: 1
border.color: Theme.primarySelected
StyledText {
anchors.centerIn: parent
text: (model.name && model.name.length > 0) ? model.name.charAt(0).toUpperCase() : "A"
font.pixelSize: Math.min(28, parent.width * 0.5)
color: Theme.primary
font.weight: Font.Bold
}
}
}
StyledText {
anchors.horizontalCenter: parent.horizontalCenter
width: resultsGrid.cellWidth - 12
text: model.name || ""
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
font.weight: Font.Medium
elide: Text.ElideRight
horizontalAlignment: Text.AlignHCenter
maximumLineCount: 1
wrapMode: Text.NoWrap
}
}
MouseArea {
id: gridMouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
acceptedButtons: Qt.LeftButton | Qt.RightButton
z: 10
onEntered: () => {
if (resultsGrid.hoverUpdatesSelection && !resultsGrid.keyboardNavigationActive)
resultsGrid.currentIndex = index
}
onPositionChanged: () => {
resultsGrid.keyboardNavigationReset()
}
onClicked: mouse => {
if (mouse.button === Qt.LeftButton) {
resultsGrid.itemClicked(index, model)
} else if (mouse.button === Qt.RightButton && !model.isPlugin) {
const globalPos = mapToItem(null, mouse.x, mouse.y)
const modalPos = resultsContainer.parent.mapFromItem(null, globalPos.x, globalPos.y)
resultsGrid.itemRightClicked(index, model, modalPos.x, modalPos.y)
}
}
}
}
}
}

View File

@@ -1,174 +0,0 @@
import QtQuick
import QtQuick.Controls
import Quickshell
import qs.Common
import qs.Services
import qs.Widgets
Rectangle {
id: root
property string currentDeviceName: ""
property string instanceId: ""
signal deviceNameChanged(string newDeviceName)
implicitHeight: brightnessContent.height + Theme.spacingM
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08)
border.width: 0
DankFlickable {
id: brightnessContent
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.margins: Theme.spacingM
anchors.topMargin: Theme.spacingM
contentHeight: brightnessColumn.height
clip: true
Column {
id: brightnessColumn
width: parent.width
spacing: Theme.spacingS
Item {
width: parent.width
height: 100
visible: !DisplayService.brightnessAvailable || !DisplayService.devices || DisplayService.devices.length === 0
Column {
anchors.centerIn: parent
spacing: Theme.spacingM
DankIcon {
anchors.horizontalCenter: parent.horizontalCenter
name: DisplayService.brightnessAvailable ? "brightness_6" : "error"
size: 32
color: DisplayService.brightnessAvailable ? Theme.primary : Theme.error
}
StyledText {
anchors.horizontalCenter: parent.horizontalCenter
text: DisplayService.brightnessAvailable ? "No brightness devices available" : "Brightness control not available"
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
horizontalAlignment: Text.AlignHCenter
}
}
}
Repeater {
model: DisplayService.devices || []
delegate: Rectangle {
required property var modelData
required property int index
width: parent.width
height: 80
radius: Theme.cornerRadius
color: Theme.surfaceContainerHighest
border.color: modelData.name === currentDeviceName ? Theme.primary : Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12)
border.width: modelData.name === currentDeviceName ? 2 : 0
Row {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: Theme.spacingM
spacing: Theme.spacingM
Column {
anchors.verticalCenter: parent.verticalCenter
spacing: 2
DankIcon {
name: {
const deviceClass = modelData.class || ""
const deviceName = modelData.name || ""
if (deviceClass === "backlight" || deviceClass === "ddc") {
const brightness = modelData.percentage || 50
if (brightness <= 33) return "brightness_low"
if (brightness <= 66) return "brightness_medium"
return "brightness_high"
} else if (deviceName.includes("kbd")) {
return "keyboard"
} else {
return "lightbulb"
}
}
size: Theme.iconSize
color: modelData.name === currentDeviceName ? Theme.primary : Theme.surfaceText
anchors.horizontalCenter: parent.horizontalCenter
}
StyledText {
text: (modelData.percentage || 50) + "%"
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
anchors.horizontalCenter: parent.horizontalCenter
}
}
Column {
anchors.verticalCenter: parent.verticalCenter
width: parent.parent.width - parent.parent.anchors.leftMargin - parent.spacing - 50 - Theme.spacingM
StyledText {
text: {
const name = modelData.name || ""
const deviceClass = modelData.class || ""
if (deviceClass === "backlight") {
return name.replace("_", " ").replace(/\b\w/g, c => c.toUpperCase())
}
return name
}
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
font.weight: modelData.name === currentDeviceName ? Font.Medium : Font.Normal
elide: Text.ElideRight
width: parent.width
}
StyledText {
text: modelData.name
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
elide: Text.ElideRight
width: parent.width
}
StyledText {
text: {
const deviceClass = modelData.class || ""
if (deviceClass === "backlight") return "Backlight device"
if (deviceClass === "ddc") return "DDC/CI monitor"
if (deviceClass === "leds") return "LED device"
return deviceClass
}
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
elide: Text.ElideRight
width: parent.width
}
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
currentDeviceName = modelData.name
deviceNameChanged(modelData.name)
}
}
}
}
}
}
}

View File

@@ -1,63 +0,0 @@
import QtQuick
import QtQuick.Controls
import Quickshell
import Quickshell.Services.Pipewire
import qs.Common
import qs.Services
import qs.Widgets
import qs.Modules.ControlCenter.Widgets
CompoundPill {
id: root
property var defaultSource: AudioService.source
iconName: {
if (!defaultSource) return "mic_off"
let volume = defaultSource.audio.volume
let muted = defaultSource.audio.muted
if (muted || volume === 0.0) return "mic_off"
return "mic"
}
isActive: defaultSource && !defaultSource.audio.muted
primaryText: {
if (!defaultSource) {
return "No input device"
}
return defaultSource.description || "Audio Input"
}
secondaryText: {
if (!defaultSource) {
return "Select device"
}
if (defaultSource.audio.muted) {
return "Muted"
}
return Math.round(defaultSource.audio.volume * 100) + "%"
}
onToggled: {
if (defaultSource && defaultSource.audio) {
defaultSource.audio.muted = !defaultSource.audio.muted
}
}
onWheelEvent: function (wheelEvent) {
if (!defaultSource || !defaultSource.audio) return
let delta = wheelEvent.angleDelta.y
let currentVolume = defaultSource.audio.volume * 100
let newVolume
if (delta > 0)
newVolume = Math.min(100, currentVolume + 5)
else
newVolume = Math.max(0, currentVolume - 5)
defaultSource.audio.muted = false
defaultSource.audio.volume = newVolume / 100
wheelEvent.accepted = true
}
}

View File

@@ -1,66 +0,0 @@
import QtQuick
import QtQuick.Controls
import Quickshell
import Quickshell.Services.Pipewire
import qs.Common
import qs.Services
import qs.Widgets
import qs.Modules.ControlCenter.Widgets
CompoundPill {
id: root
property var defaultSink: AudioService.sink
iconName: {
if (!defaultSink) return "volume_off"
let volume = defaultSink.audio.volume
let muted = defaultSink.audio.muted
if (muted || volume === 0.0) return "volume_off"
if (volume <= 0.33) return "volume_down"
if (volume <= 0.66) return "volume_up"
return "volume_up"
}
isActive: defaultSink && !defaultSink.audio.muted
primaryText: {
if (!defaultSink) {
return "No output device"
}
return defaultSink.description || "Audio Output"
}
secondaryText: {
if (!defaultSink) {
return "Select device"
}
if (defaultSink.audio.muted) {
return "Muted"
}
return Math.round(defaultSink.audio.volume * 100) + "%"
}
onToggled: {
if (defaultSink && defaultSink.audio) {
defaultSink.audio.muted = !defaultSink.audio.muted
}
}
onWheelEvent: function (wheelEvent) {
if (!defaultSink || !defaultSink.audio) return
let delta = wheelEvent.angleDelta.y
let currentVolume = defaultSink.audio.volume * 100
let newVolume
if (delta > 0)
newVolume = Math.min(100, currentVolume + 5)
else
newVolume = Math.max(0, currentVolume - 5)
defaultSink.audio.muted = false
defaultSink.audio.volume = newVolume / 100
AudioService.volumeChanged()
wheelEvent.accepted = true
}
}

View File

@@ -1,460 +0,0 @@
import QtQuick
import qs.Common
import qs.Services
Item {
id: root
property var widgetsModel: null
property var components: null
property bool noBackground: false
required property var axis
property string section: "center"
property var parentScreen: null
property real widgetThickness: 30
property real barThickness: 48
readonly property bool isVertical: axis?.isVertical ?? false
readonly property real spacing: noBackground ? 2 : Theme.spacingXS
property var centerWidgets: []
property int totalWidgets: 0
property real totalSize: 0
function updateLayout() {
const containerSize = isVertical ? height : width
if (containerSize <= 0 || !visible) {
return
}
centerWidgets = []
totalWidgets = 0
totalSize = 0
let configuredWidgets = 0
for (var i = 0; i < centerRepeater.count; i++) {
const item = centerRepeater.itemAt(i)
if (item && getWidgetVisible(item.widgetId)) {
configuredWidgets++
if (item.active && item.item) {
centerWidgets.push(item.item)
totalWidgets++
totalSize += isVertical ? item.item.height : item.item.width
}
}
}
if (totalWidgets > 1) {
totalSize += spacing * (totalWidgets - 1)
}
positionWidgets(configuredWidgets)
}
function positionWidgets(configuredWidgets) {
if (totalWidgets === 0 || (isVertical ? height : width) <= 0) {
return
}
const parentCenter = (isVertical ? height : width) / 2
const isOdd = configuredWidgets % 2 === 1
centerWidgets.forEach(widget => {
if (isVertical) {
widget.anchors.verticalCenter = undefined
} else {
widget.anchors.horizontalCenter = undefined
}
})
if (isOdd) {
const middleIndex = Math.floor(configuredWidgets / 2)
let currentActiveIndex = 0
let middleWidget = null
for (var i = 0; i < centerRepeater.count; i++) {
const item = centerRepeater.itemAt(i)
if (item && getWidgetVisible(item.widgetId)) {
if (currentActiveIndex === middleIndex && item.active && item.item) {
middleWidget = item.item
break
}
currentActiveIndex++
}
}
if (middleWidget) {
const middleSize = isVertical ? middleWidget.height : middleWidget.width
if (isVertical) {
middleWidget.y = parentCenter - (middleSize / 2)
} else {
middleWidget.x = parentCenter - (middleSize / 2)
}
let leftWidgets = []
let rightWidgets = []
let foundMiddle = false
for (var i = 0; i < centerWidgets.length; i++) {
if (centerWidgets[i] === middleWidget) {
foundMiddle = true
continue
}
if (!foundMiddle) {
leftWidgets.push(centerWidgets[i])
} else {
rightWidgets.push(centerWidgets[i])
}
}
let currentPos = isVertical ? middleWidget.y : middleWidget.x
for (var i = leftWidgets.length - 1; i >= 0; i--) {
const size = isVertical ? leftWidgets[i].height : leftWidgets[i].width
currentPos -= (spacing + size)
if (isVertical) {
leftWidgets[i].y = currentPos
} else {
leftWidgets[i].x = currentPos
}
}
currentPos = (isVertical ? middleWidget.y : middleWidget.x) + middleSize
for (var i = 0; i < rightWidgets.length; i++) {
currentPos += spacing
if (isVertical) {
rightWidgets[i].y = currentPos
} else {
rightWidgets[i].x = currentPos
}
currentPos += isVertical ? rightWidgets[i].height : rightWidgets[i].width
}
}
} else {
let configuredLeftIndex = (configuredWidgets / 2) - 1
let configuredRightIndex = configuredWidgets / 2
const halfSpacing = spacing / 2
let leftWidget = null
let rightWidget = null
let leftWidgets = []
let rightWidgets = []
let currentConfigIndex = 0
for (var i = 0; i < centerRepeater.count; i++) {
const item = centerRepeater.itemAt(i)
if (item && getWidgetVisible(item.widgetId)) {
if (item.active && item.item) {
if (currentConfigIndex < configuredLeftIndex) {
leftWidgets.push(item.item)
} else if (currentConfigIndex === configuredLeftIndex) {
leftWidget = item.item
} else if (currentConfigIndex === configuredRightIndex) {
rightWidget = item.item
} else {
rightWidgets.push(item.item)
}
}
currentConfigIndex++
}
}
if (leftWidget && rightWidget) {
const leftSize = isVertical ? leftWidget.height : leftWidget.width
if (isVertical) {
leftWidget.y = parentCenter - halfSpacing - leftSize
rightWidget.y = parentCenter + halfSpacing
} else {
leftWidget.x = parentCenter - halfSpacing - leftSize
rightWidget.x = parentCenter + halfSpacing
}
let currentPos = isVertical ? leftWidget.y : leftWidget.x
for (var i = leftWidgets.length - 1; i >= 0; i--) {
const size = isVertical ? leftWidgets[i].height : leftWidgets[i].width
currentPos -= (spacing + size)
if (isVertical) {
leftWidgets[i].y = currentPos
} else {
leftWidgets[i].x = currentPos
}
}
currentPos = (isVertical ? rightWidget.y + rightWidget.height : rightWidget.x + rightWidget.width)
for (var i = 0; i < rightWidgets.length; i++) {
currentPos += spacing
if (isVertical) {
rightWidgets[i].y = currentPos
} else {
rightWidgets[i].x = currentPos
}
currentPos += isVertical ? rightWidgets[i].height : rightWidgets[i].width
}
} else if (leftWidget && !rightWidget) {
const leftSize = isVertical ? leftWidget.height : leftWidget.width
if (isVertical) {
leftWidget.y = parentCenter - halfSpacing - leftSize
} else {
leftWidget.x = parentCenter - halfSpacing - leftSize
}
let currentPos = isVertical ? leftWidget.y : leftWidget.x
for (var i = leftWidgets.length - 1; i >= 0; i--) {
const size = isVertical ? leftWidgets[i].height : leftWidgets[i].width
currentPos -= (spacing + size)
if (isVertical) {
leftWidgets[i].y = currentPos
} else {
leftWidgets[i].x = currentPos
}
}
currentPos = (isVertical ? leftWidget.y + leftWidget.height : leftWidget.x + leftWidget.width) + spacing
for (var i = 0; i < rightWidgets.length; i++) {
currentPos += spacing
if (isVertical) {
rightWidgets[i].y = currentPos
} else {
rightWidgets[i].x = currentPos
}
currentPos += isVertical ? rightWidgets[i].height : rightWidgets[i].width
}
} else if (!leftWidget && rightWidget) {
if (isVertical) {
rightWidget.y = parentCenter + halfSpacing
} else {
rightWidget.x = parentCenter + halfSpacing
}
let currentPos = (isVertical ? rightWidget.y : rightWidget.x) - spacing
for (var i = leftWidgets.length - 1; i >= 0; i--) {
const size = isVertical ? leftWidgets[i].height : leftWidgets[i].width
currentPos -= size
if (isVertical) {
leftWidgets[i].y = currentPos
} else {
leftWidgets[i].x = currentPos
}
currentPos -= spacing
}
currentPos = (isVertical ? rightWidget.y + rightWidget.height : rightWidget.x + rightWidget.width)
for (var i = 0; i < rightWidgets.length; i++) {
currentPos += spacing
if (isVertical) {
rightWidgets[i].y = currentPos
} else {
rightWidgets[i].x = currentPos
}
currentPos += isVertical ? rightWidgets[i].height : rightWidgets[i].width
}
} else if (totalWidgets === 1 && centerWidgets[0]) {
const size = isVertical ? centerWidgets[0].height : centerWidgets[0].width
if (isVertical) {
centerWidgets[0].y = parentCenter - (size / 2)
} else {
centerWidgets[0].x = parentCenter - (size / 2)
}
}
}
}
function getWidgetVisible(widgetId) {
const widgetVisibility = {
"cpuUsage": DgopService.dgopAvailable,
"memUsage": DgopService.dgopAvailable,
"cpuTemp": DgopService.dgopAvailable,
"gpuTemp": DgopService.dgopAvailable,
"network_speed_monitor": DgopService.dgopAvailable
}
return widgetVisibility[widgetId] ?? true
}
function getWidgetComponent(widgetId) {
// Build dynamic component map including plugins
let baseMap = {
"launcherButton": "launcherButtonComponent",
"workspaceSwitcher": "workspaceSwitcherComponent",
"focusedWindow": "focusedWindowComponent",
"runningApps": "runningAppsComponent",
"clock": "clockComponent",
"music": "mediaComponent",
"weather": "weatherComponent",
"systemTray": "systemTrayComponent",
"privacyIndicator": "privacyIndicatorComponent",
"clipboard": "clipboardComponent",
"cpuUsage": "cpuUsageComponent",
"memUsage": "memUsageComponent",
"diskUsage": "diskUsageComponent",
"cpuTemp": "cpuTempComponent",
"gpuTemp": "gpuTempComponent",
"notificationButton": "notificationButtonComponent",
"battery": "batteryComponent",
"controlCenterButton": "controlCenterButtonComponent",
"idleInhibitor": "idleInhibitorComponent",
"spacer": "spacerComponent",
"separator": "separatorComponent",
"network_speed_monitor": "networkComponent",
"keyboard_layout_name": "keyboardLayoutNameComponent",
"vpn": "vpnComponent",
"notepadButton": "notepadButtonComponent",
"colorPicker": "colorPickerComponent",
"systemUpdate": "systemUpdateComponent"
}
// For built-in components, get from components property
const componentKey = baseMap[widgetId]
if (componentKey && root.components[componentKey]) {
return root.components[componentKey]
}
// For plugin components, get from PluginService
var parts = widgetId.split(":")
var pluginId = parts[0]
let pluginComponents = PluginService.getWidgetComponents()
return pluginComponents[pluginId] || null
}
height: parent.height
width: parent.width
anchors.centerIn: parent
Timer {
id: layoutTimer
interval: 0
repeat: false
onTriggered: root.updateLayout()
}
Component.onCompleted: {
layoutTimer.restart()
}
onWidthChanged: {
if (width > 0) {
layoutTimer.restart()
}
}
onHeightChanged: {
if (height > 0) {
layoutTimer.restart()
}
}
onVisibleChanged: {
if (visible && (isVertical ? height : width) > 0) {
layoutTimer.restart()
}
}
Repeater {
id: centerRepeater
model: root.widgetsModel
Loader {
property string widgetId: model.widgetId
property var widgetData: model
property int spacerSize: model.size || 20
anchors.verticalCenter: !root.isVertical ? parent.verticalCenter : undefined
anchors.horizontalCenter: root.isVertical ? parent.horizontalCenter : undefined
active: root.getWidgetVisible(model.widgetId) && (model.widgetId !== "music" || MprisController.activePlayer !== null)
sourceComponent: root.getWidgetComponent(model.widgetId)
opacity: (model.enabled !== false) ? 1 : 0
asynchronous: false
onLoaded: {
if (!item) {
return
}
item.widthChanged.connect(() => layoutTimer.restart())
item.heightChanged.connect(() => layoutTimer.restart())
if (model.widgetId === "spacer") {
item.spacerSize = Qt.binding(() => model.size || 20)
}
if (root.axis && "axis" in item) {
item.axis = Qt.binding(() => root.axis)
}
if (root.axis && "isVertical" in item) {
try {
item.isVertical = Qt.binding(() => root.axis.isVertical)
} catch (e) {
}
}
// Inject properties for plugin widgets
if ("section" in item) {
item.section = root.section
}
if ("parentScreen" in item) {
item.parentScreen = Qt.binding(() => root.parentScreen)
}
if ("widgetThickness" in item) {
item.widgetThickness = Qt.binding(() => root.widgetThickness)
}
if ("barThickness" in item) {
item.barThickness = Qt.binding(() => root.barThickness)
}
// Inject PluginService for plugin widgets
if (item.pluginService !== undefined) {
var parts = model.widgetId.split(":")
var pluginId = parts[0]
var variantId = parts.length > 1 ? parts[1] : null
if (item.pluginId !== undefined) {
item.pluginId = pluginId
}
if (item.variantId !== undefined) {
item.variantId = variantId
}
if (item.variantData !== undefined && variantId) {
item.variantData = PluginService.getPluginVariantData(pluginId, variantId)
}
item.pluginService = PluginService
}
if (item.popoutService !== undefined) {
item.popoutService = PopoutService
}
layoutTimer.restart()
}
onActiveChanged: {
layoutTimer.restart()
}
}
}
Connections {
target: widgetsModel
function onCountChanged() {
layoutTimer.restart()
}
}
// Listen for plugin changes and refresh components
Connections {
target: PluginService
function onPluginLoaded(pluginId) {
// Force refresh of component lookups
for (var i = 0; i < centerRepeater.count; i++) {
var item = centerRepeater.itemAt(i)
if (item && item.widgetId.startsWith(pluginId)) {
item.sourceComponent = root.getWidgetComponent(item.widgetId)
}
}
}
function onPluginUnloaded(pluginId) {
// Force refresh of component lookups
for (var i = 0; i < centerRepeater.count; i++) {
var item = centerRepeater.itemAt(i)
if (item && item.widgetId.startsWith(pluginId)) {
item.sourceComponent = root.getWidgetComponent(item.widgetId)
}
}
}
}
}

View File

@@ -1,661 +0,0 @@
import QtQuick
import QtQuick.Controls
import Quickshell
import Quickshell.Services.SystemTray
import Quickshell.Wayland
import Quickshell.Widgets
import qs.Common
import qs.Widgets
Item {
id: root
property bool isVertical: axis?.isVertical ?? false
property var axis: null
property var parentWindow: null
property var parentScreen: null
property real widgetThickness: 30
property real barThickness: 48
property bool isAtBottom: false
readonly property real horizontalPadding: SettingsData.dankBarNoBackground ? 2 : Theme.spacingS
readonly property var hiddenTrayIds: {
const envValue = Quickshell.env("DMS_HIDE_TRAYIDS") || ""
return envValue ? envValue.split(",").map(id => id.trim().toLowerCase()) : []
}
readonly property var visibleTrayItems: {
if (!hiddenTrayIds.length) {
return SystemTray.items.values
}
return SystemTray.items.values.filter(item => {
const itemId = item?.id || ""
return !hiddenTrayIds.includes(itemId.toLowerCase())
})
}
readonly property int calculatedSize: visibleTrayItems.length > 0 ? visibleTrayItems.length * 24 + horizontalPadding * 2 : 0
readonly property real visualWidth: isVertical ? widgetThickness : calculatedSize
readonly property real visualHeight: isVertical ? calculatedSize : widgetThickness
width: isVertical ? barThickness : visualWidth
height: isVertical ? visualHeight : barThickness
visible: visibleTrayItems.length > 0
Rectangle {
id: visualBackground
width: root.visualWidth
height: root.visualHeight
anchors.centerIn: parent
radius: SettingsData.dankBarNoBackground ? 0 : Theme.cornerRadius
color: {
if (visibleTrayItems.length === 0) {
return "transparent";
}
if (SettingsData.dankBarNoBackground) {
return "transparent";
}
const baseColor = Theme.widgetBaseBackgroundColor;
return Qt.rgba(baseColor.r, baseColor.g, baseColor.b, baseColor.a * Theme.widgetTransparency);
}
}
Loader {
id: layoutLoader
anchors.centerIn: parent
sourceComponent: root.isVertical ? columnComp : rowComp
}
Component {
id: rowComp
Row {
spacing: 0
Repeater {
model: root.visibleTrayItems
delegate: Item {
id: delegateRoot
property var trayItem: modelData
property string iconSource: {
let icon = trayItem && trayItem.icon;
if (typeof icon === 'string' || icon instanceof String) {
if (icon === "") {
return "";
}
if (icon.includes("?path=")) {
const split = icon.split("?path=");
if (split.length !== 2) {
return icon;
}
const name = split[0];
const path = split[1];
let fileName = name.substring(name.lastIndexOf("/") + 1);
if (fileName.startsWith("dropboxstatus")) {
fileName = `hicolor/16x16/status/${fileName}`;
}
return `file://${path}/${fileName}`;
}
if (icon.startsWith("/") && !icon.startsWith("file://")) {
return `file://${icon}`;
}
return icon;
}
return "";
}
width: 24
height: root.barThickness
Rectangle {
id: visualContent
width: 24
height: 24
anchors.centerIn: parent
radius: Theme.cornerRadius
color: trayItemArea.containsMouse ? Theme.primaryHover : "transparent"
IconImage {
anchors.centerIn: parent
width: 16
height: 16
source: delegateRoot.iconSource
asynchronous: true
smooth: true
mipmap: true
}
}
MouseArea {
id: trayItemArea
anchors.fill: parent
acceptedButtons: Qt.LeftButton | Qt.RightButton
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: (mouse) => {
if (!delegateRoot.trayItem) {
return;
}
if (mouse.button === Qt.LeftButton && !delegateRoot.trayItem.onlyMenu) {
delegateRoot.trayItem.activate();
return ;
}
if (delegateRoot.trayItem.hasMenu) {
root.showForTrayItem(delegateRoot.trayItem, visualContent, parentScreen, root.isAtBottom, root.isVertical, root.axis);
}
}
}
}
}
}
}
Component {
id: columnComp
Column {
spacing: 0
Repeater {
model: root.visibleTrayItems
delegate: Item {
id: delegateRoot
property var trayItem: modelData
property string iconSource: {
let icon = trayItem && trayItem.icon;
if (typeof icon === 'string' || icon instanceof String) {
if (icon === "") {
return "";
}
if (icon.includes("?path=")) {
const split = icon.split("?path=");
if (split.length !== 2) {
return icon;
}
const name = split[0];
const path = split[1];
let fileName = name.substring(name.lastIndexOf("/") + 1);
if (fileName.startsWith("dropboxstatus")) {
fileName = `hicolor/16x16/status/${fileName}`;
}
return `file://${path}/${fileName}`;
}
if (icon.startsWith("/") && !icon.startsWith("file://")) {
return `file://${icon}`;
}
return icon;
}
return "";
}
width: root.barThickness
height: 24
Rectangle {
id: visualContent
width: 24
height: 24
anchors.centerIn: parent
radius: Theme.cornerRadius
color: trayItemArea.containsMouse ? Theme.primaryHover : "transparent"
IconImage {
anchors.centerIn: parent
width: 16
height: 16
source: delegateRoot.iconSource
asynchronous: true
smooth: true
mipmap: true
}
}
MouseArea {
id: trayItemArea
anchors.fill: parent
acceptedButtons: Qt.LeftButton | Qt.RightButton
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: (mouse) => {
if (!delegateRoot.trayItem) {
return;
}
if (mouse.button === Qt.LeftButton && !delegateRoot.trayItem.onlyMenu) {
delegateRoot.trayItem.activate();
return ;
}
if (delegateRoot.trayItem.hasMenu) {
root.showForTrayItem(delegateRoot.trayItem, visualContent, parentScreen, root.isAtBottom, root.isVertical, root.axis);
}
}
}
}
}
}
}
Component {
id: trayMenuComponent
Rectangle {
id: menuRoot
property var trayItem: null
property var anchorItem: null
property var parentScreen: null
property bool isAtBottom: false
property bool isVertical: false
property var axis: null
property bool showMenu: false
property var menuHandle: null
ListModel { id: entryStack }
function topEntry() {
return entryStack.count ? entryStack.get(entryStack.count - 1).handle : null
}
function showForTrayItem(item, anchor, screen, atBottom, vertical, axisObj) {
trayItem = item
anchorItem = anchor
parentScreen = screen
isAtBottom = atBottom
isVertical = vertical
axis = axisObj
menuHandle = item?.menu
if (parentScreen) {
for (var i = 0; i < Quickshell.screens.length; i++) {
const s = Quickshell.screens[i]
if (s === parentScreen) {
menuWindow.screen = s
break
}
}
}
showMenu = true
}
function close() {
showMenu = false
}
function showSubMenu(entry) {
if (!entry || !entry.hasChildren) return;
entryStack.append({ handle: entry });
const h = entry.menu || entry;
if (h && typeof h.updateLayout === "function") h.updateLayout();
submenuHydrator.menu = h;
submenuHydrator.open();
Qt.callLater(() => submenuHydrator.close());
}
function goBack() {
if (!entryStack.count) return;
entryStack.remove(entryStack.count - 1);
}
width: 0
height: 0
color: "transparent"
PanelWindow {
id: menuWindow
visible: menuRoot.showMenu && (menuRoot.trayItem?.hasMenu ?? false)
WlrLayershell.layer: WlrLayershell.Overlay
WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
color: "transparent"
anchors {
top: true
left: true
right: true
bottom: true
}
property point anchorPos: Qt.point(screen.width / 2, screen.height / 2)
onVisibleChanged: {
if (visible) {
updatePosition()
}
}
function updatePosition() {
if (!menuRoot.anchorItem || !menuRoot.trayItem) {
anchorPos = Qt.point(screen.width / 2, screen.height / 2)
return
}
const globalPos = menuRoot.anchorItem.mapToGlobal(0, 0)
const screenX = screen.x || 0
const screenY = screen.y || 0
const relativeX = globalPos.x - screenX
const relativeY = globalPos.y - screenY
const widgetThickness = Math.max(20, 26 + SettingsData.dankBarInnerPadding * 0.6)
const effectiveBarThickness = Math.max(widgetThickness + SettingsData.dankBarInnerPadding + 4, Theme.barHeight - 4 - (8 - SettingsData.dankBarInnerPadding))
if (menuRoot.isVertical) {
const edge = menuRoot.axis?.edge
let targetX
if (edge === "left") {
targetX = effectiveBarThickness + SettingsData.dankBarSpacing + Theme.popupDistance
} else {
const popupX = effectiveBarThickness + SettingsData.dankBarSpacing + Theme.popupDistance
targetX = screen.width - popupX
}
anchorPos = Qt.point(targetX, relativeY + menuRoot.anchorItem.height / 2)
} else {
let targetY
if (menuRoot.isAtBottom) {
const popupY = effectiveBarThickness + SettingsData.dankBarSpacing + SettingsData.dankBarBottomGap + Theme.popupDistance
targetY = screen.height - popupY
} else {
targetY = effectiveBarThickness + SettingsData.dankBarSpacing + SettingsData.dankBarBottomGap + Theme.popupDistance
}
anchorPos = Qt.point(relativeX + menuRoot.anchorItem.width / 2, targetY)
}
}
Rectangle {
id: menuContainer
width: Math.min(500, Math.max(250, menuColumn.implicitWidth + Theme.spacingS * 2))
height: Math.max(40, menuColumn.implicitHeight + Theme.spacingS * 2)
x: {
if (menuRoot.isVertical) {
const edge = menuRoot.axis?.edge
if (edge === "left") {
const targetX = menuWindow.anchorPos.x
return Math.min(menuWindow.screen.width - width - 10, targetX)
} else {
const targetX = menuWindow.anchorPos.x - width
return Math.max(10, targetX)
}
} else {
const left = 10
const right = menuWindow.width - width - 10
const want = menuWindow.anchorPos.x - width / 2
return Math.max(left, Math.min(right, want))
}
}
y: {
if (menuRoot.isVertical) {
const top = 10
const bottom = menuWindow.height - height - 10
const want = menuWindow.anchorPos.y - height / 2
return Math.max(top, Math.min(bottom, want))
} else {
if (menuRoot.isAtBottom) {
const targetY = menuWindow.anchorPos.y - height
return Math.max(10, targetY)
} else {
const targetY = menuWindow.anchorPos.y
return Math.min(menuWindow.screen.height - height - 10, targetY)
}
}
}
color: Theme.popupBackground()
radius: Theme.cornerRadius
border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08)
border.width: 1
opacity: menuRoot.showMenu ? 1 : 0
scale: menuRoot.showMenu ? 1 : 0.85
Rectangle {
anchors.fill: parent
anchors.topMargin: 4
anchors.leftMargin: 2
anchors.rightMargin: -2
anchors.bottomMargin: -4
radius: parent.radius
color: Qt.rgba(0, 0, 0, 0.15)
z: parent.z - 1
}
QsMenuAnchor {
id: submenuHydrator
anchor.window: menuWindow
}
QsMenuOpener {
id: rootOpener
menu: menuRoot.menuHandle
}
QsMenuOpener {
id: subOpener
menu: {
const e = menuRoot.topEntry();
return e ? (e.menu || e) : null;
}
}
Column {
id: menuColumn
width: parent.width - Theme.spacingS * 2
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: parent.top
anchors.topMargin: Theme.spacingS
spacing: 1
Rectangle {
visible: entryStack.count > 0
width: parent.width
height: 28
radius: Theme.cornerRadius
color: backArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent"
Row {
anchors.left: parent.left
anchors.leftMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingXS
DankIcon {
name: "arrow_back"
size: 16
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: I18n.tr("Back")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
}
MouseArea {
id: backArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: menuRoot.goBack()
}
}
Rectangle {
visible: entryStack.count > 0
width: parent.width
height: 1
color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2)
}
Repeater {
model: entryStack.count
? (subOpener.children ? subOpener.children
: (menuRoot.topEntry()?.children || []))
: rootOpener.children
Rectangle {
property var menuEntry: modelData
width: menuColumn.width
height: menuEntry?.isSeparator ? 1 : 28
radius: menuEntry?.isSeparator ? 0 : Theme.cornerRadius
color: {
if (menuEntry?.isSeparator) {
return Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2)
}
return itemArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent"
}
MouseArea {
id: itemArea
anchors.fill: parent
enabled: !menuEntry?.isSeparator && (menuEntry?.enabled !== false)
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (!menuEntry || menuEntry.isSeparator) return;
if (menuEntry.hasChildren) {
menuRoot.showSubMenu(menuEntry);
} else {
if (typeof menuEntry.activate === "function") {
menuEntry.activate();
} else if (typeof menuEntry.triggered === "function") {
menuEntry.triggered();
}
Qt.createQmlObject('import QtQuick; Timer { interval: 80; running: true; repeat: false; onTriggered: menuRoot.close() }', menuRoot);
}
}
}
Row {
anchors.left: parent.left
anchors.leftMargin: Theme.spacingS
anchors.right: parent.right
anchors.rightMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingXS
visible: !menuEntry?.isSeparator
Rectangle {
width: 16
height: 16
anchors.verticalCenter: parent.verticalCenter
visible: menuEntry?.buttonType !== undefined && menuEntry.buttonType !== 0
radius: menuEntry?.buttonType === 2 ? 8 : 2
border.width: 1
border.color: Theme.outline
color: "transparent"
Rectangle {
anchors.centerIn: parent
width: parent.width - 6
height: parent.height - 6
radius: parent.radius - 3
color: Theme.primary
visible: menuEntry?.checkState === 2
}
DankIcon {
anchors.centerIn: parent
name: "check"
size: 10
color: Theme.primaryText
visible: menuEntry?.buttonType === 1 && menuEntry?.checkState === 2
}
}
Item {
width: 16
height: 16
anchors.verticalCenter: parent.verticalCenter
visible: menuEntry?.icon && menuEntry.icon !== ""
Image {
anchors.fill: parent
source: menuEntry?.icon || ""
sourceSize.width: 16
sourceSize.height: 16
fillMode: Image.PreserveAspectFit
smooth: true
}
}
StyledText {
text: menuEntry?.text || ""
font.pixelSize: Theme.fontSizeSmall
color: (menuEntry?.enabled !== false) ? Theme.surfaceText : Theme.surfaceTextMedium
elide: Text.ElideRight
anchors.verticalCenter: parent.verticalCenter
width: Math.max(150, parent.width - 64)
wrapMode: Text.NoWrap
}
Item {
width: 16
height: 16
anchors.verticalCenter: parent.verticalCenter
DankIcon {
anchors.centerIn: parent
name: "chevron_right"
size: 14
color: Theme.surfaceText
visible: menuEntry?.hasChildren ?? false
}
}
}
}
}
}
Behavior on opacity {
NumberAnimation {
duration: Theme.mediumDuration
easing.type: Theme.emphasizedEasing
}
}
Behavior on scale {
NumberAnimation {
duration: Theme.mediumDuration
easing.type: Theme.emphasizedEasing
}
}
}
MouseArea {
anchors.fill: parent
z: -1
onClicked: menuRoot.close()
}
}
}
}
property var currentTrayMenu: null
function showForTrayItem(item, anchor, screen, atBottom, vertical, axisObj) {
if (currentTrayMenu) {
currentTrayMenu.destroy()
}
currentTrayMenu = trayMenuComponent.createObject(null)
if (currentTrayMenu) {
currentTrayMenu.showForTrayItem(item, anchor, screen, atBottom, vertical ?? false, axisObj)
}
}
}

View File

@@ -1,263 +0,0 @@
import QtQuick
import QtQuick.Controls
import Quickshell
import Quickshell.Wayland
import qs.Common
import qs.Services
import qs.Widgets
Item {
id: root
property var contextMenu: null
property bool requestDockShow: false
property int pinnedAppCount: 0
property bool groupByApp: false
property bool isVertical: false
property var dockScreen: null
property real iconSize: 40
clip: false
implicitWidth: isVertical ? appLayout.height : appLayout.width
implicitHeight: isVertical ? appLayout.width : appLayout.height
function movePinnedApp(fromIndex, toIndex) {
if (fromIndex === toIndex) {
return
}
const currentPinned = [...(SessionData.pinnedApps || [])]
if (fromIndex < 0 || fromIndex >= currentPinned.length || toIndex < 0 || toIndex >= currentPinned.length) {
return
}
const movedApp = currentPinned.splice(fromIndex, 1)[0]
currentPinned.splice(toIndex, 0, movedApp)
SessionData.setPinnedApps(currentPinned)
}
Item {
id: appLayout
width: layoutFlow.width
height: layoutFlow.height
anchors.horizontalCenter: root.isVertical ? undefined : parent.horizontalCenter
anchors.verticalCenter: root.isVertical ? parent.verticalCenter : undefined
anchors.left: root.isVertical && SettingsData.dockPosition === SettingsData.Position.Left ? parent.left : undefined
anchors.right: root.isVertical && SettingsData.dockPosition === SettingsData.Position.Right ? parent.right : undefined
anchors.top: root.isVertical ? undefined : parent.top
Flow {
id: layoutFlow
flow: root.isVertical ? Flow.TopToBottom : Flow.LeftToRight
spacing: Math.min(8, Math.max(4, root.iconSize * 0.08))
Repeater {
id: repeater
model: ListModel {
id: dockModel
Component.onCompleted: updateModel()
function updateModel() {
clear()
const items = []
const pinnedApps = [...(SessionData.pinnedApps || [])]
const sortedToplevels = CompositorService.sortedToplevels
if (root.groupByApp) {
// Group windows by appId
const appGroups = new Map()
// Add pinned apps first (even if they have no windows)
pinnedApps.forEach(appId => {
appGroups.set(appId, {
appId: appId,
isPinned: true,
windows: []
})
})
// Group all running windows by appId
sortedToplevels.forEach((toplevel, index) => {
const appId = toplevel.appId || "unknown"
if (!appGroups.has(appId)) {
appGroups.set(appId, {
appId: appId,
isPinned: false,
windows: []
})
}
const title = toplevel.title || "(Unnamed)"
const truncatedTitle = title.length > 50 ? title.substring(0, 47) + "..." : title
const uniqueId = toplevel.title + "|" + (toplevel.appId || "") + "|" + index
appGroups.get(appId).windows.push({
windowId: index,
windowTitle: truncatedTitle,
uniqueId: uniqueId
})
})
// Sort groups: pinned first, then unpinned
const pinnedGroups = []
const unpinnedGroups = []
Array.from(appGroups.entries()).forEach(([appId, group]) => {
// For grouped apps, just show the first window info but track all windows
const firstWindow = group.windows.length > 0 ? group.windows[0] : null
const item = {
"type": "grouped",
"appId": appId,
"windowId": firstWindow ? firstWindow.windowId : -1,
"windowTitle": firstWindow ? firstWindow.windowTitle : "",
"workspaceId": -1,
"isPinned": group.isPinned,
"isRunning": group.windows.length > 0,
"windowCount": group.windows.length,
"uniqueId": firstWindow ? firstWindow.uniqueId : "",
"allWindows": group.windows
}
if (group.isPinned) {
pinnedGroups.push(item)
} else {
unpinnedGroups.push(item)
}
})
// Add items in order
pinnedGroups.forEach(item => items.push(item))
// Add separator if needed
if (pinnedGroups.length > 0 && unpinnedGroups.length > 0) {
items.push({
"type": "separator",
"appId": "__SEPARATOR__",
"windowId": -1,
"windowTitle": "",
"workspaceId": -1,
"isPinned": false,
"isRunning": false
})
}
unpinnedGroups.forEach(item => items.push(item))
root.pinnedAppCount = pinnedGroups.length
} else {
pinnedApps.forEach(appId => {
items.push({
"type": "pinned",
"appId": appId,
"windowId": -1,
"windowTitle": "",
"workspaceId": -1,
"isPinned": true,
"isRunning": false
})
})
root.pinnedAppCount = pinnedApps.length
if (pinnedApps.length > 0 && sortedToplevels.length > 0) {
items.push({
"type": "separator",
"appId": "__SEPARATOR__",
"windowId": -1,
"windowTitle": "",
"workspaceId": -1,
"isPinned": false,
"isRunning": false,
"isFocused": false
})
}
sortedToplevels.forEach((toplevel, index) => {
const title = toplevel.title || "(Unnamed)"
const truncatedTitle = title.length > 50 ? title.substring(0, 47) + "..." : title
const uniqueId = toplevel.title + "|" + (toplevel.appId || "") + "|" + index
items.push({
"type": "window",
"appId": toplevel.appId,
"windowId": index,
"windowTitle": truncatedTitle,
"workspaceId": -1,
"isPinned": false,
"isRunning": true,
"uniqueId": uniqueId
})
})
}
items.forEach(item => append(item))
}
}
delegate: Item {
id: delegateItem
property alias dockButton: button
clip: false
width: model.type === "separator" ? (root.isVertical ? root.iconSize : 8) : (root.isVertical ? root.iconSize : root.iconSize * 1.2)
height: model.type === "separator" ? (root.isVertical ? 8 : root.iconSize) : (root.isVertical ? root.iconSize * 1.2 : root.iconSize)
Rectangle {
visible: model.type === "separator"
width: root.isVertical ? root.iconSize * 0.5 : 2
height: root.isVertical ? 2 : root.iconSize * 0.5
color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.3)
radius: 1
anchors.centerIn: parent
}
MouseArea {
visible: model.type === "separator"
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.NoButton
}
DockAppButton {
id: button
visible: model.type !== "separator"
anchors.centerIn: parent
width: delegateItem.width
height: delegateItem.height
actualIconSize: root.iconSize
appData: model
contextMenu: root.contextMenu
dockApps: root
index: model.index
parentDockScreen: root.dockScreen
showWindowTitle: model.type === "window" || model.type === "grouped"
windowTitle: model.windowTitle || ""
}
}
}
}
}
Connections {
target: CompositorService
function onSortedToplevelsChanged() {
dockModel.updateModel()
}
}
Connections {
target: SessionData
function onPinnedAppsChanged() {
dockModel.updateModel()
}
}
onGroupByAppChanged: {
dockModel.updateModel()
}
}

View File

@@ -1,25 +0,0 @@
pragma ComponentBehavior: Bound
import QtQuick
import qs.Common
import qs.Services
import qs.Widgets
DankActionButton {
id: customButtonKeyboard
circular: false
property string text: ""
width: 40
height: 40
property bool isShift: false
color: Theme.surface
StyledText {
id: contentItem
anchors.centerIn: parent
text: parent.text
color: Theme.surfaceText
font.pixelSize: Theme.fontSizeXLarge
font.weight: Font.Normal
}
}

View File

@@ -1,361 +0,0 @@
import QtQuick
import qs.Common
Rectangle {
id: root
property Item target
height: 60 * 5
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
color: Theme.widgetBackground
property double rowSpacing: 0.01 * width // horizontal spacing between keyboard
property double columnSpacing: 0.02 * height // vertical spacing between keyboard
property bool shift: false //Boolean for the shift state
property bool symbols: false //Boolean for the symbol state
property double columns: 10 // Number of column
property double rows: 4 // Number of row
property string strShift: '\u2191' // UPWARDS ARROW unicode
property string strBackspace: "Backspace"
property var modelKeyboard: {
"row_1": [
{
text: 'q',
symbol: '1',
width: 1
},
{
text: 'w',
symbol: '2',
width: 1
},
{
text: 'e',
symbol: '3',
width: 1
},
{
text: 'r',
symbol: '4',
width: 1
},
{
text: 't',
symbol: '5',
width: 1
},
{
text: 'y',
symbol: '6',
width: 1
},
{
text: 'u',
symbol: '7',
width: 1
},
{
text: 'i',
symbol: '8',
width: 1
},
{
text: 'o',
symbol: '9',
width: 1
},
{
text: 'p',
symbol: '0',
width: 1
},
],
"row_2": [
{
text: 'a',
symbol: '-',
width: 1
},
{
text: 's',
symbol: '/',
width: 1
},
{
text: 'd',
symbol: ':',
width: 1
},
{
text: 'f',
symbol: ';',
width: 1
},
{
text: 'g',
symbol: '(',
width: 1
},
{
text: 'h',
symbol: ')',
width: 1
},
{
text: 'j',
symbol: '€',
width: 1
},
{
text: 'k',
symbol: '&',
width: 1
},
{
text: 'l',
symbol: '@',
width: 1
}
],
"row_3": [
{
text: strShift,
symbol: strShift,
width: 1.5
},
{
text: 'z',
symbol: '.',
width: 1
},
{
text: 'x',
symbol: ',',
width: 1
},
{
text: 'c',
symbol: '?',
width: 1
},
{
text: 'v',
symbol: '!',
width: 1
},
{
text: 'b',
symbol: "'",
width: 1
},
{
text: 'n',
symbol: "%",
width: 1
},
{
text: 'm',
symbol: '"',
width: 1
},
{
text: "'",
symbol: "*",
width: 1.5
}
],
"row_4": [
{
text: "123",
symbol: 'ABC',
width: 1.5
},
{
text: ' ',
symbol: ' ',
width: 6
},
{
text: '.',
symbol: '.',
width: 1
},
{
text: strBackspace,
symbol: strBackspace,
width: 1.5
}
]
}
//Here is the corresponding table between the ascii and the key event
property var tableKeyEvent: {
"_0": Qt.Key_0,
"_1": Qt.Key_1,
"_2": Qt.Key_2,
"_3": Qt.Key_3,
"_4": Qt.Key_4,
"_5": Qt.Key_5,
"_6": Qt.Key_6,
"_7": Qt.Key_7,
"_8": Qt.Key_8,
"_9": Qt.Key_9,
"_a": Qt.Key_A,
"_b": Qt.Key_B,
"_c": Qt.Key_C,
"_d": Qt.Key_D,
"_e": Qt.Key_E,
"_f": Qt.Key_F,
"_g": Qt.Key_G,
"_h": Qt.Key_H,
"_i": Qt.Key_I,
"_j": Qt.Key_J,
"_k": Qt.Key_K,
"_l": Qt.Key_L,
"_m": Qt.Key_M,
"_n": Qt.Key_N,
"_o": Qt.Key_O,
"_p": Qt.Key_P,
"_q": Qt.Key_Q,
"_r": Qt.Key_R,
"_s": Qt.Key_S,
"_t": Qt.Key_T,
"_u": Qt.Key_U,
"_v": Qt.Key_V,
"_w": Qt.Key_W,
"_x": Qt.Key_X,
"_y": Qt.Key_Y,
"_z": Qt.Key_Z,
"_\u2190": Qt.Key_Backspace,
"_return": Qt.Key_Return,
"_ ": Qt.Key_Space,
"_-": Qt.Key_Minus,
"_/": Qt.Key_Slash,
"_:": Qt.Key_Colon,
"_;": Qt.Key_Semicolon,
"_(": Qt.Key_BracketLeft,
"_)": Qt.Key_BracketRight,
"_€": parseInt("20ac", 16) // I didn't find the appropriate Qt event so I used the hex format
,
"_&": Qt.Key_Ampersand,
"_@": Qt.Key_At,
'_"': Qt.Key_QuoteDbl,
"_.": Qt.Key_Period,
"_,": Qt.Key_Comma,
"_?": Qt.Key_Question,
"_!": Qt.Key_Exclam,
"_'": Qt.Key_Apostrophe,
"_%": Qt.Key_Percent,
"_*": Qt.Key_Asterisk
}
Item {
id: keyboard_container
anchors.left: parent.left
anchors.leftMargin: 5
anchors.right: parent.right
anchors.top: parent.top
anchors.topMargin: 5
anchors.bottom: parent.bottom
anchors.bottomMargin: 5
//One column which contains 5 rows
Column {
spacing: columnSpacing
Row {
id: row_1
spacing: rowSpacing
Repeater {
model: modelKeyboard["row_1"]
delegate: CustomButtonKeyboard {
text: symbols ? modelData.symbol : shift ? modelData.text.toUpperCase() : modelData.text
width: modelData.width * keyboard_container.width / columns - rowSpacing
height: keyboard_container.height / rows - columnSpacing
onClicked: root.clicked(text)
}
}
}
Row {
id: row_2
spacing: rowSpacing
Repeater {
model: modelKeyboard["row_2"]
delegate: CustomButtonKeyboard {
text: symbols ? modelData.symbol : shift ? modelData.text.toUpperCase() : modelData.text
width: modelData.width * keyboard_container.width / columns - rowSpacing
height: keyboard_container.height / rows - columnSpacing
onClicked: root.clicked(text)
}
}
}
Row {
id: row_3
spacing: rowSpacing
Repeater {
model: modelKeyboard["row_3"]
delegate: CustomButtonKeyboard {
text: symbols ? modelData.symbol : shift ? modelData.text.toUpperCase() : modelData.text
width: modelData.width * keyboard_container.width / columns - rowSpacing
height: keyboard_container.height / rows - columnSpacing
isShift: shift && text === strShift
onClicked: root.clicked(text)
}
}
}
Row {
id: row_4
spacing: rowSpacing
Repeater {
model: modelKeyboard["row_4"]
delegate: CustomButtonKeyboard {
text: symbols ? modelData.symbol : shift ? modelData.text.toUpperCase() : modelData.text
width: modelData.width * keyboard_container.width / columns - rowSpacing
height: keyboard_container.height / rows - columnSpacing
onClicked: root.clicked(text)
}
}
}
}
}
signal clicked(string text)
Connections {
target: root
function onClicked(text) {
if (!keyboard_controller.target)
return;
if (text === strShift) {
root.shift = !root.shift; // toggle shift
} else if (text === '123') {
root.symbols = true;
} else if (text === 'ABC') {
root.symbols = false;
} else {
// insert text into target
if (text === strBackspace) {
var current = keyboard_controller.target.text;
keyboard_controller.target.text = current.slice(0, current.length - 1);
} else {
// normal character
var charToInsert = root.symbols ? text : (root.shift ? text.toUpperCase() : text);
var current = keyboard_controller.target.text;
var cursorPos = keyboard_controller.target.cursorPosition;
keyboard_controller.target.text = current.slice(0, cursorPos) + charToInsert + current.slice(cursorPos);
keyboard_controller.target.cursorPosition = cursorPos + 1;
}
// shift is momentary
if (root.shift && text !== strShift)
root.shift = false;
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,63 +0,0 @@
import QtCore
import QtQuick
import Quickshell.Io
import Quickshell
import qs.Common
Item {
id: root
property string monitor: ""
property string sceneId: ""
property string pendingSceneId: ""
Process {
id: weProcess
running: false
command: []
}
Process {
id: killer
running: false
command: []
onExited: (code) => {
if (pendingSceneId !== "") {
const cacheHome = StandardPaths.writableLocation(StandardPaths.GenericCacheLocation).toString()
const baseDir = Paths.strip(cacheHome)
const outDir = baseDir + "/DankMaterialShell/we_screenshots"
const outPath = outDir + "/" + pendingSceneId + ".jpg"
Quickshell.execDetached(["mkdir", "-p", outDir])
weProcess.command = [
"linux-wallpaperengine",
"--screen-root", monitor,
"--screenshot", outPath,
"--bg", pendingSceneId,
"--silent"
]
weProcess.running = true
sceneId = pendingSceneId
pendingSceneId = ""
}
}
}
function start(newSceneId) {
if (sceneId === newSceneId && weProcess.running) {
return
}
pendingSceneId = newSceneId
stop()
}
function stop() {
if (weProcess.running) {
weProcess.running = false
}
killer.command = [
"pkill", "-f",
"linux-wallpaperengine --screen-root " + monitor
]
killer.running = true
}
}

852
README.md
View File

@@ -1,820 +1,188 @@
# DankMaterialShell (dms)
# DankMaterialShell
<div align=center>
<div align="center">
<a href="https://danklinux.com">
<img src="assets/danklogo.svg" alt="DankMaterialShell" width="200">
</a>
### A modern desktop shell for Wayland
Built with [Quickshell](https://quickshell.org/) and [Go](https://go.dev/)
[![Documentation](https://img.shields.io/badge/docs-danklinux.com-9ccbfb?style=for-the-badge&labelColor=101418)](https://danklinux.com/docs)
[![GitHub stars](https://img.shields.io/github/stars/AvengeMedia/DankMaterialShell?style=for-the-badge&labelColor=101418&color=ffd700)](https://github.com/AvengeMedia/DankMaterialShell/stargazers)
[![GitHub License](https://img.shields.io/github/license/AvengeMedia/DankMaterialShell?style=for-the-badge&labelColor=101418&color=b9c8da)](https://github.com/AvengeMedia/DankMaterialShell/blob/master/LICENSE)
[![GitHub release](https://img.shields.io/github/v/release/AvengeMedia/DankMaterialShell?style=for-the-badge&labelColor=101418&color=9ccbfb)](https://github.com/AvengeMedia/DankMaterialShell/releases)
[![GitHub last commit](https://img.shields.io/github/last-commit/AvengeMedia/DankMaterialShell?style=for-the-badge&labelColor=101418&color=9ccbfb)](https://github.com/AvengeMedia/DankMaterialShell/commits/master)
[![AUR version](https://img.shields.io/aur/version/dms-shell-bin?style=for-the-badge&labelColor=101418&color=9ccbfb)](https://aur.archlinux.org/packages/dms-shell-bin)
[![AUR version (git)](https://img.shields.io/aur/version/dms-shell-git?style=for-the-badge&labelColor=101418&color=9ccbfb&label=AUR%20(git))](https://aur.archlinux.org/packages/dms-shell-git)
[![Ko-Fi donate](https://img.shields.io/badge/donate-kofi?style=for-the-badge&logo=ko-fi&logoColor=ffffff&label=ko-fi&labelColor=101418&color=f16061&link=https%3A%2F%2Fko-fi.com%2Favengemediallc)](https://ko-fi.com/avengemediallc)
</div>
A modern Wayland desktop shell built with [Quickshell](https://quickshell.org/) and [Go](https://go.dev/). Optimized for the [niri](https://github.com/YaLTeR/niri) and [Hyprland](https://hyprland.org/) compositors.
DankMaterialShell is a complete desktop shell for [niri](https://github.com/YaLTeR/niri), [Hyprland](https://hyprland.org/), [MangoWC](https://github.com/DreamMaoMao/mangowc), [Sway](https://swaywm.org), and other Wayland compositors. It replaces waybar, swaylock, swayidle, mako, fuzzel, polkit, and everything else you'd normally stitch together to make a desktop.
Features notifications, app launcher, wallpaper customization, and fully customizable with [plugins](https://github.com/AvengeMedia/dms-plugin-registry).
## Repository Structure
## Screenshots
This is a monorepo containing both the shell interface and the core backend services:
<div align="center">
<div style="max-width: 700px; margin: 0 auto;">
```
DankMaterialShell/
├── quickshell/ # QML-based shell interface
│ ├── Modules/ # UI components (panels, widgets, overlays)
│ ├── Services/ # System integration (audio, network, bluetooth)
│ ├── Widgets/ # Reusable UI controls
│ └── Common/ # Shared resources and themes
├── core/ # Go backend and CLI
│ ├── cmd/ # dms CLI and dankinstall binaries
│ ├── internal/ # System integration, IPC, distro support
│ └── pkg/ # Shared packages
├── distro/ # Distribution packaging
│ ├── fedora/ # Fedora RPM specs
│ ├── debian/ # Debian packaging
│ └── nix/ # NixOS/home-manager modules
└── flake.nix # Nix flake for declarative installation
```
https://github.com/user-attachments/assets/40d2c56e-c1c9-4671-b04f-8f8b7b83b9ec
</div>
</div>
<details><summary><strong>View More Screenshots</strong></summary>
<br>
## See it in Action
<div align="center">
### Desktop Overview
https://github.com/user-attachments/assets/1200a739-7770-4601-8b85-695ca527819a
<img src="https://github.com/user-attachments/assets/203a9678-c3b7-4720-bb97-853a511ac5c8" width="600" alt="DankMaterialShell Desktop" />
</div>
### Dashboard
<details><summary><strong>More Screenshots</strong></summary>
<img width="600" alt="DankDash" src="https://github.com/user-attachments/assets/a937cf35-a43b-4558-8c39-5694ff5fcac4" />
<div align="center">
### Application Launcher
<img src="https://github.com/user-attachments/assets/203a9678-c3b7-4720-bb97-853a511ac5c8" width="600" alt="Desktop" />
<img src="https://github.com/user-attachments/assets/2da00ea1-8921-4473-a2a9-44a44535a822" width="450" alt="Spotlight Launcher" />
<img src="https://github.com/user-attachments/assets/a937cf35-a43b-4558-8c39-5694ff5fcac4" width="600" alt="Dashboard" />
### Control Center
<img src="https://github.com/user-attachments/assets/2da00ea1-8921-4473-a2a9-44a44535a822" width="450" alt="Launcher" />
<img width="600" alt="Control Center" src="https://github.com/user-attachments/assets/732c30de-5f4a-4a2b-a995-c8ab656cecd5" />
### System Monitor
<img src="https://github.com/user-attachments/assets/b3c817ec-734d-4974-929f-2d11a1065349" width="600" alt="System Monitor" />
### Widget Customization
<img src="https://github.com/user-attachments/assets/903f7c60-146f-4fb3-a75d-a4823828f298" width="500" alt="Widget Customization" />
### Lock Screen
<img src="https://github.com/user-attachments/assets/3fa07de2-c1b0-4e57-8f25-3830ac6baf4f" width="600" alt="Lock Screen" />
### Dynamic Theming
<img src="https://github.com/user-attachments/assets/a81a68e3-4f7e-4246-8199-0fef1013d4cf" width="700" alt="Auto Theme" />
### Notification Center
<img src="https://github.com/user-attachments/assets/07cbde9a-0242-4989-9f97-5765c6458c85" width="350" alt="Notification Center" />
### Dock
<img src="https://github.com/user-attachments/assets/e6999daf-f7bf-4329-98fa-0ce4f0e7219c" width="400" alt="Dock" />
<img src="https://github.com/user-attachments/assets/732c30de-5f4a-4a2b-a995-c8ab656cecd5" width="600" alt="Control Center" />
</div>
</details>
## Quick start (full dotfiles, most distros)
## Installation
```bash
curl -fsSL https://install.danklinux.com | sh
```
*Or skip to [Installation](https://github.com/AvengeMedia/DankMaterialShell?tab=readme-ov-file#installation)*
<details><summary><strong>Features</strong></summary>
One command installs DMS and all dependencies on Arch, Fedora, Debian, Ubuntu, openSUSE, or Gentoo.
**Core Widgets:**
- **TopBar**: fully customizable bar where widgets can be added, removed, and re-arranged.
- **App Launcher** with fuzzy search, categories, and auto-sorting by most used apps.
- **Workspace Switcher** Configurable workspace switcher.
- **Focused Window** Displays the currently focused window app name and title.
- **Running Apps** A view of all running apps, sorted by monitor, workspace, then position on workspace.
- **Media Player** Short form media player with equalizer, song title, and controls.
- **Clock** Clock and date widget
- **Weather** Weather widget with customizable location
- **System Tray** System tray applets with context menus.
- **Process Monitor** CPU, RAM, and GPU usage percentages, temperatures. (requires [dgop](https://github.com/AvengeMedia/dgop))
- **Power/Battery** Power/Battery widget for battery metrics and power profile changing.
- **Notifications** Notification bell with a notification center popup
- **Control Center** High-level view of network, bluetooth, and audio status
- **Privacy Indicator** Attempts to reveal if a microphone or screen recording session is active, relying on Pipewire data sources
- **Idle Inhibitor** Creates a systemd idle inhibitor to prevent sleep/locking from occuring.
- **Spotlight Launcher** A central app launcher/search that can be triggered via an IPC keybinding.
- **Central Command** A combined music, weather, calendar, and events PopUp.
- **Process List** A process list, with system metrics and information. More detailed modal available via IPC.
- **Notification Center** A center for notifications that has support for grouping.
- **Dock** A dock with pinned apps support, recent apps support, and currently running application support.
- **Control Center** A full control center with user profile information, network, bluetooth, audio input/output, display controls, and night mode automation.
- **Lock Screen** Using quickshell's WlSessionLock with embedded virtual keyboard for Niri (Niri doesn't support placing virtual keyboard above lockscreen natively: [issue](https://github.com/YaLTeR/niri/issues/2201))
- **Notepad** A simple text notepad/scratchpad with auto-save to session data and file export/import functionality.
**[Manual installation guide](https://danklinux.com/docs/dankmaterialshell/installation)**
</details>
## Features
## Highlights
**Dynamic Theming**
Wallpaper-based color schemes that automatically theme GTK, Qt, terminals, editors (vscode, vscodium), and more using [matugen](https://github.com/InioX/matugen) and dank16.
- Auto-theming GTK, QT, Terminal apps, and more with [matugen](https://github.com/InioX/matugen) + optional theme generation from wallpaper.
- 20+ widgets that can be added and re-arranged on the bar.
- Process list, temperature monitoring, and resource monitoring with [dgop](https://github.com/AvengeMedia/dgop)
- Notification service with support for grouping and richtext
- App launcher + Spotlighht launcher with fuzzy search
- Control center with mpris player, weather, and calendar integration.
- Clipboard history view with image previews.
- A dock for running apps + pinned apps
- Configure bluetooth, wifi, and audio input+output devices.
- A lock screen
- Idle monitoring - configure auto lock, screen off, suspend, and hibernate with different knobs for battery + AC power.
- A greeter
- A comprehensive plugin system for endless customization possibilities.
**System Monitoring**
Real-time CPU, RAM, GPU metrics and temperatures with [dgop](https://github.com/AvengeMedia/dgop). Process list with search and management.
**TL;DR** *dms replaces your waybar, swaylock, swayidle, hypridle, hyprlock, fuzzels, walker, mako, and basically everything you use to stitch a desktop together*
**Powerful Launcher**
Spotlight-style search for applications, files ([dsearch](https://github.com/AvengeMedia/danksearch)), emojis, running windows, calculator, and commands. Extensible with plugins.
## Installation
**Control Center**
Unified interface for network, Bluetooth, audio devices, display settings, and night mode.
### Compositor Setup
**Smart Notifications**
Notification center with grouping, rich text support, and keyboard navigation.
DankMaterialShell particularly aims at supporting the **niri** and **Hyprland** compositors, but it does support more wayland compositors with a diminished feature set (no monitor off, workspace switcher, overview integration, etc.):
**Media Integration**
MPRIS player controls, calendar sync, weather widgets, and clipboard history with image previews.
**Niri**:
```bash
# Arch Linux
sudo pacman -S niri
**Session Management**
Lock screen, idle detection, auto-lock/suspend with separate AC/battery settings, and greeter support.
# Fedora
sudo dnf copr enable yalter/niri && sudo dnf install niri
```
**Plugin System**
Extend functionality with the [plugin registry](https://plugins.danklinux.com).
For detailed niri installation instructions, see the [niri Getting Started guide](https://yalter.github.io/niri/Getting-Started.html).
## Supported Compositors
**Hyprland**:
```bash
# Arch Linux
sudo pacman -S hyprland
Works best with [niri](https://github.com/YaLTeR/niri), [Hyprland](https://hyprland.org/), [Sway](https://swaywm.org/), and [MangoWC](https://github.com/DreamMaoMao/mangowc) with full workspace switching, overview integration, and monitor management. Other Wayland compositors work with reduced features.
# Or from AUR for latest
paru -S hyprland-git
[Compositor configuration guide](https://danklinux.com/docs/dankmaterialshell/compositors)
# Fedora
sudo dnf install hyprland
## Command Line Interface
# Or use Copr for latest builds
sudo dnf copr enable solopasha/hyprland && sudo dnf install hyprland
```
For detailed Hyprland installation instructions, see the [Hyprland wiki](https://wiki.hypr.land/Getting-Started/Installation/).
### Dank Shell Installation
*feel free to contribute steps for other distributions*
#### Arch Linux - via AUR
Control the shell from the command line or keybinds:
```bash
# Stable release
paru -S dms-shell-bin
# Latest -git
paru -S dms-shell-git
```
#### Fedora - via COPR
```bash
# Stable release
sudo dnf copr enable avengemedia/dms && sudo dnf install dms
# Latest -git
sudo dnf copr enable avengemedia/dms-git && sudo dnf install dms
```
#### NixOS - via flake
```bash
nix profile install github:AvengeMedia/DankMaterialShell
```
#### NixOS - via home-manager
To install using home-manager, you need to add this repo into your flake inputs:
``` nix
dgop = {
url = "github:AvengeMedia/dgop";
inputs.nixpkgs.follows = "nixpkgs";
};
dms-cli = {
url = "github:AvengeMedia/danklinux";
inputs.nixpkgs.follows = "nixpkgs";
};
dankMaterialShell = {
url = "github:AvengeMedia/DankMaterialShell";
inputs.nixpkgs.follows = "nixpkgs";
inputs.dgop.follows = "dgop";
inputs.dms-cli.follows = "dms-cli";
};
```
Then somewhere in your home-manager config, add this to the imports:
``` nix
imports = [
inputs.dankMaterialShell.homeModules.dankMaterialShell.default
];
```
If you use Niri, the `niri` homeModule provides additional options for Niri integration, such as key bindings and spawn:
``` nix
imports = [
inputs.dankMaterialShell.homeModules.dankMaterialShell.default
inputs.dankMaterialShell.homeModules.dankMaterialShell.niri
];
```
> [!IMPORTANT]
> To use the `niri` homeModule, you must have `sobidoo/niri-flake` in your inputs:
``` nix
niri = {
url = "github:sodiboo/niri-flake";
inputs.nixpkgs.follows = "nixpkgs";
};
```
And import it in home-manager:
``` nix
imports = [
inputs.niri.homeModules.niri
];
```
Now you can enable it with:
``` nix
programs.dankMaterialShell.enable = true;
```
There are a lot of possible configurations that you can enable/disable in the flake, check [nix/default.nix](nix/default.nix) and [nix/niri.nix](nix/niri.nix) to see them all.
#### Other Distributions - via manual installation
#### 1. Install Quickshell (Varies by Distribution)
```bash
# Arch
paru -S quickshell-git
# Fedora
sudo dnf copr enable avengemedia/danklinux && sudo dnf install quickshell-git
# ! TODO - document other distros
```
#### 2. Install fonts
*Inter Variable* and *Fira Code* are not strictly required, but they are the default fonts of dms.
#### 2.1 Install Material Symbols
```bash
sudo curl -L "https://github.com/google/material-design-icons/raw/master/variablefont/MaterialSymbolsRounded%5BFILL%2CGRAD%2Copsz%2Cwght%5D.ttf" -o /usr/share/fonts/MaterialSymbolsRounded.ttf
```
#### 2.2 Install Inter Variable
```bash
sudo curl -L "https://github.com/rsms/inter/raw/refs/tags/v4.1/docs/font-files/InterVariable.ttf" -o /usr/share/fonts/InterVariable.ttf
```
#### 2.3 Install Fira Code (monospace font)
```bash
sudo curl -L "https://github.com/tonsky/FiraCode/releases/latest/download/FiraCode-Regular.ttf" -o /usr/share/fonts/FiraCode-Regular.ttf
```
#### 2.4 Refresh font cache
```bash
fc-cache -fv
```
#### 3. Install the shell
#### 3.1. Clone latest QML
```bash
mkdir ~/.config/quickshell && git clone https://github.com/AvengeMedia/DankMaterialShell.git ~/.config/quickshell/dms
```
**FOR Stable Version, Checkout the latest tag**
```bash
cd ~/.config/quickshell/dms
# You'll have to re-run this, to update to the latest version.
git checkout $(git describe --tags --abbrev=0)
```
#### 3.2. Install latest dms CLI
```bash
sudo sh -c "curl -L https://github.com/AvengeMedia/danklinux/releases/latest/download/dms-$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/').gz | gunzip | tee /usr/local/bin/dms > /dev/null && chmod +x /usr/local/bin/dms"
```
**Note:** this is the latest *stable* dms CLI. If you are using QML/master (not pinned to a tag), then you may periodically be missing features, etc.
If preferred, you can build the dms-cli yourself (requires GO 1.24+)
```bash
git clone https://github.com/AvengeMedia/danklinux.git && cd danklinux
make && sudo make install
```
#### 4. Optional Features (system monitoring, clipboard history, brightness controls, etc.)
#### 4.1 Core optional dependencies
```bash
# Arch Linux
sudo pacman -S cava wl-clipboard cliphist brightnessctl qt6-multimedia
paru -S matugen-bin dgop
# Fedora
sudo dnf install cava wl-clipboard brightnessctl qt6-qtmultimedia
sudo dnf copr enable avengemedia/danklinux && sudo dnf install cliphist ghostty hyprpicker material-symbols-fonts matugen
```
Note: by enabling and installing the avengemedia/dms copr above, these core dependencies will automatically be available for use.
*Other distros will just need to find sources for the above packages*
#### 4.2 - dgop manual installation
`dgop` is available via AUR and a nix flake, other distributions can install it manually.
```bash
sudo sh -c "curl -L https://github.com/AvengeMedia/dgop/releases/latest/download/dgop-linux-$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/').gz | gunzip | tee /usr/local/bin/dgop > /dev/null && chmod +x /usr/local/bin/dgop"
```
**Optional Requirement Overview**
- `dgop`: Ability to have system resource widgets, process list modal, and temperature monitoring.
- `matugen`: Wallpaper-based dynamic theming
- `brightnessctl`: Backlight and LED brightness control
- `wl-clipboard`: Required for copying various elements to clipboard.
- `cava`: Audio visualizer
- `cliphist`: Clipboard history
- `qt6-multimedia`: System sound support
## Compositor Configuration
A lot of options are subject to personal preference, but the below sets a good starting point for most features.
### niri Integration
Add to your niri config
```kdl
// Required for clipboard history integration
spawn-at-startup "bash" "-c" "wl-paste --watch cliphist store &"
// Recommended (must install polkit-mate before hand) for elevation prompts
spawn-at-startup "/usr/lib/mate-polkit/polkit-mate-authentication-agent-1"
// This may be a different path on different distributions, the above is for the arch linux mate-polkit package
// Starts DankShell
spawn-at-startup "dms" "run"
// If using niri newer than 271534e115e5915231c99df287bbfe396185924d (~aug 17 2025)
// you can add this to disable built in config load errors since dank shell provides this
config-notification {
disable-failed
}
// Dank keybinds
// 1. These should not be in conflict with any pre-existing keybindings
// 2. You need to merge them with your existing config if you want to use these
// 3. You can change the keys to whatever you want, if you prefer something different
// 4. For the increment/decrement ones you can change the steps to whatever you like too
binds {
Mod+Space hotkey-overlay-title="Application Launcher" {
spawn "dms" "ipc" "call" "spotlight" "toggle";
}
Mod+V hotkey-overlay-title="Clipboard Manager" {
spawn "dms" "ipc" "call" "clipboard" "toggle";
}
Mod+M hotkey-overlay-title="Task Manager" {
spawn "dms" "ipc" "call" "processlist" "toggle";
}
Mod+N hotkey-overlay-title="Notification Center" {
spawn "dms" "ipc" "call" "notifications" "toggle";
}
Mod+Comma hotkey-overlay-title="Settings" {
spawn "dms" "ipc" "call" "settings" "toggle";
}
Mod+P hotkey-overlay-title="Notepad" {
spawn "dms" "ipc" "call" "notepad" "toggle";
}
Super+Alt+L hotkey-overlay-title="Lock Screen" {
spawn "dms" "ipc" "call" "lock" "lock";
}
Mod+X hotkey-overlay-title="Power Menu" {
spawn "dms" "ipc" "call" "powermenu" "toggle";
}
Mod+C hotkey-overlay-title="Control Center" {
spawn "dms" "ipc" "call" "control-center" "toggle";
}
Mod+Y hotkey-overlay-title="Browse Wallpapers" {
spawn "dms" "ipc" "call" "dankdash" "wallpaper";
}
XF86AudioRaiseVolume allow-when-locked=true {
spawn "dms" "ipc" "call" "audio" "increment" "3";
}
XF86AudioLowerVolume allow-when-locked=true {
spawn "dms" "ipc" "call" "audio" "decrement" "3";
}
XF86AudioMute allow-when-locked=true {
spawn "dms" "ipc" "call" "audio" "mute";
}
XF86AudioMicMute allow-when-locked=true {
spawn "dms" "ipc" "call" "audio" "micmute";
}
XF86MonBrightnessUp allow-when-locked=true {
spawn "dms" "ipc" "call" "brightness" "increment" "5" "";
}
// You can override the default device for e.g. keyboards by adding the device name to the last param
XF86MonBrightnessDown allow-when-locked=true {
spawn "dms" "ipc" "call" "brightness" "decrement" "5" "";
}
// Night mode toggle
Mod+Shift+N allow-when-locked=true {
spawn "dms" "ipc" "call" "night" "toggle";
}
}
```
#### niri theming
If using a niri build newer than [3933903](https://github.com/YaLTeR/niri/commit/39339032cee3453faa54c361a38db6d83756f750), you can synchronize colors and gaps with the shell settings by adding the following to your niri config.
```bash
# For colors
echo -e 'include "dms/colors.kdl"' >> ~/.config/niri/config.kdl
# For gaps, border widths, certain window rules
echo -e 'include "dms/layout.kdl"' >> ~/.config/niri/config.kdl
```
### Hyprland Integration
Add to your Hyprland config (`~/.config/hypr/hyprland.conf`):
```bash
# Required for clipboard history integration
exec-once = bash -c "wl-paste --watch cliphist store &"
# Recommended (must install polkit-mate beforehand) for elevation prompts
exec-once = /usr/lib/mate-polkit/polkit-mate-authentication-agent-1
# This may be a different path on different distributions, the above is for the arch linux mate-polkit package
# Starts DankShell
exec-once = dms run
# Dank keybinds
# 1. These should not be in conflict with any pre-existing keybindings
# 2. You need to merge them with your existing config if you want to use these
# 3. You can change the keys to whatever you want, if you prefer something different
# 4. For the increment/decrement ones you can change the steps to whatever you like too
# Application and system controls
bind = SUPER, Space, exec, dms ipc call spotlight toggle
bind = SUPER, V, exec, dms ipc call clipboard toggle
bind = SUPER, M, exec, dms ipc call processlist toggle
bind = SUPER, N, exec, dms ipc call notifications toggle
bind = SUPER, comma, exec, dms ipc call settings toggle
bind = SUPER, P, exec, dms ipc call notepad toggle
bind = SUPERALT, L, exec, dms ipc call lock lock
bind = SUPER, X, exec, dms ipc call powermenu toggle
bind = SUPER, Y, exec, dms ipc call dankdash wallpaper
bind = SUPER, C, exec, dms ipc call control-center toggle
bind = SUPER, TAB, exec, dms ipc call hypr toggleOverview
# Audio controls (function keys)
bindl = , XF86AudioRaiseVolume, exec, dms ipc call audio increment 3
bindl = , XF86AudioLowerVolume, exec, dms ipc call audio decrement 3
bindl = , XF86AudioMute, exec, dms ipc call audio mute
bindl = , XF86AudioMicMute, exec, dms ipc call audio micmute
# Brightness controls (function keys)
bindl = , XF86MonBrightnessUp, exec, dms ipc call brightness increment 5 ""
# You can override the default device for e.g. keyboards by adding the device name to the last param
bindl = , XF86MonBrightnessDown, exec, dms ipc call brightness decrement 5 ""
# Night mode toggle
bind = SUPERSHIFT, N, exec, dms ipc call night toggle
```
## Greeter
You can install a matching [greetd](https://github.com/kennylevinsen/greetd) greeter, that will give you a greeter that matches the lock screen.
It's as simple as running `dms greeter install` in most cases, but more information is in the [Greetd module](Modules/Greetd/README.md)
## IPC Commands
Control everything from the command line, or via keybinds. For comprehensive documentation of all available IPC commands, see [docs/IPC.md](docs/IPC.md).
### Audio control
```bash
dms ipc call audio setvolume 50
dms ipc call audio mute
```
### Launch applications
```bash
dms run # Start the shell
dms ipc call spotlight toggle
dms ipc call notepad toggle
dms ipc call processlist toggle
dms ipc call powermenu toggle
```
### System control
```
dms ipc call audio setvolume 50
dms ipc call wallpaper set /path/to/image.jpg
dms ipc call theme toggle
dms ipc call night toggle
dms ipc call lock lock
```
### Media control
```
dms ipc call mpris playPause
dms ipc call mpris next
dms brightness list # List available displays
dms plugins search # Browse plugin registry
```
## Theming
[Full CLI and IPC documentation](https://danklinux.com/docs/dankmaterialshell/keybinds-ipc)
dms will spawn a matugen process on theme changes to generate color palettes for installed and supported apps. If you do not want these files generated, you can set the env variable `DMS_DISABLE_MATUGEN=1` to disable it entirely.
## Documentation
### Custom Themes
- **Website:** [danklinux.com](https://danklinux.com)
- **Docs:** [danklinux.com/docs](https://danklinux.com/docs)
- **Theming:** [Application themes](https://danklinux.com/docs/dankmaterialshell/application-themes) | [Custom themes](https://danklinux.com/docs/dankmaterialshell/custom-themes)
- **Plugins:** [Development guide](https://danklinux.com/docs/dankmaterialshell/plugins-overview)
- **Support:** [Ko-fi](https://ko-fi.com/avengemediallc)
DankMaterialShell supports custom color themes! You can create your own Material Design 3 color schemes or use pre-made themes like Cyberpunk Electric, Hotline Miami, and Miami Vice.
## Development
For detailed instructions on creating and using custom themes, see [docs/CUSTOM_THEMES.md](docs/CUSTOM_THEMES.md).
See component-specific documentation:
### System App Integration
- **[quickshell/](quickshell/)** - QML shell development, widgets, and modules
- **[core/](core/)** - Go backend, CLI tools, and system integration
- **[distro/](distro/)** - Distribution packaging (Fedora, Debian, NixOS)
There's two toggles in the appearance section of settings, for GTK and QT apps.
These settings will override some local GTK and QT configuration files, you can still integrate auto-theming if you do not wish DankShell to mess with your QTCT/GTK files.
No matter what when matugen is enabled the files will be created on wallpaper changes:
- ~/.config/gtk-3.0/dank-colors.css
- ~/.config/gtk-4.0/dank-colors.css
- ~/.config/qt6ct/colors/matugen.conf
- ~/.config/qt5ct/colors/matugen.conf
If you do not like our theme path, you can integrate this with other GTK themes, matugen themes, etc.
#### GTK Apps
1. Install adw-gtk3
### Building from Source
**Core + Dankinstall:**
```bash
# Arch
sudo pacman -S adw-gtk-theme
# Fedora
sudo dnf install adw-gtk3-theme
cd core
make # Build dms CLI
make dankinstall # Build installer
```
In dms settings, navigate to Theme & Colors, and "apply GTK themes"
**Shell:**
```bash
quickshell -p quickshell/
```
This will create symlinks from `~/.config/gtk-3.0/4.0/dank-colors.css` to `~/.config/gtk-3.0/4.0/gtk.css` which enables theming.
**NixOS:**
```nix
{
inputs.dms.url = "github:AvengeMedia/DankMaterialShell";
#### QT: basic gtk3 based theme (Option 1)
If you mostly use gtk apps, you'll probably be happy to just set the QT platform theme to gtk3.
```kdl
environment {
// Add to existing environment block
QT_QPA_PLATFORMTHEME "gtk3"
QT_QPA_PLATFORMTHEME_QT6 "gtk3"
# Use in home-manager or NixOS configuration
imports = [ inputs.dms.homeModules.dankMaterialShell.default ];
}
```
#### QT: better theming (Option 2)
1. Install qt6ct-kde
```bash
# Arch
paru -S qt6ct-kde
```
*I'm not sure what it is on other distros, but you can manually install via instructions provides on [qt6ct-kde github](https://www.opencode.net/trialuser/qt6ct)
2. **Configure Environment in niri**
```kdl
// Add to existing environment block
QT_QPA_PLATFORMTHEME "qt6ct"
QT_QPA_PLATFORMTHEME_QT6 "qt6ct"
```
You'll have to restart your session for themes to take effect.
Nevigate to dms settings -> themes & colors -> and click "Apply QT Themes"
#### Firefox
There are two theme paths for Firefox, using with [pywalfox](https://github.com/Frewacom/pywalfox) or [material fox](https://github.com/edelvarden/material-fox-updated)
**(Option 1) - pywalfox**
1. **Install [pywalfox](https://github.com/Frewacom/pywalfox)** on system.
- Available in AUR via `paru -S python-pywalfox`
2. **Install [pywalfox extension](https://addons.mozilla.org/firefox/addon/pywalfox/)** in firefox.
3. **Restart dms and create symlink** to generate palette and then enable dank colors.
- Run `ln -sf ~/.cache/wal/dank-pywalfox.json ~/.cache/wal/colors.json`
**(Option 2) - Chrome-like theme with dynamic colors**
Firefox does use the GTK3 theme, but it doesn't look that good on the stock theme IMO. A separate matugen css is generated for the [material fox](https://github.com/edelvarden/material-fox-updated) theme, you can configure that theme with dynamic colors by following the steps below.
1. **In firefox, navigate to `about:config`**
- set `toolkit.legacyuserprofilecustomizations.stylesheets` to `true`
- set `svg.context-properties.content.enabled` to `true`
- Create a new property called `userChrome.theme-material` and type `boolean`
- set to `true`
<details><summary><strong>Expand for firefox screenshots</strong></summary>
<img width="1262" height="104" alt="image" src="https://github.com/user-attachments/assets/4bca43d1-5735-4401-9b91-5ee4f0b1e357" />
<img width="1262" height="104" alt="image" src="https://github.com/user-attachments/assets/348d37e0-5c6c-4db8-b7c9-89cabf282c25" />
<img width="1244" height="106" alt="image" src="https://github.com/user-attachments/assets/75fd4972-bc4a-4657-b756-b31ef8061b3b" />
</details>
2. **Install material fox theme**
```bash
# Find Firefox profile directory
export PROFILE_DIR=$(find ~/.mozilla/firefox -maxdepth 1 -type d -name "*.default-release" | head -n 1)
# Download, extract to profile dir, and cleanup
curl -L -o "$PROFILE_DIR/chrome.zip" https://github.com/edelvarden/material-fox-updated/releases/download/v2.0.0/chrome.zip
unzip -o "$PROFILE_DIR/chrome.zip" -d "$PROFILE_DIR"
rm "$PROFILE_DIR/chrome.zip"
```
3. **Configure dynamic colors for material fox theme**
```bash
export PROFILE_DIR=$(find ~/.mozilla/firefox -maxdepth 1 -type d -name "*.default-release" | head -n 1)
rm -f "$PROFILE_DIR/chrome/theme-material-blue.css"
ln -sf ~/.config/DankMaterialShell/firefox.css "$PROFILE_DIR/chrome/theme-material-blue.css"
```
### Terminal Integration
The matugen integration will automatically generate new colors for certain apps only if they are installed.
You can enable the dynamic color schemes in supported terminal apps by modifying their configurations:
**Ghostty**:
```bash
echo "config-file = ./config-dankcolors" >> ~/.config/ghostty/config
```
If you want to disable excessive config reloaded popup sin ghostty, you may wish to also add this:
```bash
# These are the default danklinux options, if you still want config reloaded and copied to clipboard popups you can skip it.
echo "app-notifications = no-clipboard-copy,no-config-reload" >> ~/.config/ghostty/config
```
**kitty**:
```bash
echo "include dank-theme.conf" >> ~/.config/kitty/kitty.conf
```
## Plugins
[Plugin registry](https://github.com/AvengeMedia/dms-plugin-registry) - collection of available dms plugins.
dms features a plugin system - meaning you can create your own widgets and load other user widgets.
More comprehensive details available in the [PLUGINS](PLUGINS/README.md) - and examples [Emoji Plugin](PLUGINS/ExampleEmojiPlugin) and [Wallpaper Change Hook](PLUGINS/WallpaperWatcherDaemon) are available for reference.
Install an example plugin by:
```bash
mkdir ~/.config/DankMaterialShell/plugins
cp -R ./PLUGINS/ExampleEmojiPlugin ~/.config/DankMaterialShell/plugins
```
**Only install plugins from TRUSTED sources.** Plugins execute QML and javascript at runtime, plugins from third parties should be reviewed before enabling them in dms.
### NixOS - via home-manager
Add the following to your home-manager config to install a plugin:
```nix
programs.dankMaterialShell.plugins = {
ExampleEmojiPlugin.src = "${inputs.dankMaterialShell}/PLUGINS/ExampleEmojiPlugin";
};
```
### Calendar Setup
Sync your caldev compatible calendar (Google, Office365, etc.) for dashboard integration:
<details><summary>Configuration Steps</summary>
**Install dependencies:**
#### Arch
```bash
sudo pacman -S vdirsyncer khal python-aiohttp-oauthlib
```
#### Fedora
```bash
sudo dnf install python3-vdirsyncer khal python3-aiohttp-oauthlib
```
**Configure vdirsyncer** (`~/.vdirsyncer/config`):
```ini
[general]
status_path = "~/.calendars/status"
[pair personal_sync]
a = "personal"
b = "personallocal"
collections = ["from a", "from b"]
conflict_resolution = "a wins"
metadata = ["color"]
[storage personal]
type = "google_calendar"
token_file = "~/.vdirsyncer/google_calendar_token"
client_id = "your_client_id"
client_secret = "your_client_secret"
[storage personallocal]
type = "filesystem"
path = "~/.calendars/Personal"
fileext = ".ics"
```
**Setup sync:**
```bash
vdirsyncer sync
khal configure
```
#### Auto-sync every 5 minutes
```bash
crontab -e
# Add: */5 * * * * /usr/bin/vdirsyncer sync
```
</details>
## Configuration
All settings are configurable in
```
~/.config/DankMaterialShell/settings.json`, or more intuitively the built-in settings modal.
```
**Key configuration areas:**
- Widget positioning and behavior
- Theme and color preferences
- Time format, weather units and location
- Light/Dark modes
- Wallpaper and Profile picture
- Dock enable/disable and various tunes.
## Troubleshooting
**Common issues:**
- **Missing icons:** Verify Material Symbols font installation with `fc-list | grep Material`
- **No dynamic theming:** Install matugen and enable in settings
- **Qt apps not themed:** Configure qt5ct/qt6ct and set QT_QPA_PLATFORMTHEME
- **Calendar not syncing:** Check vdirsyncer credentials and network connectivity
**Getting help:**
- Check the [issues](https://github.com/AvengeMedia/DankMaterialShell/issues) for known problems
- Re-run the shell with `dms kill && dms run` to capture logs.
- Join the niri community for compositor-specific questions
## Contributing
DankMaterialShell welcomes contributions! Whether it's bug fixes, new widgets, theme improvements, or documentation updates - all help is appreciated.
Contributions welcome. Bug fixes, widgets, features, documentation, and plugins all help.
**Areas that need attention:**
1. Fork the repository
2. Make your changes
3. Test thoroughly
4. Open a pull request
- More widget options and customization
- Additional compositor compatibility
- Performance optimizations
- Documentation and examples
For documentation contributions, see [DankLinux-Docs](https://github.com/AvengeMedia/DankLinux-Docs).
## Credits
- [quickshell](https://quickshell.org/) the core of what makes a shell like this possible.
- [niri](https://github.com/YaLTeR/niri) for the awesome scrolling compositor.
- [Ly-sec](http://github.com/ly-sec) for awesome wallpaper effects among other things from [Noctalia](https://github.com/noctalia-dev/noctalia-shell)
- [soramanew](https://github.com/soramanew) who built [caelestia](https://github.com/caelestia-dots/shell) which served as inspiration and guidance for many dank widgets.
- [end-4](https://github.com/end-4) for [dots-hyprland](https://github.com/end-4/dots-hyprland) which also served as inspiration and guidance for many dank widgets.
- [quickshell](https://quickshell.org/) - Shell framework
- [niri](https://github.com/YaLTeR/niri) - Scrolling window manager
- [Ly-sec](http://github.com/ly-sec) - Wallpaper effects from [Noctalia](https://github.com/noctalia-dev/noctalia-shell)
- [soramanew](https://github.com/soramanew) - [Caelestia](https://github.com/caelestia-dots/shell) inspiration
- [end-4](https://github.com/end-4) - [dots-hyprland](https://github.com/end-4/dots-hyprland) inspiration
## License
MIT License - See [LICENSE](LICENSE) for details.

File diff suppressed because it is too large Load Diff

View File

@@ -1,39 +0,0 @@
pragma Singleton
pragma ComponentBehavior: Bound
import QtQuick
import QtCore
import Quickshell
import Quickshell.Io
import qs.Common
Singleton {
id: root
readonly property string shellDir: Paths.strip(Qt.resolvedUrl(".").toString()).replace("/Services/", "")
property string scriptPath: `${shellDir}/scripts/hyprland_keybinds.py`
readonly property string _configUrl: StandardPaths.writableLocation(StandardPaths.ConfigLocation)
readonly property string _configDir: Paths.strip(_configUrl)
property string hyprConfigPath: `${_configDir}/hypr`
property var keybinds: ({"children": [], "keybinds": []})
Process {
id: getKeybinds
running: true
command: [root.scriptPath, "--path", root.hyprConfigPath]
stdout: SplitParser {
onRead: data => {
try {
root.keybinds = JSON.parse(data)
} catch (e) {
console.error("[HyprKeybindsService] Error parsing keybinds:", e)
}
}
}
}
function reload() {
getKeybinds.running = true
}
}

View File

@@ -1,109 +0,0 @@
pragma Singleton
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
Singleton {
id: root
readonly property int levelInfo: 0
readonly property int levelWarn: 1
readonly property int levelError: 2
property string currentMessage: ""
property int currentLevel: levelInfo
property bool toastVisible: false
property var toastQueue: []
property string currentDetails: ""
property string currentCommand: ""
property bool hasDetails: false
property string wallpaperErrorStatus: ""
function showToast(message, level = levelInfo, details = "", command = "") {
toastQueue.push({
"message": message,
"level": level,
"details": details,
"command": command
})
if (!toastVisible) {
processQueue()
}
}
function showInfo(message, details = "", command = "") {
showToast(message, levelInfo, details, command)
}
function showWarning(message, details = "", command = "") {
showToast(message, levelWarn, details, command)
}
function showError(message, details = "", command = "") {
showToast(message, levelError, details, command)
}
function hideToast() {
toastVisible = false
currentMessage = ""
currentDetails = ""
currentCommand = ""
hasDetails = false
currentLevel = levelInfo
toastTimer.stop()
resetToastState()
if (toastQueue.length > 0) {
processQueue()
}
}
function processQueue() {
if (toastQueue.length === 0) {
return
}
const toast = toastQueue.shift()
currentMessage = toast.message
currentLevel = toast.level
currentDetails = toast.details || ""
currentCommand = toast.command || ""
hasDetails = currentDetails.length > 0 || currentCommand.length > 0
toastVisible = true
resetToastState()
if (toast.level === levelError && hasDetails) {
toastTimer.interval = 8000
toastTimer.start()
} else {
toastTimer.interval = toast.level === levelError ? 5000 : toast.level === levelWarn ? 3000 : 1500
toastTimer.start()
}
}
signal resetToastState
function stopTimer() {
toastTimer.stop()
}
function restartTimer() {
if (hasDetails && currentLevel === levelError) {
toastTimer.interval = 8000
toastTimer.restart()
}
}
function clearWallpaperError() {
wallpaperErrorStatus = ""
}
Timer {
id: toastTimer
interval: 5000
running: false
repeat: false
onTriggered: hideToast()
}
}

View File

@@ -1,246 +0,0 @@
pragma Singleton
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import Quickshell.Io
// Minimal VPN controller backed by NetworkManager (nmcli + D-Bus monitor)
Singleton {
id: root
property int refCount: 0
onRefCountChanged: {
console.log("VpnService: refCount changed to", refCount)
if (refCount > 0 && !nmMonitor.running) {
console.log("VpnService: Starting nmMonitor")
nmMonitor.running = true
refreshAll()
} else if (refCount === 0 && nmMonitor.running) {
console.log("VpnService: Stopping nmMonitor")
nmMonitor.running = false
}
}
// State
property bool available: true
property bool isBusy: false
property string errorMessage: ""
// Profiles discovered on the system
// [{ name, uuid, type }]
property var profiles: []
// Allow multiple active VPNs (set true to allow concurrent connections)
// Default: allow multiple, to align with NetworkManager capability
property bool singleActive: false
// Active VPN connections (may be multiple)
// Full list and convenience projections
property var activeConnections: [] // [{ name, uuid, device, state }]
property var activeUuids: []
property var activeNames: []
// Back-compat single values (first active if present)
property string activeUuid: activeUuids.length > 0 ? activeUuids[0] : ""
property string activeName: activeNames.length > 0 ? activeNames[0] : ""
property string activeDevice: activeConnections.length > 0 ? (activeConnections[0].device || "") : ""
property string activeState: activeConnections.length > 0 ? (activeConnections[0].state || "") : ""
property bool connected: activeUuids.length > 0
// Use implicit property notify signals (profilesChanged, activeUuidChanged, etc.)
function refreshAll() {
listProfiles()
refreshActive()
}
// Monitor NetworkManager changes and refresh on activity
Process {
id: nmMonitor
command: ["gdbus", "monitor", "--system", "--dest", "org.freedesktop.NetworkManager"]
running: false
stdout: SplitParser {
splitMarker: "\n"
onRead: line => {
if (line.includes("ActiveConnection") || line.includes("PropertiesChanged") || line.includes("StateChanged")) {
refreshAll()
}
}
}
}
// Query all VPN profiles
function listProfiles() {
getProfiles.running = true
}
Process {
id: getProfiles
command: ["bash", "-lc", "nmcli -t -f NAME,UUID,TYPE connection show | while IFS=: read -r name uuid type; do case \"$type\" in vpn) svc=$(nmcli -g vpn.service-type connection show uuid \"$uuid\" 2>/dev/null); echo \"$name:$uuid:$type:$svc\" ;; wireguard) echo \"$name:$uuid:$type:\" ;; *) : ;; esac; done"]
running: false
stdout: StdioCollector {
onStreamFinished: {
const lines = text.trim().length ? text.trim().split('\n') : []
const out = []
for (const line of lines) {
const parts = line.split(':')
if (parts.length >= 3 && (parts[2] === "vpn" || parts[2] === "wireguard")) {
const svc = parts.length >= 4 ? parts[3] : ""
out.push({ name: parts[0], uuid: parts[1], type: parts[2], serviceType: svc })
}
}
root.profiles = out
}
}
}
// Query active VPN connection
function refreshActive() {
getActive.running = true
}
Process {
id: getActive
command: ["nmcli", "-t", "-f", "NAME,UUID,TYPE,DEVICE,STATE", "connection", "show", "--active"]
running: false
stdout: StdioCollector {
onStreamFinished: {
const lines = text.trim().length ? text.trim().split('\n') : []
let act = []
for (const line of lines) {
const parts = line.split(':')
if (parts.length >= 5 && (parts[2] === "vpn" || parts[2] === "wireguard")) {
act.push({ name: parts[0], uuid: parts[1], device: parts[3], state: parts[4] })
}
}
root.activeConnections = act
root.activeUuids = act.map(a => a.uuid).filter(u => !!u)
root.activeNames = act.map(a => a.name).filter(n => !!n)
}
}
}
function isActiveUuid(uuid) {
return root.activeUuids && root.activeUuids.indexOf(uuid) !== -1
}
function _looksLikeUuid(s) {
// Very loose check for UUID pattern
return s && s.indexOf('-') !== -1 && s.length >= 8
}
function connect(uuidOrName) {
if (root.isBusy) return
root.isBusy = true
root.errorMessage = ""
if (root.singleActive) {
// Bring down all active VPNs, then bring up the requested one
const isUuid = _looksLikeUuid(uuidOrName)
const escaped = ('' + uuidOrName).replace(/'/g, "'\\''")
const upCmd = isUuid ? `nmcli connection up uuid '${escaped}'` : `nmcli connection up id '${escaped}'`
const script = `set -e\n` +
`nmcli -t -f UUID,TYPE connection show --active | awk -F: '$2 ~ /^(vpn|wireguard)$/ {print $1}' | while read u; do [ -n \"$u\" ] && nmcli connection down uuid \"$u\" || true; done\n` +
upCmd + `\n`
vpnSwitch.command = ["bash", "-lc", script]
vpnSwitch.running = true
} else {
if (_looksLikeUuid(uuidOrName)) {
vpnUp.command = ["nmcli", "connection", "up", "uuid", uuidOrName]
} else {
vpnUp.command = ["nmcli", "connection", "up", "id", uuidOrName]
}
vpnUp.running = true
}
}
function disconnect(uuidOrName) {
if (root.isBusy) return
root.isBusy = true
root.errorMessage = ""
if (_looksLikeUuid(uuidOrName)) {
vpnDown.command = ["nmcli", "connection", "down", "uuid", uuidOrName]
} else {
vpnDown.command = ["nmcli", "connection", "down", "id", uuidOrName]
}
vpnDown.running = true
}
function toggle(uuid) {
if (uuid) {
if (isActiveUuid(uuid)) disconnect(uuid)
else connect(uuid)
return
}
if (root.profiles.length > 0) {
connect(root.profiles[0].uuid)
}
}
Process {
id: vpnUp
running: false
stdout: StdioCollector {
onStreamFinished: {
root.isBusy = false
if (!text.toLowerCase().includes("successfully")) {
root.errorMessage = text.trim()
}
refreshAll()
}
}
onExited: exitCode => {
root.isBusy = false
if (exitCode !== 0 && root.errorMessage === "") {
root.errorMessage = "Failed to connect VPN"
}
}
}
Process {
id: vpnDown
running: false
stdout: StdioCollector {
onStreamFinished: {
root.isBusy = false
if (!text.toLowerCase().includes("deactivated") && !text.toLowerCase().includes("successfully")) {
root.errorMessage = text.trim()
}
refreshAll()
}
}
onExited: exitCode => {
root.isBusy = false
if (exitCode !== 0 && root.errorMessage === "") {
root.errorMessage = "Failed to disconnect VPN"
}
}
}
function disconnectAllActive() {
if (root.isBusy) return
root.isBusy = true
const script = `nmcli -t -f UUID,TYPE connection show --active | awk -F: '$2 ~ /^(vpn|wireguard)$/ {print $1}' | while read u; do [ -n \"$u\" ] && nmcli connection down uuid \"$u\" || true; done`
vpnSwitch.command = ["bash", "-lc", script]
vpnSwitch.running = true
}
// Sequenced down/up using a single shell for exclusive switch
Process {
id: vpnSwitch
running: false
stdout: StdioCollector {
onStreamFinished: {
root.isBusy = false
refreshAll()
}
}
onExited: exitCode => {
root.isBusy = false
if (exitCode !== 0 && root.errorMessage === "") {
root.errorMessage = "Failed to switch VPN"
}
}
}
}

Binary file not shown.

View File

@@ -1 +0,0 @@
v0.2.0

View File

@@ -1,56 +0,0 @@
import QtQuick
import qs.Common
StyledText {
id: icon
property alias name: icon.text
property alias size: icon.font.pixelSize
property alias color: icon.color
property bool filled: false
property real fill: filled ? 1.0 : 0.0
property int grade: Theme.isLightMode ? 0 : -25
property int weight: filled ? 500 : 400
signal rotationCompleted()
font.family: "Material Symbols Rounded"
font.pixelSize: Theme.fontSizeMedium
font.weight: weight
color: Theme.surfaceText
verticalAlignment: Text.AlignVCenter
horizontalAlignment: Text.AlignHCenter
renderType: Text.NativeRendering
antialiasing: true
font.variableAxes: {
"FILL": fill.toFixed(1),
"GRAD": grade,
"opsz": 24,
"wght": weight
}
Behavior on fill {
NumberAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
Behavior on weight {
NumberAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
Timer {
id: rotationTimer
interval: 16
repeat: false
onTriggered: icon.rotationCompleted()
}
onRotationChanged: {
rotationTimer.restart()
}
}

View File

@@ -1,218 +0,0 @@
import QtQuick
import Quickshell
import Quickshell.Hyprland
import Quickshell.Wayland
import qs.Common
import qs.Services
PanelWindow {
id: root
WlrLayershell.namespace: "quickshell:popout"
property alias content: contentLoader.sourceComponent
property alias contentLoader: contentLoader
property real popupWidth: 400
property real popupHeight: 300
property real triggerX: 0
property real triggerY: 0
property real triggerWidth: 40
property string triggerSection: ""
property string positioning: "center"
property int animationDuration: Theme.expressiveDurations.expressiveDefaultSpatial
property real animationScaleCollapsed: 0.96
property real animationOffset: Theme.spacingL
property list<real> animationEnterCurve: Theme.expressiveCurves.expressiveDefaultSpatial
property list<real> animationExitCurve: Theme.expressiveCurves.emphasized
property bool shouldBeVisible: false
property int keyboardFocusMode: WlrKeyboardFocus.OnDemand
signal opened
signal popoutClosed
signal backgroundClicked
function open() {
closeTimer.stop()
shouldBeVisible = true
visible = true
opened()
}
function close() {
shouldBeVisible = false
closeTimer.restart()
}
function toggle() {
if (shouldBeVisible)
close()
else
open()
}
Timer {
id: closeTimer
interval: animationDuration + 120
onTriggered: {
if (!shouldBeVisible) {
visible = false
popoutClosed()
}
}
}
color: "transparent"
WlrLayershell.layer: WlrLayershell.Top
WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: shouldBeVisible ? keyboardFocusMode : WlrKeyboardFocus.None
anchors {
top: true
left: true
right: true
bottom: true
}
readonly property real screenWidth: root.screen.width
readonly property real screenHeight: root.screen.height
readonly property real dpr: {
if (CompositorService.isNiri && root.screen) {
const niriScale = NiriService.displayScales[root.screen.name]
if (niriScale !== undefined) return niriScale
}
if (CompositorService.isHyprland && root.screen) {
const hyprlandMonitor = Hyprland.monitors.values.find(m => m.name === root.screen.name)
if (hyprlandMonitor?.scale !== undefined) return hyprlandMonitor.scale
}
return root.screen?.devicePixelRatio || 1
}
readonly property real alignedWidth: Theme.px(popupWidth, dpr)
readonly property real alignedHeight: Theme.px(popupHeight, dpr)
readonly property real alignedX: Theme.snap((() => {
if (SettingsData.dankBarPosition === SettingsData.Position.Left) {
return triggerY + SettingsData.dankBarBottomGap
} else if (SettingsData.dankBarPosition === SettingsData.Position.Right) {
return screenWidth - triggerY - SettingsData.dankBarBottomGap - popupWidth
} else {
const centerX = triggerX + (triggerWidth / 2) - (popupWidth / 2)
return Math.max(Theme.popupDistance, Math.min(screenWidth - popupWidth - Theme.popupDistance, centerX))
}
})(), dpr)
readonly property real alignedY: Theme.snap((() => {
if (SettingsData.dankBarPosition === SettingsData.Position.Left || SettingsData.dankBarPosition === SettingsData.Position.Right) {
const centerY = triggerX + (triggerWidth / 2) - (popupHeight / 2)
return Math.max(Theme.popupDistance, Math.min(screenHeight - popupHeight - Theme.popupDistance, centerY))
} else if (SettingsData.dankBarPosition === SettingsData.Position.Bottom) {
return Math.max(Theme.popupDistance, screenHeight - triggerY - popupHeight)
} else {
return Math.min(screenHeight - popupHeight - Theme.popupDistance, triggerY)
}
})(), dpr)
MouseArea {
anchors.fill: parent
enabled: shouldBeVisible
onClicked: mouse => {
if (mouse.x < alignedX || mouse.x > alignedX + alignedWidth ||
mouse.y < alignedY || mouse.y > alignedY + alignedHeight) {
backgroundClicked()
close()
}
}
}
Loader {
id: contentLoader
x: alignedX
y: alignedY
width: alignedWidth
height: alignedHeight
active: root.visible
asynchronous: false
transformOrigin: Item.Center
layer.enabled: Quickshell.env("DMS_DISABLE_LAYER") !== "true"
layer.smooth: true
opacity: shouldBeVisible ? 1 : 0
transform: [scaleTransform, motionTransform]
Scale {
id: scaleTransform
origin.x: contentLoader.width / 2
origin.y: contentLoader.height / 2
xScale: root.shouldBeVisible ? 1 : root.animationScaleCollapsed
yScale: root.shouldBeVisible ? 1 : root.animationScaleCollapsed
Behavior on xScale {
NumberAnimation {
duration: root.animationDuration
easing.type: Easing.BezierSpline
easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve
}
}
Behavior on yScale {
NumberAnimation {
duration: root.animationDuration
easing.type: Easing.BezierSpline
easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve
}
}
}
Translate {
id: motionTransform
readonly property bool barTop: SettingsData.dankBarPosition === SettingsData.Position.Top
readonly property bool barBottom: SettingsData.dankBarPosition === SettingsData.Position.Bottom
readonly property bool barLeft: SettingsData.dankBarPosition === SettingsData.Position.Left
readonly property bool barRight: SettingsData.dankBarPosition === SettingsData.Position.Right
readonly property real hiddenX: barLeft ? root.animationOffset : (barRight ? -root.animationOffset : 0)
readonly property real hiddenY: barBottom ? -root.animationOffset : (barTop ? root.animationOffset : 0)
x: Theme.snap(root.shouldBeVisible ? 0 : hiddenX, root.dpr)
y: Theme.snap(root.shouldBeVisible ? 0 : hiddenY, root.dpr)
Behavior on x {
NumberAnimation {
duration: root.animationDuration
easing.type: Easing.BezierSpline
easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve
}
}
Behavior on y {
NumberAnimation {
duration: root.animationDuration
easing.type: Easing.BezierSpline
easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve
}
}
}
Behavior on opacity {
NumberAnimation {
duration: animationDuration
easing.type: Easing.BezierSpline
easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve
}
}
}
Item {
x: alignedX
y: alignedY
width: alignedWidth
height: alignedHeight
focus: true
Keys.onPressed: event => {
if (event.key === Qt.Key_Escape) {
close()
event.accepted = true
}
}
Component.onCompleted: forceActiveFocus()
onVisibleChanged: if (visible) forceActiveFocus()
}
}

View File

@@ -1,174 +0,0 @@
import QtQuick
import QtQuick.Controls
import Quickshell
import Quickshell.Wayland
import qs.Common
import qs.Widgets
pragma ComponentBehavior: Bound
PanelWindow {
id: root
WlrLayershell.namespace: "quickshell:slideout"
property bool isVisible: false
property var targetScreen: null
property var modelData: null
property real slideoutWidth: 480
property bool expandable: false
property bool expandedWidth: false
property real expandedWidthValue: 960
property Component content: null
property string title: ""
property alias container: contentContainer
property real customTransparency: -1
function show() {
visible = true
isVisible = true
}
function hide() {
isVisible = false
}
function toggle() {
if (isVisible) {
hide()
} else {
show()
}
}
visible: isVisible
screen: modelData
anchors.top: true
anchors.bottom: true
anchors.right: true
implicitWidth: expandable ? expandedWidthValue : slideoutWidth
implicitHeight: modelData ? modelData.height : 800
color: "transparent"
WlrLayershell.layer: WlrLayershell.Top
WlrLayershell.exclusiveZone: 0
WlrLayershell.keyboardFocus: isVisible ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None
StyledRect {
id: contentRect
layer.enabled: true
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.right: parent.right
width: expandable && expandedWidth ? expandedWidthValue : slideoutWidth
color: Qt.rgba(Theme.surfaceContainer.r, Theme.surfaceContainer.g, Theme.surfaceContainer.b,
customTransparency >= 0 ? customTransparency : SettingsData.popupTransparency)
border.color: Theme.outlineMedium
border.width: 1
radius: Theme.cornerRadius
visible: isVisible || slideAnimation.running
transform: Translate {
id: slideTransform
x: isVisible ? 0 : contentRect.width
Behavior on x {
NumberAnimation {
id: slideAnimation
duration: 450
easing.type: Easing.OutCubic
onRunningChanged: {
if (!running && !isVisible) {
root.visible = false
}
}
}
}
}
Behavior on width {
NumberAnimation {
duration: 250
easing.type: Easing.OutCubic
}
}
Column {
id: headerColumn
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: Theme.spacingL
spacing: Theme.spacingM
visible: root.title !== ""
Row {
width: parent.width
height: 32
Column {
width: parent.width - buttonRow.width
spacing: Theme.spacingXS
anchors.verticalCenter: parent.verticalCenter
StyledText {
text: root.title
font.pixelSize: Theme.fontSizeLarge
color: Theme.surfaceText
font.weight: Font.Medium
}
}
Row {
id: buttonRow
spacing: Theme.spacingXS
DankActionButton {
id: expandButton
iconName: root.expandedWidth ? "unfold_less" : "unfold_more"
iconSize: Theme.iconSize - 4
iconColor: Theme.surfaceText
visible: root.expandable
onClicked: root.expandedWidth = !root.expandedWidth
transform: Rotation {
angle: 90
origin.x: expandButton.width / 2
origin.y: expandButton.height / 2
}
}
DankActionButton {
id: closeButton
iconName: "close"
iconSize: Theme.iconSize - 4
iconColor: Theme.surfaceText
onClicked: root.hide()
}
}
}
}
Item {
id: contentContainer
anchors.top: root.title !== "" ? headerColumn.bottom : parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.topMargin: root.title !== "" ? 0 : Theme.spacingL
anchors.leftMargin: Theme.spacingL
anchors.rightMargin: Theme.spacingL
anchors.bottomMargin: Theme.spacingL
Loader {
anchors.fill: parent
sourceComponent: root.content
}
}
}
}

View File

@@ -1,36 +0,0 @@
import QtQuick
import QtQuick.Effects
import Quickshell
import Quickshell.Widgets
import qs.Common
IconImage {
property string colorOverride: ""
property real brightnessOverride: 0.5
property real contrastOverride: 1
readonly property bool hasColorOverride: colorOverride !== ""
smooth: true
asynchronous: true
layer.enabled: hasColorOverride
Component.onCompleted: {
Proc.runCommand(null, ["sh", "-c", ". /etc/os-release && echo $LOGO"], (output, exitCode) => {
if (exitCode !== 0) return
const logo = output.trim()
if (logo === "cachyos") {
source = "file:///usr/share/icons/cachyos.svg"
return
}
source = Quickshell.iconPath(logo, true)
}, 0)
}
layer.effect: MultiEffect {
colorization: 1
colorizationColor: colorOverride
brightness: brightnessOverride
contrast: contrastOverride
}
}

113
assets/danklogo.svg Normal file
View File

@@ -0,0 +1,113 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
version="1.1"
id="svg1"
width="482.90668"
height="558.15088"
viewBox="0 0 482.90667 558.15088"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
shape-rendering="auto"
style="image-rendering: auto; filter: url(#smoothing);">
<defs
id="defs1">
<filter id="smoothing" x="-0.05" y="-0.05" width="1.1" height="1.1">
<feGaussianBlur in="SourceGraphic" stdDeviation="0.5" />
</filter>
<color-profile
name="sRGB IEC61966-2.1"
xlink:href="data:application/vnd.iccprofile;base64,AAAMbExpbm8CEAAAbW50clJHQiBYWVogB84AAgAJAAYAMQAAYWNzcE1TRlQAAAAASUVDIHNSR0IAAAAAAAAAAAAAAAAAAPbWAAEAAAAA0y1IUCAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARY3BydAAAAVAAAAAzZGVzYwAAAYQAAACQd3RwdAAAAhQAAAAUYmtwdAAAAigAAAAUclhZWgAAAjwAAAAUZ1hZWgAAAlAAAAAUYlhZWgAAAmQAAAAUZG1uZAAAAngAAABwZG1kZAAAAugAAACIdnVlZAAAA3AAAACGdmlldwAAA/gAAAAkbHVtaQAABBwAAAAUbWVhcwAABDAAAAAkdGVjaAAABFQAAAAMclRSQwAABGAAAAgMZ1RSQwAABGAAAAgMYlRSQwAABGAAAAgMdGV4dAAAAABDb3B5cmlnaHQgKGMpIDE5OTggSGV3bGV0dC1QYWNrYXJkIENvbXBhbnkAAGRlc2MAAAAAAAAAEnNSR0IgSUVDNjE5NjYtMi4xAAAAAAAAAAASAHMAUgBHAEIAIABJAEUAQwA2ADEAOQA2ADYALQAyAC4AMQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFhZWiAAAAAAAADzUQABAAAAARbMWFlaIAAAAAAAAAAAAAAAAAAAAABYWVogAAAAAAAAb6IAADj1AAADkFhZWiAAAAAAAABimQAAt4UAABjaWFlaIAAAAAAAACSgAAAPhAAAts9kZXNjAAAAAAAAABZJRUMgaHR0cDovL3d3dy5pZWMuY2gAAAAAAAAAAAAAABZJRUMgaHR0cDovL3d3dy5pZWMuY2gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAZGVzYwAAAAAAAAAuSUVDIDYxOTY2LTIuMSBEZWZhdWx0IFJHQiBjb2xvdXIgc3BhY2UgLSBzUkdCAAAAAAAAAAAAAAAuSUVDIDYxOTY2LTIuMSBEZWZhdWx0IFJHQiBjb2xvdXIgc3BhY2UgLSBzUkdCAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGRlc2MAAAAAAAAALFJlZmVyZW5jZSBWaWV3aW5nIENvbmRpdGlvbiBpbiBJRUM2MTk2Ni0yLjEAAAAAAAAAAAAAACxSZWZlcmVuY2UgVmlld2luZyBDb25kaXRpb24gaW4gSUVDNjE5NjYtMi4xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB2aWV3AAAAAAATpP4AFF8uABDPFAAD7cwABBMLAANcngAAAAFYWVogAAAAAABMCVYAUAAAAFcf521lYXMAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAKPAAAAAnNpZyAAAAAAQ1JUIGN1cnYAAAAAAAAEAAAAAAUACgAPABQAGQAeACMAKAAtADIANwA7AEAARQBKAE8AVABZAF4AYwBoAG0AcgB3AHwAgQCGAIsAkACVAJoAnwCkAKkArgCyALcAvADBAMYAywDQANUA2wDgAOUA6wDwAPYA+wEBAQcBDQETARkBHwElASsBMgE4AT4BRQFMAVIBWQFgAWcBbgF1AXwBgwGLAZIBmgGhAakBsQG5AcEByQHRAdkB4QHpAfIB+gIDAgwCFAIdAiYCLwI4AkECSwJUAl0CZwJxAnoChAKOApgCogKsArYCwQLLAtUC4ALrAvUDAAMLAxYDIQMtAzgDQwNPA1oDZgNyA34DigOWA6IDrgO6A8cD0wPgA+wD+QQGBBMEIAQtBDsESARVBGMEcQR+BIwEmgSoBLYExATTBOEE8AT+BQ0FHAUrBToFSQVYBWcFdwWGBZYFpgW1BcUF1QXlBfYGBgYWBicGNwZIBlkGagZ7BowGnQavBsAG0QbjBvUHBwcZBysHPQdPB2EHdAeGB5kHrAe/B9IH5Qf4CAsIHwgyCEYIWghuCIIIlgiqCL4I0gjnCPsJEAklCToJTwlkCXkJjwmkCboJzwnlCfsKEQonCj0KVApqCoEKmAquCsUK3ArzCwsLIgs5C1ELaQuAC5gLsAvIC+EL+QwSDCoMQwxcDHUMjgynDMAM2QzzDQ0NJg1ADVoNdA2ODakNww3eDfgOEw4uDkkOZA5/DpsOtg7SDu4PCQ8lD0EPXg96D5YPsw/PD+wQCRAmEEMQYRB+EJsQuRDXEPURExExEU8RbRGMEaoRyRHoEgcSJhJFEmQShBKjEsMS4xMDEyMTQxNjE4MTpBPFE+UUBhQnFEkUahSLFK0UzhTwFRIVNBVWFXgVmxW9FeAWAxYmFkkWbBaPFrIW1hb6Fx0XQRdlF4kXrhfSF/cYGxhAGGUYihivGNUY+hkgGUUZaxmRGbcZ3RoEGioaURp3Gp4axRrsGxQbOxtjG4obshvaHAIcKhxSHHscoxzMHPUdHh1HHXAdmR3DHeweFh5AHmoelB6+HukfEx8+H2kflB+/H+ogFSBBIGwgmCDEIPAhHCFIIXUhoSHOIfsiJyJVIoIiryLdIwojOCNmI5QjwiPwJB8kTSR8JKsk2iUJJTglaCWXJccl9yYnJlcmhya3JugnGCdJJ3onqyfcKA0oPyhxKKIo1CkGKTgpaymdKdAqAio1KmgqmyrPKwIrNitpK50r0SwFLDksbiyiLNctDC1BLXYtqy3hLhYuTC6CLrcu7i8kL1ovkS/HL/4wNTBsMKQw2zESMUoxgjG6MfIyKjJjMpsy1DMNM0YzfzO4M/E0KzRlNJ402DUTNU01hzXCNf02NzZyNq426TckN2A3nDfXOBQ4UDiMOMg5BTlCOX85vDn5OjY6dDqyOu87LTtrO6o76DwnPGU8pDzjPSI9YT2hPeA+ID5gPqA+4D8hP2E/oj/iQCNAZECmQOdBKUFqQaxB7kIwQnJCtUL3QzpDfUPARANER0SKRM5FEkVVRZpF3kYiRmdGq0bwRzVHe0fASAVIS0iRSNdJHUljSalJ8Eo3Sn1KxEsMS1NLmkviTCpMcky6TQJNSk2TTdxOJU5uTrdPAE9JT5NP3VAnUHFQu1EGUVBRm1HmUjFSfFLHUxNTX1OqU/ZUQlSPVNtVKFV1VcJWD1ZcVqlW91dEV5JX4FgvWH1Yy1kaWWlZuFoHWlZaplr1W0VblVvlXDVchlzWXSddeF3JXhpebF69Xw9fYV+zYAVgV2CqYPxhT2GiYfViSWKcYvBjQ2OXY+tkQGSUZOllPWWSZedmPWaSZuhnPWeTZ+loP2iWaOxpQ2maafFqSGqfavdrT2una/9sV2yvbQhtYG25bhJua27Ebx5veG/RcCtwhnDgcTpxlXHwcktypnMBc11zuHQUdHB0zHUodYV14XY+dpt2+HdWd7N4EXhueMx5KnmJeed6RnqlewR7Y3vCfCF8gXzhfUF9oX4BfmJ+wn8jf4R/5YBHgKiBCoFrgc2CMIKSgvSDV4O6hB2EgITjhUeFq4YOhnKG14c7h5+IBIhpiM6JM4mZif6KZIrKizCLlov8jGOMyo0xjZiN/45mjs6PNo+ekAaQbpDWkT+RqJIRknqS45NNk7aUIJSKlPSVX5XJljSWn5cKl3WX4JhMmLiZJJmQmfyaaJrVm0Kbr5wcnImc951kndKeQJ6unx2fi5/6oGmg2KFHobaiJqKWowajdqPmpFakx6U4pammGqaLpv2nbqfgqFKoxKk3qamqHKqPqwKrdavprFys0K1ErbiuLa6hrxavi7AAsHWw6rFgsdayS7LCszizrrQltJy1E7WKtgG2ebbwt2i34LhZuNG5SrnCuju6tbsuu6e8IbybvRW9j74KvoS+/796v/XAcMDswWfB48JfwtvDWMPUxFHEzsVLxcjGRsbDx0HHv8g9yLzJOsm5yjjKt8s2y7bMNcy1zTXNtc42zrbPN8+40DnQutE80b7SP9LB00TTxtRJ1MvVTtXR1lXW2Ndc1+DYZNjo2WzZ8dp22vvbgNwF3IrdEN2W3hzeot8p36/gNuC94UThzOJT4tvjY+Pr5HPk/OWE5g3mlucf56noMui86Ubp0Opb6uXrcOv77IbtEe2c7ijutO9A78zwWPDl8XLx//KM8xnzp/Q09ML1UPXe9m32+/eK+Bn4qPk4+cf6V/rn+3f8B/yY/Sn9uv5L/tz/bf//"
id="color-profile1" />
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath21">
<path
d="M 0,1200 H 2000 V 0 H 0 Z"
transform="translate(-673.87432,-704.25842)"
id="path21" />
</clipPath>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath25">
<path
d="M 0,1200 H 2000 V 0 H 0 Z"
transform="translate(-466.30451,-703.59782)"
id="path25" />
</clipPath>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath27">
<path
d="M 0,1200 H 2000 V 0 H 0 Z"
transform="translate(-695.28002,-473.92741)"
id="path27" />
</clipPath>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath29">
<path
d="M 0,1200 H 2000 V 0 H 0 Z"
transform="translate(-457.93881,-632.99062)"
id="path29" />
</clipPath>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath31">
<path
d="M 0,1200 H 2000 V 0 H 0 Z"
transform="translate(-466.30451,-703.59782)"
id="path31" />
</clipPath>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath33">
<path
d="M 0,1200 H 2000 V 0 H 0 Z"
transform="translate(-614.51722,-638.93302)"
id="path33" />
</clipPath>
</defs>
<g
id="layer-MC0"
transform="translate(-486.31024,-515.02722)">
<path
id="path20"
d="M 0,0 C -1.568,1.568 -3.163,3.098 -4.787,4.61 -5.944,3.966 -7.185,3.35 -8.529,2.762 -9.658,2.277 -10.815,1.82 -11.981,1.4 c 1.885,-1.689 3.742,-3.425 5.552,-5.207 0.448,-0.429 0.886,-0.868 1.325,-1.306 2.221,-2.221 4.386,-4.498 6.476,-6.84 27.639,-30.784 44.453,-71.459 44.453,-116.089 0,-29.347 -7.259,-56.977 -20.09,-81.21 -2.192,-4.134 -4.544,-8.174 -7.054,-12.102 -6.83,-10.74 -14.827,-20.669 -23.785,-29.636 -5.944,-5.944 -12.317,-11.459 -19.073,-16.498 -0.56,-0.42 -1.12,-0.83 -1.689,-1.231 -28.675,-20.893 -63.975,-33.201 -102.186,-33.201 -48.018,0 -91.464,19.456 -122.948,50.93 -0.737,0.737 -1.465,1.474 -2.174,2.221 -0.55,0.569 -1.101,1.147 -1.633,1.726 -15.545,16.553 -27.881,36.14 -36.018,57.779 -3.098,8.211 -5.58,16.712 -7.409,25.464 -2.417,11.534 -3.686,23.496 -3.686,35.758 0.01,42.326 15.117,81.097 40.246,111.246 -2.072,1.278 -3.975,2.809 -5.534,4.637 -26.174,-31.399 -41.934,-71.822 -41.934,-115.883 0,-18.187 2.678,-35.748 7.67,-52.311 3.359,-11.142 7.754,-21.835 13.092,-31.95 8.528,-16.208 19.446,-30.961 32.276,-43.801 1.251,-1.25 2.529,-2.491 3.817,-3.685 0.662,-0.644 1.334,-1.26 2.006,-1.876 10.862,-9.938 22.945,-18.578 36,-25.661 25.632,-13.913 55.016,-21.816 86.229,-21.816 2.454,0 4.908,0.047 7.334,0.14 36.056,1.446 69.424,13.427 97.054,32.976 0.569,0.392 1.148,0.793 1.698,1.204 7.82,5.655 15.164,11.925 21.966,18.718 7.904,7.904 15.07,16.526 21.406,25.773 2.556,3.723 4.973,7.558 7.25,11.477 15.499,26.697 24.382,57.723 24.382,90.812 C 53.038,-78.055 32.762,-32.762 0,0"
style="fill:#D0BCFF;fill-opacity:1;fill-rule:nonzero;stroke:none"
transform="matrix(1.3333333,0,0,-1.3333333,898.49907,660.9888)"
clip-path="url(#clipPath21)" />
<path
id="path24"
d="M 0,0 -0.169,-0.292 C 1.43,-0.2 3.091,-0.108 4.798,0 Z"
style="fill:#D0BCFF;fill-opacity:1;fill-rule:nonzero;stroke:none"
transform="matrix(1.3333333,0,0,-1.3333333,621.73933,661.8696)"
clip-path="url(#clipPath25)" />
<path
id="path26"
d="m 0,0 c -6.336,-9.247 -13.502,-17.869 -21.406,-25.773 -6.802,-6.793 -14.146,-13.063 -21.965,-18.718 -0.551,-0.411 -1.129,-0.812 -1.699,-1.204 -27.629,-19.549 -60.998,-31.53 -97.053,-32.976 -33.247,1.129 -64.852,8.762 -93.564,21.676 -13.054,7.082 -25.138,15.723 -36,25.661 -0.672,0.616 -1.343,1.232 -2.006,1.876 -1.288,1.194 -2.566,2.435 -3.816,3.685 -12.831,12.84 -23.748,27.593 -32.277,43.801 -1.092,7.651 -1.941,15.378 -2.445,23.039 -0.102,1.502 -0.186,3.004 -0.261,4.497 0,0 -2.865,29.795 23.944,36.634 26.827,6.84 65.654,19.745 87.722,50.305 0,0 0.327,-8.38 5.506,-15.779 8.034,-11.422 46.674,-100.46 46.674,-100.46 l 6.121,51.135 -13.978,15.079 -4.553,-1.987 15.228,16.544 c 0,0 8.94,4.218 16.554,-0.653 7.605,-4.899 14.146,-16.153 14.146,-16.153 l -5.879,3.892 -4.227,-12.364 c -0.756,-2.184 -0.83,-4.535 -0.233,-6.784 l 16.796,-62.687 c 0,0 -0.243,87.415 -2.781,111.685 0,0 14.221,-10.367 21.621,-14.454 7.381,-4.078 47.215,-20.407 53.738,-22.395 6.504,-1.987 13.222,-5.841 15.882,-11.916 1.026,-2.351 8.594,-26.939 17.486,-56.229 C -1.829,6.028 -0.914,3.023 0,0"
style="fill:#D0BCFF;fill-opacity:1;fill-rule:nonzero;stroke:none"
transform="matrix(1.3333333,0,0,-1.3333333,927.04,968.0968)"
clip-path="url(#clipPath27)" />
<path
id="path28"
d="m 0,0 c 0,0 -3.081,-67.22 -8.64,-90.616 -5.559,-23.397 30.316,0 30.316,0 l 20.9,68.629 z"
style="fill:#D0BCFF;fill-opacity:1;fill-rule:nonzero;stroke:none"
transform="matrix(1.3333333,0,0,-1.3333333,610.58507,756.01253)"
clip-path="url(#clipPath29)" />
<path
id="path30"
d="M 0,0 -0.169,-0.292 C 1.43,-0.2 3.091,-0.108 4.798,0 Z"
style="fill:#D0BCFF;fill-opacity:1;fill-rule:nonzero;stroke:none"
transform="matrix(1.3333333,0,0,-1.3333333,621.73933,661.8696)"
clip-path="url(#clipPath31)" />
<path
id="path32"
d="m 0,0 c 0,0 12.178,7.604 15.029,18.355 0,0 36.913,-9.243 52.904,-26.719 C 66.887,-8.091 29.642,2.031 0,0 m -36.152,-5.643 c -26.791,0.404 -31.781,16.05 -34.086,29.523 -0.511,2.958 -3.148,5.073 -6.13,4.883 l -46.275,-4.764 14.922,7.14 c 3.505,1.687 7.164,3.077 10.895,4.17 3.742,1.093 7.556,1.901 11.429,2.376 4.051,0.499 8.542,1.01 13.46,1.509 0,0 6.19,2.459 7.414,-4.479 1.259,-6.926 0.499,-31.864 28.287,-31.103 27.777,0.76 32.612,19.412 32.612,19.412 0,0 3.458,14.471 9.232,14.257 5.263,-0.202 7.27,-6.142 7.853,-10.479 0.142,-1.045 -0.06,-3.54 0.582,-4.324 -0.939,1.152 -2.162,2.032 -3.564,2.554 C 7.604,26.113 3.065,26.945 2.471,21.658 1.616,13.877 -9.599,-6.059 -36.152,-5.643 m 13.306,48.354 9.528,-2.377 c 0,0 -2.388,-17.5 -11.227,-21.979 -0.25,-0.13 4.194,14.281 1.699,24.356 m 59.022,3.041 3.79,-1.687 c 0,0 2.198,-14.209 -3.79,-20.577 0,0 2.174,12.474 0,22.264 m -187.451,35.63 c 12.225,2.067 24.45,4.229 36.664,6.332 l 36.663,6.392 73.303,12.748 c 2.958,0.522 5.774,-1.462 6.285,-4.408 0.522,-2.947 -1.462,-5.762 -4.42,-6.285 -0.119,-0.024 -0.261,-0.036 -0.38,-0.047 H -3.184 L -114.243,85.029 c -12.344,-1.224 -24.676,-2.495 -37.032,-3.647 M 43.97,16.93 c 3.54,4.693 5.215,23.096 5.215,23.096 l 1.497,2.518 v 8.744 C 29.381,61.327 11.869,50.682 11.869,50.682 l -16.491,0.749 c -3.112,0.142 -7.449,2.637 -10.644,3.433 -4.42,1.093 -8.804,2.068 -13.342,2.566 -16.407,1.735 -32.933,0 -49.091,-2.934 -7.021,-1.271 -13.971,-2.851 -20.957,-4.325 -6.701,-1.425 -12.878,-3.908 -19.151,-6.605 -4.313,-1.842 -8.649,-3.659 -12.641,-6.119 -2.436,-1.496 -4.741,-3.243 -6.737,-5.31 -2.126,-2.21 -3.659,-4.859 -5.476,-7.319 -1.545,-2.091 -4.907,-0.463 -4.147,2.032 0.024,0.059 0.048,0.119 0.06,0.154 0.867,2.044 2.221,4.646 3.659,6.345 3.588,4.241 8.958,8.292 13.686,11.12 10.039,6.071 21.48,9.766 32.767,12.985 24.771,7.057 51.442,8.138 77.009,10.55 14.114,1.331 28.43,2.091 42.473,4.016 12.784,1.734 37.935,4.859 36.176,22.882 -1.71,18.129 -59.355,18.712 -59.355,18.712 0,0 -21.943,45.027 -27.372,50.468 -5.418,5.418 -16.503,18.474 -74.254,4.384 -57.739,-14.078 -55.327,-42.259 -55.327,-42.259 l -2.412,-39.622 c 0,0 -43.198,-11.179 -41.832,-23.321 1.391,-12.142 39.171,-12.819 39.171,-12.819 0,0 8.577,0.439 20.696,1.152 l -5.382,-9.683 c -6.749,-12.13 -6.38,-25.258 -8.851,-38.908 -4.277,-23.631 11.963,-52.702 33.978,-62.147 21.1,-9.053 53.949,-13.782 99.012,6.368 11.085,4.954 20.328,13.282 26.66,23.618 l 0.499,0.796 c 22.728,26.125 80.574,9.386 80.574,9.386 C 81.774,1.699 43.97,16.93 43.97,16.93"
style="fill:#D0BCFF;fill-opacity:1;fill-rule:nonzero;stroke:none"
transform="matrix(1.3333333,0,0,-1.3333333,819.35627,748.08933)"
clip-path="url(#clipPath33)" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 13 KiB

113
assets/danklogo2.svg Normal file
View File

@@ -0,0 +1,113 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
version="1.1"
id="svg1"
width="482.90668"
height="558.15088"
viewBox="0 0 482.90667 558.15088"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
shape-rendering="auto"
style="image-rendering: auto; filter: url(#smoothing);">
<defs
id="defs1">
<filter id="smoothing" x="-0.05" y="-0.05" width="1.1" height="1.1">
<feGaussianBlur in="SourceGraphic" stdDeviation="0.5" />
</filter>
<color-profile
name="sRGB IEC61966-2.1"
xlink:href="data:application/vnd.iccprofile;base64,AAAMbExpbm8CEAAAbW50clJHQiBYWVogB84AAgAJAAYAMQAAYWNzcE1TRlQAAAAASUVDIHNSR0IAAAAAAAAAAAAAAAAAAPbWAAEAAAAA0y1IUCAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARY3BydAAAAVAAAAAzZGVzYwAAAYQAAACQd3RwdAAAAhQAAAAUYmtwdAAAAigAAAAUclhZWgAAAjwAAAAUZ1hZWgAAAlAAAAAUYlhZWgAAAmQAAAAUZG1uZAAAAngAAABwZG1kZAAAAugAAACIdnVlZAAAA3AAAACGdmlldwAAA/gAAAAkbHVtaQAABBwAAAAUbWVhcwAABDAAAAAkdGVjaAAABFQAAAAMclRSQwAABGAAAAgMZ1RSQwAABGAAAAgMYlRSQwAABGAAAAgMdGV4dAAAAABDb3B5cmlnaHQgKGMpIDE5OTggSGV3bGV0dC1QYWNrYXJkIENvbXBhbnkAAGRlc2MAAAAAAAAAEnNSR0IgSUVDNjE5NjYtMi4xAAAAAAAAAAASAHMAUgBHAEIAIABJAEUAQwA2ADEAOQA2ADYALQAyAC4AMQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFhZWiAAAAAAAADzUQABAAAAARbMWFlaIAAAAAAAAAAAAAAAAAAAAABYWVogAAAAAAAAb6IAADj1AAADkFhZWiAAAAAAAABimQAAt4UAABjaWFlaIAAAAAAAACSgAAAPhAAAts9kZXNjAAAAAAAAABZJRUMgaHR0cDovL3d3dy5pZWMuY2gAAAAAAAAAAAAAABZJRUMgaHR0cDovL3d3dy5pZWMuY2gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAZGVzYwAAAAAAAAAuSUVDIDYxOTY2LTIuMSBEZWZhdWx0IFJHQiBjb2xvdXIgc3BhY2UgLSBzUkdCAAAAAAAAAAAAAAAuSUVDIDYxOTY2LTIuMSBEZWZhdWx0IFJHQiBjb2xvdXIgc3BhY2UgLSBzUkdCAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGRlc2MAAAAAAAAALFJlZmVyZW5jZSBWaWV3aW5nIENvbmRpdGlvbiBpbiBJRUM2MTk2Ni0yLjEAAAAAAAAAAAAAACxSZWZlcmVuY2UgVmlld2luZyBDb25kaXRpb24gaW4gSUVDNjE5NjYtMi4xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB2aWV3AAAAAAATpP4AFF8uABDPFAAD7cwABBMLAANcngAAAAFYWVogAAAAAABMCVYAUAAAAFcf521lYXMAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAKPAAAAAnNpZyAAAAAAQ1JUIGN1cnYAAAAAAAAEAAAAAAUACgAPABQAGQAeACMAKAAtADIANwA7AEAARQBKAE8AVABZAF4AYwBoAG0AcgB3AHwAgQCGAIsAkACVAJoAnwCkAKkArgCyALcAvADBAMYAywDQANUA2wDgAOUA6wDwAPYA+wEBAQcBDQETARkBHwElASsBMgE4AT4BRQFMAVIBWQFgAWcBbgF1AXwBgwGLAZIBmgGhAakBsQG5AcEByQHRAdkB4QHpAfIB+gIDAgwCFAIdAiYCLwI4AkECSwJUAl0CZwJxAnoChAKOApgCogKsArYCwQLLAtUC4ALrAvUDAAMLAxYDIQMtAzgDQwNPA1oDZgNyA34DigOWA6IDrgO6A8cD0wPgA+wD+QQGBBMEIAQtBDsESARVBGMEcQR+BIwEmgSoBLYExATTBOEE8AT+BQ0FHAUrBToFSQVYBWcFdwWGBZYFpgW1BcUF1QXlBfYGBgYWBicGNwZIBlkGagZ7BowGnQavBsAG0QbjBvUHBwcZBysHPQdPB2EHdAeGB5kHrAe/B9IH5Qf4CAsIHwgyCEYIWghuCIIIlgiqCL4I0gjnCPsJEAklCToJTwlkCXkJjwmkCboJzwnlCfsKEQonCj0KVApqCoEKmAquCsUK3ArzCwsLIgs5C1ELaQuAC5gLsAvIC+EL+QwSDCoMQwxcDHUMjgynDMAM2QzzDQ0NJg1ADVoNdA2ODakNww3eDfgOEw4uDkkOZA5/DpsOtg7SDu4PCQ8lD0EPXg96D5YPsw/PD+wQCRAmEEMQYRB+EJsQuRDXEPURExExEU8RbRGMEaoRyRHoEgcSJhJFEmQShBKjEsMS4xMDEyMTQxNjE4MTpBPFE+UUBhQnFEkUahSLFK0UzhTwFRIVNBVWFXgVmxW9FeAWAxYmFkkWbBaPFrIW1hb6Fx0XQRdlF4kXrhfSF/cYGxhAGGUYihivGNUY+hkgGUUZaxmRGbcZ3RoEGioaURp3Gp4axRrsGxQbOxtjG4obshvaHAIcKhxSHHscoxzMHPUdHh1HHXAdmR3DHeweFh5AHmoelB6+HukfEx8+H2kflB+/H+ogFSBBIGwgmCDEIPAhHCFIIXUhoSHOIfsiJyJVIoIiryLdIwojOCNmI5QjwiPwJB8kTSR8JKsk2iUJJTglaCWXJccl9yYnJlcmhya3JugnGCdJJ3onqyfcKA0oPyhxKKIo1CkGKTgpaymdKdAqAio1KmgqmyrPKwIrNitpK50r0SwFLDksbiyiLNctDC1BLXYtqy3hLhYuTC6CLrcu7i8kL1ovkS/HL/4wNTBsMKQw2zESMUoxgjG6MfIyKjJjMpsy1DMNM0YzfzO4M/E0KzRlNJ402DUTNU01hzXCNf02NzZyNq426TckN2A3nDfXOBQ4UDiMOMg5BTlCOX85vDn5OjY6dDqyOu87LTtrO6o76DwnPGU8pDzjPSI9YT2hPeA+ID5gPqA+4D8hP2E/oj/iQCNAZECmQOdBKUFqQaxB7kIwQnJCtUL3QzpDfUPARANER0SKRM5FEkVVRZpF3kYiRmdGq0bwRzVHe0fASAVIS0iRSNdJHUljSalJ8Eo3Sn1KxEsMS1NLmkviTCpMcky6TQJNSk2TTdxOJU5uTrdPAE9JT5NP3VAnUHFQu1EGUVBRm1HmUjFSfFLHUxNTX1OqU/ZUQlSPVNtVKFV1VcJWD1ZcVqlW91dEV5JX4FgvWH1Yy1kaWWlZuFoHWlZaplr1W0VblVvlXDVchlzWXSddeF3JXhpebF69Xw9fYV+zYAVgV2CqYPxhT2GiYfViSWKcYvBjQ2OXY+tkQGSUZOllPWWSZedmPWaSZuhnPWeTZ+loP2iWaOxpQ2maafFqSGqfavdrT2una/9sV2yvbQhtYG25bhJua27Ebx5veG/RcCtwhnDgcTpxlXHwcktypnMBc11zuHQUdHB0zHUodYV14XY+dpt2+HdWd7N4EXhueMx5KnmJeed6RnqlewR7Y3vCfCF8gXzhfUF9oX4BfmJ+wn8jf4R/5YBHgKiBCoFrgc2CMIKSgvSDV4O6hB2EgITjhUeFq4YOhnKG14c7h5+IBIhpiM6JM4mZif6KZIrKizCLlov8jGOMyo0xjZiN/45mjs6PNo+ekAaQbpDWkT+RqJIRknqS45NNk7aUIJSKlPSVX5XJljSWn5cKl3WX4JhMmLiZJJmQmfyaaJrVm0Kbr5wcnImc951kndKeQJ6unx2fi5/6oGmg2KFHobaiJqKWowajdqPmpFakx6U4pammGqaLpv2nbqfgqFKoxKk3qamqHKqPqwKrdavprFys0K1ErbiuLa6hrxavi7AAsHWw6rFgsdayS7LCszizrrQltJy1E7WKtgG2ebbwt2i34LhZuNG5SrnCuju6tbsuu6e8IbybvRW9j74KvoS+/796v/XAcMDswWfB48JfwtvDWMPUxFHEzsVLxcjGRsbDx0HHv8g9yLzJOsm5yjjKt8s2y7bMNcy1zTXNtc42zrbPN8+40DnQutE80b7SP9LB00TTxtRJ1MvVTtXR1lXW2Ndc1+DYZNjo2WzZ8dp22vvbgNwF3IrdEN2W3hzeot8p36/gNuC94UThzOJT4tvjY+Pr5HPk/OWE5g3mlucf56noMui86Ubp0Opb6uXrcOv77IbtEe2c7ijutO9A78zwWPDl8XLx//KM8xnzp/Q09ML1UPXe9m32+/eK+Bn4qPk4+cf6V/rn+3f8B/yY/Sn9uv5L/tz/bf//"
id="color-profile1" />
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath21">
<path
d="M 0,1200 H 2000 V 0 H 0 Z"
transform="translate(-673.87432,-704.25842)"
id="path21" />
</clipPath>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath25">
<path
d="M 0,1200 H 2000 V 0 H 0 Z"
transform="translate(-466.30451,-703.59782)"
id="path25" />
</clipPath>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath27">
<path
d="M 0,1200 H 2000 V 0 H 0 Z"
transform="translate(-695.28002,-473.92741)"
id="path27" />
</clipPath>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath29">
<path
d="M 0,1200 H 2000 V 0 H 0 Z"
transform="translate(-457.93881,-632.99062)"
id="path29" />
</clipPath>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath31">
<path
d="M 0,1200 H 2000 V 0 H 0 Z"
transform="translate(-466.30451,-703.59782)"
id="path31" />
</clipPath>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath33">
<path
d="M 0,1200 H 2000 V 0 H 0 Z"
transform="translate(-614.51722,-638.93302)"
id="path33" />
</clipPath>
</defs>
<g
id="layer-MC0"
transform="translate(-486.31024,-515.02722)">
<path
id="path20"
d="M 0,0 C -1.568,1.568 -3.163,3.098 -4.787,4.61 -5.944,3.966 -7.185,3.35 -8.529,2.762 -9.658,2.277 -10.815,1.82 -11.981,1.4 c 1.885,-1.689 3.742,-3.425 5.552,-5.207 0.448,-0.429 0.886,-0.868 1.325,-1.306 2.221,-2.221 4.386,-4.498 6.476,-6.84 27.639,-30.784 44.453,-71.459 44.453,-116.089 0,-29.347 -7.259,-56.977 -20.09,-81.21 -2.192,-4.134 -4.544,-8.174 -7.054,-12.102 -6.83,-10.74 -14.827,-20.669 -23.785,-29.636 -5.944,-5.944 -12.317,-11.459 -19.073,-16.498 -0.56,-0.42 -1.12,-0.83 -1.689,-1.231 -28.675,-20.893 -63.975,-33.201 -102.186,-33.201 -48.018,0 -91.464,19.456 -122.948,50.93 -0.737,0.737 -1.465,1.474 -2.174,2.221 -0.55,0.569 -1.101,1.147 -1.633,1.726 -15.545,16.553 -27.881,36.14 -36.018,57.779 -3.098,8.211 -5.58,16.712 -7.409,25.464 -2.417,11.534 -3.686,23.496 -3.686,35.758 0.01,42.326 15.117,81.097 40.246,111.246 -2.072,1.278 -3.975,2.809 -5.534,4.637 -26.174,-31.399 -41.934,-71.822 -41.934,-115.883 0,-18.187 2.678,-35.748 7.67,-52.311 3.359,-11.142 7.754,-21.835 13.092,-31.95 8.528,-16.208 19.446,-30.961 32.276,-43.801 1.251,-1.25 2.529,-2.491 3.817,-3.685 0.662,-0.644 1.334,-1.26 2.006,-1.876 10.862,-9.938 22.945,-18.578 36,-25.661 25.632,-13.913 55.016,-21.816 86.229,-21.816 2.454,0 4.908,0.047 7.334,0.14 36.056,1.446 69.424,13.427 97.054,32.976 0.569,0.392 1.148,0.793 1.698,1.204 7.82,5.655 15.164,11.925 21.966,18.718 7.904,7.904 15.07,16.526 21.406,25.773 2.556,3.723 4.973,7.558 7.25,11.477 15.499,26.697 24.382,57.723 24.382,90.812 C 53.038,-78.055 32.762,-32.762 0,0"
style="fill:#D0BCFF;fill-opacity:1;fill-rule:nonzero;stroke:none"
transform="matrix(1.3333333,0,0,-1.3333333,898.49907,660.9888)"
clip-path="url(#clipPath21)" />
<path
id="path24"
d="M 0,0 -0.169,-0.292 C 1.43,-0.2 3.091,-0.108 4.798,0 Z"
style="fill:#D0BCFF;fill-opacity:1;fill-rule:nonzero;stroke:none"
transform="matrix(1.3333333,0,0,-1.3333333,621.73933,661.8696)"
clip-path="url(#clipPath25)" />
<path
id="path26"
d="m 0,0 c -6.336,-9.247 -13.502,-17.869 -21.406,-25.773 -6.802,-6.793 -14.146,-13.063 -21.965,-18.718 -0.551,-0.411 -1.129,-0.812 -1.699,-1.204 -27.629,-19.549 -60.998,-31.53 -97.053,-32.976 -33.247,1.129 -64.852,8.762 -93.564,21.676 -13.054,7.082 -25.138,15.723 -36,25.661 -0.672,0.616 -1.343,1.232 -2.006,1.876 -1.288,1.194 -2.566,2.435 -3.816,3.685 -12.831,12.84 -23.748,27.593 -32.277,43.801 -1.092,7.651 -1.941,15.378 -2.445,23.039 -0.102,1.502 -0.186,3.004 -0.261,4.497 0,0 -2.865,29.795 23.944,36.634 26.827,6.84 65.654,19.745 87.722,50.305 0,0 0.327,-8.38 5.506,-15.779 8.034,-11.422 46.674,-100.46 46.674,-100.46 l 6.121,51.135 -13.978,15.079 -4.553,-1.987 15.228,16.544 c 0,0 8.94,4.218 16.554,-0.653 7.605,-4.899 14.146,-16.153 14.146,-16.153 l -5.879,3.892 -4.227,-12.364 c -0.756,-2.184 -0.83,-4.535 -0.233,-6.784 l 16.796,-62.687 c 0,0 -0.243,87.415 -2.781,111.685 0,0 14.221,-10.367 21.621,-14.454 7.381,-4.078 47.215,-20.407 53.738,-22.395 6.504,-1.987 13.222,-5.841 15.882,-11.916 1.026,-2.351 8.594,-26.939 17.486,-56.229 C -1.829,6.028 -0.914,3.023 0,0"
style="fill:#D0BCFF;fill-opacity:1;fill-rule:nonzero;stroke:none"
transform="matrix(1.3333333,0,0,-1.3333333,927.04,968.0968)"
clip-path="url(#clipPath27)" />
<path
id="path28"
d="m 0,0 c 0,0 -3.081,-67.22 -8.64,-90.616 -5.559,-23.397 30.316,0 30.316,0 l 20.9,68.629 z"
style="fill:#D0BCFF;fill-opacity:1;fill-rule:nonzero;stroke:none"
transform="matrix(1.3333333,0,0,-1.3333333,610.58507,756.01253)"
clip-path="url(#clipPath29)" />
<path
id="path30"
d="M 0,0 -0.169,-0.292 C 1.43,-0.2 3.091,-0.108 4.798,0 Z"
style="fill:#D0BCFF;fill-opacity:1;fill-rule:nonzero;stroke:none"
transform="matrix(1.3333333,0,0,-1.3333333,621.73933,661.8696)"
clip-path="url(#clipPath31)" />
<path
id="path32"
d="m 0,0 c 0,0 12.178,7.604 15.029,18.355 0,0 36.913,-9.243 52.904,-26.719 C 66.887,-8.091 29.642,2.031 0,0 m -36.152,-5.643 c -26.791,0.404 -31.781,16.05 -34.086,29.523 -0.511,2.958 -3.148,5.073 -6.13,4.883 l -46.275,-4.764 14.922,7.14 c 3.505,1.687 7.164,3.077 10.895,4.17 3.742,1.093 7.556,1.901 11.429,2.376 4.051,0.499 8.542,1.01 13.46,1.509 0,0 6.19,2.459 7.414,-4.479 1.259,-6.926 0.499,-31.864 28.287,-31.103 27.777,0.76 32.612,19.412 32.612,19.412 0,0 3.458,14.471 9.232,14.257 5.263,-0.202 7.27,-6.142 7.853,-10.479 0.142,-1.045 -0.06,-3.54 0.582,-4.324 -0.939,1.152 -2.162,2.032 -3.564,2.554 C 7.604,26.113 3.065,26.945 2.471,21.658 1.616,13.877 -9.599,-6.059 -36.152,-5.643 m 13.306,48.354 9.528,-2.377 c 0,0 -2.388,-17.5 -11.227,-21.979 -0.25,-0.13 4.194,14.281 1.699,24.356 m 59.022,3.041 3.79,-1.687 c 0,0 2.198,-14.209 -3.79,-20.577 0,0 2.174,12.474 0,22.264 m -187.451,35.63 c 12.225,2.067 24.45,4.229 36.664,6.332 l 36.663,6.392 73.303,12.748 c 2.958,0.522 5.774,-1.462 6.285,-4.408 0.522,-2.947 -1.462,-5.762 -4.42,-6.285 -0.119,-0.024 -0.261,-0.036 -0.38,-0.047 H -3.184 L -114.243,85.029 c -12.344,-1.224 -24.676,-2.495 -37.032,-3.647 M 43.97,16.93 c 3.54,4.693 5.215,23.096 5.215,23.096 l 1.497,2.518 v 8.744 C 29.381,61.327 11.869,50.682 11.869,50.682 l -16.491,0.749 c -3.112,0.142 -7.449,2.637 -10.644,3.433 -4.42,1.093 -8.804,2.068 -13.342,2.566 -16.407,1.735 -32.933,0 -49.091,-2.934 -7.021,-1.271 -13.971,-2.851 -20.957,-4.325 -6.701,-1.425 -12.878,-3.908 -19.151,-6.605 -4.313,-1.842 -8.649,-3.659 -12.641,-6.119 -2.436,-1.496 -4.741,-3.243 -6.737,-5.31 -2.126,-2.21 -3.659,-4.859 -5.476,-7.319 -1.545,-2.091 -4.907,-0.463 -4.147,2.032 0.024,0.059 0.048,0.119 0.06,0.154 0.867,2.044 2.221,4.646 3.659,6.345 3.588,4.241 8.958,8.292 13.686,11.12 10.039,6.071 21.48,9.766 32.767,12.985 24.771,7.057 51.442,8.138 77.009,10.55 14.114,1.331 28.43,2.091 42.473,4.016 12.784,1.734 37.935,4.859 36.176,22.882 -1.71,18.129 -59.355,18.712 -59.355,18.712 0,0 -21.943,45.027 -27.372,50.468 -5.418,5.418 -16.503,18.474 -74.254,4.384 -57.739,-14.078 -55.327,-42.259 -55.327,-42.259 l -2.412,-39.622 c 0,0 -43.198,-11.179 -41.832,-23.321 1.391,-12.142 39.171,-12.819 39.171,-12.819 0,0 8.577,0.439 20.696,1.152 l -5.382,-9.683 c -6.749,-12.13 -6.38,-25.258 -8.851,-38.908 -4.277,-23.631 11.963,-52.702 33.978,-62.147 21.1,-9.053 53.949,-13.782 99.012,6.368 11.085,4.954 20.328,13.282 26.66,23.618 l 0.499,0.796 c 22.728,26.125 80.574,9.386 80.574,9.386 C 81.774,1.699 43.97,16.93 43.97,16.93"
style="fill:#D0BCFF;fill-opacity:1;fill-rule:nonzero;stroke:none"
transform="matrix(1.3333333,0,0,-1.3333333,819.35627,748.08933)"
clip-path="url(#clipPath33)" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -1,14 +0,0 @@
[Unit]
Description=Dank Material Shell (DMS)
PartOf=graphical-session.target
After=graphical-session-pre.target
Wants=graphical-session-pre.target
[Service]
Type=simple
ExecStart=/usr/bin/dms run
Restart=on-failure
RestartSec=1
[Install]
WantedBy=graphical-session.target

48
core/.mockery.yml Normal file
View File

@@ -0,0 +1,48 @@
with-expecter: true
dir: "internal/mocks/{{.InterfaceDirRelative}}"
mockname: "Mock{{.InterfaceName}}"
outpkg: "{{.PackageName}}"
packages:
github.com/Wifx/gonetworkmanager/v2:
interfaces:
NetworkManager:
Device:
DeviceWireless:
AccessPoint:
Connection:
Settings:
ActiveConnection:
IP4Config:
net:
interfaces:
Conn:
github.com/AvengeMedia/danklinux/internal/plugins:
interfaces:
GitClient:
github.com/godbus/dbus/v5:
interfaces:
BusObject:
github.com/AvengeMedia/danklinux/internal/server/brightness:
config:
dir: "internal/mocks/brightness"
outpkg: mocks_brightness
interfaces:
DBusConn:
github.com/AvengeMedia/danklinux/internal/server/network:
config:
dir: "internal/mocks/network"
outpkg: mocks_network
interfaces:
Backend:
github.com/AvengeMedia/danklinux/internal/server/cups:
config:
dir: "internal/mocks/cups"
outpkg: mocks_cups
interfaces:
CUPSClientInterface:
github.com/AvengeMedia/DankMaterialShell/core/internal/server/evdev:
config:
dir: "internal/mocks/evdev"
outpkg: mocks_evdev
interfaces:
EvdevDevice:

157
core/Makefile Normal file
View File

@@ -0,0 +1,157 @@
BINARY_NAME=dms
BINARY_NAME_INSTALL=dankinstall
SOURCE_DIR=cmd/dms
SOURCE_DIR_INSTALL=cmd/dankinstall
BUILD_DIR=bin
PREFIX ?= /usr/local
INSTALL_DIR=$(PREFIX)/bin
GO=go
GOFLAGS=-ldflags="-s -w"
# Version and build info
VERSION=$(shell git describe --tags --always 2>/dev/null || echo "dev")
BUILD_TIME=$(shell date -u '+%Y-%m-%d_%H:%M:%S')
COMMIT=$(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
BUILD_LDFLAGS=-ldflags='-s -w -X main.Version=$(VERSION) -X main.buildTime=$(BUILD_TIME) -X main.commit=$(COMMIT)'
# Architecture to build for dist target (amd64, arm64, or all)
ARCH ?= all
.PHONY: all build dankinstall dist clean install install-all install-dankinstall uninstall uninstall-all uninstall-dankinstall install-config uninstall-config test fmt vet deps help
# Default target
all: build
# Build the main binary (dms)
build:
@echo "Building $(BINARY_NAME)..."
@mkdir -p $(BUILD_DIR)
CGO_ENABLED=0 $(GO) build $(BUILD_LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME) ./$(SOURCE_DIR)
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)"
dankinstall:
@echo "Building $(BINARY_NAME_INSTALL)..."
@mkdir -p $(BUILD_DIR)
CGO_ENABLED=0 $(GO) build $(BUILD_LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME_INSTALL) ./$(SOURCE_DIR_INSTALL)
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME_INSTALL)"
# Build distro binaries for amd64 and arm64 (Linux only, no update/greeter support)
dist:
ifeq ($(ARCH),all)
@echo "Building $(BINARY_NAME) for distribution (amd64 and arm64)..."
@mkdir -p $(BUILD_DIR)
@echo "Building for linux/amd64..."
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build -tags distro_binary $(BUILD_LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(SOURCE_DIR)
@echo "Building for linux/arm64..."
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 $(GO) build -tags distro_binary $(BUILD_LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(SOURCE_DIR)
@echo "Distribution builds complete:"
@echo " $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64"
@echo " $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64"
else
@echo "Building $(BINARY_NAME) for distribution ($(ARCH))..."
@mkdir -p $(BUILD_DIR)
@echo "Building for linux/$(ARCH)..."
CGO_ENABLED=0 GOOS=linux GOARCH=$(ARCH) $(GO) build -tags distro_binary $(BUILD_LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-$(ARCH) ./$(SOURCE_DIR)
@echo "Distribution build complete:"
@echo " $(BUILD_DIR)/$(BINARY_NAME)-linux-$(ARCH)"
endif
build-all: build dankinstall
install: build
@echo "Installing $(BINARY_NAME) to $(INSTALL_DIR)..."
@install -D -m 755 $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_DIR)/$(BINARY_NAME)
@echo "Installation complete"
install-all: build-all
@echo "Installing $(BINARY_NAME) to $(INSTALL_DIR)..."
@install -D -m 755 $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_DIR)/$(BINARY_NAME)
@echo "Installing $(BINARY_NAME_INSTALL) to $(INSTALL_DIR)..."
@install -D -m 755 $(BUILD_DIR)/$(BINARY_NAME_INSTALL) $(INSTALL_DIR)/$(BINARY_NAME_INSTALL)
@echo "Installation complete"
install-dankinstall: dankinstall
@echo "Installing $(BINARY_NAME_INSTALL) to $(INSTALL_DIR)..."
@install -D -m 755 $(BUILD_DIR)/$(BINARY_NAME_INSTALL) $(INSTALL_DIR)/$(BINARY_NAME_INSTALL)
@echo "Installation complete"
uninstall:
@echo "Uninstalling $(BINARY_NAME) from $(INSTALL_DIR)..."
@rm -f $(INSTALL_DIR)/$(BINARY_NAME)
@echo "Uninstall complete"
uninstall-all:
@echo "Uninstalling $(BINARY_NAME) from $(INSTALL_DIR)..."
@rm -f $(INSTALL_DIR)/$(BINARY_NAME)
@echo "Uninstalling $(BINARY_NAME_INSTALL) from $(INSTALL_DIR)..."
@rm -f $(INSTALL_DIR)/$(BINARY_NAME_INSTALL)
@echo "Uninstall complete"
uninstall-dankinstall:
@echo "Uninstalling $(BINARY_NAME_INSTALL) from $(INSTALL_DIR)..."
@rm -f $(INSTALL_DIR)/$(BINARY_NAME_INSTALL)
@echo "Uninstall complete"
clean:
@echo "Cleaning build artifacts..."
@rm -rf $(BUILD_DIR)
@echo "Clean complete"
test:
@echo "Running tests..."
$(GO) test -v ./...
fmt:
@echo "Formatting Go code..."
$(GO) fmt ./...
vet:
@echo "Running go vet..."
$(GO) vet ./...
deps:
@echo "Updating dependencies..."
$(GO) mod tidy
$(GO) mod download
dev:
@echo "Building $(BINARY_NAME) for development..."
@mkdir -p $(BUILD_DIR)
$(GO) build -o $(BUILD_DIR)/$(BINARY_NAME) ./$(SOURCE_DIR)
@echo "Development build complete: $(BUILD_DIR)/$(BINARY_NAME)"
check-go:
@echo "Checking Go version..."
@go version | grep -E "go1\.(2[2-9]|[3-9][0-9])" > /dev/null || (echo "ERROR: Go 1.22 or higher required" && exit 1)
@echo "Go version OK"
version: check-go
@echo "Version: $(VERSION)"
@echo "Build Time: $(BUILD_TIME)"
@echo "Commit: $(COMMIT)"
help:
@echo "Available targets:"
@echo " all - Build the main binary (dms) (default)"
@echo " build - Build the main binary (dms)"
@echo " dankinstall - Build dankinstall binary"
@echo " dist - Build dms for linux amd64/arm64 (no update/greeter)"
@echo " Use ARCH=amd64 or ARCH=arm64 to build only one"
@echo " build-all - Build both binaries"
@echo " install - Install dms to $(INSTALL_DIR)"
@echo " install-all - Install both dms and dankinstall to $(INSTALL_DIR)"
@echo " install-dankinstall - Install only dankinstall to $(INSTALL_DIR)"
@echo " uninstall - Remove dms from $(INSTALL_DIR)"
@echo " uninstall-all - Remove both binaries from $(INSTALL_DIR)"
@echo " uninstall-dankinstall - Remove only dankinstall from $(INSTALL_DIR)"
@echo " clean - Clean build artifacts"
@echo " test - Run tests"
@echo " fmt - Format Go code"
@echo " vet - Run go vet"
@echo " deps - Update dependencies"
@echo " dev - Build with debug info"
@echo " check-go - Check Go version compatibility"
@echo " version - Show version information"
@echo " help - Show this help message"

117
core/README.md Normal file
View File

@@ -0,0 +1,117 @@
# DMS Backend & CLI
Go-based backend for DankMaterialShell providing system integration, IPC, and installation tools.
**See [root README](../README.md) for project overview and installation.**
## Components
**dms CLI**
Command-line interface and daemon for shell management and system control.
**dankinstall**
Distribution-aware installer with TUI for deploying DMS and compositor configurations on Arch, Fedora, Debian, Ubuntu, openSUSE, and Gentoo.
## System Integration
**Wayland Protocols**
- `wlr-gamma-control-unstable-v1` - Night mode and gamma control
- `dwl-ipc-unstable-v2` - dwl/MangoWC workspace integration
- `ext-workspace-v1` - Workspace protocol support
- `wlr-output-management-unstable-v1` - Display configuration
**DBus Interfaces**
- NetworkManager/iwd - Network management
- logind - Session control and inhibit locks
- accountsservice - User account information
- CUPS - Printer management
- Custom IPC via unix socket (JSON API)
**Hardware Control**
- DDC/CI protocol - External monitor brightness control (like `ddcutil`)
- Backlight control - Internal display brightness via `login1` or sysfs
- LED control - Keyboard/device LED management
- evdev input monitoring - Keyboard state tracking (caps lock, etc.)
**Plugin System**
- Plugin registry integration
- Plugin lifecycle management
- Settings persistence
## CLI Commands
- `dms run [-d]` - Start shell (optionally as daemon)
- `dms restart` / `dms kill` - Manage running processes
- `dms ipc <command>` - Send IPC commands (toggle launcher, notifications, etc.)
- `dms plugins [install|browse|search]` - Plugin management
- `dms brightness [list|set]` - Control display/monitor brightness
- `dms update` - Update DMS and dependencies (disabled in distro packages)
- `dms greeter install` - Install greetd greeter (disabled in distro packages)
## Building
Requires Go 1.24+
**Development build:**
```bash
make # Build dms CLI
make dankinstall # Build installer
make test # Run tests
```
**Distribution build:**
```bash
make dist # Build without update/greeter features
```
Produces `bin/dms-linux-amd64` and `bin/dms-linux-arm64`
**Installation:**
```bash
sudo make install # Install to /usr/local/bin/dms
```
## Development
**Regenerating Wayland Protocol Bindings:**
```bash
go install github.com/rajveermalviya/go-wayland/cmd/go-wayland-scanner@latest
go-wayland-scanner -i internal/proto/xml/wlr-gamma-control-unstable-v1.xml \
-pkg wlr_gamma_control -o internal/proto/wlr_gamma_control/gamma_control.go
```
**Module Structure:**
- `cmd/` - Binary entrypoints (dms, dankinstall)
- `internal/distros/` - Distribution-specific installation logic
- `internal/proto/` - Wayland protocol bindings
- `pkg/` - Shared packages
## Installation via dankinstall
```bash
curl -fsSL https://install.danklinux.com | sh
```
## Supported Distributions
Arch, Fedora, Debian, Ubuntu, openSUSE, Gentoo (and derivatives)
**Arch Linux**
Uses `pacman` for system packages, builds AUR packages via `makepkg`, no AUR helper dependency.
**Fedora**
Uses COPR repositories (`avengemedia/danklinux`, `avengemedia/dms`).
**Ubuntu**
Requires PPA support. Most packages built from source (slow first install).
**Debian**
Debian 13+ (Trixie). niri only, no Hyprland support. Builds from source.
**openSUSE**
Most packages available in standard repos. Minimal building required.
**Gentoo**
Uses Portage with GURU overlay. Automatically configures USE flags. Variable success depending on system configuration.
See installer output for distribution-specific details during installation.

46
core/assets/dank.svg Normal file
View File

@@ -0,0 +1,46 @@
<svg viewBox="0 0 136 50" xmlns="http://www.w3.org/2000/svg">
<!-- D -->
<rect x="0" y="5" width="24" height="8" fill="#CCBEFF"/>
<rect x="0" y="13" width="8" height="8" fill="#CCBEFF"/>
<rect x="20" y="13" width="8" height="8" fill="#CCBEFF"/>
<rect x="0" y="21" width="8" height="8" fill="#CCBEFF"/>
<rect x="20" y="21" width="8" height="8" fill="#CCBEFF"/>
<rect x="0" y="29" width="8" height="8" fill="#CCBEFF"/>
<rect x="20" y="29" width="8" height="8" fill="#CCBEFF"/>
<rect x="0" y="37" width="24" height="8" fill="#CCBEFF"/>
<!-- A -->
<rect x="36" y="5" width="20" height="8" fill="#CCBEFF"/>
<rect x="32" y="13" width="8" height="8" fill="#CCBEFF"/>
<rect x="52" y="13" width="8" height="8" fill="#CCBEFF"/>
<rect x="32" y="21" width="28" height="8" fill="#CCBEFF"/>
<rect x="32" y="29" width="8" height="8" fill="#CCBEFF"/>
<rect x="52" y="29" width="8" height="8" fill="#CCBEFF"/>
<rect x="32" y="37" width="8" height="8" fill="#CCBEFF"/>
<rect x="52" y="37" width="8" height="8" fill="#CCBEFF"/>
<!-- N -->
<rect x="64" y="5" width="12" height="8" fill="#CCBEFF"/>
<rect x="92" y="5" width="8" height="8" fill="#CCBEFF"/>
<rect x="64" y="13" width="16" height="8" fill="#CCBEFF"/>
<rect x="92" y="13" width="8" height="8" fill="#CCBEFF"/>
<rect x="64" y="21" width="8" height="8" fill="#CCBEFF"/>
<rect x="76" y="21" width="8" height="8" fill="#CCBEFF"/>
<rect x="92" y="21" width="8" height="8" fill="#CCBEFF"/>
<rect x="64" y="29" width="8" height="8" fill="#CCBEFF"/>
<rect x="80" y="29" width="8" height="8" fill="#CCBEFF"/>
<rect x="92" y="29" width="8" height="8" fill="#CCBEFF"/>
<rect x="64" y="37" width="8" height="8" fill="#CCBEFF"/>
<rect x="84" y="37" width="16" height="8" fill="#CCBEFF"/>
<!-- K -->
<rect x="104" y="5" width="8" height="8" fill="#CCBEFF"/>
<rect x="124" y="5" width="8" height="8" fill="#CCBEFF"/>
<rect x="104" y="13" width="8" height="8" fill="#CCBEFF"/>
<rect x="120" y="13" width="8" height="8" fill="#CCBEFF"/>
<rect x="104" y="21" width="20" height="8" fill="#CCBEFF"/>
<rect x="104" y="29" width="8" height="8" fill="#CCBEFF"/>
<rect x="120" y="29" width="8" height="8" fill="#CCBEFF"/>
<rect x="104" y="37" width="8" height="8" fill="#CCBEFF"/>
<rect x="124" y="37" width="8" height="8" fill="#CCBEFF"/>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

113
core/assets/danklogo.svg Normal file
View File

@@ -0,0 +1,113 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
version="1.1"
id="svg1"
width="482.90668"
height="558.15088"
viewBox="0 0 482.90667 558.15088"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
shape-rendering="auto"
style="image-rendering: auto; filter: url(#smoothing);">
<defs
id="defs1">
<filter id="smoothing" x="-0.05" y="-0.05" width="1.1" height="1.1">
<feGaussianBlur in="SourceGraphic" stdDeviation="0.5" />
</filter>
<color-profile
name="sRGB IEC61966-2.1"
xlink:href="data:application/vnd.iccprofile;base64,AAAMbExpbm8CEAAAbW50clJHQiBYWVogB84AAgAJAAYAMQAAYWNzcE1TRlQAAAAASUVDIHNSR0IAAAAAAAAAAAAAAAAAAPbWAAEAAAAA0y1IUCAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARY3BydAAAAVAAAAAzZGVzYwAAAYQAAACQd3RwdAAAAhQAAAAUYmtwdAAAAigAAAAUclhZWgAAAjwAAAAUZ1hZWgAAAlAAAAAUYlhZWgAAAmQAAAAUZG1uZAAAAngAAABwZG1kZAAAAugAAACIdnVlZAAAA3AAAACGdmlldwAAA/gAAAAkbHVtaQAABBwAAAAUbWVhcwAABDAAAAAkdGVjaAAABFQAAAAMclRSQwAABGAAAAgMZ1RSQwAABGAAAAgMYlRSQwAABGAAAAgMdGV4dAAAAABDb3B5cmlnaHQgKGMpIDE5OTggSGV3bGV0dC1QYWNrYXJkIENvbXBhbnkAAGRlc2MAAAAAAAAAEnNSR0IgSUVDNjE5NjYtMi4xAAAAAAAAAAASAHMAUgBHAEIAIABJAEUAQwA2ADEAOQA2ADYALQAyAC4AMQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFhZWiAAAAAAAADzUQABAAAAARbMWFlaIAAAAAAAAAAAAAAAAAAAAABYWVogAAAAAAAAb6IAADj1AAADkFhZWiAAAAAAAABimQAAt4UAABjaWFlaIAAAAAAAACSgAAAPhAAAts9kZXNjAAAAAAAAABZJRUMgaHR0cDovL3d3dy5pZWMuY2gAAAAAAAAAAAAAABZJRUMgaHR0cDovL3d3dy5pZWMuY2gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAZGVzYwAAAAAAAAAuSUVDIDYxOTY2LTIuMSBEZWZhdWx0IFJHQiBjb2xvdXIgc3BhY2UgLSBzUkdCAAAAAAAAAAAAAAAuSUVDIDYxOTY2LTIuMSBEZWZhdWx0IFJHQiBjb2xvdXIgc3BhY2UgLSBzUkdCAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGRlc2MAAAAAAAAALFJlZmVyZW5jZSBWaWV3aW5nIENvbmRpdGlvbiBpbiBJRUM2MTk2Ni0yLjEAAAAAAAAAAAAAACxSZWZlcmVuY2UgVmlld2luZyBDb25kaXRpb24gaW4gSUVDNjE5NjYtMi4xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB2aWV3AAAAAAATpP4AFF8uABDPFAAD7cwABBMLAANcngAAAAFYWVogAAAAAABMCVYAUAAAAFcf521lYXMAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAKPAAAAAnNpZyAAAAAAQ1JUIGN1cnYAAAAAAAAEAAAAAAUACgAPABQAGQAeACMAKAAtADIANwA7AEAARQBKAE8AVABZAF4AYwBoAG0AcgB3AHwAgQCGAIsAkACVAJoAnwCkAKkArgCyALcAvADBAMYAywDQANUA2wDgAOUA6wDwAPYA+wEBAQcBDQETARkBHwElASsBMgE4AT4BRQFMAVIBWQFgAWcBbgF1AXwBgwGLAZIBmgGhAakBsQG5AcEByQHRAdkB4QHpAfIB+gIDAgwCFAIdAiYCLwI4AkECSwJUAl0CZwJxAnoChAKOApgCogKsArYCwQLLAtUC4ALrAvUDAAMLAxYDIQMtAzgDQwNPA1oDZgNyA34DigOWA6IDrgO6A8cD0wPgA+wD+QQGBBMEIAQtBDsESARVBGMEcQR+BIwEmgSoBLYExATTBOEE8AT+BQ0FHAUrBToFSQVYBWcFdwWGBZYFpgW1BcUF1QXlBfYGBgYWBicGNwZIBlkGagZ7BowGnQavBsAG0QbjBvUHBwcZBysHPQdPB2EHdAeGB5kHrAe/B9IH5Qf4CAsIHwgyCEYIWghuCIIIlgiqCL4I0gjnCPsJEAklCToJTwlkCXkJjwmkCboJzwnlCfsKEQonCj0KVApqCoEKmAquCsUK3ArzCwsLIgs5C1ELaQuAC5gLsAvIC+EL+QwSDCoMQwxcDHUMjgynDMAM2QzzDQ0NJg1ADVoNdA2ODakNww3eDfgOEw4uDkkOZA5/DpsOtg7SDu4PCQ8lD0EPXg96D5YPsw/PD+wQCRAmEEMQYRB+EJsQuRDXEPURExExEU8RbRGMEaoRyRHoEgcSJhJFEmQShBKjEsMS4xMDEyMTQxNjE4MTpBPFE+UUBhQnFEkUahSLFK0UzhTwFRIVNBVWFXgVmxW9FeAWAxYmFkkWbBaPFrIW1hb6Fx0XQRdlF4kXrhfSF/cYGxhAGGUYihivGNUY+hkgGUUZaxmRGbcZ3RoEGioaURp3Gp4axRrsGxQbOxtjG4obshvaHAIcKhxSHHscoxzMHPUdHh1HHXAdmR3DHeweFh5AHmoelB6+HukfEx8+H2kflB+/H+ogFSBBIGwgmCDEIPAhHCFIIXUhoSHOIfsiJyJVIoIiryLdIwojOCNmI5QjwiPwJB8kTSR8JKsk2iUJJTglaCWXJccl9yYnJlcmhya3JugnGCdJJ3onqyfcKA0oPyhxKKIo1CkGKTgpaymdKdAqAio1KmgqmyrPKwIrNitpK50r0SwFLDksbiyiLNctDC1BLXYtqy3hLhYuTC6CLrcu7i8kL1ovkS/HL/4wNTBsMKQw2zESMUoxgjG6MfIyKjJjMpsy1DMNM0YzfzO4M/E0KzRlNJ402DUTNU01hzXCNf02NzZyNq426TckN2A3nDfXOBQ4UDiMOMg5BTlCOX85vDn5OjY6dDqyOu87LTtrO6o76DwnPGU8pDzjPSI9YT2hPeA+ID5gPqA+4D8hP2E/oj/iQCNAZECmQOdBKUFqQaxB7kIwQnJCtUL3QzpDfUPARANER0SKRM5FEkVVRZpF3kYiRmdGq0bwRzVHe0fASAVIS0iRSNdJHUljSalJ8Eo3Sn1KxEsMS1NLmkviTCpMcky6TQJNSk2TTdxOJU5uTrdPAE9JT5NP3VAnUHFQu1EGUVBRm1HmUjFSfFLHUxNTX1OqU/ZUQlSPVNtVKFV1VcJWD1ZcVqlW91dEV5JX4FgvWH1Yy1kaWWlZuFoHWlZaplr1W0VblVvlXDVchlzWXSddeF3JXhpebF69Xw9fYV+zYAVgV2CqYPxhT2GiYfViSWKcYvBjQ2OXY+tkQGSUZOllPWWSZedmPWaSZuhnPWeTZ+loP2iWaOxpQ2maafFqSGqfavdrT2una/9sV2yvbQhtYG25bhJua27Ebx5veG/RcCtwhnDgcTpxlXHwcktypnMBc11zuHQUdHB0zHUodYV14XY+dpt2+HdWd7N4EXhueMx5KnmJeed6RnqlewR7Y3vCfCF8gXzhfUF9oX4BfmJ+wn8jf4R/5YBHgKiBCoFrgc2CMIKSgvSDV4O6hB2EgITjhUeFq4YOhnKG14c7h5+IBIhpiM6JM4mZif6KZIrKizCLlov8jGOMyo0xjZiN/45mjs6PNo+ekAaQbpDWkT+RqJIRknqS45NNk7aUIJSKlPSVX5XJljSWn5cKl3WX4JhMmLiZJJmQmfyaaJrVm0Kbr5wcnImc951kndKeQJ6unx2fi5/6oGmg2KFHobaiJqKWowajdqPmpFakx6U4pammGqaLpv2nbqfgqFKoxKk3qamqHKqPqwKrdavprFys0K1ErbiuLa6hrxavi7AAsHWw6rFgsdayS7LCszizrrQltJy1E7WKtgG2ebbwt2i34LhZuNG5SrnCuju6tbsuu6e8IbybvRW9j74KvoS+/796v/XAcMDswWfB48JfwtvDWMPUxFHEzsVLxcjGRsbDx0HHv8g9yLzJOsm5yjjKt8s2y7bMNcy1zTXNtc42zrbPN8+40DnQutE80b7SP9LB00TTxtRJ1MvVTtXR1lXW2Ndc1+DYZNjo2WzZ8dp22vvbgNwF3IrdEN2W3hzeot8p36/gNuC94UThzOJT4tvjY+Pr5HPk/OWE5g3mlucf56noMui86Ubp0Opb6uXrcOv77IbtEe2c7ijutO9A78zwWPDl8XLx//KM8xnzp/Q09ML1UPXe9m32+/eK+Bn4qPk4+cf6V/rn+3f8B/yY/Sn9uv5L/tz/bf//"
id="color-profile1" />
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath21">
<path
d="M 0,1200 H 2000 V 0 H 0 Z"
transform="translate(-673.87432,-704.25842)"
id="path21" />
</clipPath>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath25">
<path
d="M 0,1200 H 2000 V 0 H 0 Z"
transform="translate(-466.30451,-703.59782)"
id="path25" />
</clipPath>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath27">
<path
d="M 0,1200 H 2000 V 0 H 0 Z"
transform="translate(-695.28002,-473.92741)"
id="path27" />
</clipPath>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath29">
<path
d="M 0,1200 H 2000 V 0 H 0 Z"
transform="translate(-457.93881,-632.99062)"
id="path29" />
</clipPath>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath31">
<path
d="M 0,1200 H 2000 V 0 H 0 Z"
transform="translate(-466.30451,-703.59782)"
id="path31" />
</clipPath>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath33">
<path
d="M 0,1200 H 2000 V 0 H 0 Z"
transform="translate(-614.51722,-638.93302)"
id="path33" />
</clipPath>
</defs>
<g
id="layer-MC0"
transform="translate(-486.31024,-515.02722)">
<path
id="path20"
d="M 0,0 C -1.568,1.568 -3.163,3.098 -4.787,4.61 -5.944,3.966 -7.185,3.35 -8.529,2.762 -9.658,2.277 -10.815,1.82 -11.981,1.4 c 1.885,-1.689 3.742,-3.425 5.552,-5.207 0.448,-0.429 0.886,-0.868 1.325,-1.306 2.221,-2.221 4.386,-4.498 6.476,-6.84 27.639,-30.784 44.453,-71.459 44.453,-116.089 0,-29.347 -7.259,-56.977 -20.09,-81.21 -2.192,-4.134 -4.544,-8.174 -7.054,-12.102 -6.83,-10.74 -14.827,-20.669 -23.785,-29.636 -5.944,-5.944 -12.317,-11.459 -19.073,-16.498 -0.56,-0.42 -1.12,-0.83 -1.689,-1.231 -28.675,-20.893 -63.975,-33.201 -102.186,-33.201 -48.018,0 -91.464,19.456 -122.948,50.93 -0.737,0.737 -1.465,1.474 -2.174,2.221 -0.55,0.569 -1.101,1.147 -1.633,1.726 -15.545,16.553 -27.881,36.14 -36.018,57.779 -3.098,8.211 -5.58,16.712 -7.409,25.464 -2.417,11.534 -3.686,23.496 -3.686,35.758 0.01,42.326 15.117,81.097 40.246,111.246 -2.072,1.278 -3.975,2.809 -5.534,4.637 -26.174,-31.399 -41.934,-71.822 -41.934,-115.883 0,-18.187 2.678,-35.748 7.67,-52.311 3.359,-11.142 7.754,-21.835 13.092,-31.95 8.528,-16.208 19.446,-30.961 32.276,-43.801 1.251,-1.25 2.529,-2.491 3.817,-3.685 0.662,-0.644 1.334,-1.26 2.006,-1.876 10.862,-9.938 22.945,-18.578 36,-25.661 25.632,-13.913 55.016,-21.816 86.229,-21.816 2.454,0 4.908,0.047 7.334,0.14 36.056,1.446 69.424,13.427 97.054,32.976 0.569,0.392 1.148,0.793 1.698,1.204 7.82,5.655 15.164,11.925 21.966,18.718 7.904,7.904 15.07,16.526 21.406,25.773 2.556,3.723 4.973,7.558 7.25,11.477 15.499,26.697 24.382,57.723 24.382,90.812 C 53.038,-78.055 32.762,-32.762 0,0"
style="fill:#D0BCFF;fill-opacity:1;fill-rule:nonzero;stroke:none"
transform="matrix(1.3333333,0,0,-1.3333333,898.49907,660.9888)"
clip-path="url(#clipPath21)" />
<path
id="path24"
d="M 0,0 -0.169,-0.292 C 1.43,-0.2 3.091,-0.108 4.798,0 Z"
style="fill:#D0BCFF;fill-opacity:1;fill-rule:nonzero;stroke:none"
transform="matrix(1.3333333,0,0,-1.3333333,621.73933,661.8696)"
clip-path="url(#clipPath25)" />
<path
id="path26"
d="m 0,0 c -6.336,-9.247 -13.502,-17.869 -21.406,-25.773 -6.802,-6.793 -14.146,-13.063 -21.965,-18.718 -0.551,-0.411 -1.129,-0.812 -1.699,-1.204 -27.629,-19.549 -60.998,-31.53 -97.053,-32.976 -33.247,1.129 -64.852,8.762 -93.564,21.676 -13.054,7.082 -25.138,15.723 -36,25.661 -0.672,0.616 -1.343,1.232 -2.006,1.876 -1.288,1.194 -2.566,2.435 -3.816,3.685 -12.831,12.84 -23.748,27.593 -32.277,43.801 -1.092,7.651 -1.941,15.378 -2.445,23.039 -0.102,1.502 -0.186,3.004 -0.261,4.497 0,0 -2.865,29.795 23.944,36.634 26.827,6.84 65.654,19.745 87.722,50.305 0,0 0.327,-8.38 5.506,-15.779 8.034,-11.422 46.674,-100.46 46.674,-100.46 l 6.121,51.135 -13.978,15.079 -4.553,-1.987 15.228,16.544 c 0,0 8.94,4.218 16.554,-0.653 7.605,-4.899 14.146,-16.153 14.146,-16.153 l -5.879,3.892 -4.227,-12.364 c -0.756,-2.184 -0.83,-4.535 -0.233,-6.784 l 16.796,-62.687 c 0,0 -0.243,87.415 -2.781,111.685 0,0 14.221,-10.367 21.621,-14.454 7.381,-4.078 47.215,-20.407 53.738,-22.395 6.504,-1.987 13.222,-5.841 15.882,-11.916 1.026,-2.351 8.594,-26.939 17.486,-56.229 C -1.829,6.028 -0.914,3.023 0,0"
style="fill:#D0BCFF;fill-opacity:1;fill-rule:nonzero;stroke:none"
transform="matrix(1.3333333,0,0,-1.3333333,927.04,968.0968)"
clip-path="url(#clipPath27)" />
<path
id="path28"
d="m 0,0 c 0,0 -3.081,-67.22 -8.64,-90.616 -5.559,-23.397 30.316,0 30.316,0 l 20.9,68.629 z"
style="fill:#D0BCFF;fill-opacity:1;fill-rule:nonzero;stroke:none"
transform="matrix(1.3333333,0,0,-1.3333333,610.58507,756.01253)"
clip-path="url(#clipPath29)" />
<path
id="path30"
d="M 0,0 -0.169,-0.292 C 1.43,-0.2 3.091,-0.108 4.798,0 Z"
style="fill:#D0BCFF;fill-opacity:1;fill-rule:nonzero;stroke:none"
transform="matrix(1.3333333,0,0,-1.3333333,621.73933,661.8696)"
clip-path="url(#clipPath31)" />
<path
id="path32"
d="m 0,0 c 0,0 12.178,7.604 15.029,18.355 0,0 36.913,-9.243 52.904,-26.719 C 66.887,-8.091 29.642,2.031 0,0 m -36.152,-5.643 c -26.791,0.404 -31.781,16.05 -34.086,29.523 -0.511,2.958 -3.148,5.073 -6.13,4.883 l -46.275,-4.764 14.922,7.14 c 3.505,1.687 7.164,3.077 10.895,4.17 3.742,1.093 7.556,1.901 11.429,2.376 4.051,0.499 8.542,1.01 13.46,1.509 0,0 6.19,2.459 7.414,-4.479 1.259,-6.926 0.499,-31.864 28.287,-31.103 27.777,0.76 32.612,19.412 32.612,19.412 0,0 3.458,14.471 9.232,14.257 5.263,-0.202 7.27,-6.142 7.853,-10.479 0.142,-1.045 -0.06,-3.54 0.582,-4.324 -0.939,1.152 -2.162,2.032 -3.564,2.554 C 7.604,26.113 3.065,26.945 2.471,21.658 1.616,13.877 -9.599,-6.059 -36.152,-5.643 m 13.306,48.354 9.528,-2.377 c 0,0 -2.388,-17.5 -11.227,-21.979 -0.25,-0.13 4.194,14.281 1.699,24.356 m 59.022,3.041 3.79,-1.687 c 0,0 2.198,-14.209 -3.79,-20.577 0,0 2.174,12.474 0,22.264 m -187.451,35.63 c 12.225,2.067 24.45,4.229 36.664,6.332 l 36.663,6.392 73.303,12.748 c 2.958,0.522 5.774,-1.462 6.285,-4.408 0.522,-2.947 -1.462,-5.762 -4.42,-6.285 -0.119,-0.024 -0.261,-0.036 -0.38,-0.047 H -3.184 L -114.243,85.029 c -12.344,-1.224 -24.676,-2.495 -37.032,-3.647 M 43.97,16.93 c 3.54,4.693 5.215,23.096 5.215,23.096 l 1.497,2.518 v 8.744 C 29.381,61.327 11.869,50.682 11.869,50.682 l -16.491,0.749 c -3.112,0.142 -7.449,2.637 -10.644,3.433 -4.42,1.093 -8.804,2.068 -13.342,2.566 -16.407,1.735 -32.933,0 -49.091,-2.934 -7.021,-1.271 -13.971,-2.851 -20.957,-4.325 -6.701,-1.425 -12.878,-3.908 -19.151,-6.605 -4.313,-1.842 -8.649,-3.659 -12.641,-6.119 -2.436,-1.496 -4.741,-3.243 -6.737,-5.31 -2.126,-2.21 -3.659,-4.859 -5.476,-7.319 -1.545,-2.091 -4.907,-0.463 -4.147,2.032 0.024,0.059 0.048,0.119 0.06,0.154 0.867,2.044 2.221,4.646 3.659,6.345 3.588,4.241 8.958,8.292 13.686,11.12 10.039,6.071 21.48,9.766 32.767,12.985 24.771,7.057 51.442,8.138 77.009,10.55 14.114,1.331 28.43,2.091 42.473,4.016 12.784,1.734 37.935,4.859 36.176,22.882 -1.71,18.129 -59.355,18.712 -59.355,18.712 0,0 -21.943,45.027 -27.372,50.468 -5.418,5.418 -16.503,18.474 -74.254,4.384 -57.739,-14.078 -55.327,-42.259 -55.327,-42.259 l -2.412,-39.622 c 0,0 -43.198,-11.179 -41.832,-23.321 1.391,-12.142 39.171,-12.819 39.171,-12.819 0,0 8.577,0.439 20.696,1.152 l -5.382,-9.683 c -6.749,-12.13 -6.38,-25.258 -8.851,-38.908 -4.277,-23.631 11.963,-52.702 33.978,-62.147 21.1,-9.053 53.949,-13.782 99.012,6.368 11.085,4.954 20.328,13.282 26.66,23.618 l 0.499,0.796 c 22.728,26.125 80.574,9.386 80.574,9.386 C 81.774,1.699 43.97,16.93 43.97,16.93"
style="fill:#D0BCFF;fill-opacity:1;fill-rule:nonzero;stroke:none"
transform="matrix(1.3333333,0,0,-1.3333333,819.35627,748.08933)"
clip-path="url(#clipPath33)" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 13 KiB

38
core/build_dankinstall.sh Executable file
View File

@@ -0,0 +1,38 @@
#!/bin/bash
set -euo pipefail
# Colors for output
GREEN='\033[0;32m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Get latest version tag
VERSION=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
echo -e "${GREEN}Building dankinstall ${VERSION}${NC}"
# Create bin directory if it doesn't exist
mkdir -p bin
# Build for each architecture
for ARCH in amd64 arm64; do
echo -e "${BLUE}Building for ${ARCH}...${NC}"
cd cmd/dankinstall
GOOS=linux CGO_ENABLED=0 GOARCH=${ARCH} \
go build -trimpath -ldflags "-s -w -X main.Version=${VERSION}" \
-o ../../bin/dankinstall-${ARCH}
cd ../..
# Compress
gzip -9 -k -f bin/dankinstall-${ARCH}
# Generate checksum
(cd bin && sha256sum dankinstall-${ARCH}.gz > dankinstall-${ARCH}.gz.sha256)
echo -e "${GREEN}✓ Built bin/dankinstall-${ARCH}.gz${NC}"
done
echo -e "${GREEN}Done! Files ready in bin/:${NC}"
ls -lh bin/dankinstall-*

View File

@@ -0,0 +1,50 @@
package main
import (
"fmt"
"os"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/tui"
tea "github.com/charmbracelet/bubbletea"
)
var Version = "dev"
func main() {
fileLogger, err := log.NewFileLogger()
if err != nil {
fmt.Printf("Warning: Failed to create log file: %v\n", err)
fmt.Println("Continuing without file logging...")
}
logFilePath := ""
if fileLogger != nil {
logFilePath = fileLogger.GetLogPath()
fmt.Printf("Logging to: %s\n", logFilePath)
defer func() {
if err := fileLogger.Close(); err != nil {
fmt.Printf("Warning: Failed to close log file: %v\n", err)
}
}()
}
model := tui.NewModel(Version, logFilePath)
if fileLogger != nil {
fileLogger.StartListening(model.GetLogChan())
}
p := tea.NewProgram(model, tea.WithAltScreen())
if _, err := p.Run(); err != nil {
fmt.Printf("Error running program: %v\n", err)
if logFilePath != "" {
fmt.Printf("\nFull logs are available at: %s\n", logFilePath)
}
os.Exit(1)
}
if logFilePath != "" {
fmt.Printf("\nFull logs are available at: %s\n", logFilePath)
}
}

View File

@@ -0,0 +1,303 @@
package main
import (
"fmt"
"strings"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/brightness"
"github.com/spf13/cobra"
)
var brightnessCmd = &cobra.Command{
Use: "brightness",
Short: "Control device brightness",
Long: "Control brightness for backlight and LED devices (use --ddc to include DDC/I2C monitors)",
}
var brightnessListCmd = &cobra.Command{
Use: "list",
Short: "List all brightness devices",
Long: "List all available brightness devices with their current values",
Run: runBrightnessList,
}
var brightnessSetCmd = &cobra.Command{
Use: "set <device_id> <percent>",
Short: "Set brightness for a device",
Long: "Set brightness percentage (0-100) for a specific device",
Args: cobra.ExactArgs(2),
Run: runBrightnessSet,
}
var brightnessGetCmd = &cobra.Command{
Use: "get <device_id>",
Short: "Get brightness for a device",
Long: "Get current brightness percentage for a specific device",
Args: cobra.ExactArgs(1),
Run: runBrightnessGet,
}
func init() {
brightnessListCmd.Flags().Bool("ddc", false, "Include DDC/I2C monitors (slower)")
brightnessSetCmd.Flags().Bool("ddc", false, "Include DDC/I2C monitors (slower)")
brightnessSetCmd.Flags().Bool("exponential", false, "Use exponential brightness scaling")
brightnessSetCmd.Flags().Float64("exponent", 1.2, "Exponent for exponential scaling (default 1.2)")
brightnessGetCmd.Flags().Bool("ddc", false, "Include DDC/I2C monitors (slower)")
brightnessCmd.SetHelpTemplate(`{{.Long}}
Usage:
{{.UseLine}}{{if .HasAvailableSubCommands}}
Available Commands:{{range .Commands}}{{if (or .IsAvailableCommand (eq .Name "help"))}}
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}}
Flags:
{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}
Global Flags:
{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}}
Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}}
{{rpad .Name .NamePadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}}
Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}}
`)
brightnessListCmd.SetHelpTemplate(`{{.Long}}
Usage:
{{.UseLine}}{{if .HasAvailableLocalFlags}}
Flags:
{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}
Global Flags:
{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}
`)
brightnessSetCmd.SetHelpTemplate(`{{.Long}}
Usage:
{{.UseLine}}{{if .HasAvailableLocalFlags}}
Flags:
{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}
Global Flags:
{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}
`)
brightnessGetCmd.SetHelpTemplate(`{{.Long}}
Usage:
{{.UseLine}}{{if .HasAvailableLocalFlags}}
Flags:
{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}
Global Flags:
{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}
`)
brightnessCmd.AddCommand(brightnessListCmd, brightnessSetCmd, brightnessGetCmd)
}
func runBrightnessList(cmd *cobra.Command, args []string) {
includeDDC, _ := cmd.Flags().GetBool("ddc")
allDevices := []brightness.Device{}
sysfs, err := brightness.NewSysfsBackend()
if err != nil {
log.Debugf("Failed to initialize sysfs backend: %v", err)
} else {
devices, err := sysfs.GetDevices()
if err != nil {
log.Debugf("Failed to get sysfs devices: %v", err)
} else {
allDevices = append(allDevices, devices...)
}
}
if includeDDC {
ddc, err := brightness.NewDDCBackend()
if err != nil {
fmt.Printf("Warning: Failed to initialize DDC backend: %v\n", err)
} else {
time.Sleep(100 * time.Millisecond)
devices, err := ddc.GetDevices()
if err != nil {
fmt.Printf("Warning: Failed to get DDC devices: %v\n", err)
} else {
allDevices = append(allDevices, devices...)
}
ddc.Close()
}
}
if len(allDevices) == 0 {
fmt.Println("No brightness devices found")
return
}
maxIDLen := len("Device")
maxNameLen := len("Name")
for _, dev := range allDevices {
if len(dev.ID) > maxIDLen {
maxIDLen = len(dev.ID)
}
if len(dev.Name) > maxNameLen {
maxNameLen = len(dev.Name)
}
}
idPad := maxIDLen + 2
namePad := maxNameLen + 2
fmt.Printf("%-*s %-12s %-*s %s\n", idPad, "Device", "Class", namePad, "Name", "Brightness")
sepLen := idPad + 2 + 12 + 2 + namePad + 2 + 15
for i := 0; i < sepLen; i++ {
fmt.Print("─")
}
fmt.Println()
for _, device := range allDevices {
fmt.Printf("%-*s %-12s %-*s %3d%%\n",
idPad,
device.ID,
device.Class,
namePad,
device.Name,
device.CurrentPercent,
)
}
}
func runBrightnessSet(cmd *cobra.Command, args []string) {
deviceID := args[0]
var percent int
if _, err := fmt.Sscanf(args[1], "%d", &percent); err != nil {
log.Fatalf("Invalid percent value: %s", args[1])
}
if percent < 0 || percent > 100 {
log.Fatalf("Percent must be between 0 and 100")
}
includeDDC, _ := cmd.Flags().GetBool("ddc")
exponential, _ := cmd.Flags().GetBool("exponential")
exponent, _ := cmd.Flags().GetFloat64("exponent")
// For backlight/leds devices, try logind backend first (requires D-Bus connection)
parts := strings.SplitN(deviceID, ":", 2)
if len(parts) == 2 && (parts[0] == "backlight" || parts[0] == "leds") {
subsystem := parts[0]
name := parts[1]
// Initialize backends needed for logind approach
sysfs, err := brightness.NewSysfsBackend()
if err != nil {
log.Debugf("NewSysfsBackend failed: %v", err)
} else {
logind, err := brightness.NewLogindBackend()
if err != nil {
log.Debugf("NewLogindBackend failed: %v", err)
} else {
defer logind.Close()
// Get device info to convert percent to value
dev, err := sysfs.GetDevice(deviceID)
if err == nil {
// Calculate hardware value using the same logic as Manager.setViaSysfsWithLogind
value := sysfs.PercentToValueWithExponent(percent, dev, exponential, exponent)
// Call logind with hardware value
if err := logind.SetBrightness(subsystem, name, uint32(value)); err == nil {
log.Debugf("set %s to %d%% (%d) via logind", deviceID, percent, value)
fmt.Printf("Set %s to %d%%\n", deviceID, percent)
return
} else {
log.Debugf("logind.SetBrightness failed: %v", err)
}
} else {
log.Debugf("sysfs.GetDeviceByID failed: %v", err)
}
}
}
}
// Fallback to direct sysfs (requires write permissions)
sysfs, err := brightness.NewSysfsBackend()
if err == nil {
if err := sysfs.SetBrightnessWithExponent(deviceID, percent, exponential, exponent); err == nil {
fmt.Printf("Set %s to %d%%\n", deviceID, percent)
return
}
log.Debugf("sysfs.SetBrightness failed: %v", err)
} else {
log.Debugf("NewSysfsBackend failed: %v", err)
}
// Try DDC if requested
if includeDDC {
ddc, err := brightness.NewDDCBackend()
if err == nil {
defer ddc.Close()
time.Sleep(100 * time.Millisecond)
if err := ddc.SetBrightnessWithExponent(deviceID, percent, exponential, exponent, nil); err == nil {
fmt.Printf("Set %s to %d%%\n", deviceID, percent)
return
}
log.Debugf("ddc.SetBrightness failed: %v", err)
} else {
log.Debugf("NewDDCBackend failed: %v", err)
}
}
log.Fatalf("Failed to set brightness for device: %s", deviceID)
}
func runBrightnessGet(cmd *cobra.Command, args []string) {
deviceID := args[0]
includeDDC, _ := cmd.Flags().GetBool("ddc")
allDevices := []brightness.Device{}
sysfs, err := brightness.NewSysfsBackend()
if err == nil {
devices, err := sysfs.GetDevices()
if err == nil {
allDevices = append(allDevices, devices...)
}
}
if includeDDC {
ddc, err := brightness.NewDDCBackend()
if err == nil {
defer ddc.Close()
time.Sleep(100 * time.Millisecond)
devices, err := ddc.GetDevices()
if err == nil {
allDevices = append(allDevices, devices...)
}
}
}
for _, device := range allDevices {
if device.ID == deviceID {
fmt.Printf("%s: %d%% (%d/%d)\n",
device.ID,
device.CurrentPercent,
device.Current,
device.Max,
)
return
}
}
log.Fatalf("Device not found: %s", deviceID)
}

View File

@@ -0,0 +1,375 @@
package main
import (
"fmt"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/plugins"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server"
"github.com/spf13/cobra"
)
var versionCmd = &cobra.Command{
Use: "version",
Short: "Show version information",
Run: runVersion,
}
var runCmd = &cobra.Command{
Use: "run",
Short: "Launch quickshell with DMS configuration",
Long: "Launch quickshell with DMS configuration (qs -c dms)",
PreRunE: findConfig,
Run: func(cmd *cobra.Command, args []string) {
daemon, _ := cmd.Flags().GetBool("daemon")
session, _ := cmd.Flags().GetBool("session")
if daemon {
runShellDaemon(session)
} else {
runShellInteractive(session)
}
},
}
var restartCmd = &cobra.Command{
Use: "restart",
Short: "Restart quickshell with DMS configuration",
Long: "Kill existing DMS shell processes and restart quickshell with DMS configuration",
PreRunE: findConfig,
Run: func(cmd *cobra.Command, args []string) {
restartShell()
},
}
var restartDetachedCmd = &cobra.Command{
Use: "restart-detached <pid>",
Hidden: true,
Args: cobra.ExactArgs(1),
PreRunE: findConfig,
Run: func(cmd *cobra.Command, args []string) {
runDetachedRestart(args[0])
},
}
var killCmd = &cobra.Command{
Use: "kill",
Short: "Kill running DMS shell processes",
Long: "Kill all running quickshell processes with DMS configuration",
Run: func(cmd *cobra.Command, args []string) {
killShell()
},
}
var ipcCmd = &cobra.Command{
Use: "ipc",
Short: "Send IPC commands to running DMS shell",
Long: "Send IPC commands to running DMS shell (qs -c dms ipc <args>)",
PreRunE: findConfig,
Run: func(cmd *cobra.Command, args []string) {
runShellIPCCommand(args)
},
}
var debugSrvCmd = &cobra.Command{
Use: "debug-srv",
Short: "Start the debug server",
Long: "Start the Unix socket debug server for DMS",
Run: func(cmd *cobra.Command, args []string) {
if err := startDebugServer(); err != nil {
log.Fatalf("Error starting debug server: %v", err)
}
},
}
var pluginsCmd = &cobra.Command{
Use: "plugins",
Short: "Manage DMS plugins",
Long: "Browse and manage DMS plugins from the registry",
}
var pluginsBrowseCmd = &cobra.Command{
Use: "browse",
Short: "Browse available plugins",
Long: "Browse available plugins from the DMS plugin registry",
Run: func(cmd *cobra.Command, args []string) {
if err := browsePlugins(); err != nil {
log.Fatalf("Error browsing plugins: %v", err)
}
},
}
var pluginsListCmd = &cobra.Command{
Use: "list",
Short: "List installed plugins",
Long: "List all installed DMS plugins",
Run: func(cmd *cobra.Command, args []string) {
if err := listInstalledPlugins(); err != nil {
log.Fatalf("Error listing plugins: %v", err)
}
},
}
var pluginsInstallCmd = &cobra.Command{
Use: "install <plugin-id>",
Short: "Install a plugin by ID",
Long: "Install a DMS plugin from the registry using its ID (e.g., 'myPlugin'). Plugin names with spaces are also supported for backward compatibility.",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
if err := installPluginCLI(args[0]); err != nil {
log.Fatalf("Error installing plugin: %v", err)
}
},
}
var pluginsUninstallCmd = &cobra.Command{
Use: "uninstall <plugin-id>",
Short: "Uninstall a plugin by ID",
Long: "Uninstall a DMS plugin using its ID (e.g., 'myPlugin'). Plugin names with spaces are also supported for backward compatibility.",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
if err := uninstallPluginCLI(args[0]); err != nil {
log.Fatalf("Error uninstalling plugin: %v", err)
}
},
}
func runVersion(cmd *cobra.Command, args []string) {
printASCII()
fmt.Printf("%s\n", Version)
}
func startDebugServer() error {
return server.Start(true)
}
func browsePlugins() error {
registry, err := plugins.NewRegistry()
if err != nil {
return fmt.Errorf("failed to create registry: %w", err)
}
manager, err := plugins.NewManager()
if err != nil {
return fmt.Errorf("failed to create manager: %w", err)
}
fmt.Println("Fetching plugin registry...")
pluginList, err := registry.List()
if err != nil {
return fmt.Errorf("failed to list plugins: %w", err)
}
if len(pluginList) == 0 {
fmt.Println("No plugins found in registry.")
return nil
}
fmt.Printf("\nAvailable Plugins (%d):\n\n", len(pluginList))
for _, plugin := range pluginList {
installed, _ := manager.IsInstalled(plugin)
installedMarker := ""
if installed {
installedMarker = " [Installed]"
}
fmt.Printf(" %s%s\n", plugin.Name, installedMarker)
fmt.Printf(" ID: %s\n", plugin.ID)
fmt.Printf(" Category: %s\n", plugin.Category)
fmt.Printf(" Author: %s\n", plugin.Author)
fmt.Printf(" Description: %s\n", plugin.Description)
fmt.Printf(" Repository: %s\n", plugin.Repo)
if len(plugin.Capabilities) > 0 {
fmt.Printf(" Capabilities: %s\n", strings.Join(plugin.Capabilities, ", "))
}
if len(plugin.Compositors) > 0 {
fmt.Printf(" Compositors: %s\n", strings.Join(plugin.Compositors, ", "))
}
if len(plugin.Dependencies) > 0 {
fmt.Printf(" Dependencies: %s\n", strings.Join(plugin.Dependencies, ", "))
}
fmt.Println()
}
return nil
}
func listInstalledPlugins() error {
manager, err := plugins.NewManager()
if err != nil {
return fmt.Errorf("failed to create manager: %w", err)
}
registry, err := plugins.NewRegistry()
if err != nil {
return fmt.Errorf("failed to create registry: %w", err)
}
installedNames, err := manager.ListInstalled()
if err != nil {
return fmt.Errorf("failed to list installed plugins: %w", err)
}
if len(installedNames) == 0 {
fmt.Println("No plugins installed.")
return nil
}
allPlugins, err := registry.List()
if err != nil {
return fmt.Errorf("failed to list plugins: %w", err)
}
pluginMap := make(map[string]plugins.Plugin)
for _, p := range allPlugins {
pluginMap[p.ID] = p
}
fmt.Printf("\nInstalled Plugins (%d):\n\n", len(installedNames))
for _, id := range installedNames {
if plugin, ok := pluginMap[id]; ok {
fmt.Printf(" %s\n", plugin.Name)
fmt.Printf(" ID: %s\n", plugin.ID)
fmt.Printf(" Category: %s\n", plugin.Category)
fmt.Printf(" Author: %s\n", plugin.Author)
fmt.Println()
} else {
fmt.Printf(" %s (not in registry)\n\n", id)
}
}
return nil
}
func installPluginCLI(idOrName string) error {
registry, err := plugins.NewRegistry()
if err != nil {
return fmt.Errorf("failed to create registry: %w", err)
}
manager, err := plugins.NewManager()
if err != nil {
return fmt.Errorf("failed to create manager: %w", err)
}
pluginList, err := registry.List()
if err != nil {
return fmt.Errorf("failed to list plugins: %w", err)
}
// First, try to find by ID (preferred method)
var plugin *plugins.Plugin
for _, p := range pluginList {
if p.ID == idOrName {
plugin = &p
break
}
}
// Fallback to name for backward compatibility
if plugin == nil {
for _, p := range pluginList {
if p.Name == idOrName {
plugin = &p
break
}
}
}
if plugin == nil {
return fmt.Errorf("plugin not found: %s", idOrName)
}
installed, err := manager.IsInstalled(*plugin)
if err != nil {
return fmt.Errorf("failed to check install status: %w", err)
}
if installed {
return fmt.Errorf("plugin already installed: %s", plugin.Name)
}
fmt.Printf("Installing plugin: %s (ID: %s)\n", plugin.Name, plugin.ID)
if err := manager.Install(*plugin); err != nil {
return fmt.Errorf("failed to install plugin: %w", err)
}
fmt.Printf("Plugin installed successfully: %s\n", plugin.Name)
return nil
}
func uninstallPluginCLI(idOrName string) error {
manager, err := plugins.NewManager()
if err != nil {
return fmt.Errorf("failed to create manager: %w", err)
}
registry, err := plugins.NewRegistry()
if err != nil {
return fmt.Errorf("failed to create registry: %w", err)
}
pluginList, err := registry.List()
if err != nil {
return fmt.Errorf("failed to list plugins: %w", err)
}
// First, try to find by ID (preferred method)
var plugin *plugins.Plugin
for _, p := range pluginList {
if p.ID == idOrName {
plugin = &p
break
}
}
// Fallback to name for backward compatibility
if plugin == nil {
for _, p := range pluginList {
if p.Name == idOrName {
plugin = &p
break
}
}
}
if plugin == nil {
return fmt.Errorf("plugin not found: %s", idOrName)
}
installed, err := manager.IsInstalled(*plugin)
if err != nil {
return fmt.Errorf("failed to check install status: %w", err)
}
if !installed {
return fmt.Errorf("plugin not installed: %s", plugin.Name)
}
fmt.Printf("Uninstalling plugin: %s (ID: %s)\n", plugin.Name, plugin.ID)
if err := manager.Uninstall(*plugin); err != nil {
return fmt.Errorf("failed to uninstall plugin: %w", err)
}
fmt.Printf("Plugin uninstalled successfully: %s\n", plugin.Name)
return nil
}
// getCommonCommands returns the commands available in all builds
func getCommonCommands() []*cobra.Command {
return []*cobra.Command{
versionCmd,
runCmd,
restartCmd,
restartDetachedCmd,
killCmd,
ipcCmd,
debugSrvCmd,
pluginsCmd,
dank16Cmd,
brightnessCmd,
keybindsCmd,
greeterCmd,
setupCmd,
}
}

View File

@@ -0,0 +1,94 @@
package main
import (
"fmt"
"os"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/dank16"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/spf13/cobra"
)
var dank16Cmd = &cobra.Command{
Use: "dank16 <hex_color>",
Short: "Generate Base16 color palettes",
Long: "Generate Base16 color palettes from a color with support for various output formats",
Args: cobra.ExactArgs(1),
Run: runDank16,
}
func init() {
dank16Cmd.Flags().Bool("light", false, "Generate light theme variant")
dank16Cmd.Flags().Bool("json", false, "Output in JSON format")
dank16Cmd.Flags().Bool("kitty", false, "Output in Kitty terminal format")
dank16Cmd.Flags().Bool("foot", false, "Output in Foot terminal format")
dank16Cmd.Flags().Bool("alacritty", false, "Output in Alacritty terminal format")
dank16Cmd.Flags().Bool("ghostty", false, "Output in Ghostty terminal format")
dank16Cmd.Flags().Bool("wezterm", false, "Output in Wezterm terminal format")
dank16Cmd.Flags().String("vscode-enrich", "", "Enrich existing VSCode theme file with terminal colors")
dank16Cmd.Flags().String("background", "", "Custom background color")
dank16Cmd.Flags().String("contrast", "dps", "Contrast algorithm: dps (Delta Phi Star, default) or wcag")
}
func runDank16(cmd *cobra.Command, args []string) {
primaryColor := args[0]
if !strings.HasPrefix(primaryColor, "#") {
primaryColor = "#" + primaryColor
}
isLight, _ := cmd.Flags().GetBool("light")
isJson, _ := cmd.Flags().GetBool("json")
isKitty, _ := cmd.Flags().GetBool("kitty")
isFoot, _ := cmd.Flags().GetBool("foot")
isAlacritty, _ := cmd.Flags().GetBool("alacritty")
isGhostty, _ := cmd.Flags().GetBool("ghostty")
isWezterm, _ := cmd.Flags().GetBool("wezterm")
vscodeEnrich, _ := cmd.Flags().GetString("vscode-enrich")
background, _ := cmd.Flags().GetString("background")
contrastAlgo, _ := cmd.Flags().GetString("contrast")
if background != "" && !strings.HasPrefix(background, "#") {
background = "#" + background
}
contrastAlgo = strings.ToLower(contrastAlgo)
if contrastAlgo != "dps" && contrastAlgo != "wcag" {
log.Fatalf("Invalid contrast algorithm: %s (must be 'dps' or 'wcag')", contrastAlgo)
}
opts := dank16.PaletteOptions{
IsLight: isLight,
Background: background,
UseDPS: contrastAlgo == "dps",
}
colors := dank16.GeneratePalette(primaryColor, opts)
if vscodeEnrich != "" {
data, err := os.ReadFile(vscodeEnrich)
if err != nil {
log.Fatalf("Error reading file: %v", err)
}
enriched, err := dank16.EnrichVSCodeTheme(data, colors)
if err != nil {
log.Fatalf("Error enriching theme: %v", err)
}
fmt.Println(string(enriched))
} else if isJson {
fmt.Print(dank16.GenerateJSON(colors))
} else if isKitty {
fmt.Print(dank16.GenerateKittyTheme(colors))
} else if isFoot {
fmt.Print(dank16.GenerateFootTheme(colors))
} else if isAlacritty {
fmt.Print(dank16.GenerateAlacrittyTheme(colors))
} else if isGhostty {
fmt.Print(dank16.GenerateGhosttyTheme(colors))
} else if isWezterm {
fmt.Print(dank16.GenerateWeztermTheme(colors))
} else {
fmt.Print(dank16.GenerateGhosttyTheme(colors))
}
}

View File

@@ -0,0 +1,488 @@
//go:build !distro_binary
package main
import (
"bufio"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
"github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/version"
"github.com/spf13/cobra"
)
var updateCmd = &cobra.Command{
Use: "update",
Short: "Update DankMaterialShell to the latest version",
Long: "Update DankMaterialShell to the latest version using the appropriate package manager for your distribution",
PreRunE: findConfig,
Run: func(cmd *cobra.Command, args []string) {
runUpdate()
},
}
var updateCheckCmd = &cobra.Command{
Use: "check",
Short: "Check if updates are available for DankMaterialShell",
Long: "Check for available updates without performing the actual update",
Run: func(cmd *cobra.Command, args []string) {
runUpdateCheck()
},
}
func runUpdateCheck() {
fmt.Println("Checking for DankMaterialShell updates...")
fmt.Println()
versionInfo, err := version.GetDMSVersionInfo()
if err != nil {
log.Fatalf("Error checking for updates: %v", err)
}
fmt.Printf("Current version: %s\n", versionInfo.Current)
fmt.Printf("Latest version: %s\n", versionInfo.Latest)
fmt.Println()
if versionInfo.HasUpdate {
fmt.Println("✓ Update available!")
fmt.Println()
fmt.Println("Run 'dms update' to install the latest version.")
os.Exit(0)
} else {
fmt.Println("✓ You are running the latest version.")
os.Exit(0)
}
}
func runUpdate() {
osInfo, err := distros.GetOSInfo()
if err != nil {
log.Fatalf("Error detecting OS: %v", err)
}
config, exists := distros.Registry[osInfo.Distribution.ID]
if !exists {
log.Fatalf("Unsupported distribution: %s", osInfo.Distribution.ID)
}
var updateErr error
switch config.Family {
case distros.FamilyArch:
updateErr = updateArchLinux()
case distros.FamilyNix:
updateErr = updateNixOS()
case distros.FamilySUSE:
updateErr = updateOtherDistros()
default:
updateErr = updateOtherDistros()
}
if updateErr != nil {
if errors.Is(updateErr, errdefs.ErrUpdateCancelled) {
log.Info("Update cancelled.")
return
}
if errors.Is(updateErr, errdefs.ErrNoUpdateNeeded) {
return
}
log.Fatalf("Error updating DMS: %v", updateErr)
}
log.Info("Update complete! Restarting DMS...")
restartShell()
}
func updateArchLinux() error {
homeDir, err := os.UserHomeDir()
if err == nil {
dmsPath := filepath.Join(homeDir, ".config", "quickshell", "dms")
if _, err := os.Stat(dmsPath); err == nil {
return updateOtherDistros()
}
}
var packageName string
if isArchPackageInstalled("dms-shell-bin") {
packageName = "dms-shell-bin"
} else if isArchPackageInstalled("dms-shell-git") {
packageName = "dms-shell-git"
} else {
fmt.Println("Info: Neither dms-shell-bin nor dms-shell-git package found.")
fmt.Println("Info: Falling back to git-based update method...")
return updateOtherDistros()
}
var helper string
var updateCmd *exec.Cmd
if commandExists("yay") {
helper = "yay"
updateCmd = exec.Command("yay", "-S", packageName)
} else if commandExists("paru") {
helper = "paru"
updateCmd = exec.Command("paru", "-S", packageName)
} else {
fmt.Println("Error: Neither yay nor paru found - please install an AUR helper")
fmt.Println("Info: Falling back to git-based update method...")
return updateOtherDistros()
}
fmt.Printf("This will update DankMaterialShell using %s.\n", helper)
if !confirmUpdate() {
return errdefs.ErrUpdateCancelled
}
fmt.Printf("\nRunning: %s -S %s\n", helper, packageName)
updateCmd.Stdout = os.Stdout
updateCmd.Stderr = os.Stderr
err = updateCmd.Run()
if err != nil {
fmt.Printf("Error: Failed to update using %s: %v\n", helper, err)
}
fmt.Println("dms successfully updated")
return nil
}
func updateNixOS() error {
fmt.Println("This will update DankMaterialShell using nix profile.")
if !confirmUpdate() {
return errdefs.ErrUpdateCancelled
}
fmt.Println("\nRunning: nix profile upgrade github:AvengeMedia/DankMaterialShell")
updateCmd := exec.Command("nix", "profile", "upgrade", "github:AvengeMedia/DankMaterialShell")
updateCmd.Stdout = os.Stdout
updateCmd.Stderr = os.Stderr
err := updateCmd.Run()
if err != nil {
fmt.Printf("Error: Failed to update using nix profile: %v\n", err)
fmt.Println("Falling back to git-based update method...")
return updateOtherDistros()
}
fmt.Println("dms successfully updated")
return nil
}
func updateOtherDistros() error {
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get user home directory: %w", err)
}
dmsPath := filepath.Join(homeDir, ".config", "quickshell", "dms")
if _, err := os.Stat(dmsPath); os.IsNotExist(err) {
return fmt.Errorf("DMS configuration directory not found at %s", dmsPath)
}
fmt.Printf("Found DMS configuration at %s\n", dmsPath)
versionInfo, err := version.GetDMSVersionInfo()
if err == nil && !versionInfo.HasUpdate {
fmt.Println()
fmt.Printf("Current version: %s\n", versionInfo.Current)
fmt.Printf("Latest version: %s\n", versionInfo.Latest)
fmt.Println()
fmt.Println("✓ You are already running the latest version.")
return errdefs.ErrNoUpdateNeeded
}
fmt.Println("\nThis will update:")
fmt.Println(" 1. The dms binary from GitHub releases")
fmt.Println(" 2. DankMaterialShell configuration using git")
if !confirmUpdate() {
return errdefs.ErrUpdateCancelled
}
fmt.Println("\n=== Updating dms binary ===")
if err := updateDMSBinary(); err != nil {
fmt.Printf("Warning: Failed to update dms binary: %v\n", err)
fmt.Println("Continuing with shell configuration update...")
} else {
fmt.Println("dms binary successfully updated")
}
fmt.Println("\n=== Updating DMS shell configuration ===")
if err := os.Chdir(dmsPath); err != nil {
return fmt.Errorf("failed to change to DMS directory: %w", err)
}
statusCmd := exec.Command("git", "status", "--porcelain")
statusOutput, _ := statusCmd.Output()
hasLocalChanges := len(strings.TrimSpace(string(statusOutput))) > 0
currentRefCmd := exec.Command("git", "symbolic-ref", "-q", "HEAD")
currentRefOutput, _ := currentRefCmd.Output()
onBranch := len(currentRefOutput) > 0
var currentTag string
var currentBranch string
if !onBranch {
tagCmd := exec.Command("git", "describe", "--exact-match", "--tags", "HEAD")
if tagOutput, err := tagCmd.Output(); err == nil {
currentTag = strings.TrimSpace(string(tagOutput))
}
} else {
branchCmd := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD")
if branchOutput, err := branchCmd.Output(); err == nil {
currentBranch = strings.TrimSpace(string(branchOutput))
}
}
fmt.Println("Fetching latest changes...")
fetchCmd := exec.Command("git", "fetch", "origin", "--tags", "--force")
fetchCmd.Stdout = os.Stdout
fetchCmd.Stderr = os.Stderr
if err := fetchCmd.Run(); err != nil {
return fmt.Errorf("failed to fetch changes: %w", err)
}
if currentTag != "" {
latestTagCmd := exec.Command("git", "tag", "-l", "v*", "--sort=-version:refname")
latestTagOutput, err := latestTagCmd.Output()
if err != nil {
return fmt.Errorf("failed to get latest tag: %w", err)
}
tags := strings.Split(strings.TrimSpace(string(latestTagOutput)), "\n")
if len(tags) == 0 || tags[0] == "" {
return fmt.Errorf("no version tags found")
}
latestTag := tags[0]
if latestTag == currentTag {
fmt.Printf("Already on latest tag: %s\n", currentTag)
return nil
}
fmt.Printf("Current tag: %s\n", currentTag)
fmt.Printf("Latest tag: %s\n", latestTag)
if hasLocalChanges {
fmt.Println("\nWarning: You have local changes in your DMS configuration.")
if offerReclone(dmsPath) {
return nil
}
return errdefs.ErrUpdateCancelled
}
fmt.Printf("Updating to %s...\n", latestTag)
checkoutCmd := exec.Command("git", "checkout", latestTag)
checkoutCmd.Stdout = os.Stdout
checkoutCmd.Stderr = os.Stderr
if err := checkoutCmd.Run(); err != nil {
fmt.Printf("Error: Failed to checkout %s: %v\n", latestTag, err)
if offerReclone(dmsPath) {
return nil
}
return fmt.Errorf("update cancelled")
}
fmt.Printf("\nUpdate complete! Updated from %s to %s\n", currentTag, latestTag)
return nil
}
if currentBranch == "" {
currentBranch = "master"
}
fmt.Printf("Current branch: %s\n", currentBranch)
if hasLocalChanges {
fmt.Println("\nWarning: You have local changes in your DMS configuration.")
if offerReclone(dmsPath) {
return nil
}
return errdefs.ErrUpdateCancelled
}
pullCmd := exec.Command("git", "pull", "origin", currentBranch)
pullCmd.Stdout = os.Stdout
pullCmd.Stderr = os.Stderr
if err := pullCmd.Run(); err != nil {
fmt.Printf("Error: Failed to pull latest changes: %v\n", err)
if offerReclone(dmsPath) {
return nil
}
return fmt.Errorf("update cancelled")
}
fmt.Println("\nUpdate complete!")
return nil
}
func offerReclone(dmsPath string) bool {
fmt.Println("\nWould you like to backup and re-clone the repository? (y/N): ")
reader := bufio.NewReader(os.Stdin)
response, err := reader.ReadString('\n')
if err != nil || !strings.HasPrefix(strings.ToLower(strings.TrimSpace(response)), "y") {
return false
}
timestamp := time.Now().Unix()
backupPath := fmt.Sprintf("%s.backup-%d", dmsPath, timestamp)
fmt.Printf("Backing up current directory to %s...\n", backupPath)
if err := os.Rename(dmsPath, backupPath); err != nil {
fmt.Printf("Error: Failed to backup directory: %v\n", err)
return false
}
fmt.Println("Cloning fresh copy...")
cloneCmd := exec.Command("git", "clone", "https://github.com/AvengeMedia/DankMaterialShell.git", dmsPath)
cloneCmd.Stdout = os.Stdout
cloneCmd.Stderr = os.Stderr
if err := cloneCmd.Run(); err != nil {
fmt.Printf("Error: Failed to clone repository: %v\n", err)
fmt.Printf("Restoring backup...\n")
os.Rename(backupPath, dmsPath)
return false
}
fmt.Printf("Successfully re-cloned repository (backup at %s)\n", backupPath)
return true
}
func confirmUpdate() bool {
fmt.Print("Do you want to proceed with the update? (y/N): ")
reader := bufio.NewReader(os.Stdin)
response, err := reader.ReadString('\n')
if err != nil {
fmt.Printf("Error reading input: %v\n", err)
return false
}
response = strings.TrimSpace(strings.ToLower(response))
return response == "y" || response == "yes"
}
func updateDMSBinary() error {
arch := ""
switch strings.ToLower(os.Getenv("HOSTTYPE")) {
case "x86_64", "amd64":
arch = "amd64"
case "aarch64", "arm64":
arch = "arm64"
default:
cmd := exec.Command("uname", "-m")
output, err := cmd.Output()
if err != nil {
return fmt.Errorf("failed to detect architecture: %w", err)
}
archStr := strings.TrimSpace(string(output))
switch archStr {
case "x86_64":
arch = "amd64"
case "aarch64":
arch = "arm64"
default:
return fmt.Errorf("unsupported architecture: %s", archStr)
}
}
fmt.Println("Fetching latest release version...")
cmd := exec.Command("curl", "-s", "https://api.github.com/repos/AvengeMedia/DankMaterialShell/releases/latest")
output, err := cmd.Output()
if err != nil {
return fmt.Errorf("failed to fetch latest release: %w", err)
}
version := ""
for _, line := range strings.Split(string(output), "\n") {
if strings.Contains(line, "\"tag_name\"") {
parts := strings.Split(line, "\"")
if len(parts) >= 4 {
version = parts[3]
break
}
}
}
if version == "" {
return fmt.Errorf("could not determine latest version")
}
fmt.Printf("Latest version: %s\n", version)
tempDir, err := os.MkdirTemp("", "dms-update-*")
if err != nil {
return fmt.Errorf("failed to create temp directory: %w", err)
}
defer os.RemoveAll(tempDir)
binaryURL := fmt.Sprintf("https://github.com/AvengeMedia/DankMaterialShell/releases/download/%s/dms-cli-%s.gz", version, arch)
checksumURL := fmt.Sprintf("https://github.com/AvengeMedia/DankMaterialShell/releases/download/%s/dms-cli-%s.gz.sha256", version, arch)
binaryPath := filepath.Join(tempDir, "dms.gz")
checksumPath := filepath.Join(tempDir, "dms.gz.sha256")
fmt.Println("Downloading dms binary...")
downloadCmd := exec.Command("curl", "-L", binaryURL, "-o", binaryPath)
if err := downloadCmd.Run(); err != nil {
return fmt.Errorf("failed to download binary: %w", err)
}
fmt.Println("Downloading checksum...")
downloadCmd = exec.Command("curl", "-L", checksumURL, "-o", checksumPath)
if err := downloadCmd.Run(); err != nil {
return fmt.Errorf("failed to download checksum: %w", err)
}
fmt.Println("Verifying checksum...")
checksumData, err := os.ReadFile(checksumPath)
if err != nil {
return fmt.Errorf("failed to read checksum file: %w", err)
}
expectedChecksum := strings.Fields(string(checksumData))[0]
actualCmd := exec.Command("sha256sum", binaryPath)
actualOutput, err := actualCmd.Output()
if err != nil {
return fmt.Errorf("failed to calculate checksum: %w", err)
}
actualChecksum := strings.Fields(string(actualOutput))[0]
if expectedChecksum != actualChecksum {
return fmt.Errorf("checksum verification failed\nExpected: %s\nGot: %s", expectedChecksum, actualChecksum)
}
fmt.Println("Decompressing binary...")
decompressCmd := exec.Command("gunzip", binaryPath)
if err := decompressCmd.Run(); err != nil {
return fmt.Errorf("failed to decompress binary: %w", err)
}
decompressedPath := filepath.Join(tempDir, "dms")
if err := os.Chmod(decompressedPath, 0755); err != nil {
return fmt.Errorf("failed to make binary executable: %w", err)
}
currentPath, err := exec.LookPath("dms")
if err != nil {
return fmt.Errorf("could not find current dms binary: %w", err)
}
fmt.Printf("Installing to %s...\n", currentPath)
replaceCmd := exec.Command("sudo", "install", "-m", "0755", decompressedPath, currentPath)
replaceCmd.Stdin = os.Stdin
replaceCmd.Stdout = os.Stdout
replaceCmd.Stderr = os.Stderr
if err := replaceCmd.Run(); err != nil {
return fmt.Errorf("failed to replace binary: %w", err)
}
return nil
}

View File

@@ -0,0 +1,500 @@
package main
import (
"fmt"
"os"
"os/exec"
"os/user"
"path/filepath"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/greeter"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/spf13/cobra"
)
var greeterCmd = &cobra.Command{
Use: "greeter",
Short: "Manage DMS greeter",
Long: "Manage DMS greeter (greetd)",
}
var greeterInstallCmd = &cobra.Command{
Use: "install",
Short: "Install and configure DMS greeter",
Long: "Install greetd and configure it to use DMS as the greeter interface",
Run: func(cmd *cobra.Command, args []string) {
if err := installGreeter(); err != nil {
log.Fatalf("Error installing greeter: %v", err)
}
},
}
var greeterSyncCmd = &cobra.Command{
Use: "sync",
Short: "Sync DMS theme and settings with greeter",
Long: "Synchronize your current user's DMS theme, settings, and wallpaper configuration with the login greeter screen",
Run: func(cmd *cobra.Command, args []string) {
if err := syncGreeter(); err != nil {
log.Fatalf("Error syncing greeter: %v", err)
}
},
}
var greeterEnableCmd = &cobra.Command{
Use: "enable",
Short: "Enable DMS greeter in greetd config",
Long: "Configure greetd to use DMS as the greeter",
Run: func(cmd *cobra.Command, args []string) {
if err := enableGreeter(); err != nil {
log.Fatalf("Error enabling greeter: %v", err)
}
},
}
var greeterStatusCmd = &cobra.Command{
Use: "status",
Short: "Check greeter sync status",
Long: "Check the status of greeter installation and configuration sync",
Run: func(cmd *cobra.Command, args []string) {
if err := checkGreeterStatus(); err != nil {
log.Fatalf("Error checking greeter status: %v", err)
}
},
}
func installGreeter() error {
fmt.Println("=== DMS Greeter Installation ===")
logFunc := func(msg string) {
fmt.Println(msg)
}
if err := greeter.EnsureGreetdInstalled(logFunc, ""); err != nil {
return err
}
fmt.Println("\nDetecting DMS installation...")
dmsPath, err := greeter.DetectDMSPath()
if err != nil {
return err
}
fmt.Printf("✓ Found DMS at: %s\n", dmsPath)
fmt.Println("\nDetecting installed compositors...")
compositors := greeter.DetectCompositors()
if len(compositors) == 0 {
return fmt.Errorf("no supported compositors found (niri or Hyprland required)")
}
var selectedCompositor string
if len(compositors) == 1 {
selectedCompositor = compositors[0]
fmt.Printf("✓ Found compositor: %s\n", selectedCompositor)
} else {
var err error
selectedCompositor, err = greeter.PromptCompositorChoice(compositors)
if err != nil {
return err
}
fmt.Printf("✓ Selected compositor: %s\n", selectedCompositor)
}
fmt.Println("\nSetting up dms-greeter group and permissions...")
if err := greeter.SetupDMSGroup(logFunc, ""); err != nil {
return err
}
fmt.Println("\nCopying greeter files...")
if err := greeter.CopyGreeterFiles(dmsPath, selectedCompositor, logFunc, ""); err != nil {
return err
}
fmt.Println("\nConfiguring greetd...")
if err := greeter.ConfigureGreetd(dmsPath, selectedCompositor, logFunc, ""); err != nil {
return err
}
fmt.Println("\nSynchronizing DMS configurations...")
if err := greeter.SyncDMSConfigs(dmsPath, logFunc, ""); err != nil {
return err
}
fmt.Println("\n=== Installation Complete ===")
fmt.Println("\nTo test the greeter, run:")
fmt.Println(" sudo systemctl start greetd")
fmt.Println("\nTo enable on boot, run:")
fmt.Println(" sudo systemctl enable --now greetd")
return nil
}
func syncGreeter() error {
fmt.Println("=== DMS Greeter Theme Sync ===")
fmt.Println()
logFunc := func(msg string) {
fmt.Println(msg)
}
fmt.Println("Detecting DMS installation...")
dmsPath, err := greeter.DetectDMSPath()
if err != nil {
return err
}
fmt.Printf("✓ Found DMS at: %s\n", dmsPath)
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)
}
greeterGroupExists := checkGroupExists("greeter")
if greeterGroupExists {
currentUser, err := user.Current()
if err != nil {
return fmt.Errorf("failed to get current user: %w", err)
}
groupsCmd := exec.Command("groups", currentUser.Username)
groupsOutput, err := groupsCmd.Output()
if err != nil {
return fmt.Errorf("failed to check groups: %w", err)
}
inGreeterGroup := strings.Contains(string(groupsOutput), "greeter")
if !inGreeterGroup {
fmt.Println("\n⚠ Warning: You are not in the greeter group.")
fmt.Print("Would you like to add your user to the greeter group? (y/N): ")
var response string
fmt.Scanln(&response)
response = strings.ToLower(strings.TrimSpace(response))
if response == "y" || response == "yes" {
fmt.Println("\nAdding user to greeter group...")
addUserCmd := exec.Command("sudo", "usermod", "-aG", "greeter", currentUser.Username)
addUserCmd.Stdout = os.Stdout
addUserCmd.Stderr = os.Stderr
if err := addUserCmd.Run(); err != nil {
return fmt.Errorf("failed to add user to greeter group: %w", err)
}
fmt.Println("✓ User added to greeter group")
fmt.Println("⚠ You will need to log out and back in for the group change to take effect")
}
}
}
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 {
return err
}
fmt.Println("\n=== Sync Complete ===")
fmt.Println("\nYour theme, settings, and wallpaper configuration have been synced with the greeter.")
fmt.Println("The changes will be visible on the next login screen.")
return nil
}
func checkGroupExists(groupName string) bool {
data, err := os.ReadFile("/etc/group")
if err != nil {
return false
}
lines := strings.Split(string(data), "\n")
for _, line := range lines {
if strings.HasPrefix(line, groupName+":") {
return true
}
}
return false
}
func enableGreeter() error {
fmt.Println("=== DMS Greeter Enable ===")
fmt.Println()
configPath := "/etc/greetd/config.toml"
if _, err := os.Stat(configPath); os.IsNotExist(err) {
return fmt.Errorf("greetd config not found at %s\nPlease install greetd first", configPath)
}
data, err := os.ReadFile(configPath)
if err != nil {
return fmt.Errorf("failed to read greetd config: %w", err)
}
configContent := string(data)
if strings.Contains(configContent, "dms-greeter") {
fmt.Println("✓ Greeter is already configured with dms-greeter")
return nil
}
fmt.Println("Detecting installed compositors...")
compositors := greeter.DetectCompositors()
if commandExists("sway") {
compositors = append(compositors, "sway")
}
if len(compositors) == 0 {
return fmt.Errorf("no supported compositors found (niri, Hyprland, or sway required)")
}
var selectedCompositor string
if len(compositors) == 1 {
selectedCompositor = compositors[0]
fmt.Printf("✓ Found compositor: %s\n", selectedCompositor)
} else {
var err error
selectedCompositor, err = promptCompositorChoice(compositors)
if err != nil {
return err
}
fmt.Printf("✓ Selected compositor: %s\n", selectedCompositor)
}
backupPath := configPath + ".backup"
backupCmd := exec.Command("sudo", "cp", configPath, backupPath)
if err := backupCmd.Run(); err != nil {
return fmt.Errorf("failed to backup config: %w", err)
}
fmt.Printf("✓ Backed up config to %s\n", backupPath)
lines := strings.Split(configContent, "\n")
var newLines []string
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if !strings.HasPrefix(trimmed, "command =") && !strings.HasPrefix(trimmed, "command=") {
newLines = append(newLines, line)
}
}
wrapperCmd, err := findCommandPath("dms-greeter")
if err != nil {
return fmt.Errorf("dms-greeter not found in PATH. Please ensure it is installed and accessible")
}
compositorLower := strings.ToLower(selectedCompositor)
commandLine := fmt.Sprintf(`command = "%s --command %s"`, wrapperCmd, compositorLower)
var finalLines []string
inDefaultSession := false
commandAdded := false
for _, line := range newLines {
finalLines = append(finalLines, line)
trimmed := strings.TrimSpace(line)
if trimmed == "[default_session]" {
inDefaultSession = true
}
if inDefaultSession && !commandAdded {
if strings.HasPrefix(trimmed, "user =") || strings.HasPrefix(trimmed, "user=") {
finalLines = append(finalLines, commandLine)
commandAdded = true
}
}
}
if !commandAdded {
finalLines = append(finalLines, commandLine)
}
newConfig := strings.Join(finalLines, "\n")
tmpFile := "/tmp/greetd-config.toml"
if err := os.WriteFile(tmpFile, []byte(newConfig), 0644); err != nil {
return fmt.Errorf("failed to write temp config: %w", err)
}
moveCmd := exec.Command("sudo", "mv", tmpFile, configPath)
if err := moveCmd.Run(); err != nil {
return fmt.Errorf("failed to update config: %w", err)
}
fmt.Printf("✓ Updated greetd configuration to use %s\n", selectedCompositor)
fmt.Println("\n=== Enable Complete ===")
fmt.Println("\nTo start the greeter, run:")
fmt.Println(" sudo systemctl start greetd")
fmt.Println("\nTo enable on boot, run:")
fmt.Println(" sudo systemctl enable --now greetd")
return nil
}
func promptCompositorChoice(compositors []string) (string, error) {
fmt.Println("\nMultiple compositors detected:")
for i, comp := range compositors {
fmt.Printf("%d) %s\n", i+1, comp)
}
var response string
fmt.Print("Choose compositor for greeter: ")
fmt.Scanln(&response)
response = strings.TrimSpace(response)
choice := 0
fmt.Sscanf(response, "%d", &choice)
if choice < 1 || choice > len(compositors) {
return "", fmt.Errorf("invalid choice")
}
return compositors[choice-1], nil
}
func checkGreeterStatus() error {
fmt.Println("=== DMS Greeter Status ===")
fmt.Println()
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get user home directory: %w", err)
}
currentUser, err := user.Current()
if err != nil {
return fmt.Errorf("failed to get current user: %w", err)
}
configPath := "/etc/greetd/config.toml"
fmt.Println("Greeter Configuration:")
if data, err := os.ReadFile(configPath); err == nil {
configContent := string(data)
if strings.Contains(configContent, "dms-greeter") {
lines := strings.Split(configContent, "\n")
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "command =") || strings.HasPrefix(trimmed, "command=") {
parts := strings.SplitN(trimmed, "=", 2)
if len(parts) == 2 {
command := strings.Trim(strings.TrimSpace(parts[1]), `"`)
fmt.Println(" ✓ Greeter is enabled")
if strings.Contains(command, "--command niri") {
fmt.Println(" Compositor: niri")
} else if strings.Contains(command, "--command hyprland") {
fmt.Println(" Compositor: Hyprland")
} else if strings.Contains(command, "--command sway") {
fmt.Println(" Compositor: sway")
} else {
fmt.Println(" Compositor: unknown")
}
}
break
}
}
} else {
fmt.Println(" ✗ Greeter is NOT enabled")
fmt.Println(" Run 'dms greeter enable' to enable it")
}
} else {
fmt.Println(" ✗ Greeter config not found")
fmt.Println(" Run 'dms greeter install' to install greeter")
}
fmt.Println("\nGroup Membership:")
groupsCmd := exec.Command("groups", currentUser.Username)
groupsOutput, err := groupsCmd.Output()
if err != nil {
return fmt.Errorf("failed to check groups: %w", err)
}
inGreeterGroup := strings.Contains(string(groupsOutput), "greeter")
if inGreeterGroup {
fmt.Println(" ✓ User is in greeter group")
} else {
fmt.Println(" ✗ User is NOT in greeter group")
fmt.Println(" Run 'dms greeter install' to add user to greeter group")
}
cacheDir := "/var/cache/dms-greeter"
fmt.Println("\nGreeter Cache Directory:")
if stat, err := os.Stat(cacheDir); err == nil && stat.IsDir() {
fmt.Printf(" ✓ %s exists\n", cacheDir)
} else {
fmt.Printf(" ✗ %s not found\n", cacheDir)
fmt.Println(" Run 'dms greeter install' to create cache directory")
return nil
}
fmt.Println("\nConfiguration Symlinks:")
symlinks := []struct {
source string
target string
desc string
}{
{
source: filepath.Join(homeDir, ".config", "DankMaterialShell", "settings.json"),
target: filepath.Join(cacheDir, "settings.json"),
desc: "Settings",
},
{
source: filepath.Join(homeDir, ".local", "state", "DankMaterialShell", "session.json"),
target: filepath.Join(cacheDir, "session.json"),
desc: "Session state",
},
{
source: filepath.Join(homeDir, ".cache", "DankMaterialShell", "dms-colors.json"),
target: filepath.Join(cacheDir, "colors.json"),
desc: "Color theme",
},
}
allGood := true
for _, link := range symlinks {
targetInfo, err := os.Lstat(link.target)
if err != nil {
fmt.Printf(" ✗ %s: symlink not found at %s\n", link.desc, link.target)
allGood = false
continue
}
if targetInfo.Mode()&os.ModeSymlink == 0 {
fmt.Printf(" ✗ %s: %s is not a symlink\n", link.desc, link.target)
allGood = false
continue
}
linkDest, err := os.Readlink(link.target)
if err != nil {
fmt.Printf(" ✗ %s: failed to read symlink\n", link.desc)
allGood = false
continue
}
if linkDest != link.source {
fmt.Printf(" ✗ %s: symlink points to wrong location\n", link.desc)
fmt.Printf(" Expected: %s\n", link.source)
fmt.Printf(" Got: %s\n", linkDest)
allGood = false
continue
}
if _, err := os.Stat(link.source); os.IsNotExist(err) {
fmt.Printf(" ⚠ %s: symlink OK, but source file doesn't exist yet\n", link.desc)
fmt.Printf(" Will be created when you run DMS\n")
continue
}
fmt.Printf(" ✓ %s: synced correctly\n", link.desc)
}
fmt.Println()
if allGood && inGreeterGroup {
fmt.Println("✓ All checks passed! Greeter is properly configured.")
} else if !allGood {
fmt.Println("⚠ Some issues detected. Run 'dms greeter sync' to fix symlinks.")
}
return nil
}

View File

@@ -0,0 +1,136 @@
package main
import (
"encoding/json"
"fmt"
"os"
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds"
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds/providers"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/spf13/cobra"
)
var keybindsCmd = &cobra.Command{
Use: "keybinds",
Aliases: []string{"cheatsheet", "chsht"},
Short: "Manage keybinds and cheatsheets",
Long: "Display and manage keybinds and cheatsheets for various applications",
}
var keybindsListCmd = &cobra.Command{
Use: "list",
Short: "List available providers",
Long: "List all available keybind/cheatsheet providers",
Run: runKeybindsList,
}
var keybindsShowCmd = &cobra.Command{
Use: "show <provider>",
Short: "Show keybinds for a provider",
Long: "Display keybinds/cheatsheet for the specified provider",
Args: cobra.ExactArgs(1),
Run: runKeybindsShow,
}
func init() {
keybindsShowCmd.Flags().String("path", "", "Override config path for the provider")
keybindsCmd.AddCommand(keybindsListCmd)
keybindsCmd.AddCommand(keybindsShowCmd)
keybinds.SetJSONProviderFactory(func(filePath string) (keybinds.Provider, error) {
return providers.NewJSONFileProvider(filePath)
})
initializeProviders()
}
func initializeProviders() {
registry := keybinds.GetDefaultRegistry()
hyprlandProvider := providers.NewHyprlandProvider("$HOME/.config/hypr")
if err := registry.Register(hyprlandProvider); err != nil {
log.Warnf("Failed to register Hyprland provider: %v", err)
}
mangowcProvider := providers.NewMangoWCProvider("$HOME/.config/mango")
if err := registry.Register(mangowcProvider); err != nil {
log.Warnf("Failed to register MangoWC provider: %v", err)
}
swayProvider := providers.NewSwayProvider("$HOME/.config/sway")
if err := registry.Register(swayProvider); err != nil {
log.Warnf("Failed to register Sway provider: %v", err)
}
config := keybinds.DefaultDiscoveryConfig()
if err := keybinds.AutoDiscoverProviders(registry, config); err != nil {
log.Warnf("Failed to auto-discover providers: %v", err)
}
}
func runKeybindsList(cmd *cobra.Command, args []string) {
registry := keybinds.GetDefaultRegistry()
providers := registry.List()
if len(providers) == 0 {
fmt.Fprintln(os.Stdout, "No providers available")
return
}
fmt.Fprintln(os.Stdout, "Available providers:")
for _, name := range providers {
fmt.Fprintf(os.Stdout, " - %s\n", name)
}
}
func runKeybindsShow(cmd *cobra.Command, args []string) {
providerName := args[0]
registry := keybinds.GetDefaultRegistry()
customPath, _ := cmd.Flags().GetString("path")
if customPath != "" {
var provider keybinds.Provider
switch providerName {
case "hyprland":
provider = providers.NewHyprlandProvider(customPath)
case "mangowc":
provider = providers.NewMangoWCProvider(customPath)
case "sway":
provider = providers.NewSwayProvider(customPath)
default:
log.Fatalf("Provider %s does not support custom path", providerName)
}
sheet, err := provider.GetCheatSheet()
if err != nil {
log.Fatalf("Error getting cheatsheet: %v", err)
}
output, err := json.MarshalIndent(sheet, "", " ")
if err != nil {
log.Fatalf("Error generating JSON: %v", err)
}
fmt.Fprintln(os.Stdout, string(output))
return
}
provider, err := registry.Get(providerName)
if err != nil {
log.Fatalf("Error: %v", err)
}
sheet, err := provider.GetCheatSheet()
if err != nil {
log.Fatalf("Error getting cheatsheet: %v", err)
}
output, err := json.MarshalIndent(sheet, "", " ")
if err != nil {
log.Fatalf("Error generating JSON: %v", err)
}
fmt.Fprintln(os.Stdout, string(output))
}

View File

@@ -0,0 +1,99 @@
package main
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
"github.com/AvengeMedia/DankMaterialShell/core/internal/dms"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
tea "github.com/charmbracelet/bubbletea"
"github.com/spf13/cobra"
)
var customConfigPath string
var configPath string
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")
}
func findConfig(cmd *cobra.Command, args []string) error {
if customConfigPath != "" {
log.Debug("Custom config path provided via -c flag: %s", customConfigPath)
shellPath := filepath.Join(customConfigPath, "shell.qml")
info, statErr := os.Stat(shellPath)
if statErr == nil && !info.IsDir() {
configPath = customConfigPath
log.Debug("Using config from: %s", configPath)
return nil // <-- Guard statement
}
if statErr != nil {
return fmt.Errorf("custom config path error: %w", statErr)
}
return fmt.Errorf("path is a directory, not a file: %s", shellPath)
}
configStateFile := filepath.Join(getRuntimeDir(), "danklinux.path")
if data, readErr := os.ReadFile(configStateFile); readErr == nil {
statePath := strings.TrimSpace(string(data))
shellPath := filepath.Join(statePath, "shell.qml")
if info, statErr := os.Stat(shellPath); statErr == nil && !info.IsDir() {
log.Debug("Using config from active session state file: %s", statePath)
configPath = statePath
log.Debug("Using config from: %s", configPath)
return nil // <-- Guard statement
} else {
os.Remove(configStateFile)
}
}
log.Debug("No custom path or active session, searching default XDG locations...")
var err error
configPath, err = config.LocateDMSConfig()
if err != nil {
return err
}
log.Debug("Using config from: %s", configPath)
return nil
}
func runInteractiveMode(cmd *cobra.Command, args []string) {
detector, err := dms.NewDetector()
if err != nil && !errors.Is(err, &distros.UnsupportedDistributionError{}) {
log.Fatalf("Error initializing DMS detector: %v", err)
} else if errors.Is(err, &distros.UnsupportedDistributionError{}) {
log.Error("Interactive mode is not supported on this distribution.")
log.Info("Please run 'dms --help' for available commands.")
os.Exit(1)
}
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

@@ -0,0 +1,182 @@
package main
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/spf13/cobra"
)
var setupCmd = &cobra.Command{
Use: "setup",
Short: "Deploy DMS configurations",
Long: "Deploy compositor and terminal configurations with interactive prompts",
Run: func(cmd *cobra.Command, args []string) {
if err := runSetup(); err != nil {
log.Fatalf("Error during setup: %v", err)
}
},
}
func runSetup() error {
fmt.Println("=== DMS Configuration Setup ===")
wm, wmSelected := promptCompositor()
terminal, terminalSelected := promptTerminal()
if !wmSelected && !terminalSelected {
fmt.Println("No configurations selected. Exiting.")
return nil
}
if wmSelected || terminalSelected {
willBackup := checkExistingConfigs(wm, wmSelected, terminal, terminalSelected)
if willBackup {
fmt.Println("\n⚠ Existing configurations will be backed up with timestamps.")
}
fmt.Print("\nProceed with deployment? (y/N): ")
var response string
fmt.Scanln(&response)
response = strings.ToLower(strings.TrimSpace(response))
if response != "y" && response != "yes" {
fmt.Println("Setup cancelled.")
return nil
}
}
fmt.Println("\nDeploying configurations...")
logChan := make(chan string, 100)
deployer := config.NewConfigDeployer(logChan)
go func() {
for msg := range logChan {
fmt.Println(" " + msg)
}
}()
ctx := context.Background()
var results []config.DeploymentResult
var err error
if wmSelected && terminalSelected {
results, err = deployer.DeployConfigurationsWithTerminal(ctx, wm, terminal)
} else if wmSelected {
results, err = deployer.DeployConfigurationsWithTerminal(ctx, wm, deps.TerminalGhostty)
if len(results) > 1 {
results = results[:1]
}
} else if terminalSelected {
results, err = deployer.DeployConfigurationsWithTerminal(ctx, deps.WindowManagerNiri, terminal)
if len(results) > 0 && results[0].ConfigType == "Niri" {
results = results[1:]
}
}
close(logChan)
if err != nil {
return fmt.Errorf("deployment failed: %w", err)
}
fmt.Println("\n=== Deployment Complete ===")
for _, result := range results {
if result.Deployed {
fmt.Printf("✓ %s: %s\n", result.ConfigType, result.Path)
if result.BackupPath != "" {
fmt.Printf(" Backup: %s\n", result.BackupPath)
}
}
}
return nil
}
func promptCompositor() (deps.WindowManager, bool) {
fmt.Println("Select compositor:")
fmt.Println("1) Niri")
fmt.Println("2) Hyprland")
fmt.Println("3) None")
var response string
fmt.Print("\nChoice (1-3): ")
fmt.Scanln(&response)
response = strings.TrimSpace(response)
switch response {
case "1":
return deps.WindowManagerNiri, true
case "2":
return deps.WindowManagerHyprland, true
default:
return deps.WindowManagerNiri, false
}
}
func promptTerminal() (deps.Terminal, bool) {
fmt.Println("\nSelect terminal:")
fmt.Println("1) Ghostty")
fmt.Println("2) Kitty")
fmt.Println("3) Alacritty")
fmt.Println("4) None")
var response string
fmt.Print("\nChoice (1-4): ")
fmt.Scanln(&response)
response = strings.TrimSpace(response)
switch response {
case "1":
return deps.TerminalGhostty, true
case "2":
return deps.TerminalKitty, true
case "3":
return deps.TerminalAlacritty, true
default:
return deps.TerminalGhostty, false
}
}
func checkExistingConfigs(wm deps.WindowManager, wmSelected bool, terminal deps.Terminal, terminalSelected bool) bool {
homeDir := os.Getenv("HOME")
willBackup := false
if wmSelected {
var configPath string
switch wm {
case deps.WindowManagerNiri:
configPath = filepath.Join(homeDir, ".config", "niri", "config.kdl")
case deps.WindowManagerHyprland:
configPath = filepath.Join(homeDir, ".config", "hypr", "hyprland.conf")
}
if _, err := os.Stat(configPath); err == nil {
willBackup = true
}
}
if terminalSelected {
var configPath string
switch terminal {
case deps.TerminalGhostty:
configPath = filepath.Join(homeDir, ".config", "ghostty", "config")
case deps.TerminalKitty:
configPath = filepath.Join(homeDir, ".config", "kitty", "kitty.conf")
case deps.TerminalAlacritty:
configPath = filepath.Join(homeDir, ".config", "alacritty", "alacritty.toml")
}
if _, err := os.Stat(configPath); err == nil {
willBackup = true
}
}
return willBackup
}

44
core/cmd/dms/main.go Normal file
View File

@@ -0,0 +1,44 @@
//go:build !distro_binary
package main
import (
"os"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
)
var Version = "dev"
func init() {
runCmd.Flags().BoolP("daemon", "d", false, "Run in daemon mode")
runCmd.Flags().Bool("daemon-child", false, "Internal flag for daemon child process")
runCmd.Flags().Bool("session", false, "Session managed (like as a systemd unit)")
runCmd.Flags().MarkHidden("daemon-child")
// Add subcommands to greeter
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd)
// Add subcommands to update
updateCmd.AddCommand(updateCheckCmd)
// Add subcommands to plugins
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd)
// Add common commands to root
rootCmd.AddCommand(getCommonCommands()...)
rootCmd.AddCommand(updateCmd)
rootCmd.SetHelpTemplate(getHelpTemplate())
}
func main() {
if os.Geteuid() == 0 {
log.Fatal("This program should not be run as root. Exiting.")
}
if err := rootCmd.Execute(); err != nil {
log.Fatal(err)
}
}

View File

@@ -0,0 +1,41 @@
//go:build distro_binary
package main
import (
"os"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
)
var Version = "dev"
func init() {
// Add flags
runCmd.Flags().BoolP("daemon", "d", false, "Run in daemon mode")
runCmd.Flags().Bool("daemon-child", false, "Internal flag for daemon child process")
runCmd.Flags().Bool("session", false, "Session managed (like as a systemd unit)")
runCmd.Flags().MarkHidden("daemon-child")
// Add subcommands to greeter
greeterCmd.AddCommand(greeterSyncCmd, greeterEnableCmd, greeterStatusCmd)
// Add subcommands to plugins
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd)
// Add common commands to root
rootCmd.AddCommand(getCommonCommands()...)
rootCmd.SetHelpTemplate(getHelpTemplate())
}
func main() {
// Block root
if os.Geteuid() == 0 {
log.Fatal("This program should not be run as root. Exiting.")
}
if err := rootCmd.Execute(); err != nil {
log.Fatal(err)
}
}

482
core/cmd/dms/shell.go Normal file
View File

@@ -0,0 +1,482 @@
package main
import (
"context"
"fmt"
"os"
"os/exec"
"os/signal"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server"
)
var isSessionManaged bool
func execDetachedRestart(targetPID int) {
selfPath, err := os.Executable()
if err != nil {
return
}
cmd := exec.Command(selfPath, "restart-detached", strconv.Itoa(targetPID))
cmd.SysProcAttr = &syscall.SysProcAttr{
Setsid: true,
}
cmd.Start()
}
func runDetachedRestart(targetPIDStr string) {
targetPID, err := strconv.Atoi(targetPIDStr)
if err != nil {
return
}
time.Sleep(200 * time.Millisecond)
proc, err := os.FindProcess(targetPID)
if err == nil {
proc.Signal(syscall.SIGTERM)
}
time.Sleep(500 * time.Millisecond)
killShell()
runShellDaemon(false)
}
func getRuntimeDir() string {
if runtime := os.Getenv("XDG_RUNTIME_DIR"); runtime != "" {
return runtime
}
return os.TempDir()
}
func getPIDFilePath() string {
return filepath.Join(getRuntimeDir(), fmt.Sprintf("danklinux-%d.pid", os.Getpid()))
}
func writePIDFile(childPID int) error {
pidFile := getPIDFilePath()
return os.WriteFile(pidFile, []byte(strconv.Itoa(childPID)), 0644)
}
func removePIDFile() {
pidFile := getPIDFilePath()
os.Remove(pidFile)
}
func getAllDMSPIDs() []int {
dir := getRuntimeDir()
entries, err := os.ReadDir(dir)
if err != nil {
return nil
}
var pids []int
for _, entry := range entries {
if !strings.HasPrefix(entry.Name(), "danklinux-") || !strings.HasSuffix(entry.Name(), ".pid") {
continue
}
pidFile := filepath.Join(dir, entry.Name())
data, err := os.ReadFile(pidFile)
if err != nil {
continue
}
childPID, err := strconv.Atoi(strings.TrimSpace(string(data)))
if err != nil {
os.Remove(pidFile)
continue
}
// Check if the child process is still alive
proc, err := os.FindProcess(childPID)
if err != nil {
os.Remove(pidFile)
continue
}
if err := proc.Signal(syscall.Signal(0)); err != nil {
// Process is dead, remove stale PID file
os.Remove(pidFile)
continue
}
pids = append(pids, childPID)
// Also get the parent PID from the filename
parentPIDStr := strings.TrimPrefix(entry.Name(), "danklinux-")
parentPIDStr = strings.TrimSuffix(parentPIDStr, ".pid")
if parentPID, err := strconv.Atoi(parentPIDStr); err == nil {
// Check if parent is still alive
if parentProc, err := os.FindProcess(parentPID); err == nil {
if err := parentProc.Signal(syscall.Signal(0)); err == nil {
pids = append(pids, parentPID)
}
}
}
}
return pids
}
func runShellInteractive(session bool) {
isSessionManaged = session
go printASCII()
fmt.Fprintf(os.Stderr, "dms %s\n", Version)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
socketPath := server.GetSocketPath()
configStateFile := filepath.Join(getRuntimeDir(), "danklinux.path")
if err := os.WriteFile(configStateFile, []byte(configPath), 0644); err != nil {
log.Warnf("Failed to write config state file: %v", err)
}
defer os.Remove(configStateFile)
errChan := make(chan error, 2)
go func() {
defer func() {
if r := recover(); r != nil {
errChan <- fmt.Errorf("server panic: %v", r)
}
}()
if err := server.Start(false); err != nil {
errChan <- fmt.Errorf("server error: %w", err)
}
}()
log.Infof("Spawning quickshell with -p %s", configPath)
cmd := exec.CommandContext(ctx, "qs", "-p", configPath)
cmd.Env = append(os.Environ(), "DMS_SOCKET="+socketPath)
if qtRules := log.GetQtLoggingRules(); qtRules != "" {
cmd.Env = append(cmd.Env, "QT_LOGGING_RULES="+qtRules)
}
homeDir, err := os.UserHomeDir()
if err == nil && os.Getenv("DMS_DISABLE_HOT_RELOAD") == "" {
if !strings.HasPrefix(configPath, homeDir) {
cmd.Env = append(cmd.Env, "DMS_DISABLE_HOT_RELOAD=1")
}
}
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
log.Fatalf("Error starting quickshell: %v", err)
}
// Write PID file for the quickshell child process
if err := writePIDFile(cmd.Process.Pid); err != nil {
log.Warnf("Failed to write PID file: %v", err)
}
defer removePIDFile()
defer func() {
if cmd.Process != nil {
cmd.Process.Signal(syscall.SIGTERM)
}
}()
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM, syscall.SIGUSR1)
go func() {
if err := cmd.Wait(); err != nil {
errChan <- fmt.Errorf("quickshell exited: %w", err)
} else {
errChan <- fmt.Errorf("quickshell exited")
}
}()
for {
select {
case sig := <-sigChan:
// Handle SIGUSR1 restart for non-session managed processes
if sig == syscall.SIGUSR1 && !isSessionManaged {
log.Infof("Received SIGUSR1, spawning detached restart process...")
execDetachedRestart(os.Getpid())
// Exit immediately to avoid race conditions with detached restart
return
}
// All other signals: clean shutdown
log.Infof("\nReceived signal %v, shutting down...", sig)
cancel()
cmd.Process.Signal(syscall.SIGTERM)
os.Remove(socketPath)
return
case err := <-errChan:
log.Error(err)
cancel()
if cmd.Process != nil {
cmd.Process.Signal(syscall.SIGTERM)
}
os.Remove(socketPath)
os.Exit(1)
}
}
}
func restartShell() {
pids := getAllDMSPIDs()
if len(pids) == 0 {
log.Info("No running DMS shell instances found. Starting daemon...")
runShellDaemon(false)
return
}
currentPid := os.Getpid()
uniquePids := make(map[int]bool)
for _, pid := range pids {
if pid != currentPid {
uniquePids[pid] = true
}
}
for pid := range uniquePids {
proc, err := os.FindProcess(pid)
if err != nil {
log.Errorf("Error finding process %d: %v", pid, err)
continue
}
if err := proc.Signal(syscall.Signal(0)); err != nil {
continue
}
if err := proc.Signal(syscall.SIGUSR1); err != nil {
log.Errorf("Error sending SIGUSR1 to process %d: %v", pid, err)
} else {
log.Infof("Sent SIGUSR1 to DMS process with PID %d", pid)
}
}
}
func killShell() {
// Get all tracked DMS PIDs from PID files
pids := getAllDMSPIDs()
if len(pids) == 0 {
log.Info("No running DMS shell instances found.")
return
}
currentPid := os.Getpid()
uniquePids := make(map[int]bool)
// Deduplicate and filter out current process
for _, pid := range pids {
if pid != currentPid {
uniquePids[pid] = true
}
}
// Kill all tracked processes
for pid := range uniquePids {
proc, err := os.FindProcess(pid)
if err != nil {
log.Errorf("Error finding process %d: %v", pid, err)
continue
}
// Check if process is still alive before killing
if err := proc.Signal(syscall.Signal(0)); err != nil {
continue
}
if err := proc.Kill(); err != nil {
log.Errorf("Error killing process %d: %v", pid, err)
} else {
log.Infof("Killed DMS process with PID %d", pid)
}
}
// Clean up any remaining PID files
dir := getRuntimeDir()
entries, err := os.ReadDir(dir)
if err != nil {
return
}
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), "danklinux-") && strings.HasSuffix(entry.Name(), ".pid") {
pidFile := filepath.Join(dir, entry.Name())
os.Remove(pidFile)
}
}
}
func runShellDaemon(session bool) {
isSessionManaged = session
// Check if this is the daemon child process by looking for the hidden flag
isDaemonChild := false
for _, arg := range os.Args {
if arg == "--daemon-child" {
isDaemonChild = true
break
}
}
if !isDaemonChild {
fmt.Fprintf(os.Stderr, "dms %s\n", Version)
cmd := exec.Command(os.Args[0], "run", "-d", "--daemon-child")
cmd.Env = os.Environ()
cmd.SysProcAttr = &syscall.SysProcAttr{
Setsid: true,
}
if err := cmd.Start(); err != nil {
log.Fatalf("Error starting daemon: %v", err)
}
log.Infof("DMS shell daemon started (PID: %d)", cmd.Process.Pid)
return
}
fmt.Fprintf(os.Stderr, "dms %s\n", Version)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
socketPath := server.GetSocketPath()
configStateFile := filepath.Join(getRuntimeDir(), "danklinux.path")
if err := os.WriteFile(configStateFile, []byte(configPath), 0644); err != nil {
log.Warnf("Failed to write config state file: %v", err)
}
defer os.Remove(configStateFile)
errChan := make(chan error, 2)
go func() {
defer func() {
if r := recover(); r != nil {
errChan <- fmt.Errorf("server panic: %v", r)
}
}()
if err := server.Start(false); err != nil {
errChan <- fmt.Errorf("server error: %w", err)
}
}()
log.Infof("Spawning quickshell with -p %s", configPath)
cmd := exec.CommandContext(ctx, "qs", "-p", configPath)
cmd.Env = append(os.Environ(), "DMS_SOCKET="+socketPath)
if qtRules := log.GetQtLoggingRules(); qtRules != "" {
cmd.Env = append(cmd.Env, "QT_LOGGING_RULES="+qtRules)
}
homeDir, err := os.UserHomeDir()
if err == nil && os.Getenv("DMS_DISABLE_HOT_RELOAD") == "" {
if !strings.HasPrefix(configPath, homeDir) {
cmd.Env = append(cmd.Env, "DMS_DISABLE_HOT_RELOAD=1")
}
}
devNull, err := os.OpenFile("/dev/null", os.O_RDWR, 0)
if err != nil {
log.Fatalf("Error opening /dev/null: %v", err)
}
defer devNull.Close()
cmd.Stdin = devNull
cmd.Stdout = devNull
cmd.Stderr = devNull
if err := cmd.Start(); err != nil {
log.Fatalf("Error starting daemon: %v", err)
}
// Write PID file for the quickshell child process
if err := writePIDFile(cmd.Process.Pid); err != nil {
log.Warnf("Failed to write PID file: %v", err)
}
defer removePIDFile()
defer func() {
if cmd.Process != nil {
cmd.Process.Signal(syscall.SIGTERM)
}
}()
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM, syscall.SIGUSR1)
go func() {
if err := cmd.Wait(); err != nil {
errChan <- fmt.Errorf("quickshell exited: %w", err)
} else {
errChan <- fmt.Errorf("quickshell exited")
}
}()
for {
select {
case sig := <-sigChan:
// Handle SIGUSR1 restart for non-session managed processes
if sig == syscall.SIGUSR1 && !isSessionManaged {
log.Infof("Received SIGUSR1, spawning detached restart process...")
execDetachedRestart(os.Getpid())
// Exit immediately to avoid race conditions with detached restart
return
}
// All other signals: clean shutdown
cancel()
cmd.Process.Signal(syscall.SIGTERM)
os.Remove(socketPath)
return
case <-errChan:
cancel()
if cmd.Process != nil {
cmd.Process.Signal(syscall.SIGTERM)
}
os.Remove(socketPath)
os.Exit(1)
}
}
}
func runShellIPCCommand(args []string) {
if len(args) == 0 {
log.Error("IPC command requires arguments")
log.Info("Usage: dms ipc <command> [args...]")
os.Exit(1)
}
if args[0] != "call" {
args = append([]string{"call"}, args...)
}
cmdArgs := append([]string{"-p", configPath, "ipc"}, args...)
cmd := exec.Command("qs", cmdArgs...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
log.Fatalf("Error running IPC command: %v", err)
}
}

53
core/cmd/dms/ui.go Normal file
View File

@@ -0,0 +1,53 @@
package main
import (
"fmt"
"github.com/AvengeMedia/DankMaterialShell/core/internal/tui"
"github.com/charmbracelet/lipgloss"
)
func printASCII() {
fmt.Print(getThemedASCII())
}
func getThemedASCII() string {
theme := tui.TerminalTheme()
logo := `
██████╗ █████╗ ███╗ ██╗██╗ ██╗
██╔══██╗██╔══██╗████╗ ██║██║ ██╔╝
██║ ██║███████║██╔██╗ ██║█████╔╝
██║ ██║██╔══██║██║╚██╗██║██╔═██╗
██████╔╝██║ ██║██║ ╚████║██║ ██╗
╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝`
style := lipgloss.NewStyle().
Foreground(lipgloss.Color(theme.Primary)).
Bold(true)
return style.Render(logo) + "\n"
}
func getHelpTemplate() string {
return getThemedASCII() + `
{{.Long}}
Usage:
{{.UseLine}}{{if .HasAvailableSubCommands}}
Available Commands:{{range .Commands}}{{if (or .IsAvailableCommand (eq .Name "help"))}}
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}}
Flags:
{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}
Global Flags:
{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}}
Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}}
{{rpad .Name .NamePadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}}
Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}}
`
}

26
core/cmd/dms/utils.go Normal file
View File

@@ -0,0 +1,26 @@
package main
import (
"fmt"
"os/exec"
)
func commandExists(cmd string) bool {
_, err := exec.LookPath(cmd)
return err == nil
}
// findCommandPath returns the absolute path to a command in PATH
func findCommandPath(cmd string) (string, error) {
path, err := exec.LookPath(cmd)
if err != nil {
return "", fmt.Errorf("command '%s' not found in PATH", cmd)
}
return path, nil
}
func isArchPackageInstalled(packageName string) bool {
cmd := exec.Command("pacman", "-Q", packageName)
err := cmd.Run()
return err == nil
}

69
core/go.mod Normal file
View File

@@ -0,0 +1,69 @@
module github.com/AvengeMedia/DankMaterialShell/core
go 1.24.6
require (
github.com/Wifx/gonetworkmanager/v2 v2.2.0
github.com/charmbracelet/bubbles v0.21.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/charmbracelet/log v0.4.2
github.com/fsnotify/fsnotify v1.9.0
github.com/godbus/dbus/v5 v5.1.0
github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83
github.com/spf13/cobra v1.10.1
github.com/stretchr/testify v1.11.1
github.com/yaslama/go-wayland/wayland v0.0.0-20250907155644-2874f32d9c34
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6
)
require (
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.3.0 // indirect
github.com/clipperhouse/displaywidth v0.5.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.1 // indirect
github.com/cyphar/filepath-securejoin v0.6.0 // 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-20251111123000-fb5ff8f3f0b0 // 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
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
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.44.0 // indirect
golang.org/x/net v0.47.0 // indirect
)
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.3.3 // indirect
github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/charmbracelet/x/ansi v0.11.0 // 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-20251112161705-8cc3e21f07a9
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
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
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.38.0
golang.org/x/text v0.31.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

151
core/go.sum Normal file
View File

@@ -0,0 +1,151 @@
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
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/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=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
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/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/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.0 h1:uuIVK7GIplwX6UBIz8S2TF8nkr7xRlygSsBRjSJqIvA=
github.com/charmbracelet/x/ansi v0.11.0/go.mod h1:uQt8bOrq/xgXjlGcFMc8U2WYbnxyjrKhnvTQluvfCaE=
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.5.0 h1:AIG5vQaSL2EKqzt0M9JMnvNxOCRTKUc4vUnLWGgP89I=
github.com/clipperhouse/displaywidth v0.5.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
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/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/cyphar/filepath-securejoin v0.6.0 h1:BtGB77njd6SVO6VztOHfPxKitJvd/VPT+OFBFMOi1Is=
github.com/cyphar/filepath-securejoin v0.6.0/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/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=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
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-20251111123000-fb5ff8f3f0b0 h1:EC9n6hr6yKDoVJ6g7Ko523LbbceJfR0ohbOp809Fyf4=
github.com/go-git/go-billy/v6 v6.0.0-20251111123000-fb5ff8f3f0b0/go.mod h1:E3VhlS+AKkrq6ZNn1axE2/nDRJ87l1FJk9r5HT2vPX0=
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/v6 v6.0.0-20251112161705-8cc3e21f07a9 h1:SOFrnF9LCssC6q6Rb0084Bzg2aBYbe8QXv9xKGXmt/w=
github.com/go-git/go-git/v6 v6.0.0-20251112161705-8cc3e21f07a9/go.mod h1:0wtvm/JfPC9RFVEAP3ks0ec5h64/qmZkTTUE3pjz7Hc=
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 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/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/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=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PWkxoFkQ=
github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
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/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/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
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.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/yaslama/go-wayland/wayland v0.0.0-20250907155644-2874f32d9c34 h1:iTAt1me6SBYsuzrl/CmrxtATPlOG/pVviosM3DhUdKE=
github.com/yaslama/go-wayland/wayland v0.0.0-20250907155644-2874f32d9c34/go.mod h1:jzmUN5lUAv2O8e63OvcauV4S30rIZ1BvF/PNYE37vDo=
golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
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/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/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

86
core/install.sh Executable file
View File

@@ -0,0 +1,86 @@
#!/bin/sh
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
NC='\033[0m' # No Color
# Check for root privileges
if [ "$(id -u)" == "0" ]; then
printf "%bError: This script must not be run as root%b\n" "$RED" "$NC"
exit 1
fi
# Check if running on Linux
if [ "$(uname)" != "Linux" ]; then
printf "%bError: This installer only supports Linux systems%b\n" "$RED" "$NC"
exit 1
fi
# Detect architecture
ARCH=$(uname -m)
case "$ARCH" in
x86_64)
ARCH="amd64"
;;
aarch64)
ARCH="arm64"
;;
*)
printf "%bError: Unsupported architecture: %s%b\n" "$RED" "$ARCH" "$NC"
printf "This installer only supports x86_64 (amd64) and aarch64 (arm64) architectures\n"
exit 1
;;
esac
# Get the latest release version
LATEST_VERSION=$(curl -s https://api.github.com/repos/AvengeMedia/DankMaterialShell/releases/latest | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
if [ -z "$LATEST_VERSION" ]; then
printf "%bError: Could not fetch latest version%b\n" "$RED" "$NC"
exit 1
fi
printf "%bInstalling Dankinstall %s for %s...%b\n" "$GREEN" "$LATEST_VERSION" "$ARCH" "$NC"
# Download and install
TEMP_DIR=$(mktemp -d)
cd "$TEMP_DIR" || exit 1
# Download the gzipped binary and its checksum
printf "%bDownloading installer...%b\n" "$GREEN" "$NC"
curl -L "https://github.com/AvengeMedia/DankMaterialShell/releases/download/$LATEST_VERSION/dankinstall-$ARCH.gz" -o "installer.gz"
curl -L "https://github.com/AvengeMedia/DankMaterialShell/releases/download/$LATEST_VERSION/dankinstall-$ARCH.gz.sha256" -o "expected.sha256"
# Get the expected checksum
EXPECTED_CHECKSUM=$(cat expected.sha256 | awk '{print $1}')
# Calculate actual checksum
printf "%bVerifying checksum...%b\n" "$GREEN" "$NC"
ACTUAL_CHECKSUM=$(sha256sum installer.gz | awk '{print $1}')
# Compare checksums
if [ "$EXPECTED_CHECKSUM" != "$ACTUAL_CHECKSUM" ]; then
printf "%bError: Checksum verification failed%b\n" "$RED" "$NC"
printf "Expected: %s\n" "$EXPECTED_CHECKSUM"
printf "Got: %s\n" "$ACTUAL_CHECKSUM"
printf "The downloaded file may be corrupted or tampered with\n"
cd - > /dev/null
rm -rf "$TEMP_DIR"
exit 1
fi
# Decompress the binary
printf "%bDecompressing installer...%b\n" "$GREEN" "$NC"
gunzip installer.gz
chmod +x installer
# Execute the installer
printf "%bRunning installer...%b\n" "$GREEN" "$NC"
./installer
# Cleanup
cd - > /dev/null
rm -rf "$TEMP_DIR"

View File

@@ -0,0 +1,574 @@
package config
import (
"context"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
)
type ConfigDeployer struct {
logChan chan<- string
}
type DeploymentResult struct {
ConfigType string
Path string
BackupPath string
Deployed bool
Error error
}
func NewConfigDeployer(logChan chan<- string) *ConfigDeployer {
return &ConfigDeployer{
logChan: logChan,
}
}
func (cd *ConfigDeployer) log(message string) {
if cd.logChan != nil {
cd.logChan <- message
}
}
// DeployConfigurations deploys all necessary configurations based on the chosen window manager
func (cd *ConfigDeployer) DeployConfigurations(ctx context.Context, wm deps.WindowManager) ([]DeploymentResult, error) {
return cd.DeployConfigurationsWithTerminal(ctx, wm, deps.TerminalGhostty)
}
// DeployConfigurationsWithTerminal deploys all necessary configurations based on chosen window manager and terminal
func (cd *ConfigDeployer) DeployConfigurationsWithTerminal(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal) ([]DeploymentResult, error) {
return cd.DeployConfigurationsSelective(ctx, wm, terminal, nil, nil)
}
func (cd *ConfigDeployer) DeployConfigurationsSelective(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal, installedDeps []deps.Dependency, replaceConfigs map[string]bool) ([]DeploymentResult, error) {
return cd.DeployConfigurationsSelectiveWithReinstalls(ctx, wm, terminal, installedDeps, replaceConfigs, nil)
}
func (cd *ConfigDeployer) DeployConfigurationsSelectiveWithReinstalls(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal, installedDeps []deps.Dependency, replaceConfigs map[string]bool, reinstallItems map[string]bool) ([]DeploymentResult, error) {
var results []DeploymentResult
shouldReplaceConfig := func(configType string) bool {
if replaceConfigs == nil {
return true
}
replace, exists := replaceConfigs[configType]
return !exists || replace
}
switch wm {
case deps.WindowManagerNiri:
if shouldReplaceConfig("Niri") {
result, err := cd.deployNiriConfig(terminal)
results = append(results, result)
if err != nil {
return results, fmt.Errorf("failed to deploy Niri config: %w", err)
}
}
case deps.WindowManagerHyprland:
if shouldReplaceConfig("Hyprland") {
result, err := cd.deployHyprlandConfig(terminal)
results = append(results, result)
if err != nil {
return results, fmt.Errorf("failed to deploy Hyprland config: %w", err)
}
}
}
switch terminal {
case deps.TerminalGhostty:
if shouldReplaceConfig("Ghostty") {
ghosttyResults, err := cd.deployGhosttyConfig()
results = append(results, ghosttyResults...)
if err != nil {
return results, fmt.Errorf("failed to deploy Ghostty config: %w", err)
}
}
case deps.TerminalKitty:
if shouldReplaceConfig("Kitty") {
kittyResults, err := cd.deployKittyConfig()
results = append(results, kittyResults...)
if err != nil {
return results, fmt.Errorf("failed to deploy Kitty config: %w", err)
}
}
case deps.TerminalAlacritty:
if shouldReplaceConfig("Alacritty") {
alacrittyResults, err := cd.deployAlacrittyConfig()
results = append(results, alacrittyResults...)
if err != nil {
return results, fmt.Errorf("failed to deploy Alacritty config: %w", err)
}
}
}
return results, nil
}
// deployNiriConfig handles Niri configuration deployment with backup and merging
func (cd *ConfigDeployer) deployNiriConfig(terminal deps.Terminal) (DeploymentResult, error) {
result := DeploymentResult{
ConfigType: "Niri",
Path: filepath.Join(os.Getenv("HOME"), ".config", "niri", "config.kdl"),
}
configDir := filepath.Dir(result.Path)
if err := os.MkdirAll(configDir, 0755); err != nil {
result.Error = fmt.Errorf("failed to create config directory: %w", err)
return result, result.Error
}
var existingConfig string
if _, err := os.Stat(result.Path); err == nil {
cd.log("Found existing Niri configuration")
existingData, err := os.ReadFile(result.Path)
if err != nil {
result.Error = fmt.Errorf("failed to read existing config: %w", err)
return result, result.Error
}
existingConfig = string(existingData)
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 {
result.Error = fmt.Errorf("failed to create backup: %w", err)
return result, result.Error
}
cd.log(fmt.Sprintf("Backed up existing config to %s", result.BackupPath))
}
// Detect polkit agent path
polkitPath, err := cd.detectPolkitAgent()
if err != nil {
cd.log(fmt.Sprintf("Warning: Could not detect polkit agent: %v", err))
polkitPath = "/usr/lib/mate-polkit/polkit-mate-authentication-agent-1" // fallback
}
// Determine terminal command based on choice
var terminalCommand string
switch terminal {
case deps.TerminalGhostty:
terminalCommand = "ghostty"
case deps.TerminalKitty:
terminalCommand = "kitty"
case deps.TerminalAlacritty:
terminalCommand = "alacritty"
default:
terminalCommand = "ghostty" // fallback to ghostty
}
newConfig := strings.ReplaceAll(NiriConfig, "{{POLKIT_AGENT_PATH}}", polkitPath)
newConfig = strings.ReplaceAll(newConfig, "{{TERMINAL_COMMAND}}", terminalCommand)
// If there was an existing config, merge the output sections
if existingConfig != "" {
mergedConfig, err := cd.mergeNiriOutputSections(newConfig, existingConfig)
if err != nil {
cd.log(fmt.Sprintf("Warning: Failed to merge output sections: %v", err))
} else {
newConfig = mergedConfig
cd.log("Successfully merged existing output sections")
}
}
if err := os.WriteFile(result.Path, []byte(newConfig), 0644); err != nil {
result.Error = fmt.Errorf("failed to write config: %w", err)
return result, result.Error
}
result.Deployed = true
cd.log("Successfully deployed Niri configuration")
return result, nil
}
func (cd *ConfigDeployer) deployGhosttyConfig() ([]DeploymentResult, error) {
var results []DeploymentResult
mainResult := DeploymentResult{
ConfigType: "Ghostty",
Path: filepath.Join(os.Getenv("HOME"), ".config", "ghostty", "config"),
}
configDir := filepath.Dir(mainResult.Path)
if err := os.MkdirAll(configDir, 0755); err != nil {
mainResult.Error = fmt.Errorf("failed to create config directory: %w", err)
return []DeploymentResult{mainResult}, mainResult.Error
}
if _, err := os.Stat(mainResult.Path); err == nil {
cd.log("Found existing Ghostty configuration")
existingData, err := os.ReadFile(mainResult.Path)
if err != nil {
mainResult.Error = fmt.Errorf("failed to read existing config: %w", err)
return []DeploymentResult{mainResult}, mainResult.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 {
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 {
mainResult.Error = fmt.Errorf("failed to write config: %w", err)
return []DeploymentResult{mainResult}, mainResult.Error
}
mainResult.Deployed = true
cd.log("Successfully deployed Ghostty configuration")
results = append(results, mainResult)
colorResult := DeploymentResult{
ConfigType: "Ghostty Colors",
Path: filepath.Join(os.Getenv("HOME"), ".config", "ghostty", "config-dankcolors"),
}
if err := os.WriteFile(colorResult.Path, []byte(GhosttyColorConfig), 0644); err != nil {
colorResult.Error = fmt.Errorf("failed to write color config: %w", err)
return results, colorResult.Error
}
colorResult.Deployed = true
cd.log("Successfully deployed Ghostty color configuration")
results = append(results, colorResult)
return results, nil
}
func (cd *ConfigDeployer) deployKittyConfig() ([]DeploymentResult, error) {
var results []DeploymentResult
mainResult := DeploymentResult{
ConfigType: "Kitty",
Path: filepath.Join(os.Getenv("HOME"), ".config", "kitty", "kitty.conf"),
}
configDir := filepath.Dir(mainResult.Path)
if err := os.MkdirAll(configDir, 0755); err != nil {
mainResult.Error = fmt.Errorf("failed to create config directory: %w", err)
return []DeploymentResult{mainResult}, mainResult.Error
}
if _, err := os.Stat(mainResult.Path); err == nil {
cd.log("Found existing Kitty configuration")
existingData, err := os.ReadFile(mainResult.Path)
if err != nil {
mainResult.Error = fmt.Errorf("failed to read existing config: %w", err)
return []DeploymentResult{mainResult}, mainResult.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 {
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 {
mainResult.Error = fmt.Errorf("failed to write config: %w", err)
return []DeploymentResult{mainResult}, mainResult.Error
}
mainResult.Deployed = true
cd.log("Successfully deployed Kitty configuration")
results = append(results, mainResult)
themeResult := DeploymentResult{
ConfigType: "Kitty Theme",
Path: filepath.Join(os.Getenv("HOME"), ".config", "kitty", "dank-theme.conf"),
}
if err := os.WriteFile(themeResult.Path, []byte(KittyThemeConfig), 0644); err != nil {
themeResult.Error = fmt.Errorf("failed to write theme config: %w", err)
return results, themeResult.Error
}
themeResult.Deployed = true
cd.log("Successfully deployed Kitty theme configuration")
results = append(results, themeResult)
tabsResult := DeploymentResult{
ConfigType: "Kitty Tabs",
Path: filepath.Join(os.Getenv("HOME"), ".config", "kitty", "dank-tabs.conf"),
}
if err := os.WriteFile(tabsResult.Path, []byte(KittyTabsConfig), 0644); err != nil {
tabsResult.Error = fmt.Errorf("failed to write tabs config: %w", err)
return results, tabsResult.Error
}
tabsResult.Deployed = true
cd.log("Successfully deployed Kitty tabs configuration")
results = append(results, tabsResult)
return results, nil
}
func (cd *ConfigDeployer) deployAlacrittyConfig() ([]DeploymentResult, error) {
var results []DeploymentResult
mainResult := DeploymentResult{
ConfigType: "Alacritty",
Path: filepath.Join(os.Getenv("HOME"), ".config", "alacritty", "alacritty.toml"),
}
configDir := filepath.Dir(mainResult.Path)
if err := os.MkdirAll(configDir, 0755); err != nil {
mainResult.Error = fmt.Errorf("failed to create config directory: %w", err)
return []DeploymentResult{mainResult}, mainResult.Error
}
if _, err := os.Stat(mainResult.Path); err == nil {
cd.log("Found existing Alacritty configuration")
existingData, err := os.ReadFile(mainResult.Path)
if err != nil {
mainResult.Error = fmt.Errorf("failed to read existing config: %w", err)
return []DeploymentResult{mainResult}, mainResult.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 {
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 {
mainResult.Error = fmt.Errorf("failed to write config: %w", err)
return []DeploymentResult{mainResult}, mainResult.Error
}
mainResult.Deployed = true
cd.log("Successfully deployed Alacritty configuration")
results = append(results, mainResult)
themeResult := DeploymentResult{
ConfigType: "Alacritty Theme",
Path: filepath.Join(os.Getenv("HOME"), ".config", "alacritty", "dank-theme.toml"),
}
if err := os.WriteFile(themeResult.Path, []byte(AlacrittyThemeConfig), 0644); err != nil {
themeResult.Error = fmt.Errorf("failed to write theme config: %w", err)
return results, themeResult.Error
}
themeResult.Deployed = true
cd.log("Successfully deployed Alacritty theme configuration")
results = append(results, themeResult)
return results, nil
}
// detectPolkitAgent tries to find the polkit authentication agent on the system
// Prioritizes mate-polkit paths since that's what we install
func (cd *ConfigDeployer) detectPolkitAgent() (string, error) {
// Prioritize mate-polkit paths first
matePaths := []string{
"/usr/libexec/polkit-mate-authentication-agent-1", // Fedora path
"/usr/lib/mate-polkit/polkit-mate-authentication-agent-1",
"/usr/libexec/mate-polkit/polkit-mate-authentication-agent-1",
"/usr/lib/polkit-mate/polkit-mate-authentication-agent-1",
"/usr/lib/x86_64-linux-gnu/mate-polkit/polkit-mate-authentication-agent-1",
}
for _, path := range matePaths {
if _, err := os.Stat(path); err == nil {
cd.log(fmt.Sprintf("Found mate-polkit agent at: %s", path))
return path, nil
}
}
// Fallback to other polkit agents if mate-polkit is not found
fallbackPaths := []string{
"/usr/lib/polkit-gnome/polkit-gnome-authentication-agent-1",
"/usr/libexec/polkit-gnome-authentication-agent-1",
}
for _, path := range fallbackPaths {
if _, err := os.Stat(path); err == nil {
cd.log(fmt.Sprintf("Found fallback polkit agent at: %s", path))
return path, nil
}
}
return "", fmt.Errorf("no polkit agent found in common locations")
}
// mergeNiriOutputSections extracts output sections from existing config and merges them into the new config
func (cd *ConfigDeployer) mergeNiriOutputSections(newConfig, existingConfig string) (string, error) {
// Regular expression to match output sections (including commented ones)
outputRegex := regexp.MustCompile(`(?m)^(/-)?\s*output\s+"[^"]+"\s*\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}`)
// Find all output sections in the existing config
existingOutputs := outputRegex.FindAllString(existingConfig, -1)
if len(existingOutputs) == 0 {
// No output sections to merge
return newConfig, nil
}
// Remove the example output section from the new config
exampleOutputRegex := regexp.MustCompile(`(?m)^/-output "eDP-2" \{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}`)
mergedConfig := exampleOutputRegex.ReplaceAllString(newConfig, "")
// Find where to insert the output sections (after the input section)
inputEndRegex := regexp.MustCompile(`(?m)^}$`)
inputMatches := inputEndRegex.FindAllStringIndex(newConfig, -1)
if len(inputMatches) < 1 {
return "", fmt.Errorf("could not find insertion point for output sections")
}
// Insert after the first closing brace (end of input section)
insertPos := inputMatches[0][1]
var builder strings.Builder
builder.WriteString(mergedConfig[:insertPos])
builder.WriteString("\n// Outputs from existing configuration\n")
for _, output := range existingOutputs {
builder.WriteString(output)
builder.WriteString("\n")
}
builder.WriteString(mergedConfig[insertPos:])
return builder.String(), nil
}
// deployHyprlandConfig handles Hyprland configuration deployment with backup and merging
func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal) (DeploymentResult, error) {
result := DeploymentResult{
ConfigType: "Hyprland",
Path: filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.conf"),
}
configDir := filepath.Dir(result.Path)
if err := os.MkdirAll(configDir, 0755); err != nil {
result.Error = fmt.Errorf("failed to create config directory: %w", err)
return result, result.Error
}
var existingConfig string
if _, err := os.Stat(result.Path); err == nil {
cd.log("Found existing Hyprland configuration")
existingData, err := os.ReadFile(result.Path)
if err != nil {
result.Error = fmt.Errorf("failed to read existing config: %w", err)
return result, result.Error
}
existingConfig = string(existingData)
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 {
result.Error = fmt.Errorf("failed to create backup: %w", err)
return result, result.Error
}
cd.log(fmt.Sprintf("Backed up existing config to %s", result.BackupPath))
}
// Detect polkit agent path
polkitPath, err := cd.detectPolkitAgent()
if err != nil {
cd.log(fmt.Sprintf("Warning: Could not detect polkit agent: %v", err))
polkitPath = "/usr/lib/mate-polkit/polkit-mate-authentication-agent-1" // fallback
}
// Determine terminal command based on choice
var terminalCommand string
switch terminal {
case deps.TerminalGhostty:
terminalCommand = "ghostty"
case deps.TerminalKitty:
terminalCommand = "kitty"
case deps.TerminalAlacritty:
terminalCommand = "alacritty"
default:
terminalCommand = "ghostty" // fallback to ghostty
}
newConfig := strings.ReplaceAll(HyprlandConfig, "{{POLKIT_AGENT_PATH}}", polkitPath)
newConfig = strings.ReplaceAll(newConfig, "{{TERMINAL_COMMAND}}", terminalCommand)
// If there was an existing config, merge the monitor sections
if existingConfig != "" {
mergedConfig, err := cd.mergeHyprlandMonitorSections(newConfig, existingConfig)
if err != nil {
cd.log(fmt.Sprintf("Warning: Failed to merge monitor sections: %v", err))
} else {
newConfig = mergedConfig
cd.log("Successfully merged existing monitor sections")
}
}
if err := os.WriteFile(result.Path, []byte(newConfig), 0644); err != nil {
result.Error = fmt.Errorf("failed to write config: %w", err)
return result, result.Error
}
result.Deployed = true
cd.log("Successfully deployed Hyprland configuration")
return result, nil
}
// mergeHyprlandMonitorSections extracts monitor sections from existing config and merges them into the new config
func (cd *ConfigDeployer) mergeHyprlandMonitorSections(newConfig, existingConfig string) (string, error) {
// Regular expression to match monitor lines (including commented ones)
// Matches: monitor = NAME, RESOLUTION, POSITION, SCALE, etc.
// Also matches commented versions: # monitor = ...
monitorRegex := regexp.MustCompile(`(?m)^#?\s*monitor\s*=.*$`)
// Find all monitor lines in the existing config
existingMonitors := monitorRegex.FindAllString(existingConfig, -1)
if len(existingMonitors) == 0 {
// No monitor sections to merge
return newConfig, nil
}
// Remove the example monitor line from the new config
exampleMonitorRegex := regexp.MustCompile(`(?m)^# monitor = eDP-2.*$`)
mergedConfig := exampleMonitorRegex.ReplaceAllString(newConfig, "")
// Find where to insert the monitor sections (after the MONITOR CONFIG header)
monitorHeaderRegex := regexp.MustCompile(`(?m)^# MONITOR CONFIG\n# ==================$`)
headerMatch := monitorHeaderRegex.FindStringIndex(mergedConfig)
if headerMatch == nil {
return "", fmt.Errorf("could not find MONITOR CONFIG section")
}
// Insert after the header
insertPos := headerMatch[1] + 1 // +1 for the newline
var builder strings.Builder
builder.WriteString(mergedConfig[:insertPos])
builder.WriteString("# Monitors from existing configuration\n")
for _, monitor := range existingMonitors {
builder.WriteString(monitor)
builder.WriteString("\n")
}
builder.WriteString(mergedConfig[insertPos:])
return builder.String(), nil
}

View File

@@ -0,0 +1,660 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDetectPolkitAgent(t *testing.T) {
cd := &ConfigDeployer{}
// This test depends on the system having a polkit agent installed
// We'll just test that the function doesn't crash and returns some path or error
path, err := cd.detectPolkitAgent()
if err != nil {
// If no polkit agent is found, that's okay for testing
assert.Contains(t, err.Error(), "no polkit agent found")
} else {
// If found, it should be a valid path
assert.NotEmpty(t, path)
assert.True(t, strings.Contains(path, "polkit"))
}
}
func TestMergeNiriOutputSections(t *testing.T) {
cd := &ConfigDeployer{}
tests := []struct {
name string
newConfig string
existingConfig string
wantError bool
wantContains []string
}{
{
name: "no existing outputs",
newConfig: `input {
keyboard {
xkb {
}
}
}
layout {
gaps 5
}`,
existingConfig: `input {
keyboard {
xkb {
}
}
}
layout {
gaps 10
}`,
wantError: false,
wantContains: []string{"gaps 5"}, // Should keep new config
},
{
name: "merge single output",
newConfig: `input {
keyboard {
xkb {
}
}
}
/-output "eDP-2" {
mode "2560x1600@239.998993"
position x=2560 y=0
}
layout {
gaps 5
}`,
existingConfig: `input {
keyboard {
xkb {
}
}
}
output "eDP-1" {
mode "1920x1080@60.000000"
position x=0 y=0
scale 1.0
}
layout {
gaps 10
}`,
wantError: false,
wantContains: []string{
"gaps 5", // New config preserved
`output "eDP-1"`, // Existing output merged
"1920x1080@60.000000", // Existing output details
"Outputs from existing configuration", // Comment added
},
},
{
name: "merge multiple outputs",
newConfig: `input {
keyboard {
xkb {
}
}
}
/-output "eDP-2" {
mode "2560x1600@239.998993"
position x=2560 y=0
}
layout {
gaps 5
}`,
existingConfig: `input {
keyboard {
xkb {
}
}
}
output "eDP-1" {
mode "1920x1080@60.000000"
position x=0 y=0
scale 1.0
}
/-output "HDMI-1" {
mode "1920x1080@60.000000"
position x=1920 y=0
}
layout {
gaps 10
}`,
wantError: false,
wantContains: []string{
"gaps 5", // New config preserved
`output "eDP-1"`, // First existing output
`/-output "HDMI-1"`, // Second existing output (commented)
"1920x1080@60.000000", // Output details
},
},
{
name: "merge commented outputs",
newConfig: `input {
keyboard {
xkb {
}
}
}
/-output "eDP-2" {
mode "2560x1600@239.998993"
position x=2560 y=0
}
layout {
gaps 5
}`,
existingConfig: `input {
keyboard {
xkb {
}
}
}
/-output "eDP-1" {
mode "1920x1080@60.000000"
position x=0 y=0
scale 1.0
}
layout {
gaps 10
}`,
wantError: false,
wantContains: []string{
"gaps 5", // New config preserved
`/-output "eDP-1"`, // Commented output preserved
"1920x1080@60.000000", // Output details
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := cd.mergeNiriOutputSections(tt.newConfig, tt.existingConfig)
if tt.wantError {
assert.Error(t, err)
return
}
require.NoError(t, err)
for _, want := range tt.wantContains {
assert.Contains(t, result, want, "merged config should contain: %s", want)
}
assert.NotContains(t, result, `/-output "eDP-2"`, "example output should be removed")
})
}
}
func TestConfigDeploymentFlow(t *testing.T) {
tempDir, err := os.MkdirTemp("", "dankinstall-test")
require.NoError(t, err)
defer os.RemoveAll(tempDir)
originalHome := os.Getenv("HOME")
os.Setenv("HOME", tempDir)
defer os.Setenv("HOME", originalHome)
logChan := make(chan string, 100)
cd := NewConfigDeployer(logChan)
t.Run("deploy ghostty config to empty directory", func(t *testing.T) {
results, err := cd.deployGhosttyConfig()
require.NoError(t, err)
require.Len(t, results, 2)
mainResult := results[0]
assert.Equal(t, "Ghostty", mainResult.ConfigType)
assert.True(t, mainResult.Deployed)
assert.Empty(t, mainResult.BackupPath)
assert.FileExists(t, mainResult.Path)
content, err := os.ReadFile(mainResult.Path)
require.NoError(t, err)
assert.Contains(t, string(content), "window-decoration = false")
colorResult := results[1]
assert.Equal(t, "Ghostty Colors", colorResult.ConfigType)
assert.True(t, colorResult.Deployed)
assert.FileExists(t, colorResult.Path)
colorContent, err := os.ReadFile(colorResult.Path)
require.NoError(t, err)
assert.Contains(t, string(colorContent), "background = #101418")
})
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)
require.NoError(t, err)
err = os.WriteFile(ghosttyPath, []byte(existingContent), 0644)
require.NoError(t, err)
results, err := cd.deployGhosttyConfig()
require.NoError(t, err)
require.Len(t, results, 2)
mainResult := results[0]
assert.Equal(t, "Ghostty", mainResult.ConfigType)
assert.True(t, mainResult.Deployed)
assert.NotEmpty(t, mainResult.BackupPath)
assert.FileExists(t, mainResult.Path)
assert.FileExists(t, mainResult.BackupPath)
backupContent, err := os.ReadFile(mainResult.BackupPath)
require.NoError(t, err)
assert.Equal(t, existingContent, string(backupContent))
newContent, err := os.ReadFile(mainResult.Path)
require.NoError(t, err)
assert.NotContains(t, string(newContent), "# Old config")
colorResult := results[1]
assert.Equal(t, "Ghostty Colors", colorResult.ConfigType)
assert.True(t, colorResult.Deployed)
assert.FileExists(t, colorResult.Path)
})
}
func getGhosttyPath() string {
return filepath.Join(os.Getenv("HOME"), ".config", "ghostty", "config")
}
func TestPolkitPathInjection(t *testing.T) {
testConfig := `spawn-at-startup "{{POLKIT_AGENT_PATH}}"
other content`
result := strings.Replace(testConfig, "{{POLKIT_AGENT_PATH}}", "/test/polkit/path", 1)
assert.Contains(t, result, `spawn-at-startup "/test/polkit/path"`)
assert.NotContains(t, result, "{{POLKIT_AGENT_PATH}}")
}
func TestMergeHyprlandMonitorSections(t *testing.T) {
cd := &ConfigDeployer{}
tests := []struct {
name string
newConfig string
existingConfig string
wantError bool
wantContains []string
wantNotContains []string
}{
{
name: "no existing monitors",
newConfig: `# ==================
# MONITOR CONFIG
# ==================
# monitor = eDP-2, 2560x1600@239.998993, 2560x0, 1, vrr, 1
# ==================
# ENVIRONMENT VARS
# ==================
env = XDG_CURRENT_DESKTOP,niri`,
existingConfig: `# Some other config
input {
kb_layout = us
}`,
wantError: false,
wantContains: []string{"MONITOR CONFIG", "ENVIRONMENT VARS"},
},
{
name: "merge single monitor",
newConfig: `# ==================
# MONITOR CONFIG
# ==================
# monitor = eDP-2, 2560x1600@239.998993, 2560x0, 1, vrr, 1
# ==================
# ENVIRONMENT VARS
# ==================`,
existingConfig: `# My config
monitor = DP-1, 1920x1080@144, 0x0, 1
input {
kb_layout = us
}`,
wantError: false,
wantContains: []string{
"MONITOR CONFIG",
"monitor = DP-1, 1920x1080@144, 0x0, 1",
"Monitors from existing configuration",
},
wantNotContains: []string{
"monitor = eDP-2", // Example monitor should be removed
},
},
{
name: "merge multiple monitors",
newConfig: `# ==================
# MONITOR CONFIG
# ==================
# monitor = eDP-2, 2560x1600@239.998993, 2560x0, 1, vrr, 1
# ==================
# ENVIRONMENT VARS
# ==================`,
existingConfig: `monitor = DP-1, 1920x1080@144, 0x0, 1
# monitor = HDMI-A-1, 1920x1080@60, 1920x0, 1
monitor = eDP-1, 2560x1440@165, auto, 1.25`,
wantError: false,
wantContains: []string{
"monitor = DP-1",
"# monitor = HDMI-A-1", // Commented monitor preserved
"monitor = eDP-1",
"Monitors from existing configuration",
},
wantNotContains: []string{
"monitor = eDP-2", // Example monitor should be removed
},
},
{
name: "preserve commented monitors",
newConfig: `# ==================
# MONITOR CONFIG
# ==================
# monitor = eDP-2, 2560x1600@239.998993, 2560x0, 1, vrr, 1
# ==================`,
existingConfig: `# monitor = DP-1, 1920x1080@144, 0x0, 1
# monitor = HDMI-A-1, 1920x1080@60, 1920x0, 1`,
wantError: false,
wantContains: []string{
"# monitor = DP-1",
"# monitor = HDMI-A-1",
"Monitors from existing configuration",
},
},
{
name: "no monitor config section",
newConfig: `# Some config without monitor section
input {
kb_layout = us
}`,
existingConfig: `monitor = DP-1, 1920x1080@144, 0x0, 1`,
wantError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := cd.mergeHyprlandMonitorSections(tt.newConfig, tt.existingConfig)
if tt.wantError {
assert.Error(t, err)
return
}
require.NoError(t, err)
for _, want := range tt.wantContains {
assert.Contains(t, result, want, "merged config should contain: %s", want)
}
for _, notWant := range tt.wantNotContains {
assert.NotContains(t, result, notWant, "merged config should NOT contain: %s", notWant)
}
})
}
}
func TestHyprlandConfigDeployment(t *testing.T) {
tempDir, err := os.MkdirTemp("", "dankinstall-hyprland-test")
require.NoError(t, err)
defer os.RemoveAll(tempDir)
originalHome := os.Getenv("HOME")
os.Setenv("HOME", tempDir)
defer os.Setenv("HOME", originalHome)
logChan := make(chan string, 100)
cd := NewConfigDeployer(logChan)
t.Run("deploy hyprland config to empty directory", func(t *testing.T) {
result, err := cd.deployHyprlandConfig(deps.TerminalGhostty)
require.NoError(t, err)
assert.Equal(t, "Hyprland", result.ConfigType)
assert.True(t, result.Deployed)
assert.Empty(t, result.BackupPath)
assert.FileExists(t, result.Path)
content, err := os.ReadFile(result.Path)
require.NoError(t, err)
assert.Contains(t, string(content), "# MONITOR CONFIG")
assert.Contains(t, string(content), "bind = $mod, T, exec, ghostty")
assert.Contains(t, string(content), "exec-once = ")
})
t.Run("deploy hyprland config with existing monitors", func(t *testing.T) {
existingContent := `# My existing Hyprland config
monitor = DP-1, 1920x1080@144, 0x0, 1
monitor = HDMI-A-1, 3840x2160@60, 1920x0, 1.5
general {
gaps_in = 10
}
`
hyprPath := filepath.Join(tempDir, ".config", "hypr", "hyprland.conf")
err := os.MkdirAll(filepath.Dir(hyprPath), 0755)
require.NoError(t, err)
err = os.WriteFile(hyprPath, []byte(existingContent), 0644)
require.NoError(t, err)
result, err := cd.deployHyprlandConfig(deps.TerminalKitty)
require.NoError(t, err)
assert.Equal(t, "Hyprland", result.ConfigType)
assert.True(t, result.Deployed)
assert.NotEmpty(t, result.BackupPath)
assert.FileExists(t, result.Path)
assert.FileExists(t, result.BackupPath)
backupContent, err := os.ReadFile(result.BackupPath)
require.NoError(t, err)
assert.Equal(t, existingContent, string(backupContent))
newContent, err := os.ReadFile(result.Path)
require.NoError(t, err)
assert.Contains(t, string(newContent), "monitor = DP-1, 1920x1080@144")
assert.Contains(t, string(newContent), "monitor = HDMI-A-1, 3840x2160@60")
assert.Contains(t, string(newContent), "bind = $mod, T, exec, kitty")
assert.NotContains(t, string(newContent), "monitor = eDP-2")
})
}
func TestNiriConfigStructure(t *testing.T) {
assert.Contains(t, NiriConfig, "input {")
assert.Contains(t, NiriConfig, "layout {")
assert.Contains(t, NiriConfig, "binds {")
assert.Contains(t, NiriConfig, "{{POLKIT_AGENT_PATH}}")
assert.Contains(t, NiriConfig, `spawn "{{TERMINAL_COMMAND}}"`)
}
func TestHyprlandConfigStructure(t *testing.T) {
assert.Contains(t, HyprlandConfig, "# MONITOR CONFIG")
assert.Contains(t, HyprlandConfig, "# ENVIRONMENT VARS")
assert.Contains(t, HyprlandConfig, "# STARTUP APPS")
assert.Contains(t, HyprlandConfig, "# INPUT CONFIG")
assert.Contains(t, HyprlandConfig, "# KEYBINDINGS")
assert.Contains(t, HyprlandConfig, "{{POLKIT_AGENT_PATH}}")
assert.Contains(t, HyprlandConfig, "{{TERMINAL_COMMAND}}")
assert.Contains(t, HyprlandConfig, "exec-once = dms run")
assert.Contains(t, HyprlandConfig, "bind = $mod, T, exec,")
assert.Contains(t, HyprlandConfig, "bind = $mod, space, exec, dms ipc call spotlight toggle")
assert.Contains(t, HyprlandConfig, "windowrulev2 = noborder, class:^(com\\.mitchellh\\.ghostty)$")
}
func TestGhosttyConfigStructure(t *testing.T) {
assert.Contains(t, GhosttyConfig, "window-decoration = false")
assert.Contains(t, GhosttyConfig, "background-opacity = 1.0")
assert.Contains(t, GhosttyConfig, "config-file = ./config-dankcolors")
}
func TestGhosttyColorConfigStructure(t *testing.T) {
assert.Contains(t, GhosttyColorConfig, "background = #101418")
assert.Contains(t, GhosttyColorConfig, "foreground = #e0e2e8")
assert.Contains(t, GhosttyColorConfig, "cursor-color = #9dcbfb")
assert.Contains(t, GhosttyColorConfig, "palette = 0=#101418")
assert.Contains(t, GhosttyColorConfig, "palette = 15=#ffffff")
}
func TestKittyConfigStructure(t *testing.T) {
assert.Contains(t, KittyConfig, "font_size 12.0")
assert.Contains(t, KittyConfig, "window_padding_width 12")
assert.Contains(t, KittyConfig, "background_opacity 1.0")
assert.Contains(t, KittyConfig, "include dank-tabs.conf")
assert.Contains(t, KittyConfig, "include dank-theme.conf")
}
func TestKittyThemeConfigStructure(t *testing.T) {
assert.Contains(t, KittyThemeConfig, "foreground #e0e2e8")
assert.Contains(t, KittyThemeConfig, "background #101418")
assert.Contains(t, KittyThemeConfig, "cursor #e0e2e8")
assert.Contains(t, KittyThemeConfig, "color0 #101418")
assert.Contains(t, KittyThemeConfig, "color15 #ffffff")
}
func TestKittyTabsConfigStructure(t *testing.T) {
assert.Contains(t, KittyTabsConfig, "tab_bar_style powerline")
assert.Contains(t, KittyTabsConfig, "tab_powerline_style slanted")
assert.Contains(t, KittyTabsConfig, "active_tab_background #124a73")
assert.Contains(t, KittyTabsConfig, "inactive_tab_background #101418")
}
func TestAlacrittyConfigStructure(t *testing.T) {
assert.Contains(t, AlacrittyConfig, "[general]")
assert.Contains(t, AlacrittyConfig, "~/.config/alacritty/dank-theme.toml")
assert.Contains(t, AlacrittyConfig, "[window]")
assert.Contains(t, AlacrittyConfig, "decorations = \"None\"")
assert.Contains(t, AlacrittyConfig, "padding = { x = 12, y = 12 }")
assert.Contains(t, AlacrittyConfig, "[cursor]")
assert.Contains(t, AlacrittyConfig, "[keyboard]")
}
func TestAlacrittyThemeConfigStructure(t *testing.T) {
assert.Contains(t, AlacrittyThemeConfig, "[colors.primary]")
assert.Contains(t, AlacrittyThemeConfig, "background = '#101418'")
assert.Contains(t, AlacrittyThemeConfig, "foreground = '#e0e2e8'")
assert.Contains(t, AlacrittyThemeConfig, "[colors.cursor]")
assert.Contains(t, AlacrittyThemeConfig, "cursor = '#9dcbfb'")
assert.Contains(t, AlacrittyThemeConfig, "[colors.normal]")
assert.Contains(t, AlacrittyThemeConfig, "[colors.bright]")
}
func TestKittyConfigDeployment(t *testing.T) {
tempDir, err := os.MkdirTemp("", "dankinstall-kitty-test")
require.NoError(t, err)
defer os.RemoveAll(tempDir)
originalHome := os.Getenv("HOME")
os.Setenv("HOME", tempDir)
defer os.Setenv("HOME", originalHome)
logChan := make(chan string, 100)
cd := NewConfigDeployer(logChan)
t.Run("deploy kitty config to empty directory", func(t *testing.T) {
results, err := cd.deployKittyConfig()
require.NoError(t, err)
require.Len(t, results, 3)
mainResult := results[0]
assert.Equal(t, "Kitty", mainResult.ConfigType)
assert.True(t, mainResult.Deployed)
assert.FileExists(t, mainResult.Path)
content, err := os.ReadFile(mainResult.Path)
require.NoError(t, err)
assert.Contains(t, string(content), "include dank-theme.conf")
themeResult := results[1]
assert.Equal(t, "Kitty Theme", themeResult.ConfigType)
assert.True(t, themeResult.Deployed)
assert.FileExists(t, themeResult.Path)
tabsResult := results[2]
assert.Equal(t, "Kitty Tabs", tabsResult.ConfigType)
assert.True(t, tabsResult.Deployed)
assert.FileExists(t, tabsResult.Path)
})
}
func TestAlacrittyConfigDeployment(t *testing.T) {
tempDir, err := os.MkdirTemp("", "dankinstall-alacritty-test")
require.NoError(t, err)
defer os.RemoveAll(tempDir)
originalHome := os.Getenv("HOME")
os.Setenv("HOME", tempDir)
defer os.Setenv("HOME", originalHome)
logChan := make(chan string, 100)
cd := NewConfigDeployer(logChan)
t.Run("deploy alacritty config to empty directory", func(t *testing.T) {
results, err := cd.deployAlacrittyConfig()
require.NoError(t, err)
require.Len(t, results, 2)
mainResult := results[0]
assert.Equal(t, "Alacritty", mainResult.ConfigType)
assert.True(t, mainResult.Deployed)
assert.FileExists(t, mainResult.Path)
content, err := os.ReadFile(mainResult.Path)
require.NoError(t, err)
assert.Contains(t, string(content), "~/.config/alacritty/dank-theme.toml")
assert.Contains(t, string(content), "[window]")
themeResult := results[1]
assert.Equal(t, "Alacritty Theme", themeResult.ConfigType)
assert.True(t, themeResult.Deployed)
assert.FileExists(t, themeResult.Path)
themeContent, err := os.ReadFile(themeResult.Path)
require.NoError(t, err)
assert.Contains(t, string(themeContent), "[colors.primary]")
assert.Contains(t, string(themeContent), "background = '#101418'")
})
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)
require.NoError(t, err)
err = os.WriteFile(alacrittyPath, []byte(existingContent), 0644)
require.NoError(t, err)
results, err := cd.deployAlacrittyConfig()
require.NoError(t, err)
require.Len(t, results, 2)
mainResult := results[0]
assert.True(t, mainResult.Deployed)
assert.NotEmpty(t, mainResult.BackupPath)
assert.FileExists(t, mainResult.BackupPath)
backupContent, err := os.ReadFile(mainResult.BackupPath)
require.NoError(t, err)
assert.Equal(t, existingContent, string(backupContent))
newContent, err := os.ReadFile(mainResult.Path)
require.NoError(t, err)
assert.NotContains(t, string(newContent), "# Old alacritty config")
assert.Contains(t, string(newContent), "decorations = \"None\"")
})
}

View File

@@ -0,0 +1,53 @@
package config
import (
"fmt"
"os"
"path/filepath"
"strings"
)
// LocateDMSConfig searches for DMS installation following XDG Base Directory specification
func LocateDMSConfig() (string, error) {
var primaryPaths []string
configHome := os.Getenv("XDG_CONFIG_HOME")
if configHome == "" {
if homeDir, err := os.UserHomeDir(); err == nil {
configHome = filepath.Join(homeDir, ".config")
}
}
if configHome != "" {
primaryPaths = append(primaryPaths, filepath.Join(configHome, "quickshell", "dms"))
}
primaryPaths = append(primaryPaths, "/usr/share/quickshell/dms")
configDirs := os.Getenv("XDG_CONFIG_DIRS")
if configDirs == "" {
configDirs = "/etc/xdg"
}
for _, dir := range strings.Split(configDirs, ":") {
if dir != "" {
primaryPaths = append(primaryPaths, filepath.Join(dir, "quickshell", "dms"))
}
}
// Build search paths with secondary (monorepo) paths interleaved
var searchPaths []string
for _, path := range primaryPaths {
searchPaths = append(searchPaths, path)
searchPaths = append(searchPaths, filepath.Join(path, "quickshell"))
}
for _, path := range searchPaths {
shellPath := filepath.Join(path, "shell.qml")
if info, err := os.Stat(shellPath); err == nil && !info.IsDir() {
return path, nil
}
}
return "", fmt.Errorf("could not find DMS config (shell.qml) in any valid config path")
}

View File

@@ -0,0 +1,31 @@
[colors.primary]
background = '#101418'
foreground = '#e0e2e8'
[colors.selection]
text = '#e0e2e8'
background = '#124a73'
[colors.cursor]
text = '#101418'
cursor = '#9dcbfb'
[colors.normal]
black = '#101418'
red = '#d75a59'
green = '#8ed88c'
yellow = '#e0d99d'
blue = '#4087bc'
magenta = '#839fbc'
cyan = '#9dcbfb'
white = '#abb2bf'
[colors.bright]
black = '#5c6370'
red = '#e57e7e'
green = '#a2e5a0'
yellow = '#efe9b3'
blue = '#a7d9ff'
magenta = '#3d8197'
cyan = '#5c7ba3'
white = '#ffffff'

View File

@@ -0,0 +1,37 @@
[general]
import = [
"~/.config/alacritty/dank-theme.toml"
]
[window]
decorations = "None"
padding = { x = 12, y = 12 }
opacity = 1.0
[scrolling]
history = 3023
[cursor]
style = { shape = "Block", blinking = "On" }
blink_interval = 500
unfocused_hollow = true
[mouse]
hide_when_typing = true
[selection]
save_to_clipboard = false
[bell]
duration = 0
[keyboard]
bindings = [
{ key = "C", mods = "Control|Shift", action = "Copy" },
{ key = "V", mods = "Control|Shift", action = "Paste" },
{ key = "N", mods = "Control|Shift", action = "SpawnNewInstance" },
{ key = "Equals", mods = "Control|Shift", action = "IncreaseFontSize" },
{ key = "Minus", mods = "Control", action = "DecreaseFontSize" },
{ key = "Key0", mods = "Control", action = "ResetFontSize" },
{ key = "Enter", mods = "Shift", chars = "\n" },
]

View File

@@ -0,0 +1,21 @@
background = #101418
foreground = #e0e2e8
cursor-color = #9dcbfb
selection-background = #124a73
selection-foreground = #e0e2e8
palette = 0=#101418
palette = 1=#d75a59
palette = 2=#8ed88c
palette = 3=#e0d99d
palette = 4=#4087bc
palette = 5=#839fbc
palette = 6=#9dcbfb
palette = 7=#abb2bf
palette = 8=#5c6370
palette = 9=#e57e7e
palette = 10=#a2e5a0
palette = 11=#efe9b3
palette = 12=#a7d9ff
palette = 13=#3d8197
palette = 14=#5c7ba3
palette = 15=#ffffff

View File

@@ -0,0 +1,51 @@
# Font Configuration
font-size = 12
# Window Configuration
window-decoration = false
window-padding-x = 12
window-padding-y = 12
background-opacity = 1.0
background-blur-radius = 32
# Cursor Configuration
cursor-style = block
cursor-style-blink = true
# Scrollback
scrollback-limit = 3023
# Terminal features
mouse-hide-while-typing = true
copy-on-select = false
confirm-close-surface = false
# Disable annoying copied to clipboard
app-notifications = no-clipboard-copy,no-config-reload
# Key bindings for common actions
#keybind = ctrl+c=copy_to_clipboard
#keybind = ctrl+v=paste_from_clipboard
keybind = ctrl+shift+n=new_window
keybind = ctrl+t=new_tab
keybind = ctrl+plus=increase_font_size:1
keybind = ctrl+minus=decrease_font_size:1
keybind = ctrl+zero=reset_font_size
# Material 3 UI elements
unfocused-split-opacity = 0.7
unfocused-split-fill = #44464f
# Tab configuration
gtk-titlebar = false
# Shell integration
shell-integration = detect
shell-integration-features = cursor,sudo,title,no-cursor
keybind = shift+enter=text:\n
# Rando stuff
gtk-single-instance = true
# Dank color generation
config-file = ./config-dankcolors

View File

@@ -0,0 +1,290 @@
# Hyprland Configuration
# https://wiki.hypr.land/Configuring/
# ==================
# MONITOR CONFIG
# ==================
# monitor = eDP-2, 2560x1600@239.998993, 2560x0, 1, vrr, 1
monitor = , preferred,auto,auto
# ==================
# ENVIRONMENT VARS
# ==================
env = QT_QPA_PLATFORM,wayland
env = ELECTRON_OZONE_PLATFORM_HINT,auto
env = QT_QPA_PLATFORMTHEME,gtk3
env = QT_QPA_PLATFORMTHEME_QT6,gtk3
env = TERMINAL,{{TERMINAL_COMMAND}}
# ==================
# STARTUP APPS
# ==================
exec-once = bash -c "wl-paste --watch cliphist store &"
exec-once = dms run
exec-once = {{POLKIT_AGENT_PATH}}
# ==================
# INPUT CONFIG
# ==================
input {
kb_layout = us
numlock_by_default = true
}
# ==================
# GENERAL LAYOUT
# ==================
general {
gaps_in = 5
gaps_out = 5
border_size = 0 # off in niri
col.active_border = rgba(707070ff)
col.inactive_border = rgba(d0d0d0ff)
layout = dwindle
}
# ==================
# DECORATION
# ==================
decoration {
rounding = 12
active_opacity = 1.0
inactive_opacity = 0.9
shadow {
enabled = true
range = 30
render_power = 5
offset = 0 5
color = rgba(00000070)
}
}
# ==================
# ANIMATIONS
# ==================
animations {
enabled = true
animation = windowsIn, 1, 3, default
animation = windowsOut, 1, 3, default
animation = workspaces, 1, 5, default
animation = windowsMove, 1, 4, default
animation = fade, 1, 3, default
animation = border, 1, 3, default
}
# ==================
# LAYOUTS
# ==================
dwindle {
preserve_split = true
}
master {
mfact = 0.5
}
# ==================
# MISC
# ==================
misc {
disable_hyprland_logo = true
disable_splash_rendering = true
vrr = 1
}
# ==================
# WINDOW RULES
# ==================
windowrulev2 = tile, class:^(org\.wezfurlong\.wezterm)$
windowrulev2 = rounding 12, class:^(org\.gnome\.)
windowrulev2 = noborder, class:^(org\.gnome\.)
windowrulev2 = tile, class:^(gnome-control-center)$
windowrulev2 = tile, class:^(pavucontrol)$
windowrulev2 = tile, class:^(nm-connection-editor)$
windowrulev2 = float, class:^(gnome-calculator)$
windowrulev2 = float, class:^(galculator)$
windowrulev2 = float, class:^(blueman-manager)$
windowrulev2 = float, class:^(org\.gnome\.Nautilus)$
windowrulev2 = float, class:^(steam)$
windowrulev2 = float, class:^(xdg-desktop-portal)$
windowrulev2 = noborder, class:^(org\.wezfurlong\.wezterm)$
windowrulev2 = noborder, class:^(Alacritty)$
windowrulev2 = noborder, class:^(zen)$
windowrulev2 = noborder, class:^(com\.mitchellh\.ghostty)$
windowrulev2 = noborder, class:^(kitty)$
windowrulev2 = float, class:^(firefox)$, title:^(Picture-in-Picture)$
windowrulev2 = float, class:^(zoom)$
windowrulev2 = opacity 0.9 0.9, floating:0, focus:0
layerrule = noanim, ^(quickshell)$
# ==================
# KEYBINDINGS
# ==================
$mod = SUPER
# === Application Launchers ===
bind = $mod, T, exec, {{TERMINAL_COMMAND}}
bind = $mod, space, exec, dms ipc call spotlight toggle
bind = $mod, V, exec, dms ipc call clipboard toggle
bind = $mod, M, exec, dms ipc call processlist toggle
bind = $mod, comma, exec, dms ipc call settings toggle
bind = $mod, N, exec, dms ipc call notifications toggle
bind = $mod SHIFT, N, exec, dms ipc call notepad toggle
bind = $mod, Y, exec, dms ipc call dankdash wallpaper
bind = $mod, TAB, exec, dms ipc call hypr toggleOverview
# === Cheat sheet
bind = $mod SHIFT, Slash, exec, dms ipc call keybinds toggle hyprland
# === Security ===
bind = $mod ALT, L, exec, dms ipc call lock lock
bind = $mod SHIFT, E, exit
bind = CTRL ALT, Delete, exec, dms ipc call processlist toggle
# === Audio Controls ===
bindel = , XF86AudioRaiseVolume, exec, dms ipc call audio increment 3
bindel = , XF86AudioLowerVolume, exec, dms ipc call audio decrement 3
bindl = , XF86AudioMute, exec, dms ipc call audio mute
bindl = , XF86AudioMicMute, exec, dms ipc call audio micmute
# === Brightness Controls ===
bindel = , XF86MonBrightnessUp, exec, dms ipc call brightness increment 5 ""
bindel = , XF86MonBrightnessDown, exec, dms ipc call brightness decrement 5 ""
# === Window Management ===
bind = $mod, Q, killactive
bind = $mod, F, fullscreen, 1
bind = $mod SHIFT, F, fullscreen, 0
bind = $mod SHIFT, T, togglefloating
bind = $mod, W, togglegroup
# === Focus Navigation ===
bind = $mod, left, movefocus, l
bind = $mod, down, movefocus, d
bind = $mod, up, movefocus, u
bind = $mod, right, movefocus, r
bind = $mod, H, movefocus, l
bind = $mod, J, movefocus, d
bind = $mod, K, movefocus, u
bind = $mod, L, movefocus, r
# === Window Movement ===
bind = $mod SHIFT, left, movewindow, l
bind = $mod SHIFT, down, movewindow, d
bind = $mod SHIFT, up, movewindow, u
bind = $mod SHIFT, right, movewindow, r
bind = $mod SHIFT, H, movewindow, l
bind = $mod SHIFT, J, movewindow, d
bind = $mod SHIFT, K, movewindow, u
bind = $mod SHIFT, L, movewindow, r
# === Column Navigation ===
bind = $mod, Home, focuswindow, first
bind = $mod, End, focuswindow, last
# === Monitor Navigation ===
bind = $mod CTRL, left, focusmonitor, l
bind = $mod CTRL, right, focusmonitor, r
bind = $mod CTRL, H, focusmonitor, l
bind = $mod CTRL, J, focusmonitor, d
bind = $mod CTRL, K, focusmonitor, u
bind = $mod CTRL, L, focusmonitor, r
# === Move to Monitor ===
bind = $mod SHIFT CTRL, left, movewindow, mon:l
bind = $mod SHIFT CTRL, down, movewindow, mon:d
bind = $mod SHIFT CTRL, up, movewindow, mon:u
bind = $mod SHIFT CTRL, right, movewindow, mon:r
bind = $mod SHIFT CTRL, H, movewindow, mon:l
bind = $mod SHIFT CTRL, J, movewindow, mon:d
bind = $mod SHIFT CTRL, K, movewindow, mon:u
bind = $mod SHIFT CTRL, L, movewindow, mon:r
# === Workspace Navigation ===
bind = $mod, Page_Down, workspace, e+1
bind = $mod, Page_Up, workspace, e-1
bind = $mod, U, workspace, e+1
bind = $mod, I, workspace, e-1
bind = $mod CTRL, down, movetoworkspace, e+1
bind = $mod CTRL, up, movetoworkspace, e-1
bind = $mod CTRL, U, movetoworkspace, e+1
bind = $mod CTRL, I, movetoworkspace, e-1
# === Move Workspaces ===
bind = $mod SHIFT, Page_Down, movetoworkspace, e+1
bind = $mod SHIFT, Page_Up, movetoworkspace, e-1
bind = $mod SHIFT, U, movetoworkspace, e+1
bind = $mod SHIFT, I, movetoworkspace, e-1
# === Mouse Wheel Navigation ===
bind = $mod, mouse_down, workspace, e+1
bind = $mod, mouse_up, workspace, e-1
bind = $mod CTRL, mouse_down, movetoworkspace, e+1
bind = $mod CTRL, mouse_up, movetoworkspace, e-1
# === Numbered Workspaces ===
bind = $mod, 1, workspace, 1
bind = $mod, 2, workspace, 2
bind = $mod, 3, workspace, 3
bind = $mod, 4, workspace, 4
bind = $mod, 5, workspace, 5
bind = $mod, 6, workspace, 6
bind = $mod, 7, workspace, 7
bind = $mod, 8, workspace, 8
bind = $mod, 9, workspace, 9
# === Move to Numbered Workspaces ===
bind = $mod SHIFT, 1, movetoworkspace, 1
bind = $mod SHIFT, 2, movetoworkspace, 2
bind = $mod SHIFT, 3, movetoworkspace, 3
bind = $mod SHIFT, 4, movetoworkspace, 4
bind = $mod SHIFT, 5, movetoworkspace, 5
bind = $mod SHIFT, 6, movetoworkspace, 6
bind = $mod SHIFT, 7, movetoworkspace, 7
bind = $mod SHIFT, 8, movetoworkspace, 8
bind = $mod SHIFT, 9, movetoworkspace, 9
# === Column Management ===
bind = $mod, bracketleft, layoutmsg, preselect l
bind = $mod, bracketright, layoutmsg, preselect r
# === Sizing & Layout ===
bind = $mod, R, layoutmsg, togglesplit
bind = $mod CTRL, F, resizeactive, exact 100%
# === Move/resize windows with mainMod + LMB/RMB and dragging ===
bindmd = $mod, mouse:272, Move window, movewindow
bindmd = $mod, mouse:273, Resize window, resizewindow
# === Move/resize windows with mainMod + LMB/RMB and dragging ===
bindd = $mod, code:20, Expand window left, resizeactive, -100 0
bindd = $mod, code:21, Shrink window left, resizeactive, 100 0
# === Manual Sizing ===
binde = $mod, minus, resizeactive, -10% 0
binde = $mod, equal, resizeactive, 10% 0
binde = $mod SHIFT, minus, resizeactive, 0 -10%
binde = $mod SHIFT, equal, resizeactive, 0 10%
# === Screenshots ===
bind = , XF86Launch1, exec, grimblast copy area
bind = CTRL, XF86Launch1, exec, grimblast copy screen
bind = ALT, XF86Launch1, exec, grimblast copy active
bind = , Print, exec, grimblast copy area
bind = CTRL, Print, exec, grimblast copy screen
bind = ALT, Print, exec, grimblast copy active
# === System Controls ===
bind = $mod SHIFT, P, dpms, off

View File

@@ -0,0 +1,24 @@
tab_bar_edge top
tab_bar_style powerline
tab_powerline_style slanted
tab_bar_align left
tab_bar_min_tabs 2
tab_bar_margin_width 0.0
tab_bar_margin_height 2.5 1.5
tab_bar_margin_color #101418
tab_bar_background #101418
active_tab_foreground #cfe5ff
active_tab_background #124a73
active_tab_font_style bold
inactive_tab_foreground #c2c7cf
inactive_tab_background #101418
inactive_tab_font_style normal
tab_activity_symbol " ● "
tab_numbers_style 1
tab_title_template "{fmt.fg.red}{bell_symbol}{activity_symbol}{fmt.fg.tab}{title[:30]}{title[30:] and '…'} [{index}]"
active_tab_title_template "{fmt.fg.red}{bell_symbol}{activity_symbol}{fmt.fg.tab}{title[:30]}{title[30:] and '…'} [{index}]"

View File

@@ -0,0 +1,24 @@
cursor #e0e2e8
cursor_text_color #c2c7cf
foreground #e0e2e8
background #101418
selection_foreground #243240
selection_background #b9c8da
url_color #9dcbfb
color0 #101418
color1 #d75a59
color2 #8ed88c
color3 #e0d99d
color4 #4087bc
color5 #839fbc
color6 #9dcbfb
color7 #abb2bf
color8 #5c6370
color9 #e57e7e
color10 #a2e5a0
color11 #efe9b3
color12 #a7d9ff
color13 #3d8197
color14 #5c7ba3
color15 #ffffff

View File

@@ -0,0 +1,37 @@
# Font Configuration
font_size 12.0
# Window Configuration
window_padding_width 12
background_opacity 1.0
background_blur 32
hide_window_decorations yes
# Cursor Configuration
cursor_shape block
cursor_blink_interval 1
# Scrollback
scrollback_lines 3000
# Terminal features
copy_on_select yes
strip_trailing_spaces smart
# Key bindings for common actions
map ctrl+shift+n new_window
map ctrl+t new_tab
map ctrl+plus change_font_size all +1.0
map ctrl+minus change_font_size all -1.0
map ctrl+0 change_font_size all 0
# Tab configuration
tab_bar_style powerline
tab_bar_align left
# Shell integration
shell_integration enabled
# Dank color generation
include dank-tabs.conf
include dank-theme.conf

View File

@@ -0,0 +1,418 @@
// This config is in the KDL format: https://kdl.dev
// "/-" comments out the following node.
// Check the wiki for a full description of the configuration:
// https://github.com/YaLTeR/niri/wiki/Configuration:-Introduction
config-notification {
disable-failed
}
gestures {
hot-corners {
off
}
}
// Input device configuration.
// Find the full list of options on the wiki:
// https://github.com/YaLTeR/niri/wiki/Configuration:-Input
input {
keyboard {
xkb {
}
numlock
}
touchpad {
}
mouse {
}
trackpoint {
}
}
// You can configure outputs by their name, which you can find
// by running `niri msg outputs` while inside a niri instance.
// The built-in laptop monitor is usually called "eDP-1".
// Find more information on the wiki:
// https://github.com/YaLTeR/niri/wiki/Configuration:-Outputs
// Remember to uncomment the node by removing "/-"!
/-output "eDP-2" {
mode "2560x1600@239.998993"
position x=2560 y=0
variable-refresh-rate
}
// Settings that influence how windows are positioned and sized.
// Find more information on the wiki:
// https://github.com/YaLTeR/niri/wiki/Configuration:-Layout
layout {
// Set gaps around windows in logical pixels.
gaps 5
background-color "transparent"
// When to center a column when changing focus, options are:
// - "never", default behavior, focusing an off-screen column will keep at the left
// or right edge of the screen.
// - "always", the focused column will always be centered.
// - "on-overflow", focusing a column will center it if it doesn't fit
// together with the previously focused column.
center-focused-column "never"
// You can customize the widths that "switch-preset-column-width" (Mod+R) toggles between.
preset-column-widths {
// Proportion sets the width as a fraction of the output width, taking gaps into account.
// For example, you can perfectly fit four windows sized "proportion 0.25" on an output.
// The default preset widths are 1/3, 1/2 and 2/3 of the output.
proportion 0.33333
proportion 0.5
proportion 0.66667
// Fixed sets the width in logical pixels exactly.
// fixed 1920
}
// You can also customize the heights that "switch-preset-window-height" (Mod+Shift+R) toggles between.
// preset-window-heights { }
// You can change the default width of the new windows.
default-column-width { proportion 0.5; }
// If you leave the brackets empty, the windows themselves will decide their initial width.
// default-column-width {}
// By default focus ring and border are rendered as a solid background rectangle
// behind windows. That is, they will show up through semitransparent windows.
// This is because windows using client-side decorations can have an arbitrary shape.
//
// If you don't like that, you should uncomment `prefer-no-csd` below.
// Niri will draw focus ring and border *around* windows that agree to omit their
// client-side decorations.
//
// Alternatively, you can override it with a window rule called
// `draw-border-with-background`.
border {
off
width 4
active-color "#707070" // Neutral gray
inactive-color "#d0d0d0" // Light gray
urgent-color "#cc4444" // Softer red
}
focus-ring {
width 2
active-color "#808080" // Medium gray
inactive-color "#505050" // Dark gray
}
shadow {
softness 30
spread 5
offset x=0 y=5
color "#0007"
}
struts {
}
}
layer-rule {
match namespace="^quickshell$"
place-within-backdrop true
}
overview {
workspace-shadow {
off
}
}
// Add lines like this to spawn processes at startup.
// Note that running niri as a session supports xdg-desktop-autostart,
// which may be more convenient to use.
// See the binds section below for more spawn examples.
// This line starts waybar, a commonly used bar for Wayland compositors.
spawn-at-startup "bash" "-c" "wl-paste --watch cliphist store &"
spawn-at-startup "dms" "run"
spawn-at-startup "{{POLKIT_AGENT_PATH}}"
environment {
XDG_CURRENT_DESKTOP "niri"
QT_QPA_PLATFORM "wayland"
ELECTRON_OZONE_PLATFORM_HINT "auto"
QT_QPA_PLATFORMTHEME "gtk3"
QT_QPA_PLATFORMTHEME_QT6 "gtk3"
TERMINAL "{{TERMINAL_COMMAND}}"
}
hotkey-overlay {
skip-at-startup
}
prefer-no-csd
screenshot-path "~/Pictures/Screenshots/Screenshot from %Y-%m-%d %H-%M-%S.png"
animations {
workspace-switch {
spring damping-ratio=0.80 stiffness=523 epsilon=0.0001
}
window-open {
duration-ms 150
curve "ease-out-expo"
}
window-close {
duration-ms 150
curve "ease-out-quad"
}
horizontal-view-movement {
spring damping-ratio=0.85 stiffness=423 epsilon=0.0001
}
window-movement {
spring damping-ratio=0.75 stiffness=323 epsilon=0.0001
}
window-resize {
spring damping-ratio=0.85 stiffness=423 epsilon=0.0001
}
config-notification-open-close {
spring damping-ratio=0.65 stiffness=923 epsilon=0.001
}
screenshot-ui-open {
duration-ms 200
curve "ease-out-quad"
}
overview-open-close {
spring damping-ratio=0.85 stiffness=800 epsilon=0.0001
}
}
// Window rules let you adjust behavior for individual windows.
// Find more information on the wiki:
// https://github.com/YaLTeR/niri/wiki/Configuration:-Window-Rules
// Work around WezTerm's initial configure bug
// by setting an empty default-column-width.
window-rule {
// This regular expression is intentionally made as specific as possible,
// since this is the default config, and we want no false positives.
// You can get away with just app-id="wezterm" if you want.
match app-id=r#"^org\.wezfurlong\.wezterm$"#
default-column-width {}
}
window-rule {
match app-id=r#"^org\.gnome\."#
draw-border-with-background false
geometry-corner-radius 12
clip-to-geometry true
}
window-rule {
match app-id=r#"^gnome-control-center$"#
match app-id=r#"^pavucontrol$"#
match app-id=r#"^nm-connection-editor$"#
default-column-width { proportion 0.5; }
open-floating false
}
window-rule {
match app-id=r#"^gnome-calculator$"#
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#"^org\.wezfurlong\.wezterm$"#
match app-id="Alacritty"
match app-id="zen"
match app-id="com.mitchellh.ghostty"
match app-id="kitty"
draw-border-with-background false
}
window-rule {
match is-active=false
opacity 0.9
}
window-rule {
match app-id=r#"firefox$"# title="^Picture-in-Picture$"
match app-id="zoom"
open-floating true
}
window-rule {
geometry-corner-radius 12
clip-to-geometry true
}
binds {
// === System & Overview ===
Mod+D { spawn "niri" "msg" "action" "toggle-overview"; }
Mod+Tab repeat=false { toggle-overview; }
Mod+Shift+Slash { show-hotkey-overlay; }
// === Application Launchers ===
Mod+T hotkey-overlay-title="Open Terminal" { spawn "{{TERMINAL_COMMAND}}"; }
Mod+Space hotkey-overlay-title="Application Launcher" {
spawn "dms" "ipc" "call" "spotlight" "toggle";
}
Mod+V hotkey-overlay-title="Clipboard Manager" {
spawn "dms" "ipc" "call" "clipboard" "toggle";
}
Mod+M hotkey-overlay-title="Task Manager" {
spawn "dms" "ipc" "call" "processlist" "toggle";
}
Mod+Comma hotkey-overlay-title="Settings" {
spawn "dms" "ipc" "call" "settings" "toggle";
}
Mod+Y hotkey-overlay-title="Browse Wallpapers" {
spawn "dms" "ipc" "call" "dankdash" "wallpaper";
}
Mod+N hotkey-overlay-title="Notification Center" { spawn "dms" "ipc" "call" "notifications" "toggle"; }
Mod+Shift+N hotkey-overlay-title="Notepad" { spawn "dms" "ipc" "call" "notepad" "toggle"; }
// === Security ===
Mod+Alt+L hotkey-overlay-title="Lock Screen" {
spawn "dms" "ipc" "call" "lock" "lock";
}
Mod+Shift+E { quit; }
Ctrl+Alt+Delete hotkey-overlay-title="Task Manager" {
spawn "dms" "ipc" "call" "processlist" "toggle";
}
// === Audio Controls ===
XF86AudioRaiseVolume allow-when-locked=true {
spawn "dms" "ipc" "call" "audio" "increment" "3";
}
XF86AudioLowerVolume allow-when-locked=true {
spawn "dms" "ipc" "call" "audio" "decrement" "3";
}
XF86AudioMute allow-when-locked=true {
spawn "dms" "ipc" "call" "audio" "mute";
}
XF86AudioMicMute allow-when-locked=true {
spawn "dms" "ipc" "call" "audio" "micmute";
}
// === Brightness Controls ===
XF86MonBrightnessUp allow-when-locked=true {
spawn "dms" "ipc" "call" "brightness" "increment" "5" "";
}
XF86MonBrightnessDown allow-when-locked=true {
spawn "dms" "ipc" "call" "brightness" "decrement" "5" "";
}
// === Window Management ===
Mod+Q repeat=false { close-window; }
Mod+F { maximize-column; }
Mod+Shift+F { fullscreen-window; }
Mod+Shift+T { toggle-window-floating; }
Mod+Shift+V { switch-focus-between-floating-and-tiling; }
Mod+W { toggle-column-tabbed-display; }
// === Focus Navigation ===
Mod+Left { focus-column-left; }
Mod+Down { focus-window-down; }
Mod+Up { focus-window-up; }
Mod+Right { focus-column-right; }
Mod+H { focus-column-left; }
Mod+J { focus-window-down; }
Mod+K { focus-window-up; }
Mod+L { focus-column-right; }
// === Window Movement ===
Mod+Shift+Left { move-column-left; }
Mod+Shift+Down { move-window-down; }
Mod+Shift+Up { move-window-up; }
Mod+Shift+Right { move-column-right; }
Mod+Shift+H { move-column-left; }
Mod+Shift+J { move-window-down; }
Mod+Shift+K { move-window-up; }
Mod+Shift+L { move-column-right; }
// === Column Navigation ===
Mod+Home { focus-column-first; }
Mod+End { focus-column-last; }
Mod+Ctrl+Home { move-column-to-first; }
Mod+Ctrl+End { move-column-to-last; }
// === Monitor Navigation ===
Mod+Ctrl+Left { focus-monitor-left; }
//Mod+Ctrl+Down { focus-monitor-down; }
//Mod+Ctrl+Up { focus-monitor-up; }
Mod+Ctrl+Right { focus-monitor-right; }
Mod+Ctrl+H { focus-monitor-left; }
Mod+Ctrl+J { focus-monitor-down; }
Mod+Ctrl+K { focus-monitor-up; }
Mod+Ctrl+L { focus-monitor-right; }
// === Move to Monitor ===
Mod+Shift+Ctrl+Left { move-column-to-monitor-left; }
Mod+Shift+Ctrl+Down { move-column-to-monitor-down; }
Mod+Shift+Ctrl+Up { move-column-to-monitor-up; }
Mod+Shift+Ctrl+Right { move-column-to-monitor-right; }
Mod+Shift+Ctrl+H { move-column-to-monitor-left; }
Mod+Shift+Ctrl+J { move-column-to-monitor-down; }
Mod+Shift+Ctrl+K { move-column-to-monitor-up; }
Mod+Shift+Ctrl+L { move-column-to-monitor-right; }
// === Workspace Navigation ===
Mod+Page_Down { focus-workspace-down; }
Mod+Page_Up { focus-workspace-up; }
Mod+U { focus-workspace-down; }
Mod+I { focus-workspace-up; }
Mod+Ctrl+Down { move-column-to-workspace-down; }
Mod+Ctrl+Up { move-column-to-workspace-up; }
Mod+Ctrl+U { move-column-to-workspace-down; }
Mod+Ctrl+I { move-column-to-workspace-up; }
// === Move Workspaces ===
Mod+Shift+Page_Down { move-workspace-down; }
Mod+Shift+Page_Up { move-workspace-up; }
Mod+Shift+U { move-workspace-down; }
Mod+Shift+I { move-workspace-up; }
// === Mouse Wheel Navigation ===
Mod+WheelScrollDown cooldown-ms=150 { focus-workspace-down; }
Mod+WheelScrollUp cooldown-ms=150 { focus-workspace-up; }
Mod+Ctrl+WheelScrollDown cooldown-ms=150 { move-column-to-workspace-down; }
Mod+Ctrl+WheelScrollUp cooldown-ms=150 { move-column-to-workspace-up; }
Mod+WheelScrollRight { focus-column-right; }
Mod+WheelScrollLeft { focus-column-left; }
Mod+Ctrl+WheelScrollRight { move-column-right; }
Mod+Ctrl+WheelScrollLeft { move-column-left; }
Mod+Shift+WheelScrollDown { focus-column-right; }
Mod+Shift+WheelScrollUp { focus-column-left; }
Mod+Ctrl+Shift+WheelScrollDown { move-column-right; }
Mod+Ctrl+Shift+WheelScrollUp { move-column-left; }
// === Numbered Workspaces ===
Mod+1 { focus-workspace 1; }
Mod+2 { focus-workspace 2; }
Mod+3 { focus-workspace 3; }
Mod+4 { focus-workspace 4; }
Mod+5 { focus-workspace 5; }
Mod+6 { focus-workspace 6; }
Mod+7 { focus-workspace 7; }
Mod+8 { focus-workspace 8; }
Mod+9 { focus-workspace 9; }
// === Move to Numbered Workspaces ===
Mod+Shift+1 { move-column-to-workspace 1; }
Mod+Shift+2 { move-column-to-workspace 2; }
Mod+Shift+3 { move-column-to-workspace 3; }
Mod+Shift+4 { move-column-to-workspace 4; }
Mod+Shift+5 { move-column-to-workspace 5; }
Mod+Shift+6 { move-column-to-workspace 6; }
Mod+Shift+7 { move-column-to-workspace 7; }
Mod+Shift+8 { move-column-to-workspace 8; }
Mod+Shift+9 { move-column-to-workspace 9; }
// === Column Management ===
Mod+BracketLeft { consume-or-expel-window-left; }
Mod+BracketRight { consume-or-expel-window-right; }
Mod+Period { expel-window-from-column; }
// === Sizing & Layout ===
Mod+R { switch-preset-column-width; }
Mod+Shift+R { switch-preset-window-height; }
Mod+Ctrl+R { reset-window-height; }
Mod+Ctrl+F { expand-column-to-available-width; }
Mod+C { center-column; }
Mod+Ctrl+C { center-visible-columns; }
// === Manual Sizing ===
Mod+Minus { set-column-width "-10%"; }
Mod+Equal { set-column-width "+10%"; }
Mod+Shift+Minus { set-window-height "-10%"; }
Mod+Shift+Equal { set-window-height "+10%"; }
// === Screenshots ===
XF86Launch1 { screenshot; }
Ctrl+XF86Launch1 { screenshot-screen; }
Alt+XF86Launch1 { screenshot-window; }
Print { screenshot; }
Ctrl+Print { screenshot-screen; }
Alt+Print { screenshot-window; }
// === System Controls ===
Mod+Escape allow-inhibiting=false { toggle-keyboard-shortcuts-inhibit; }
Mod+Shift+P { power-off-monitors; }
}
debug {
honor-xdg-activation-with-invalid-serial
}

View File

@@ -0,0 +1,6 @@
package config
import _ "embed"
//go:embed embedded/hyprland.conf
var HyprlandConfig string

View File

@@ -0,0 +1,6 @@
package config
import _ "embed"
//go:embed embedded/niri.kdl
var NiriConfig string

View File

@@ -0,0 +1,24 @@
package config
import _ "embed"
//go:embed embedded/ghostty.conf
var GhosttyConfig string
//go:embed embedded/ghostty-colors.conf
var GhosttyColorConfig string
//go:embed embedded/kitty.conf
var KittyConfig string
//go:embed embedded/kitty-theme.conf
var KittyThemeConfig string
//go:embed embedded/kitty-tabs.conf
var KittyTabsConfig string
//go:embed embedded/alacritty.toml
var AlacrittyConfig string
//go:embed embedded/alacritty-theme.toml
var AlacrittyThemeConfig string

View File

@@ -0,0 +1,453 @@
package dank16
import (
"fmt"
"math"
"github.com/lucasb-eyer/go-colorful"
)
type RGB struct {
R, G, B float64
}
type HSV struct {
H, S, V float64
}
func HexToRGB(hex string) RGB {
if hex[0] == '#' {
hex = hex[1:]
}
var r, g, b uint8
fmt.Sscanf(hex, "%02x%02x%02x", &r, &g, &b)
return RGB{
R: float64(r) / 255.0,
G: float64(g) / 255.0,
B: float64(b) / 255.0,
}
}
func RGBToHex(rgb RGB) string {
r := math.Max(0, math.Min(1, rgb.R))
g := math.Max(0, math.Min(1, rgb.G))
b := math.Max(0, math.Min(1, rgb.B))
return fmt.Sprintf("#%02x%02x%02x", int(r*255), int(g*255), int(b*255))
}
func RGBToHSV(rgb RGB) HSV {
max := math.Max(math.Max(rgb.R, rgb.G), rgb.B)
min := math.Min(math.Min(rgb.R, rgb.G), rgb.B)
delta := max - min
var h float64
if delta == 0 {
h = 0
} else if max == rgb.R {
h = math.Mod((rgb.G-rgb.B)/delta, 6.0) / 6.0
} else if max == rgb.G {
h = ((rgb.B-rgb.R)/delta + 2.0) / 6.0
} else {
h = ((rgb.R-rgb.G)/delta + 4.0) / 6.0
}
if h < 0 {
h += 1.0
}
var s float64
if max == 0 {
s = 0
} else {
s = delta / max
}
return HSV{H: h, S: s, V: max}
}
func HSVToRGB(hsv HSV) RGB {
h := hsv.H * 6.0
c := hsv.V * hsv.S
x := c * (1.0 - math.Abs(math.Mod(h, 2.0)-1.0))
m := hsv.V - c
var r, g, b float64
switch int(h) {
case 0:
r, g, b = c, x, 0
case 1:
r, g, b = x, c, 0
case 2:
r, g, b = 0, c, x
case 3:
r, g, b = 0, x, c
case 4:
r, g, b = x, 0, c
case 5:
r, g, b = c, 0, x
default:
r, g, b = c, 0, x
}
return RGB{R: r + m, G: g + m, B: b + m}
}
func sRGBToLinear(c float64) float64 {
if c <= 0.04045 {
return c / 12.92
}
return math.Pow((c+0.055)/1.055, 2.4)
}
func Luminance(hex string) float64 {
rgb := HexToRGB(hex)
return 0.2126*sRGBToLinear(rgb.R) + 0.7152*sRGBToLinear(rgb.G) + 0.0722*sRGBToLinear(rgb.B)
}
func ContrastRatio(hexFg, hexBg string) float64 {
lumFg := Luminance(hexFg)
lumBg := Luminance(hexBg)
lighter := math.Max(lumFg, lumBg)
darker := math.Min(lumFg, lumBg)
return (lighter + 0.05) / (darker + 0.05)
}
func getLstar(hex string) float64 {
rgb := HexToRGB(hex)
col := colorful.Color{R: rgb.R, G: rgb.G, B: rgb.B}
L, _, _ := col.Lab()
return L * 100.0 // go-colorful uses 0-1, we need 0-100 for DPS
}
// Lab to hex, clamping if needed
func labToHex(L, a, b float64) string {
c := colorful.Lab(L/100.0, a, b) // back to 0-1 for go-colorful
r, g, b2 := c.Clamped().RGB255()
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)
phi := 1.618
inv := 0.618
lc := math.Pow(math.Abs(math.Pow(Lb, phi)-math.Pow(Lf, phi)), inv)*1.414 - 40
if negativePolarity {
lc += 5
}
return lc
}
func DeltaPhiStarContrast(hexFg, hexBg string, isLightMode bool) float64 {
negativePolarity := !isLightMode
return DeltaPhiStar(hexFg, hexBg, negativePolarity)
}
func EnsureContrast(hexColor, hexBg string, minRatio float64, isLightMode bool) string {
currentRatio := ContrastRatio(hexColor, hexBg)
if currentRatio >= minRatio {
return hexColor
}
rgb := HexToRGB(hexColor)
hsv := RGBToHSV(rgb)
for step := 1; step < 30; step++ {
delta := float64(step) * 0.02
if isLightMode {
newV := math.Max(0, hsv.V-delta)
candidate := RGBToHex(HSVToRGB(HSV{H: hsv.H, S: hsv.S, V: newV}))
if ContrastRatio(candidate, hexBg) >= minRatio {
return candidate
}
newV = math.Min(1, hsv.V+delta)
candidate = RGBToHex(HSVToRGB(HSV{H: hsv.H, S: hsv.S, V: newV}))
if ContrastRatio(candidate, hexBg) >= minRatio {
return candidate
}
} else {
newV := math.Min(1, hsv.V+delta)
candidate := RGBToHex(HSVToRGB(HSV{H: hsv.H, S: hsv.S, V: newV}))
if ContrastRatio(candidate, hexBg) >= minRatio {
return candidate
}
newV = math.Max(0, hsv.V-delta)
candidate = RGBToHex(HSVToRGB(HSV{H: hsv.H, S: hsv.S, V: newV}))
if ContrastRatio(candidate, hexBg) >= minRatio {
return candidate
}
}
}
return hexColor
}
func EnsureContrastDPS(hexColor, hexBg string, minLc float64, isLightMode bool) string {
currentLc := DeltaPhiStarContrast(hexColor, hexBg, isLightMode)
if currentLc >= minLc {
return hexColor
}
rgb := HexToRGB(hexColor)
hsv := RGBToHSV(rgb)
for step := 1; step < 50; step++ {
delta := float64(step) * 0.015
if isLightMode {
newV := math.Max(0, hsv.V-delta)
candidate := RGBToHex(HSVToRGB(HSV{H: hsv.H, S: hsv.S, V: newV}))
if DeltaPhiStarContrast(candidate, hexBg, isLightMode) >= minLc {
return candidate
}
newV = math.Min(1, hsv.V+delta)
candidate = RGBToHex(HSVToRGB(HSV{H: hsv.H, S: hsv.S, V: newV}))
if DeltaPhiStarContrast(candidate, hexBg, isLightMode) >= minLc {
return candidate
}
} else {
newV := math.Min(1, hsv.V+delta)
candidate := RGBToHex(HSVToRGB(HSV{H: hsv.H, S: hsv.S, V: newV}))
if DeltaPhiStarContrast(candidate, hexBg, isLightMode) >= minLc {
return candidate
}
newV = math.Max(0, hsv.V-delta)
candidate = RGBToHex(HSVToRGB(HSV{H: hsv.H, S: hsv.S, V: newV}))
if DeltaPhiStarContrast(candidate, hexBg, isLightMode) >= minLc {
return candidate
}
}
}
return hexColor
}
// Nudge L* until contrast is good enough. Keeps hue intact unlike HSV fiddling.
func EnsureContrastDPSLstar(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}
Lf, af, bf := cf.Lab()
dir := 1.0
if isLightMode {
dir = -1.0 // light mode = darker text
}
step := 0.5
for i := 0; i < 120; i++ {
Lf = math.Max(0, math.Min(100, Lf+dir*step))
cand := labToHex(Lf, af, bf)
if DeltaPhiStarContrast(cand, hexBg, isLightMode) >= minLc {
return cand
}
}
return hexColor
}
type PaletteOptions struct {
IsLight bool
Background string
UseDPS bool
}
func ensureContrastAuto(hexColor, hexBg string, target float64, opts PaletteOptions) string {
if opts.UseDPS {
return EnsureContrastDPSLstar(hexColor, hexBg, target, opts.IsLight)
}
return EnsureContrast(hexColor, hexBg, target, opts.IsLight)
}
func DeriveContainer(primary string, isLight bool) string {
rgb := HexToRGB(primary)
hsv := RGBToHSV(rgb)
if isLight {
containerV := math.Min(hsv.V*1.77, 1.0)
containerS := hsv.S * 0.32
return RGBToHex(HSVToRGB(HSV{H: hsv.H, S: containerS, V: containerV}))
}
containerV := hsv.V * 0.463
containerS := math.Min(hsv.S*1.834, 1.0)
return RGBToHex(HSVToRGB(HSV{H: hsv.H, S: containerS, V: containerV}))
}
func GeneratePalette(primaryColor string, opts PaletteOptions) []string {
baseColor := DeriveContainer(primaryColor, opts.IsLight)
rgb := HexToRGB(baseColor)
hsv := RGBToHSV(rgb)
palette := make([]string, 0, 16)
var normalTextTarget, secondaryTarget float64
if opts.UseDPS {
normalTextTarget = 40.0
secondaryTarget = 35.0
} else {
normalTextTarget = 4.5
secondaryTarget = 3.0
}
var bgColor string
if opts.Background != "" {
bgColor = opts.Background
} else if opts.IsLight {
bgColor = "#f8f8f8"
} else {
bgColor = "#1a1a1a"
}
palette = append(palette, bgColor)
hueShift := (hsv.H - 0.6) * 0.12
satBoost := 1.15
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 = append(palette, ensureContrastAuto(redColor, bgColor, normalTextTarget, opts))
} else {
redColor = RGBToHex(HSVToRGB(HSV{H: redH, S: math.Min(0.65*satBoost, 1.0), V: 0.80}))
palette = append(palette, ensureContrastAuto(redColor, bgColor, normalTextTarget, opts))
}
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 = append(palette, ensureContrastAuto(greenColor, bgColor, normalTextTarget, opts))
} else {
greenColor = RGBToHex(HSVToRGB(HSV{H: greenH, S: math.Min(0.42*satBoost, 1.0), V: 0.84}))
palette = append(palette, 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 = append(palette, ensureContrastAuto(yellowColor, bgColor, normalTextTarget, opts))
} else {
yellowColor = RGBToHex(HSVToRGB(HSV{H: yellowH, S: math.Min(0.38*satBoost, 1.0), V: 0.86}))
palette = append(palette, 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 = append(palette, 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 = append(palette, 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 = append(palette, ensureContrastAuto(magColor, bgColor, normalTextTarget, opts))
} else {
magColor = RGBToHex(HSVToRGB(HSV{H: hh.H, S: hh.S * 0.8, V: hh.V * 0.75}))
palette = append(palette, ensureContrastAuto(magColor, bgColor, normalTextTarget, opts))
}
cyanH := hsv.H + 0.08
if cyanH > 1.0 {
cyanH -= 1.0
}
palette = append(palette, ensureContrastAuto(primaryColor, bgColor, normalTextTarget, opts))
if opts.IsLight {
palette = append(palette, "#1a1a1a")
palette = append(palette, "#2e2e2e")
} else {
palette = append(palette, "#abb2bf")
palette = append(palette, "#5c6370")
}
if opts.IsLight {
brightRed := RGBToHex(HSVToRGB(HSV{H: redH, S: math.Min(0.70*satBoost, 1.0), V: 0.65}))
palette = append(palette, 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 = append(palette, ensureContrastAuto(brightGreen, bgColor, secondaryTarget, opts))
brightYellow := RGBToHex(HSVToRGB(HSV{H: yellowH, S: math.Min(0.68*satBoost, 1.0), V: 0.60}))
palette = append(palette, 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 = append(palette, 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 = append(palette, 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 = append(palette, ensureContrastAuto(brightCyan, bgColor, secondaryTarget, opts))
} else {
brightRed := RGBToHex(HSVToRGB(HSV{H: redH, S: math.Min(0.50*satBoost, 1.0), V: 0.88}))
palette = append(palette, ensureContrastAuto(brightRed, bgColor, secondaryTarget, opts))
brightGreen := RGBToHex(HSVToRGB(HSV{H: greenH, S: math.Min(0.35*satBoost, 1.0), V: 0.88}))
palette = append(palette, ensureContrastAuto(brightGreen, bgColor, secondaryTarget, opts))
brightYellow := RGBToHex(HSVToRGB(HSV{H: yellowH, S: math.Min(0.30*satBoost, 1.0), V: 0.91}))
palette = append(palette, ensureContrastAuto(brightYellow, bgColor, secondaryTarget, opts))
// Make it way brighter for type names in dark mode
brightBlue := retoneToL(primaryColor, 85.0)
palette = append(palette, 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 = append(palette, 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 = append(palette, ensureContrastAuto(brightCyan, bgColor, secondaryTarget, opts))
}
if opts.IsLight {
palette = append(palette, "#1a1a1a")
} else {
palette = append(palette, "#ffffff")
}
return palette
}

View File

@@ -0,0 +1,727 @@
package dank16
import (
"encoding/json"
"math"
"testing"
)
func TestHexToRGB(t *testing.T) {
tests := []struct {
name string
input string
expected RGB
}{
{
name: "black with hash",
input: "#000000",
expected: RGB{R: 0.0, G: 0.0, B: 0.0},
},
{
name: "white with hash",
input: "#ffffff",
expected: RGB{R: 1.0, G: 1.0, B: 1.0},
},
{
name: "red without hash",
input: "ff0000",
expected: RGB{R: 1.0, G: 0.0, B: 0.0},
},
{
name: "purple",
input: "#625690",
expected: RGB{R: 0.3843137254901961, G: 0.33725490196078434, B: 0.5647058823529412},
},
{
name: "mid gray",
input: "#808080",
expected: RGB{R: 0.5019607843137255, G: 0.5019607843137255, B: 0.5019607843137255},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := HexToRGB(tt.input)
if !floatEqual(result.R, tt.expected.R) || !floatEqual(result.G, tt.expected.G) || !floatEqual(result.B, tt.expected.B) {
t.Errorf("HexToRGB(%s) = %v, expected %v", tt.input, result, tt.expected)
}
})
}
}
func TestRGBToHex(t *testing.T) {
tests := []struct {
name string
input RGB
expected string
}{
{
name: "black",
input: RGB{R: 0.0, G: 0.0, B: 0.0},
expected: "#000000",
},
{
name: "white",
input: RGB{R: 1.0, G: 1.0, B: 1.0},
expected: "#ffffff",
},
{
name: "red",
input: RGB{R: 1.0, G: 0.0, B: 0.0},
expected: "#ff0000",
},
{
name: "clamping above 1.0",
input: RGB{R: 1.5, G: 0.5, B: 0.5},
expected: "#ff7f7f",
},
{
name: "clamping below 0.0",
input: RGB{R: -0.5, G: 0.5, B: 0.5},
expected: "#007f7f",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := RGBToHex(tt.input)
if result != tt.expected {
t.Errorf("RGBToHex(%v) = %s, expected %s", tt.input, result, tt.expected)
}
})
}
}
func TestRGBToHSV(t *testing.T) {
tests := []struct {
name string
input RGB
expected HSV
}{
{
name: "black",
input: RGB{R: 0.0, G: 0.0, B: 0.0},
expected: HSV{H: 0.0, S: 0.0, V: 0.0},
},
{
name: "white",
input: RGB{R: 1.0, G: 1.0, B: 1.0},
expected: HSV{H: 0.0, S: 0.0, V: 1.0},
},
{
name: "red",
input: RGB{R: 1.0, G: 0.0, B: 0.0},
expected: HSV{H: 0.0, S: 1.0, V: 1.0},
},
{
name: "green",
input: RGB{R: 0.0, G: 1.0, B: 0.0},
expected: HSV{H: 0.3333333333333333, S: 1.0, V: 1.0},
},
{
name: "blue",
input: RGB{R: 0.0, G: 0.0, B: 1.0},
expected: HSV{H: 0.6666666666666666, S: 1.0, V: 1.0},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := RGBToHSV(tt.input)
if !floatEqual(result.H, tt.expected.H) || !floatEqual(result.S, tt.expected.S) || !floatEqual(result.V, tt.expected.V) {
t.Errorf("RGBToHSV(%v) = %v, expected %v", tt.input, result, tt.expected)
}
})
}
}
func TestHSVToRGB(t *testing.T) {
tests := []struct {
name string
input HSV
expected RGB
}{
{
name: "black",
input: HSV{H: 0.0, S: 0.0, V: 0.0},
expected: RGB{R: 0.0, G: 0.0, B: 0.0},
},
{
name: "white",
input: HSV{H: 0.0, S: 0.0, V: 1.0},
expected: RGB{R: 1.0, G: 1.0, B: 1.0},
},
{
name: "red",
input: HSV{H: 0.0, S: 1.0, V: 1.0},
expected: RGB{R: 1.0, G: 0.0, B: 0.0},
},
{
name: "green",
input: HSV{H: 0.3333333333333333, S: 1.0, V: 1.0},
expected: RGB{R: 0.0, G: 1.0, B: 0.0},
},
{
name: "blue",
input: HSV{H: 0.6666666666666666, S: 1.0, V: 1.0},
expected: RGB{R: 0.0, G: 0.0, B: 1.0},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := HSVToRGB(tt.input)
if !floatEqual(result.R, tt.expected.R) || !floatEqual(result.G, tt.expected.G) || !floatEqual(result.B, tt.expected.B) {
t.Errorf("HSVToRGB(%v) = %v, expected %v", tt.input, result, tt.expected)
}
})
}
}
func TestLuminance(t *testing.T) {
tests := []struct {
name string
input string
expected float64
}{
{
name: "black",
input: "#000000",
expected: 0.0,
},
{
name: "white",
input: "#ffffff",
expected: 1.0,
},
{
name: "red",
input: "#ff0000",
expected: 0.2126,
},
{
name: "green",
input: "#00ff00",
expected: 0.7152,
},
{
name: "blue",
input: "#0000ff",
expected: 0.0722,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := Luminance(tt.input)
if !floatEqual(result, tt.expected) {
t.Errorf("Luminance(%s) = %f, expected %f", tt.input, result, tt.expected)
}
})
}
}
func TestContrastRatio(t *testing.T) {
tests := []struct {
name string
fg string
bg string
expected float64
}{
{
name: "black on white",
fg: "#000000",
bg: "#ffffff",
expected: 21.0,
},
{
name: "white on black",
fg: "#ffffff",
bg: "#000000",
expected: 21.0,
},
{
name: "same color",
fg: "#808080",
bg: "#808080",
expected: 1.0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ContrastRatio(tt.fg, tt.bg)
if !floatEqual(result, tt.expected) {
t.Errorf("ContrastRatio(%s, %s) = %f, expected %f", tt.fg, tt.bg, result, tt.expected)
}
})
}
}
func TestEnsureContrast(t *testing.T) {
tests := []struct {
name string
color string
bg string
minRatio float64
isLightMode bool
}{
{
name: "already sufficient contrast dark mode",
color: "#ffffff",
bg: "#000000",
minRatio: 4.5,
isLightMode: false,
},
{
name: "already sufficient contrast light mode",
color: "#000000",
bg: "#ffffff",
minRatio: 4.5,
isLightMode: true,
},
{
name: "needs adjustment dark mode",
color: "#404040",
bg: "#1a1a1a",
minRatio: 4.5,
isLightMode: false,
},
{
name: "needs adjustment light mode",
color: "#c0c0c0",
bg: "#f8f8f8",
minRatio: 4.5,
isLightMode: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := EnsureContrast(tt.color, tt.bg, tt.minRatio, tt.isLightMode)
actualRatio := ContrastRatio(result, tt.bg)
if actualRatio < tt.minRatio {
t.Errorf("EnsureContrast(%s, %s, %f, %t) = %s with ratio %f, expected ratio >= %f",
tt.color, tt.bg, tt.minRatio, tt.isLightMode, result, actualRatio, tt.minRatio)
}
})
}
}
func TestGeneratePalette(t *testing.T) {
tests := []struct {
name string
base string
opts PaletteOptions
}{
{
name: "dark theme default",
base: "#625690",
opts: PaletteOptions{IsLight: false},
},
{
name: "light theme default",
base: "#625690",
opts: PaletteOptions{IsLight: true},
},
{
name: "light theme with custom background",
base: "#625690",
opts: PaletteOptions{
IsLight: true,
Background: "#fafafa",
},
},
{
name: "dark theme with custom background",
base: "#625690",
opts: PaletteOptions{
IsLight: false,
Background: "#0a0a0a",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := GeneratePalette(tt.base, tt.opts)
if len(result) != 16 {
t.Errorf("GeneratePalette returned %d colors, expected 16", len(result))
}
for i, color := range result {
if len(color) != 7 || color[0] != '#' {
t.Errorf("Color at index %d (%s) is not a valid hex color", i, color)
}
}
if tt.opts.Background != "" && result[0] != tt.opts.Background {
t.Errorf("Background color = %s, expected %s", result[0], tt.opts.Background)
} else if !tt.opts.IsLight && tt.opts.Background == "" && result[0] != "#1a1a1a" {
t.Errorf("Dark mode background = %s, expected #1a1a1a", result[0])
} else if tt.opts.IsLight && tt.opts.Background == "" && result[0] != "#f8f8f8" {
t.Errorf("Light mode background = %s, expected #f8f8f8", result[0])
}
if tt.opts.IsLight && result[15] != "#1a1a1a" {
t.Errorf("Light mode foreground = %s, expected #1a1a1a", result[15])
} else if !tt.opts.IsLight && result[15] != "#ffffff" {
t.Errorf("Dark mode foreground = %s, expected #ffffff", result[15])
}
})
}
}
func TestEnrichVSCodeTheme(t *testing.T) {
colors := GeneratePalette("#625690", PaletteOptions{IsLight: false})
baseTheme := map[string]interface{}{
"name": "Test Theme",
"type": "dark",
"colors": map[string]interface{}{
"editor.background": "#000000",
},
}
themeJSON, err := json.Marshal(baseTheme)
if err != nil {
t.Fatalf("Failed to marshal base theme: %v", err)
}
result, err := EnrichVSCodeTheme(themeJSON, colors)
if err != nil {
t.Fatalf("EnrichVSCodeTheme failed: %v", err)
}
var enriched map[string]interface{}
if err := json.Unmarshal(result, &enriched); err != nil {
t.Fatalf("Failed to unmarshal result: %v", err)
}
colorsMap, ok := enriched["colors"].(map[string]interface{})
if !ok {
t.Fatal("colors is not a map")
}
terminalColors := []string{
"terminal.ansiBlack",
"terminal.ansiRed",
"terminal.ansiGreen",
"terminal.ansiYellow",
"terminal.ansiBlue",
"terminal.ansiMagenta",
"terminal.ansiCyan",
"terminal.ansiWhite",
"terminal.ansiBrightBlack",
"terminal.ansiBrightRed",
"terminal.ansiBrightGreen",
"terminal.ansiBrightYellow",
"terminal.ansiBrightBlue",
"terminal.ansiBrightMagenta",
"terminal.ansiBrightCyan",
"terminal.ansiBrightWhite",
}
for i, key := range terminalColors {
if val, ok := colorsMap[key]; !ok {
t.Errorf("Missing terminal color: %s", key)
} else if val != colors[i] {
t.Errorf("%s = %s, expected %s", key, val, colors[i])
}
}
if colorsMap["editor.background"] != "#000000" {
t.Error("Original theme colors should be preserved")
}
}
func TestEnrichVSCodeThemeInvalidJSON(t *testing.T) {
colors := GeneratePalette("#625690", PaletteOptions{IsLight: false})
invalidJSON := []byte("{invalid json")
_, err := EnrichVSCodeTheme(invalidJSON, colors)
if err == nil {
t.Error("Expected error for invalid JSON, got nil")
}
}
func TestRoundTripConversion(t *testing.T) {
testColors := []string{"#000000", "#ffffff", "#ff0000", "#00ff00", "#0000ff", "#625690", "#808080"}
for _, hex := range testColors {
t.Run(hex, func(t *testing.T) {
rgb := HexToRGB(hex)
result := RGBToHex(rgb)
if result != hex {
t.Errorf("Round trip %s -> RGB -> %s failed", hex, result)
}
})
}
}
func TestRGBHSVRoundTrip(t *testing.T) {
testCases := []RGB{
{R: 0.0, G: 0.0, B: 0.0},
{R: 1.0, G: 1.0, B: 1.0},
{R: 1.0, G: 0.0, B: 0.0},
{R: 0.0, G: 1.0, B: 0.0},
{R: 0.0, G: 0.0, B: 1.0},
{R: 0.5, G: 0.5, B: 0.5},
{R: 0.3843137254901961, G: 0.33725490196078434, B: 0.5647058823529412},
}
for _, rgb := range testCases {
t.Run("", func(t *testing.T) {
hsv := RGBToHSV(rgb)
result := HSVToRGB(hsv)
if !floatEqual(result.R, rgb.R) || !floatEqual(result.G, rgb.G) || !floatEqual(result.B, rgb.B) {
t.Errorf("Round trip RGB->HSV->RGB failed: %v -> %v -> %v", rgb, hsv, result)
}
})
}
}
func floatEqual(a, b float64) bool {
return math.Abs(a-b) < 1e-9
}
func TestDeltaPhiStar(t *testing.T) {
tests := []struct {
name string
fg string
bg string
negativePolarity bool
minExpected float64
}{
{
name: "white on black (negative polarity)",
fg: "#ffffff",
bg: "#000000",
negativePolarity: true,
minExpected: 100.0,
},
{
name: "black on white (positive polarity)",
fg: "#000000",
bg: "#ffffff",
negativePolarity: false,
minExpected: 100.0,
},
{
name: "low contrast same color",
fg: "#808080",
bg: "#808080",
negativePolarity: false,
minExpected: -40.0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := DeltaPhiStar(tt.fg, tt.bg, tt.negativePolarity)
if result < tt.minExpected {
t.Errorf("DeltaPhiStar(%s, %s, %v) = %f, expected >= %f",
tt.fg, tt.bg, tt.negativePolarity, result, tt.minExpected)
}
})
}
}
func TestDeltaPhiStarContrast(t *testing.T) {
tests := []struct {
name string
fg string
bg string
isLightMode bool
minExpected float64
}{
{
name: "white on black (dark mode)",
fg: "#ffffff",
bg: "#000000",
isLightMode: false,
minExpected: 100.0,
},
{
name: "black on white (light mode)",
fg: "#000000",
bg: "#ffffff",
isLightMode: true,
minExpected: 100.0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := DeltaPhiStarContrast(tt.fg, tt.bg, tt.isLightMode)
if result < tt.minExpected {
t.Errorf("DeltaPhiStarContrast(%s, %s, %v) = %f, expected >= %f",
tt.fg, tt.bg, tt.isLightMode, result, tt.minExpected)
}
})
}
}
func TestEnsureContrastDPS(t *testing.T) {
tests := []struct {
name string
color string
bg string
minLc float64
isLightMode bool
}{
{
name: "already sufficient contrast dark mode",
color: "#ffffff",
bg: "#000000",
minLc: 60.0,
isLightMode: false,
},
{
name: "already sufficient contrast light mode",
color: "#000000",
bg: "#ffffff",
minLc: 60.0,
isLightMode: true,
},
{
name: "needs adjustment dark mode",
color: "#404040",
bg: "#1a1a1a",
minLc: 60.0,
isLightMode: false,
},
{
name: "needs adjustment light mode",
color: "#c0c0c0",
bg: "#f8f8f8",
minLc: 60.0,
isLightMode: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := EnsureContrastDPS(tt.color, tt.bg, tt.minLc, tt.isLightMode)
actualLc := DeltaPhiStarContrast(result, tt.bg, tt.isLightMode)
if actualLc < tt.minLc {
t.Errorf("EnsureContrastDPS(%s, %s, %f, %t) = %s with Lc %f, expected Lc >= %f",
tt.color, tt.bg, tt.minLc, tt.isLightMode, result, actualLc, tt.minLc)
}
})
}
}
func TestGeneratePaletteWithDPS(t *testing.T) {
tests := []struct {
name string
base string
opts PaletteOptions
}{
{
name: "dark theme with DPS",
base: "#625690",
opts: PaletteOptions{IsLight: false, UseDPS: true},
},
{
name: "light theme with DPS",
base: "#625690",
opts: PaletteOptions{IsLight: true, UseDPS: true},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := GeneratePalette(tt.base, tt.opts)
if len(result) != 16 {
t.Errorf("GeneratePalette returned %d colors, expected 16", len(result))
}
for i, color := range result {
if len(color) != 7 || color[0] != '#' {
t.Errorf("Color at index %d (%s) is not a valid hex color", i, color)
}
}
bgColor := result[0]
for i := 1; i < 8; i++ {
lc := DeltaPhiStarContrast(result[i], bgColor, tt.opts.IsLight)
minLc := 30.0
if lc < minLc && lc > 0 {
t.Errorf("Color %d (%s) has insufficient DPS contrast %f with background %s (expected >= %f)",
i, result[i], lc, bgColor, minLc)
}
}
})
}
}
func TestDeriveContainer(t *testing.T) {
tests := []struct {
name string
primary string
isLight bool
expected string
}{
{
name: "dark mode",
primary: "#ccbdff",
isLight: false,
expected: "#4a3e76",
},
{
name: "light mode",
primary: "#625690",
isLight: true,
expected: "#e7deff",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := DeriveContainer(tt.primary, tt.isLight)
resultRGB := HexToRGB(result)
expectedRGB := HexToRGB(tt.expected)
rDiff := math.Abs(resultRGB.R - expectedRGB.R)
gDiff := math.Abs(resultRGB.G - expectedRGB.G)
bDiff := math.Abs(resultRGB.B - expectedRGB.B)
tolerance := 0.02
if rDiff > tolerance || gDiff > tolerance || bDiff > tolerance {
t.Errorf("DeriveContainer(%s, %v) = %s, expected %s (RGB diff: R:%.4f G:%.4f B:%.4f)",
tt.primary, tt.isLight, result, tt.expected, rDiff, gDiff, bDiff)
}
})
}
}
func TestContrastAlgorithmComparison(t *testing.T) {
base := "#625690"
optsWCAG := PaletteOptions{IsLight: false, UseDPS: false}
optsDPS := PaletteOptions{IsLight: false, UseDPS: true}
paletteWCAG := GeneratePalette(base, optsWCAG)
paletteDPS := GeneratePalette(base, optsDPS)
if len(paletteWCAG) != 16 || len(paletteDPS) != 16 {
t.Fatal("Both palettes should have 16 colors")
}
if paletteWCAG[0] != paletteDPS[0] {
t.Errorf("Background colors differ: WCAG=%s, DPS=%s", paletteWCAG[0], paletteDPS[0])
}
differentCount := 0
for i := 0; i < 16; i++ {
if paletteWCAG[i] != paletteDPS[i] {
differentCount++
}
}
t.Logf("WCAG and DPS palettes differ in %d/16 colors", differentCount)
}

View File

@@ -0,0 +1,140 @@
package dank16
import (
"encoding/json"
"fmt"
"strings"
)
func GenerateJSON(colors []string) string {
colorMap := make(map[string]string)
for i, color := range colors {
colorMap[fmt.Sprintf("color%d", i)] = color
}
marshalled, _ := json.Marshal(colorMap)
return string(marshalled)
}
func GenerateKittyTheme(colors []string) string {
kittyColors := []struct {
name string
index int
}{
{"color0", 0},
{"color1", 1},
{"color2", 2},
{"color3", 3},
{"color4", 4},
{"color5", 5},
{"color6", 6},
{"color7", 7},
{"color8", 8},
{"color9", 9},
{"color10", 10},
{"color11", 11},
{"color12", 12},
{"color13", 13},
{"color14", 14},
{"color15", 15},
}
var result strings.Builder
for _, kc := range kittyColors {
fmt.Fprintf(&result, "%s %s\n", kc.name, colors[kc.index])
}
return result.String()
}
func GenerateFootTheme(colors []string) string {
footColors := []struct {
name string
index int
}{
{"regular0", 0},
{"regular1", 1},
{"regular2", 2},
{"regular3", 3},
{"regular4", 4},
{"regular5", 5},
{"regular6", 6},
{"regular7", 7},
{"bright0", 8},
{"bright1", 9},
{"bright2", 10},
{"bright3", 11},
{"bright4", 12},
{"bright5", 13},
{"bright6", 14},
{"bright7", 15},
}
var result strings.Builder
for _, fc := range footColors {
fmt.Fprintf(&result, "%s=%s\n", fc.name, strings.TrimPrefix(colors[fc.index], "#"))
}
return result.String()
}
func GenerateAlacrittyTheme(colors []string) string {
alacrittyColors := []struct {
section string
name string
index int
}{
{"normal", "black", 0},
{"normal", "red", 1},
{"normal", "green", 2},
{"normal", "yellow", 3},
{"normal", "blue", 4},
{"normal", "magenta", 5},
{"normal", "cyan", 6},
{"normal", "white", 7},
{"bright", "black", 8},
{"bright", "red", 9},
{"bright", "green", 10},
{"bright", "yellow", 11},
{"bright", "blue", 12},
{"bright", "magenta", 13},
{"bright", "cyan", 14},
{"bright", "white", 15},
}
var result strings.Builder
currentSection := ""
for _, ac := range alacrittyColors {
if ac.section != currentSection {
if currentSection != "" {
result.WriteString("\n")
}
fmt.Fprintf(&result, "[colors.%s]\n", ac.section)
currentSection = ac.section
}
fmt.Fprintf(&result, "%-7s = '%s'\n", ac.name, colors[ac.index])
}
return result.String()
}
func GenerateGhosttyTheme(colors []string) string {
var result strings.Builder
for i, color := range colors {
fmt.Fprintf(&result, "palette = %d=%s\n", i, color)
}
return result.String()
}
func GenerateWeztermTheme(colors []string) string {
var result strings.Builder
labels := []string{"ansi", "brights"}
for j, label := range labels {
start := j * 8
colorSlice := make([]string, 8)
for i, color := range colors[start : start+8] {
colorSlice[i] = fmt.Sprintf("'%s'", color)
}
fmt.Fprintf(&result, "%s = [%s]\n", label, strings.Join(colorSlice, ", "))
}
return result.String()
}

View File

@@ -0,0 +1,250 @@
package dank16
import (
"encoding/json"
"fmt"
)
type VSCodeTheme struct {
Schema string `json:"$schema"`
Name string `json:"name"`
Type string `json:"type"`
Colors map[string]string `json:"colors"`
TokenColors []VSCodeTokenColor `json:"tokenColors"`
SemanticHighlighting bool `json:"semanticHighlighting"`
SemanticTokenColors map[string]VSCodeTokenSetting `json:"semanticTokenColors"`
}
type VSCodeTokenColor struct {
Scope interface{} `json:"scope"`
Settings VSCodeTokenSetting `json:"settings"`
}
type VSCodeTokenSetting struct {
Foreground string `json:"foreground,omitempty"`
FontStyle string `json:"fontStyle,omitempty"`
}
func updateTokenColor(tc interface{}, scopeToColor map[string]string) {
tcMap, ok := tc.(map[string]interface{})
if !ok {
return
}
scopes, ok := tcMap["scope"].([]interface{})
if !ok {
return
}
settings, ok := tcMap["settings"].(map[string]interface{})
if !ok {
return
}
isYaml := hasScopeContaining(scopes, "yaml")
for _, scope := range scopes {
scopeStr, ok := scope.(string)
if !ok {
continue
}
if scopeStr == "string" && isYaml {
continue
}
if applyColorToScope(settings, scope, scopeToColor) {
break
}
}
}
func applyColorToScope(settings map[string]interface{}, scope interface{}, scopeToColor map[string]string) bool {
scopeStr, ok := scope.(string)
if !ok {
return false
}
newColor, exists := scopeToColor[scopeStr]
if !exists {
return false
}
settings["foreground"] = newColor
return true
}
func hasScopeContaining(scopes []interface{}, substring string) bool {
for _, scope := range scopes {
scopeStr, ok := scope.(string)
if !ok {
continue
}
for i := 0; i <= len(scopeStr)-len(substring); i++ {
if scopeStr[i:i+len(substring)] == substring {
return true
}
}
}
return false
}
func EnrichVSCodeTheme(themeData []byte, colors []string) ([]byte, error) {
var theme map[string]interface{}
if err := json.Unmarshal(themeData, &theme); err != nil {
return nil, err
}
colorsMap, ok := theme["colors"].(map[string]interface{})
if !ok {
colorsMap = make(map[string]interface{})
theme["colors"] = colorsMap
}
bg := colors[0]
isLight := false
if len(bg) == 7 && bg[0] == '#' {
r, g, b := 0, 0, 0
fmt.Sscanf(bg[1:], "%02x%02x%02x", &r, &g, &b)
luminance := (0.299*float64(r) + 0.587*float64(g) + 0.114*float64(b)) / 255.0
isLight = luminance > 0.5
}
if isLight {
theme["type"] = "light"
} else {
theme["type"] = "dark"
}
colorsMap["terminal.ansiBlack"] = colors[0]
colorsMap["terminal.ansiRed"] = colors[1]
colorsMap["terminal.ansiGreen"] = colors[2]
colorsMap["terminal.ansiYellow"] = colors[3]
colorsMap["terminal.ansiBlue"] = colors[4]
colorsMap["terminal.ansiMagenta"] = colors[5]
colorsMap["terminal.ansiCyan"] = colors[6]
colorsMap["terminal.ansiWhite"] = colors[7]
colorsMap["terminal.ansiBrightBlack"] = colors[8]
colorsMap["terminal.ansiBrightRed"] = colors[9]
colorsMap["terminal.ansiBrightGreen"] = colors[10]
colorsMap["terminal.ansiBrightYellow"] = colors[11]
colorsMap["terminal.ansiBrightBlue"] = colors[12]
colorsMap["terminal.ansiBrightMagenta"] = colors[13]
colorsMap["terminal.ansiBrightCyan"] = colors[14]
colorsMap["terminal.ansiBrightWhite"] = colors[15]
tokenColors, ok := theme["tokenColors"].([]interface{})
if ok {
scopeToColor := map[string]string{
"comment": colors[8],
"punctuation.definition.comment": colors[8],
"keyword": colors[5],
"storage.type": colors[13],
"storage.modifier": colors[5],
"variable": colors[15],
"variable.parameter": colors[7],
"meta.object-literal.key": colors[4],
"meta.property.object": colors[4],
"variable.other.property": colors[4],
"constant.other.symbol": colors[12],
"constant.numeric": colors[12],
"constant.language": colors[12],
"constant.character": colors[3],
"entity.name.type": colors[12],
"support.type": colors[13],
"entity.name.class": colors[12],
"entity.name.function": colors[2],
"support.function": colors[2],
"support.class": colors[15],
"support.variable": colors[15],
"variable.language": colors[12],
"entity.name.tag.yaml": colors[12],
"string.unquoted.plain.out.yaml": colors[15],
"string.unquoted.yaml": colors[15],
"string": colors[3],
}
for i, tc := range tokenColors {
updateTokenColor(tc, scopeToColor)
tokenColors[i] = tc
}
yamlRules := []VSCodeTokenColor{
{
Scope: "entity.name.tag.yaml",
Settings: VSCodeTokenSetting{Foreground: colors[12]},
},
{
Scope: []string{"string.unquoted.plain.out.yaml", "string.unquoted.yaml"},
Settings: VSCodeTokenSetting{Foreground: colors[15]},
},
}
for _, rule := range yamlRules {
tokenColors = append(tokenColors, rule)
}
theme["tokenColors"] = tokenColors
}
if semanticTokenColors, ok := theme["semanticTokenColors"].(map[string]interface{}); ok {
updates := map[string]string{
"variable": colors[15],
"variable.readonly": colors[12],
"property": colors[4],
"function": colors[2],
"method": colors[2],
"type": colors[12],
"class": colors[12],
"typeParameter": colors[13],
"enumMember": colors[12],
"string": colors[3],
"number": colors[12],
"comment": colors[8],
"keyword": colors[5],
"operator": colors[15],
"parameter": colors[7],
"namespace": colors[15],
}
for key, color := range updates {
if existing, ok := semanticTokenColors[key].(map[string]interface{}); ok {
existing["foreground"] = color
} else {
semanticTokenColors[key] = map[string]interface{}{
"foreground": color,
}
}
}
} else {
semanticTokenColors := make(map[string]interface{})
updates := map[string]string{
"variable": colors[7],
"variable.readonly": colors[12],
"property": colors[4],
"function": colors[2],
"method": colors[2],
"type": colors[12],
"class": colors[12],
"typeParameter": colors[13],
"enumMember": colors[12],
"string": colors[3],
"number": colors[12],
"comment": colors[8],
"keyword": colors[5],
"operator": colors[15],
"parameter": colors[7],
"namespace": colors[15],
}
for key, color := range updates {
semanticTokenColors[key] = map[string]interface{}{
"foreground": color,
}
}
theme["semanticTokenColors"] = semanticTokenColors
}
return json.MarshalIndent(theme, "", " ")
}

View File

@@ -0,0 +1,51 @@
package deps
import (
"context"
)
type DependencyStatus int
const (
StatusMissing DependencyStatus = iota
StatusInstalled
StatusNeedsUpdate
StatusNeedsReinstall
)
type PackageVariant int
const (
VariantStable PackageVariant = iota
VariantGit
)
type Dependency struct {
Name string
Status DependencyStatus
Version string
Description string
Required bool
Variant PackageVariant
CanToggle bool
}
type WindowManager int
const (
WindowManagerHyprland WindowManager = iota
WindowManagerNiri
)
type Terminal int
const (
TerminalGhostty Terminal = iota
TerminalKitty
TerminalAlacritty
)
type DependencyDetector interface {
DetectDependencies(ctx context.Context, wm WindowManager) ([]Dependency, error)
DetectDependenciesWithTerminal(ctx context.Context, wm WindowManager, terminal Terminal) ([]Dependency, error)
}

View File

@@ -0,0 +1,785 @@
package distros
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
)
func init() {
Register("arch", "#1793D1", FamilyArch, func(config DistroConfig, logChan chan<- string) Distribution {
return NewArchDistribution(config, logChan)
})
Register("archarm", "#1793D1", FamilyArch, func(config DistroConfig, logChan chan<- string) Distribution {
return NewArchDistribution(config, logChan)
})
Register("archcraft", "#1793D1", FamilyArch, func(config DistroConfig, logChan chan<- string) Distribution {
return NewArchDistribution(config, logChan)
})
Register("cachyos", "#08A283", FamilyArch, func(config DistroConfig, logChan chan<- string) Distribution {
return NewArchDistribution(config, logChan)
})
Register("endeavouros", "#7F3FBF", FamilyArch, func(config DistroConfig, logChan chan<- string) Distribution {
return NewArchDistribution(config, logChan)
})
Register("manjaro", "#35BF5C", FamilyArch, func(config DistroConfig, logChan chan<- string) Distribution {
return NewArchDistribution(config, logChan)
})
Register("obarun", "#2494be", FamilyArch, func(config DistroConfig, logChan chan<- string) Distribution {
return NewArchDistribution(config, logChan)
})
Register("garuda", "#cba6f7", FamilyArch, func(config DistroConfig, logChan chan<- string) Distribution {
return NewArchDistribution(config, logChan)
})
}
type ArchDistribution struct {
*BaseDistribution
*ManualPackageInstaller
config DistroConfig
}
func NewArchDistribution(config DistroConfig, logChan chan<- string) *ArchDistribution {
base := NewBaseDistribution(logChan)
return &ArchDistribution{
BaseDistribution: base,
ManualPackageInstaller: &ManualPackageInstaller{BaseDistribution: base},
config: config,
}
}
func (a *ArchDistribution) GetID() string {
return a.config.ID
}
func (a *ArchDistribution) GetColorHex() string {
return a.config.ColorHex
}
func (a *ArchDistribution) GetFamily() DistroFamily {
return a.config.Family
}
func (a *ArchDistribution) GetPackageManager() PackageManagerType {
return PackageManagerPacman
}
func (a *ArchDistribution) DetectDependencies(ctx context.Context, wm deps.WindowManager) ([]deps.Dependency, error) {
return a.DetectDependenciesWithTerminal(ctx, wm, deps.TerminalGhostty)
}
func (a *ArchDistribution) DetectDependenciesWithTerminal(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal) ([]deps.Dependency, error) {
var dependencies []deps.Dependency
// DMS at the top (shell is prominent)
dependencies = append(dependencies, a.detectDMS())
// Terminal with choice support
dependencies = append(dependencies, a.detectSpecificTerminal(terminal))
// Common detections using base methods
dependencies = append(dependencies, a.detectGit())
dependencies = append(dependencies, a.detectWindowManager(wm))
dependencies = append(dependencies, a.detectQuickshell())
dependencies = append(dependencies, a.detectXDGPortal())
dependencies = append(dependencies, a.detectPolkitAgent())
dependencies = append(dependencies, a.detectAccountsService())
// Hyprland-specific tools
if wm == deps.WindowManagerHyprland {
dependencies = append(dependencies, a.detectHyprlandTools()...)
}
// Niri-specific tools
if wm == deps.WindowManagerNiri {
dependencies = append(dependencies, a.detectXwaylandSatellite())
}
// Base detections (common across distros)
dependencies = append(dependencies, a.detectMatugen())
dependencies = append(dependencies, a.detectDgop())
dependencies = append(dependencies, a.detectHyprpicker())
dependencies = append(dependencies, a.detectClipboardTools()...)
return dependencies, nil
}
func (a *ArchDistribution) detectXDGPortal() deps.Dependency {
status := deps.StatusMissing
if a.packageInstalled("xdg-desktop-portal-gtk") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "xdg-desktop-portal-gtk",
Status: status,
Description: "Desktop integration portal for GTK",
Required: true,
}
}
func (a *ArchDistribution) detectPolkitAgent() deps.Dependency {
status := deps.StatusMissing
if a.packageInstalled("mate-polkit") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "mate-polkit",
Status: status,
Description: "PolicyKit authentication agent",
Required: true,
}
}
func (a *ArchDistribution) detectAccountsService() deps.Dependency {
status := deps.StatusMissing
if a.packageInstalled("accountsservice") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "accountsservice",
Status: status,
Description: "D-Bus interface for user account query and manipulation",
Required: true,
}
}
func (a *ArchDistribution) packageInstalled(pkg string) bool {
cmd := exec.Command("pacman", "-Q", pkg)
err := cmd.Run()
return err == nil
}
func (a *ArchDistribution) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping {
return a.GetPackageMappingWithVariants(wm, make(map[string]deps.PackageVariant))
}
func (a *ArchDistribution) GetPackageMappingWithVariants(wm deps.WindowManager, variants map[string]deps.PackageVariant) map[string]PackageMapping {
packages := map[string]PackageMapping{
"dms (DankMaterialShell)": a.getDMSMapping(variants["dms (DankMaterialShell)"]),
"git": {Name: "git", Repository: RepoTypeSystem},
"quickshell": a.getQuickshellMapping(variants["quickshell"]),
"matugen": a.getMatugenMapping(variants["matugen"]),
"dgop": {Name: "dgop", Repository: RepoTypeSystem},
"ghostty": {Name: "ghostty", Repository: RepoTypeSystem},
"kitty": {Name: "kitty", Repository: RepoTypeSystem},
"alacritty": {Name: "alacritty", Repository: RepoTypeSystem},
"cliphist": {Name: "cliphist", Repository: RepoTypeSystem},
"wl-clipboard": {Name: "wl-clipboard", Repository: RepoTypeSystem},
"xdg-desktop-portal-gtk": {Name: "xdg-desktop-portal-gtk", Repository: RepoTypeSystem},
"mate-polkit": {Name: "mate-polkit", Repository: RepoTypeSystem},
"accountsservice": {Name: "accountsservice", Repository: RepoTypeSystem},
"hyprpicker": {Name: "hyprpicker", Repository: RepoTypeSystem},
}
switch wm {
case deps.WindowManagerHyprland:
packages["hyprland"] = a.getHyprlandMapping(variants["hyprland"])
packages["grim"] = PackageMapping{Name: "grim", Repository: RepoTypeSystem}
packages["slurp"] = PackageMapping{Name: "slurp", Repository: RepoTypeSystem}
packages["hyprctl"] = a.getHyprlandMapping(variants["hyprland"])
packages["grimblast"] = PackageMapping{Name: "grimblast", Repository: RepoTypeManual, BuildFunc: "installGrimblast"}
packages["jq"] = PackageMapping{Name: "jq", Repository: RepoTypeSystem}
case deps.WindowManagerNiri:
packages["niri"] = a.getNiriMapping(variants["niri"])
packages["xwayland-satellite"] = PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeSystem}
}
return packages
}
func (a *ArchDistribution) getQuickshellMapping(variant deps.PackageVariant) PackageMapping {
if forceQuickshellGit || variant == deps.VariantGit {
return PackageMapping{Name: "quickshell-git", Repository: RepoTypeAUR}
}
return PackageMapping{Name: "quickshell", Repository: RepoTypeSystem}
}
func (a *ArchDistribution) getHyprlandMapping(variant deps.PackageVariant) PackageMapping {
if variant == deps.VariantGit {
return PackageMapping{Name: "hyprland-git", Repository: RepoTypeAUR}
}
return PackageMapping{Name: "hyprland", Repository: RepoTypeSystem}
}
func (a *ArchDistribution) getNiriMapping(variant deps.PackageVariant) PackageMapping {
if variant == deps.VariantGit {
return PackageMapping{Name: "niri-git", Repository: RepoTypeAUR}
}
return PackageMapping{Name: "niri", Repository: RepoTypeSystem}
}
func (a *ArchDistribution) getMatugenMapping(variant deps.PackageVariant) PackageMapping {
if runtime.GOARCH == "arm64" {
return PackageMapping{Name: "matugen-git", Repository: RepoTypeAUR}
}
if variant == deps.VariantGit {
return PackageMapping{Name: "matugen-git", Repository: RepoTypeAUR}
}
return PackageMapping{Name: "matugen", Repository: RepoTypeSystem}
}
func (a *ArchDistribution) getDMSMapping(variant deps.PackageVariant) PackageMapping {
if forceDMSGit || variant == deps.VariantGit {
return PackageMapping{Name: "dms-shell-git", Repository: RepoTypeAUR}
}
if a.packageInstalled("dms-shell-git") {
return PackageMapping{Name: "dms-shell-git", Repository: RepoTypeAUR}
}
if a.packageInstalled("dms-shell-bin") {
return PackageMapping{Name: "dms-shell-bin", Repository: RepoTypeAUR}
}
return PackageMapping{Name: "dms-shell-bin", Repository: RepoTypeAUR}
}
func (a *ArchDistribution) detectXwaylandSatellite() deps.Dependency {
status := deps.StatusMissing
if a.commandExists("xwayland-satellite") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "xwayland-satellite",
Status: status,
Description: "Xwayland support",
Required: true,
}
}
func (a *ArchDistribution) InstallPrerequisites(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.06,
Step: "Checking base-devel...",
IsComplete: false,
LogOutput: "Checking if base-devel is installed",
}
checkCmd := exec.CommandContext(ctx, "pacman", "-Qq", "base-devel")
if err := checkCmd.Run(); err == nil {
a.log("base-devel already installed")
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.10,
Step: "base-devel already installed",
IsComplete: false,
LogOutput: "base-devel is already installed on the system",
}
return nil
}
a.log("Installing base-devel...")
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.08,
Step: "Installing base-devel...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo pacman -S --needed --noconfirm base-devel",
LogOutput: "Installing base-devel development tools",
}
cmd := ExecSudoCommand(ctx, sudoPassword, "pacman -S --needed --noconfirm base-devel")
if err := a.runWithProgress(cmd, progressChan, PhasePrerequisites, 0.08, 0.10); err != nil {
return fmt.Errorf("failed to install base-devel: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.12,
Step: "base-devel installation complete",
IsComplete: false,
LogOutput: "base-devel successfully installed",
}
return nil
}
func (a *ArchDistribution) InstallPackages(ctx context.Context, dependencies []deps.Dependency, wm deps.WindowManager, sudoPassword string, reinstallFlags map[string]bool, disabledFlags map[string]bool, skipGlobalUseFlags bool, progressChan chan<- InstallProgressMsg) error {
// Phase 1: Check Prerequisites
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.05,
Step: "Checking system prerequisites...",
IsComplete: false,
LogOutput: "Starting prerequisite check...",
}
if err := a.InstallPrerequisites(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install prerequisites: %w", err)
}
systemPkgs, aurPkgs, manualPkgs, variantMap := a.categorizePackages(dependencies, wm, reinstallFlags, disabledFlags)
// Phase 3: System Packages
if len(systemPkgs) > 0 {
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.35,
Step: fmt.Sprintf("Installing %d system packages...", len(systemPkgs)),
IsComplete: false,
NeedsSudo: true,
LogOutput: fmt.Sprintf("Installing system packages: %s", strings.Join(systemPkgs, ", ")),
}
if err := a.installSystemPackages(ctx, systemPkgs, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install system packages: %w", err)
}
}
// Phase 4: AUR Packages
if len(aurPkgs) > 0 {
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages,
Progress: 0.65,
Step: fmt.Sprintf("Installing %d AUR packages...", len(aurPkgs)),
IsComplete: false,
LogOutput: fmt.Sprintf("Installing AUR packages: %s", strings.Join(aurPkgs, ", ")),
}
if err := a.installAURPackages(ctx, aurPkgs, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install AUR packages: %w", err)
}
}
// Phase 5: Manual Builds
if len(manualPkgs) > 0 {
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.85,
Step: fmt.Sprintf("Building %d packages from source...", len(manualPkgs)),
IsComplete: false,
LogOutput: fmt.Sprintf("Building from source: %s", strings.Join(manualPkgs, ", ")),
}
if err := a.InstallManualPackages(ctx, manualPkgs, variantMap, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install manual packages: %w", err)
}
}
// Phase 6: Configuration
progressChan <- InstallProgressMsg{
Phase: PhaseConfiguration,
Progress: 0.90,
Step: "Configuring system...",
IsComplete: false,
LogOutput: "Starting post-installation configuration...",
}
// Phase 7: Complete
progressChan <- InstallProgressMsg{
Phase: PhaseComplete,
Progress: 1.0,
Step: "Installation complete!",
IsComplete: true,
LogOutput: "All packages installed and configured successfully",
}
return nil
}
func (a *ArchDistribution) categorizePackages(dependencies []deps.Dependency, wm deps.WindowManager, reinstallFlags map[string]bool, disabledFlags map[string]bool) ([]string, []string, []string, map[string]deps.PackageVariant) {
systemPkgs := []string{}
aurPkgs := []string{}
manualPkgs := []string{}
variantMap := make(map[string]deps.PackageVariant)
for _, dep := range dependencies {
variantMap[dep.Name] = dep.Variant
}
packageMap := a.GetPackageMappingWithVariants(wm, variantMap)
for _, dep := range dependencies {
if disabledFlags[dep.Name] {
continue
}
if dep.Status == deps.StatusInstalled && !reinstallFlags[dep.Name] {
continue
}
pkgInfo, exists := packageMap[dep.Name]
if !exists {
manualPkgs = append(manualPkgs, dep.Name)
continue
}
switch pkgInfo.Repository {
case RepoTypeAUR:
aurPkgs = append(aurPkgs, pkgInfo.Name)
case RepoTypeSystem:
systemPkgs = append(systemPkgs, pkgInfo.Name)
case RepoTypeManual:
manualPkgs = append(manualPkgs, dep.Name)
}
}
return systemPkgs, aurPkgs, manualPkgs, variantMap
}
func (a *ArchDistribution) installSystemPackages(ctx context.Context, packages []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if len(packages) == 0 {
return nil
}
a.log(fmt.Sprintf("Installing system packages: %s", strings.Join(packages, ", ")))
args := []string{"pacman", "-S", "--needed", "--noconfirm"}
args = append(args, packages...)
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.40,
Step: "Installing system packages...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
}
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
return a.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.40, 0.60)
}
func (a *ArchDistribution) installAURPackages(ctx context.Context, packages []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if len(packages) == 0 {
return nil
}
a.log(fmt.Sprintf("Installing AUR packages manually: %s", strings.Join(packages, ", ")))
hasNiri := false
hasQuickshell := false
for _, pkg := range packages {
if pkg == "niri-git" {
hasNiri = true
}
if pkg == "quickshell" || pkg == "quickshell-git" {
hasQuickshell = true
}
}
// If quickshell is in the list, always reinstall google-breakpad first
if hasQuickshell {
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages,
Progress: 0.63,
Step: "Reinstalling google-breakpad for quickshell...",
IsComplete: false,
CommandInfo: "Reinstalling prerequisite AUR package for quickshell",
}
if err := a.installSingleAURPackage(ctx, "google-breakpad", sudoPassword, progressChan, 0.63, 0.65); err != nil {
return fmt.Errorf("failed to reinstall google-breakpad prerequisite for quickshell: %w", err)
}
}
// If niri is in the list, install makepkg-git-lfs-proto first if not already installed
if hasNiri {
if !a.packageInstalled("makepkg-git-lfs-proto") {
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages,
Progress: 0.65,
Step: "Installing makepkg-git-lfs-proto for niri...",
IsComplete: false,
CommandInfo: "Installing prerequisite for niri-git",
}
if err := a.installSingleAURPackage(ctx, "makepkg-git-lfs-proto", sudoPassword, progressChan, 0.65, 0.67); err != nil {
return fmt.Errorf("failed to install makepkg-git-lfs-proto prerequisite for niri: %w", err)
}
}
}
// Reorder packages to ensure dms-shell-git dependencies are installed first
orderedPackages := a.reorderAURPackages(packages)
baseProgress := 0.67
progressStep := 0.13 / float64(len(orderedPackages))
for i, pkg := range orderedPackages {
currentProgress := baseProgress + (float64(i) * progressStep)
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages,
Progress: currentProgress,
Step: fmt.Sprintf("Installing AUR package %s (%d/%d)...", pkg, i+1, len(packages)),
IsComplete: false,
CommandInfo: fmt.Sprintf("Building and installing %s", pkg),
}
if err := a.installSingleAURPackage(ctx, pkg, sudoPassword, progressChan, currentProgress, currentProgress+progressStep); err != nil {
return fmt.Errorf("failed to install AUR package %s: %w", pkg, err)
}
}
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages,
Progress: 0.80,
Step: "All AUR packages installed successfully",
IsComplete: false,
LogOutput: fmt.Sprintf("Successfully installed AUR packages: %s", strings.Join(packages, ", ")),
}
return nil
}
func (a *ArchDistribution) reorderAURPackages(packages []string) []string {
dmsDepencies := []string{"quickshell", "quickshell-git", "dgop"}
var deps []string
var others []string
var dmsShell []string
for _, pkg := range packages {
if pkg == "dms-shell-git" || pkg == "dms-shell-bin" {
dmsShell = append(dmsShell, pkg)
} else {
isDep := false
for _, dep := range dmsDepencies {
if pkg == dep {
deps = append(deps, pkg)
isDep = true
break
}
}
if !isDep {
others = append(others, pkg)
}
}
}
result := append(deps, others...)
result = append(result, dmsShell...)
return result
}
func (a *ArchDistribution) installSingleAURPackage(ctx context.Context, pkg, sudoPassword string, progressChan chan<- InstallProgressMsg, startProgress, endProgress float64) error {
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get user home directory: %w", err)
}
buildDir := filepath.Join(homeDir, ".cache", "dankinstall", "aur-builds", pkg)
// Clean up any existing cache first
if err := os.RemoveAll(buildDir); err != nil {
a.log(fmt.Sprintf("Warning: failed to clean existing cache for %s: %v", pkg, err))
}
if err := os.MkdirAll(buildDir, 0755); err != nil {
return fmt.Errorf("failed to create build directory: %w", err)
}
defer func() {
if removeErr := os.RemoveAll(buildDir); removeErr != nil {
a.log(fmt.Sprintf("Warning: failed to cleanup build directory %s: %v", buildDir, removeErr))
}
}()
// Clone the AUR package
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages,
Progress: startProgress + 0.1*(endProgress-startProgress),
Step: fmt.Sprintf("Cloning %s from AUR...", pkg),
IsComplete: false,
CommandInfo: fmt.Sprintf("git clone https://aur.archlinux.org/%s.git", pkg),
}
cloneCmd := exec.CommandContext(ctx, "git", "clone", fmt.Sprintf("https://aur.archlinux.org/%s.git", pkg), filepath.Join(buildDir, pkg))
if err := a.runWithProgress(cloneCmd, progressChan, PhaseAURPackages, startProgress+0.1*(endProgress-startProgress), startProgress+0.2*(endProgress-startProgress)); err != nil {
return fmt.Errorf("failed to clone %s: %w", pkg, err)
}
packageDir := filepath.Join(buildDir, pkg)
if pkg == "niri-git" {
pkgbuildPath := filepath.Join(packageDir, "PKGBUILD")
sedCmd := exec.CommandContext(ctx, "sed", "-i", "s/makepkg-git-lfs-proto//g", pkgbuildPath)
if err := sedCmd.Run(); err != nil {
return fmt.Errorf("failed to patch PKGBUILD for niri-git: %w", err)
}
srcinfoPath := filepath.Join(packageDir, ".SRCINFO")
sedCmd2 := exec.CommandContext(ctx, "sed", "-i", "/makedepends = makepkg-git-lfs-proto/d", srcinfoPath)
if err := sedCmd2.Run(); err != nil {
return fmt.Errorf("failed to patch .SRCINFO for niri-git: %w", err)
}
}
if pkg == "dms-shell-git" || pkg == "dms-shell-bin" {
srcinfoPath := filepath.Join(packageDir, ".SRCINFO")
depsToRemove := []string{
"depends = quickshell",
"depends = dgop",
}
for _, dep := range depsToRemove {
sedCmd := exec.CommandContext(ctx, "sed", "-i", fmt.Sprintf("/%s/d", dep), srcinfoPath)
if err := sedCmd.Run(); err != nil {
return fmt.Errorf("failed to remove dependency %s from .SRCINFO for %s: %w", dep, pkg, err)
}
}
}
// Remove all optdepends from .SRCINFO for all packages
srcinfoPath := filepath.Join(packageDir, ".SRCINFO")
optdepsCmd := exec.CommandContext(ctx, "sed", "-i", "/^[[:space:]]*optdepends = /d", srcinfoPath)
if err := optdepsCmd.Run(); err != nil {
return fmt.Errorf("failed to remove optdepends from .SRCINFO for %s: %w", pkg, err)
}
// Skip dependency installation for dms-shell-git and dms-shell-bin
// since we manually manage those dependencies
if pkg != "dms-shell-git" && pkg != "dms-shell-bin" {
// Pre-install dependencies from .SRCINFO
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages,
Progress: startProgress + 0.3*(endProgress-startProgress),
Step: fmt.Sprintf("Installing dependencies for %s...", pkg),
IsComplete: false,
CommandInfo: "Installing package dependencies and makedepends",
}
// Install dependencies and makedepends explicitly
srcinfoPath = filepath.Join(packageDir, ".SRCINFO")
depsCmd := exec.CommandContext(ctx, "bash", "-c",
fmt.Sprintf(`
deps=$(grep "depends = " "%s" | grep -v "makedepends" | sed 's/.*depends = //' | tr '\n' ' ' | sed 's/[[:space:]]*$//')
if [[ "%s" == *"quickshell"* ]]; then
deps=$(echo "$deps" | sed 's/google-breakpad//g' | sed 's/ / /g' | sed 's/^ *//g' | sed 's/ *$//g')
fi
if [ ! -z "$deps" ] && [ "$deps" != " " ]; then
echo '%s' | sudo -S pacman -S --needed --noconfirm $deps
fi
`, srcinfoPath, pkg, sudoPassword))
if err := a.runWithProgress(depsCmd, progressChan, PhaseAURPackages, startProgress+0.3*(endProgress-startProgress), startProgress+0.35*(endProgress-startProgress)); err != nil {
return fmt.Errorf("FAILED to install runtime dependencies for %s: %w", pkg, err)
}
makedepsCmd := exec.CommandContext(ctx, "bash", "-c",
fmt.Sprintf(`
makedeps=$(grep -E "^[[:space:]]*makedepends = " "%s" | sed 's/^[[:space:]]*makedepends = //' | tr '\n' ' ')
if [ ! -z "$makedeps" ]; then
echo '%s' | sudo -S pacman -S --needed --noconfirm $makedeps
fi
`, srcinfoPath, sudoPassword))
if err := a.runWithProgress(makedepsCmd, progressChan, PhaseAURPackages, startProgress+0.35*(endProgress-startProgress), startProgress+0.4*(endProgress-startProgress)); err != nil {
return fmt.Errorf("FAILED to install make dependencies for %s: %w", pkg, err)
}
} else {
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages,
Progress: startProgress + 0.35*(endProgress-startProgress),
Step: fmt.Sprintf("Skipping dependency installation for %s (manually managed)...", pkg),
IsComplete: false,
LogOutput: fmt.Sprintf("Dependencies for %s are installed separately", pkg),
}
}
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages,
Progress: startProgress + 0.4*(endProgress-startProgress),
Step: fmt.Sprintf("Building %s...", pkg),
IsComplete: false,
CommandInfo: "makepkg --noconfirm",
}
buildCmd := exec.CommandContext(ctx, "makepkg", "--noconfirm")
buildCmd.Dir = packageDir
buildCmd.Env = append(os.Environ(), "PKGEXT=.pkg.tar") // Disable compression for speed
if err := a.runWithProgress(buildCmd, progressChan, PhaseAURPackages, startProgress+0.4*(endProgress-startProgress), startProgress+0.7*(endProgress-startProgress)); err != nil {
return fmt.Errorf("failed to build %s: %w", pkg, err)
}
// Find built package file
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages,
Progress: startProgress + 0.7*(endProgress-startProgress),
Step: fmt.Sprintf("Installing %s...", pkg),
IsComplete: false,
CommandInfo: "sudo pacman -U built-package",
}
// Find .pkg.tar* files - for split packages, install the base and any installed compositor variants
var files []string
if pkg == "dms-shell-git" || pkg == "dms-shell-bin" {
// For DMS split packages, install base package
pattern := filepath.Join(packageDir, fmt.Sprintf("%s-%s*.pkg.tar*", pkg, "*"))
matches, err := filepath.Glob(pattern)
if err == nil {
for _, match := range matches {
basename := filepath.Base(match)
// Always include base package
if !strings.Contains(basename, "hyprland") && !strings.Contains(basename, "niri") {
files = append(files, match)
}
}
}
// Also update compositor-specific packages if they're installed
if strings.HasSuffix(pkg, "-git") {
if a.packageInstalled("dms-shell-hyprland-git") {
hyprlandPattern := filepath.Join(packageDir, "dms-shell-hyprland-git-*.pkg.tar*")
if hyprlandMatches, err := filepath.Glob(hyprlandPattern); err == nil && len(hyprlandMatches) > 0 {
files = append(files, hyprlandMatches[0])
}
}
if a.packageInstalled("dms-shell-niri-git") {
niriPattern := filepath.Join(packageDir, "dms-shell-niri-git-*.pkg.tar*")
if niriMatches, err := filepath.Glob(niriPattern); err == nil && len(niriMatches) > 0 {
files = append(files, niriMatches[0])
}
}
}
} else {
// For other packages, install all built packages
matches, _ := filepath.Glob(filepath.Join(packageDir, "*.pkg.tar*"))
files = matches
}
if len(files) == 0 {
return fmt.Errorf("no package files found after building %s", pkg)
}
installArgs := []string{"pacman", "-U", "--noconfirm"}
installArgs = append(installArgs, files...)
installCmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(installArgs, " "))
fileNames := make([]string, len(files))
for i, f := range files {
fileNames[i] = filepath.Base(f)
}
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages,
Progress: startProgress + 0.7*(endProgress-startProgress),
LogOutput: fmt.Sprintf("Installing packages: %s", strings.Join(fileNames, ", ")),
}
if err := a.runWithProgress(installCmd, progressChan, PhaseAURPackages, startProgress+0.7*(endProgress-startProgress), endProgress); err != nil {
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages,
Progress: startProgress,
LogOutput: fmt.Sprintf("ERROR: pacman -U failed for %s with error: %v", pkg, err),
Error: err,
}
return fmt.Errorf("failed to install built package %s: %w", pkg, err)
}
a.log(fmt.Sprintf("Successfully installed AUR package: %s", pkg))
return nil
}

View File

@@ -0,0 +1,659 @@
package distros
import (
"bufio"
"context"
_ "embed"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"strings"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/version"
)
const forceQuickshellGit = false
const forceDMSGit = false
// BaseDistribution provides common functionality for all distributions
type BaseDistribution struct {
logChan chan<- string
}
// NewBaseDistribution creates a new base distribution
func NewBaseDistribution(logChan chan<- string) *BaseDistribution {
return &BaseDistribution{
logChan: logChan,
}
}
// Common helper methods
func (b *BaseDistribution) commandExists(cmd string) bool {
_, err := exec.LookPath(cmd)
return err == nil
}
func (b *BaseDistribution) CommandExists(cmd string) bool {
return b.commandExists(cmd)
}
func (b *BaseDistribution) log(message string) {
if b.logChan != nil {
b.logChan <- message
}
}
func (b *BaseDistribution) logError(message string, err error) {
errorMsg := fmt.Sprintf("ERROR: %s: %v", message, err)
b.log(errorMsg)
}
// escapeSingleQuotes escapes single quotes in a string for safe use in bash single-quoted strings.
// It replaces each ' with '\” which closes the quote, adds an escaped quote, and reopens the quote.
// This prevents shell injection and syntax errors when passwords contain single quotes or apostrophes.
func escapeSingleQuotes(s string) string {
return strings.ReplaceAll(s, "'", "'\\''")
}
// MakeSudoCommand creates a command string that safely passes password to sudo.
// This helper escapes special characters in the password to prevent shell injection
// and syntax errors when passwords contain single quotes, apostrophes, or other special chars.
func MakeSudoCommand(sudoPassword string, command string) string {
return fmt.Sprintf("echo '%s' | sudo -S %s", escapeSingleQuotes(sudoPassword), command)
}
// ExecSudoCommand creates an exec.Cmd that runs a command with sudo using the provided password.
// The password is properly escaped to prevent shell injection and syntax errors.
func ExecSudoCommand(ctx context.Context, sudoPassword string, command string) *exec.Cmd {
cmdStr := MakeSudoCommand(sudoPassword, command)
return exec.CommandContext(ctx, "bash", "-c", cmdStr)
}
// Common dependency detection methods
func (b *BaseDistribution) detectGit() deps.Dependency {
status := deps.StatusMissing
if b.commandExists("git") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "git",
Status: status,
Description: "Version control system",
Required: true,
}
}
func (b *BaseDistribution) detectMatugen() deps.Dependency {
status := deps.StatusMissing
if b.commandExists("matugen") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "matugen",
Status: status,
Description: "Material Design color generation tool",
Required: true,
}
}
func (b *BaseDistribution) detectDgop() deps.Dependency {
status := deps.StatusMissing
if b.commandExists("dgop") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "dgop",
Status: status,
Description: "Desktop portal management tool",
Required: true,
}
}
func (b *BaseDistribution) detectDMS() deps.Dependency {
dmsPath := filepath.Join(os.Getenv("HOME"), ".config/quickshell/dms")
status := deps.StatusMissing
currentVersion := ""
if _, err := os.Stat(dmsPath); err == nil {
status = deps.StatusInstalled
// Only get current version, don't check for updates (lazy loading)
current, err := version.GetCurrentDMSVersion()
if err == nil {
currentVersion = current
}
}
dep := deps.Dependency{
Name: "dms (DankMaterialShell)",
Status: status,
Description: "Desktop Management System configuration",
Required: true,
CanToggle: true,
}
if currentVersion != "" {
dep.Version = currentVersion
}
return dep
}
func (b *BaseDistribution) detectSpecificTerminal(terminal deps.Terminal) deps.Dependency {
switch terminal {
case deps.TerminalGhostty:
status := deps.StatusMissing
if b.commandExists("ghostty") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "ghostty",
Status: status,
Description: "A fast, native terminal emulator built in Zig.",
Required: true,
}
case deps.TerminalKitty:
status := deps.StatusMissing
if b.commandExists("kitty") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "kitty",
Status: status,
Description: "A feature-rich, customizable terminal emulator.",
Required: true,
}
case deps.TerminalAlacritty:
status := deps.StatusMissing
if b.commandExists("alacritty") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "alacritty",
Status: status,
Description: "A simple terminal emulator. (No dynamic theming)",
Required: true,
}
default:
return b.detectSpecificTerminal(deps.TerminalGhostty)
}
}
func (b *BaseDistribution) detectClipboardTools() []deps.Dependency {
var dependencies []deps.Dependency
cliphist := deps.StatusMissing
if b.commandExists("cliphist") {
cliphist = deps.StatusInstalled
}
wlClipboard := deps.StatusMissing
if b.commandExists("wl-copy") && b.commandExists("wl-paste") {
wlClipboard = deps.StatusInstalled
}
dependencies = append(dependencies,
deps.Dependency{
Name: "cliphist",
Status: cliphist,
Description: "Wayland clipboard manager",
Required: true,
},
deps.Dependency{
Name: "wl-clipboard",
Status: wlClipboard,
Description: "Wayland clipboard utilities",
Required: true,
},
)
return dependencies
}
func (b *BaseDistribution) detectHyprpicker() deps.Dependency {
status := deps.StatusMissing
if b.commandExists("hyprpicker") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "hyprpicker",
Status: status,
Description: "Color picker for Wayland",
Required: true,
}
}
func (b *BaseDistribution) detectHyprlandTools() []deps.Dependency {
var dependencies []deps.Dependency
tools := []struct {
name string
description string
}{
{"grim", "Screenshot utility for Wayland"},
{"slurp", "Region selection utility for Wayland"},
{"hyprctl", "Hyprland control utility"},
{"grimblast", "Screenshot script for Hyprland"},
{"jq", "JSON processor"},
}
for _, tool := range tools {
status := deps.StatusMissing
if b.commandExists(tool.name) {
status = deps.StatusInstalled
}
dependencies = append(dependencies, deps.Dependency{
Name: tool.name,
Status: status,
Description: tool.description,
Required: true,
})
}
return dependencies
}
func (b *BaseDistribution) detectQuickshell() deps.Dependency {
if !b.commandExists("qs") {
return deps.Dependency{
Name: "quickshell",
Status: deps.StatusMissing,
Description: "QtQuick based desktop shell toolkit",
Required: true,
Variant: deps.VariantStable,
CanToggle: true,
}
}
cmd := exec.Command("qs", "--version")
output, err := cmd.Output()
if err != nil {
return deps.Dependency{
Name: "quickshell",
Status: deps.StatusNeedsReinstall,
Description: "QtQuick based desktop shell toolkit (version check failed)",
Required: true,
Variant: deps.VariantStable,
CanToggle: true,
}
}
versionStr := string(output)
versionRegex := regexp.MustCompile(`quickshell (\d+\.\d+\.\d+)`)
matches := versionRegex.FindStringSubmatch(versionStr)
if len(matches) < 2 {
return deps.Dependency{
Name: "quickshell",
Status: deps.StatusNeedsReinstall,
Description: "QtQuick based desktop shell toolkit (unknown version)",
Required: true,
Variant: deps.VariantStable,
CanToggle: true,
}
}
version := matches[1]
variant := deps.VariantStable
if strings.Contains(versionStr, "git") || strings.Contains(versionStr, "+") {
variant = deps.VariantGit
}
if b.versionCompare(version, "0.2.0") >= 0 {
return deps.Dependency{
Name: "quickshell",
Status: deps.StatusInstalled,
Version: version,
Description: "QtQuick based desktop shell toolkit",
Required: true,
Variant: variant,
CanToggle: true,
}
}
return deps.Dependency{
Name: "quickshell",
Status: deps.StatusNeedsUpdate,
Variant: variant,
CanToggle: true,
Version: version,
Description: "QtQuick based desktop shell toolkit (needs 0.2.0+)",
Required: true,
}
}
func (b *BaseDistribution) detectWindowManager(wm deps.WindowManager) deps.Dependency {
switch wm {
case deps.WindowManagerHyprland:
status := deps.StatusMissing
variant := deps.VariantStable
version := ""
if b.commandExists("hyprland") || b.commandExists("Hyprland") {
status = deps.StatusInstalled
cmd := exec.Command("hyprctl", "version")
if output, err := cmd.Output(); err == nil {
outStr := string(output)
if strings.Contains(outStr, "git") || strings.Contains(outStr, "dirty") {
variant = deps.VariantGit
}
if versionRegex := regexp.MustCompile(`v(\d+\.\d+\.\d+)`); versionRegex.MatchString(outStr) {
matches := versionRegex.FindStringSubmatch(outStr)
if len(matches) > 1 {
version = matches[1]
}
}
}
}
return deps.Dependency{
Name: "hyprland",
Status: status,
Version: version,
Description: "Dynamic tiling Wayland compositor",
Required: true,
Variant: variant,
CanToggle: true,
}
case deps.WindowManagerNiri:
status := deps.StatusMissing
variant := deps.VariantStable
version := ""
if b.commandExists("niri") {
status = deps.StatusInstalled
cmd := exec.Command("niri", "--version")
if output, err := cmd.Output(); err == nil {
outStr := string(output)
if strings.Contains(outStr, "git") || strings.Contains(outStr, "+") {
variant = deps.VariantGit
}
if versionRegex := regexp.MustCompile(`niri (\d+\.\d+)`); versionRegex.MatchString(outStr) {
matches := versionRegex.FindStringSubmatch(outStr)
if len(matches) > 1 {
version = matches[1]
}
}
}
}
return deps.Dependency{
Name: "niri",
Status: status,
Version: version,
Description: "Scrollable-tiling Wayland compositor",
Required: true,
Variant: variant,
CanToggle: true,
}
default:
return deps.Dependency{
Name: "unknown-wm",
Status: deps.StatusMissing,
Description: "Unknown window manager",
Required: true,
}
}
}
// Version comparison helper
func (b *BaseDistribution) versionCompare(v1, v2 string) int {
parts1 := strings.Split(v1, ".")
parts2 := strings.Split(v2, ".")
for i := 0; i < len(parts1) && i < len(parts2); i++ {
if parts1[i] < parts2[i] {
return -1
}
if parts1[i] > parts2[i] {
return 1
}
}
if len(parts1) < len(parts2) {
return -1
}
if len(parts1) > len(parts2) {
return 1
}
return 0
}
// Common installation helper
func (b *BaseDistribution) runWithProgress(cmd *exec.Cmd, progressChan chan<- InstallProgressMsg, phase InstallPhase, startProgress, endProgress float64) error {
return b.runWithProgressTimeout(cmd, progressChan, phase, startProgress, endProgress, 20*time.Minute)
}
func (b *BaseDistribution) runWithProgressTimeout(cmd *exec.Cmd, progressChan chan<- InstallProgressMsg, phase InstallPhase, startProgress, endProgress float64, timeout time.Duration) error {
return b.runWithProgressStepTimeout(cmd, progressChan, phase, startProgress, endProgress, "Installing...", timeout)
}
func (b *BaseDistribution) runWithProgressStep(cmd *exec.Cmd, progressChan chan<- InstallProgressMsg, phase InstallPhase, startProgress, endProgress float64, stepMessage string) error {
return b.runWithProgressStepTimeout(cmd, progressChan, phase, startProgress, endProgress, stepMessage, 20*time.Minute)
}
func (b *BaseDistribution) runWithProgressStepTimeout(cmd *exec.Cmd, progressChan chan<- InstallProgressMsg, phase InstallPhase, startProgress, endProgress float64, stepMessage string, timeoutDuration time.Duration) error {
stdout, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("failed to create stdout pipe: %w", err)
}
stderr, err := cmd.StderrPipe()
if err != nil {
return fmt.Errorf("failed to create stderr pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return err
}
outputChan := make(chan string, 100)
done := make(chan error, 1)
go func() {
scanner := bufio.NewScanner(stdout)
for scanner.Scan() {
line := scanner.Text()
b.log(line)
outputChan <- line
}
}()
go func() {
scanner := bufio.NewScanner(stderr)
for scanner.Scan() {
line := scanner.Text()
b.log(line)
outputChan <- line
}
}()
go func() {
done <- cmd.Wait()
close(outputChan)
}()
ticker := time.NewTicker(200 * time.Millisecond)
defer ticker.Stop()
progress := startProgress
progressStep := (endProgress - startProgress) / 50
lastOutput := ""
var timeout *time.Timer
var timeoutChan <-chan time.Time
if timeoutDuration > 0 {
timeout = time.NewTimer(timeoutDuration)
defer timeout.Stop()
timeoutChan = timeout.C
}
for {
select {
case err := <-done:
if err != nil {
b.logError("Command execution failed", err)
b.log(fmt.Sprintf("Last output before failure: %s", lastOutput))
progressChan <- InstallProgressMsg{
Phase: phase,
Progress: startProgress,
Step: "Command failed",
IsComplete: false,
LogOutput: lastOutput,
Error: err,
}
return err
}
progressChan <- InstallProgressMsg{
Phase: phase,
Progress: endProgress,
Step: "Installation step complete",
IsComplete: false,
LogOutput: lastOutput,
}
return nil
case output, ok := <-outputChan:
if ok {
lastOutput = output
progressChan <- InstallProgressMsg{
Phase: phase,
Progress: progress,
Step: stepMessage,
IsComplete: false,
LogOutput: output,
}
if timeout != nil {
timeout.Reset(timeoutDuration)
}
}
case <-timeoutChan:
if cmd.Process != nil {
cmd.Process.Kill()
}
err := fmt.Errorf("installation timed out after %v", timeoutDuration)
progressChan <- InstallProgressMsg{
Phase: phase,
Progress: startProgress,
Step: "Installation timed out",
IsComplete: false,
LogOutput: lastOutput,
Error: err,
}
return err
case <-ticker.C:
if progress < endProgress-0.01 {
progress += progressStep
progressChan <- InstallProgressMsg{
Phase: phase,
Progress: progress,
Step: "Installing...",
IsComplete: false,
LogOutput: lastOutput,
}
}
}
}
}
// installDMSBinary installs the DMS binary from GitHub releases
func (b *BaseDistribution) installDMSBinary(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
b.log("Installing/updating DMS binary...")
// Detect architecture
arch := runtime.GOARCH
switch arch {
case "amd64":
case "arm64":
default:
return fmt.Errorf("unsupported architecture for DMS: %s", arch)
}
progressChan <- InstallProgressMsg{
Phase: PhaseConfiguration,
Progress: 0.80,
Step: "Downloading DMS binary...",
IsComplete: false,
CommandInfo: fmt.Sprintf("Downloading dms-%s.gz", arch),
}
// Get latest release version
latestVersionCmd := exec.CommandContext(ctx, "bash", "-c",
`curl -s https://api.github.com/repos/AvengeMedia/DankMaterialShell/releases/latest | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/'`)
versionOutput, err := latestVersionCmd.Output()
if err != nil {
return fmt.Errorf("failed to get latest DMS version: %w", err)
}
version := strings.TrimSpace(string(versionOutput))
if version == "" {
return fmt.Errorf("could not determine latest DMS version")
}
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get user home directory: %w", err)
}
tmpDir := filepath.Join(homeDir, ".cache", "dankinstall", "manual-builds")
if err := os.MkdirAll(tmpDir, 0755); err != nil {
return fmt.Errorf("failed to create temp directory: %w", err)
}
defer os.RemoveAll(tmpDir)
// Download the gzipped binary
downloadURL := fmt.Sprintf("https://github.com/AvengeMedia/DankMaterialShell/releases/download/%s/dms-cli-%s.gz", version, arch)
gzPath := filepath.Join(tmpDir, "dms.gz")
downloadCmd := exec.CommandContext(ctx, "curl", "-L", downloadURL, "-o", gzPath)
if err := downloadCmd.Run(); err != nil {
return fmt.Errorf("failed to download DMS binary: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhaseConfiguration,
Progress: 0.85,
Step: "Extracting DMS binary...",
IsComplete: false,
CommandInfo: "gunzip dms.gz",
}
// Extract the binary
extractCmd := exec.CommandContext(ctx, "gunzip", gzPath)
if err := extractCmd.Run(); err != nil {
return fmt.Errorf("failed to extract DMS binary: %w", err)
}
binaryPath := filepath.Join(tmpDir, "dms")
// Make it executable
chmodCmd := exec.CommandContext(ctx, "chmod", "+x", binaryPath)
if err := chmodCmd.Run(); err != nil {
return fmt.Errorf("failed to make DMS binary executable: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhaseConfiguration,
Progress: 0.88,
Step: "Installing DMS to /usr/local/bin...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo cp dms /usr/local/bin/",
}
// Install to /usr/local/bin
installCmd := ExecSudoCommand(ctx, sudoPassword,
fmt.Sprintf("cp %s /usr/local/bin/dms", binaryPath))
if err := installCmd.Run(); err != nil {
return fmt.Errorf("failed to install DMS binary: %w", err)
}
b.log("DMS binary installed successfully")
return nil
}

View File

@@ -0,0 +1,220 @@
package distros
import (
"os"
"os/exec"
"path/filepath"
"testing"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
)
func TestBaseDistribution_detectDMS_NotInstalled(t *testing.T) {
originalHome := os.Getenv("HOME")
defer os.Setenv("HOME", originalHome)
tempDir := t.TempDir()
os.Setenv("HOME", tempDir)
logChan := make(chan string, 10)
defer close(logChan)
base := NewBaseDistribution(logChan)
dep := base.detectDMS()
if dep.Status != deps.StatusMissing {
t.Errorf("Expected StatusMissing, got %d", dep.Status)
}
if dep.Name != "dms (DankMaterialShell)" {
t.Errorf("Expected name 'dms (DankMaterialShell)', got %s", dep.Name)
}
if !dep.Required {
t.Error("Expected Required to be true")
}
}
func TestBaseDistribution_detectDMS_Installed(t *testing.T) {
if !commandExists("git") {
t.Skip("git not available")
}
tempDir := t.TempDir()
dmsPath := filepath.Join(tempDir, ".config", "quickshell", "dms")
os.MkdirAll(dmsPath, 0755)
originalHome := os.Getenv("HOME")
defer os.Setenv("HOME", originalHome)
os.Setenv("HOME", tempDir)
exec.Command("git", "init", dmsPath).Run()
exec.Command("git", "-C", dmsPath, "config", "user.email", "test@test.com").Run()
exec.Command("git", "-C", dmsPath, "config", "user.name", "Test User").Run()
exec.Command("git", "-C", dmsPath, "checkout", "-b", "master").Run()
testFile := filepath.Join(dmsPath, "test.txt")
os.WriteFile(testFile, []byte("test"), 0644)
exec.Command("git", "-C", dmsPath, "add", ".").Run()
exec.Command("git", "-C", dmsPath, "commit", "-m", "initial").Run()
logChan := make(chan string, 10)
defer close(logChan)
base := NewBaseDistribution(logChan)
dep := base.detectDMS()
if dep.Status == deps.StatusMissing {
t.Error("Expected DMS to be detected as installed")
}
if dep.Name != "dms (DankMaterialShell)" {
t.Errorf("Expected name 'dms (DankMaterialShell)', got %s", dep.Name)
}
if !dep.Required {
t.Error("Expected Required to be true")
}
t.Logf("Status: %d, Version: %s", dep.Status, dep.Version)
}
func TestBaseDistribution_detectDMS_NeedsUpdate(t *testing.T) {
if !commandExists("git") {
t.Skip("git not available")
}
tempDir := t.TempDir()
dmsPath := filepath.Join(tempDir, ".config", "quickshell", "dms")
os.MkdirAll(dmsPath, 0755)
originalHome := os.Getenv("HOME")
defer os.Setenv("HOME", originalHome)
os.Setenv("HOME", tempDir)
exec.Command("git", "init", dmsPath).Run()
exec.Command("git", "-C", dmsPath, "config", "user.email", "test@test.com").Run()
exec.Command("git", "-C", dmsPath, "config", "user.name", "Test User").Run()
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)
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()
exec.Command("git", "-C", dmsPath, "checkout", "v0.0.1").Run()
logChan := make(chan string, 10)
defer close(logChan)
base := NewBaseDistribution(logChan)
dep := base.detectDMS()
if dep.Name != "dms (DankMaterialShell)" {
t.Errorf("Expected name 'dms (DankMaterialShell)', got %s", dep.Name)
}
if !dep.Required {
t.Error("Expected Required to be true")
}
t.Logf("Status: %d, Version: %s", dep.Status, dep.Version)
}
func TestBaseDistribution_detectDMS_DirectoryWithoutGit(t *testing.T) {
tempDir := t.TempDir()
dmsPath := filepath.Join(tempDir, ".config", "quickshell", "dms")
os.MkdirAll(dmsPath, 0755)
originalHome := os.Getenv("HOME")
defer os.Setenv("HOME", originalHome)
os.Setenv("HOME", tempDir)
logChan := make(chan string, 10)
defer close(logChan)
base := NewBaseDistribution(logChan)
dep := base.detectDMS()
if dep.Status == deps.StatusMissing {
t.Error("Expected DMS to be detected as present")
}
if dep.Name != "dms (DankMaterialShell)" {
t.Errorf("Expected name 'dms (DankMaterialShell)', got %s", dep.Name)
}
if !dep.Required {
t.Error("Expected Required to be true")
}
}
func TestBaseDistribution_NewBaseDistribution(t *testing.T) {
logChan := make(chan string, 10)
defer close(logChan)
base := NewBaseDistribution(logChan)
if base == nil {
t.Fatal("NewBaseDistribution returned nil")
}
if base.logChan == nil {
t.Error("logChan was not set")
}
}
func commandExists(cmd string) bool {
_, err := exec.LookPath(cmd)
return err == nil
}
func TestBaseDistribution_versionCompare(t *testing.T) {
logChan := make(chan string, 10)
defer close(logChan)
base := NewBaseDistribution(logChan)
tests := []struct {
v1 string
v2 string
expected int
}{
{"0.1.0", "0.1.0", 0},
{"0.1.0", "0.1.1", -1},
{"0.1.1", "0.1.0", 1},
{"0.2.0", "0.1.9", 1},
{"1.0.0", "0.9.9", 1},
}
for _, tt := range tests {
result := base.versionCompare(tt.v1, tt.v2)
if result != tt.expected {
t.Errorf("versionCompare(%q, %q) = %d; want %d", tt.v1, tt.v2, result, tt.expected)
}
}
}
func TestBaseDistribution_versionCompare_WithPrefix(t *testing.T) {
logChan := make(chan string, 10)
defer close(logChan)
base := NewBaseDistribution(logChan)
tests := []struct {
v1 string
v2 string
expected int
}{
{"v0.1.0", "v0.1.0", 0},
{"v0.1.0", "v0.1.1", -1},
{"v0.1.1", "v0.1.0", 1},
}
for _, tt := range tests {
result := base.versionCompare(tt.v1, tt.v2)
if result != tt.expected {
t.Errorf("versionCompare(%q, %q) = %d; want %d", tt.v1, tt.v2, result, tt.expected)
}
}
}

View File

@@ -0,0 +1,542 @@
package distros
import (
"context"
"fmt"
"os/exec"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
)
func init() {
Register("debian", "#A80030", FamilyDebian, func(config DistroConfig, logChan chan<- string) Distribution {
return NewDebianDistribution(config, logChan)
})
}
type DebianDistribution struct {
*BaseDistribution
*ManualPackageInstaller
config DistroConfig
}
func NewDebianDistribution(config DistroConfig, logChan chan<- string) *DebianDistribution {
base := NewBaseDistribution(logChan)
return &DebianDistribution{
BaseDistribution: base,
ManualPackageInstaller: &ManualPackageInstaller{BaseDistribution: base},
config: config,
}
}
func (d *DebianDistribution) GetID() string {
return d.config.ID
}
func (d *DebianDistribution) GetColorHex() string {
return d.config.ColorHex
}
func (d *DebianDistribution) GetFamily() DistroFamily {
return d.config.Family
}
func (d *DebianDistribution) GetPackageManager() PackageManagerType {
return PackageManagerAPT
}
func (d *DebianDistribution) DetectDependencies(ctx context.Context, wm deps.WindowManager) ([]deps.Dependency, error) {
return d.DetectDependenciesWithTerminal(ctx, wm, deps.TerminalGhostty)
}
func (d *DebianDistribution) DetectDependenciesWithTerminal(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal) ([]deps.Dependency, error) {
var dependencies []deps.Dependency
dependencies = append(dependencies, d.detectDMS())
dependencies = append(dependencies, d.detectSpecificTerminal(terminal))
dependencies = append(dependencies, d.detectGit())
dependencies = append(dependencies, d.detectWindowManager(wm))
dependencies = append(dependencies, d.detectQuickshell())
dependencies = append(dependencies, d.detectXDGPortal())
dependencies = append(dependencies, d.detectPolkitAgent())
dependencies = append(dependencies, d.detectAccountsService())
if wm == deps.WindowManagerNiri {
dependencies = append(dependencies, d.detectXwaylandSatellite())
}
dependencies = append(dependencies, d.detectMatugen())
dependencies = append(dependencies, d.detectDgop())
dependencies = append(dependencies, d.detectHyprpicker())
dependencies = append(dependencies, d.detectClipboardTools()...)
return dependencies, nil
}
func (d *DebianDistribution) detectXDGPortal() deps.Dependency {
status := deps.StatusMissing
if d.packageInstalled("xdg-desktop-portal-gtk") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "xdg-desktop-portal-gtk",
Status: status,
Description: "Desktop integration portal for GTK",
Required: true,
}
}
func (d *DebianDistribution) detectPolkitAgent() deps.Dependency {
status := deps.StatusMissing
if d.packageInstalled("mate-polkit") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "mate-polkit",
Status: status,
Description: "PolicyKit authentication agent",
Required: true,
}
}
func (d *DebianDistribution) detectXwaylandSatellite() deps.Dependency {
status := deps.StatusMissing
if d.commandExists("xwayland-satellite") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "xwayland-satellite",
Status: status,
Description: "Xwayland support",
Required: true,
}
}
func (d *DebianDistribution) detectAccountsService() deps.Dependency {
status := deps.StatusMissing
if d.packageInstalled("accountsservice") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "accountsservice",
Status: status,
Description: "D-Bus interface for user account query and manipulation",
Required: true,
}
}
func (d *DebianDistribution) packageInstalled(pkg string) bool {
cmd := exec.Command("dpkg", "-l", pkg)
err := cmd.Run()
return err == nil
}
func (d *DebianDistribution) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping {
packages := map[string]PackageMapping{
"git": {Name: "git", Repository: RepoTypeSystem},
"kitty": {Name: "kitty", Repository: RepoTypeSystem},
"alacritty": {Name: "alacritty", Repository: RepoTypeSystem},
"wl-clipboard": {Name: "wl-clipboard", Repository: RepoTypeSystem},
"xdg-desktop-portal-gtk": {Name: "xdg-desktop-portal-gtk", Repository: RepoTypeSystem},
"mate-polkit": {Name: "mate-polkit", Repository: RepoTypeSystem},
"accountsservice": {Name: "accountsservice", Repository: RepoTypeSystem},
"dms (DankMaterialShell)": {Name: "dms", Repository: RepoTypeManual, BuildFunc: "installDankMaterialShell"},
"niri": {Name: "niri", Repository: RepoTypeManual, BuildFunc: "installNiri"},
"quickshell": {Name: "quickshell", Repository: RepoTypeManual, BuildFunc: "installQuickshell"},
"ghostty": {Name: "ghostty", Repository: RepoTypeManual, BuildFunc: "installGhostty"},
"matugen": {Name: "matugen", Repository: RepoTypeManual, BuildFunc: "installMatugen"},
"dgop": {Name: "dgop", Repository: RepoTypeManual, BuildFunc: "installDgop"},
"cliphist": {Name: "cliphist", Repository: RepoTypeManual, BuildFunc: "installCliphist"},
"hyprpicker": {Name: "hyprpicker", Repository: RepoTypeManual, BuildFunc: "installHyprpicker"},
}
if wm == deps.WindowManagerNiri {
packages["niri"] = PackageMapping{Name: "niri", Repository: RepoTypeManual, BuildFunc: "installNiri"}
packages["xwayland-satellite"] = PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeManual, BuildFunc: "installXwaylandSatellite"}
}
return packages
}
func (d *DebianDistribution) InstallPrerequisites(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.06,
Step: "Updating package lists...",
IsComplete: false,
LogOutput: "Updating APT package lists",
}
updateCmd := ExecSudoCommand(ctx, sudoPassword, "apt-get update")
if err := d.runWithProgress(updateCmd, progressChan, PhasePrerequisites, 0.06, 0.07); err != nil {
return fmt.Errorf("failed to update package lists: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.08,
Step: "Installing build-essential...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo apt-get install -y build-essential",
LogOutput: "Installing build tools",
}
checkCmd := exec.CommandContext(ctx, "dpkg", "-l", "build-essential")
if err := checkCmd.Run(); err != nil {
cmd := ExecSudoCommand(ctx, sudoPassword, "apt-get install -y build-essential")
if err := d.runWithProgress(cmd, progressChan, PhasePrerequisites, 0.08, 0.09); err != nil {
return fmt.Errorf("failed to install build-essential: %w", err)
}
}
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.10,
Step: "Installing development dependencies...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo apt-get install -y curl wget git cmake ninja-build pkg-config libxcb-cursor-dev libglib2.0-dev libpolkit-agent-1-dev",
LogOutput: "Installing additional development tools",
}
devToolsCmd := ExecSudoCommand(ctx, sudoPassword,
"apt-get install -y curl wget git cmake ninja-build pkg-config libxcb-cursor-dev libglib2.0-dev libpolkit-agent-1-dev libjpeg-dev libpugixml-dev")
if err := d.runWithProgress(devToolsCmd, progressChan, PhasePrerequisites, 0.10, 0.12); err != nil {
return fmt.Errorf("failed to install development tools: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.12,
Step: "Prerequisites installation complete",
IsComplete: false,
LogOutput: "Prerequisites successfully installed",
}
return nil
}
func (d *DebianDistribution) InstallPackages(ctx context.Context, dependencies []deps.Dependency, wm deps.WindowManager, sudoPassword string, reinstallFlags map[string]bool, disabledFlags map[string]bool, skipGlobalUseFlags bool, progressChan chan<- InstallProgressMsg) error {
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.05,
Step: "Checking system prerequisites...",
IsComplete: false,
LogOutput: "Starting prerequisite check...",
}
if err := d.InstallPrerequisites(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install prerequisites: %w", err)
}
systemPkgs, manualPkgs, variantMap := d.categorizePackages(dependencies, wm, reinstallFlags, disabledFlags)
if len(systemPkgs) > 0 {
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.35,
Step: fmt.Sprintf("Installing %d system packages...", len(systemPkgs)),
IsComplete: false,
NeedsSudo: true,
LogOutput: fmt.Sprintf("Installing system packages: %s", strings.Join(systemPkgs, ", ")),
}
if err := d.installAPTPackages(ctx, systemPkgs, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install APT packages: %w", err)
}
}
if len(manualPkgs) > 0 {
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.80,
Step: "Installing build dependencies...",
IsComplete: false,
LogOutput: "Installing build tools for manual compilation",
}
if err := d.installBuildDependencies(ctx, manualPkgs, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install build dependencies: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.85,
Step: fmt.Sprintf("Building %d packages from source...", len(manualPkgs)),
IsComplete: false,
LogOutput: fmt.Sprintf("Building from source: %s", strings.Join(manualPkgs, ", ")),
}
if err := d.InstallManualPackages(ctx, manualPkgs, variantMap, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install manual packages: %w", err)
}
}
progressChan <- InstallProgressMsg{
Phase: PhaseConfiguration,
Progress: 0.90,
Step: "Configuring system...",
IsComplete: false,
LogOutput: "Starting post-installation configuration...",
}
progressChan <- InstallProgressMsg{
Phase: PhaseComplete,
Progress: 1.0,
Step: "Installation complete!",
IsComplete: true,
LogOutput: "All packages installed and configured successfully",
}
return nil
}
func (d *DebianDistribution) categorizePackages(dependencies []deps.Dependency, wm deps.WindowManager, reinstallFlags map[string]bool, disabledFlags map[string]bool) ([]string, []string, map[string]deps.PackageVariant) {
systemPkgs := []string{}
manualPkgs := []string{}
variantMap := make(map[string]deps.PackageVariant)
for _, dep := range dependencies {
variantMap[dep.Name] = dep.Variant
}
packageMap := d.GetPackageMapping(wm)
for _, dep := range dependencies {
if disabledFlags[dep.Name] {
continue
}
if dep.Status == deps.StatusInstalled && !reinstallFlags[dep.Name] {
continue
}
pkgInfo, exists := packageMap[dep.Name]
if !exists {
d.log(fmt.Sprintf("Warning: No package mapping for %s", dep.Name))
continue
}
switch pkgInfo.Repository {
case RepoTypeSystem:
systemPkgs = append(systemPkgs, pkgInfo.Name)
case RepoTypeManual:
manualPkgs = append(manualPkgs, dep.Name)
}
}
return systemPkgs, manualPkgs, variantMap
}
func (d *DebianDistribution) installAPTPackages(ctx context.Context, packages []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if len(packages) == 0 {
return nil
}
d.log(fmt.Sprintf("Installing APT packages: %s", strings.Join(packages, ", ")))
args := []string{"apt-get", "install", "-y"}
args = append(args, packages...)
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.40,
Step: "Installing system packages...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
}
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
return d.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.40, 0.60)
}
func (d *DebianDistribution) installBuildDependencies(ctx context.Context, manualPkgs []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
buildDeps := make(map[string]bool)
for _, pkg := range manualPkgs {
switch pkg {
case "niri":
buildDeps["curl"] = true
buildDeps["libxkbcommon-dev"] = true
buildDeps["libwayland-dev"] = true
buildDeps["libudev-dev"] = true
buildDeps["libinput-dev"] = true
buildDeps["libdisplay-info-dev"] = true
buildDeps["libpango1.0-dev"] = true
buildDeps["libcairo-dev"] = true
buildDeps["libpipewire-0.3-dev"] = true
buildDeps["libc6-dev"] = true
buildDeps["clang"] = true
buildDeps["libseat-dev"] = true
buildDeps["libgbm-dev"] = true
buildDeps["alacritty"] = true
buildDeps["fuzzel"] = true
case "quickshell":
buildDeps["qt6-base-dev"] = true
buildDeps["qt6-base-private-dev"] = true
buildDeps["qt6-declarative-dev"] = true
buildDeps["qt6-declarative-private-dev"] = true
buildDeps["qt6-wayland-dev"] = true
buildDeps["qt6-wayland-private-dev"] = true
buildDeps["qt6-tools-dev"] = true
buildDeps["libqt6svg6-dev"] = true
buildDeps["qt6-shadertools-dev"] = true
buildDeps["spirv-tools"] = true
buildDeps["libcli11-dev"] = true
buildDeps["libjemalloc-dev"] = true
buildDeps["libwayland-dev"] = true
buildDeps["wayland-protocols"] = true
buildDeps["libdrm-dev"] = true
buildDeps["libgbm-dev"] = true
buildDeps["libegl-dev"] = true
buildDeps["libgles2-mesa-dev"] = true
buildDeps["libgl1-mesa-dev"] = true
buildDeps["libxcb1-dev"] = true
buildDeps["libpipewire-0.3-dev"] = true
buildDeps["libpam0g-dev"] = true
case "ghostty":
buildDeps["curl"] = true
case "matugen":
buildDeps["curl"] = true
}
}
for _, pkg := range manualPkgs {
switch pkg {
case "niri", "matugen":
if err := d.installRust(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install Rust: %w", err)
}
case "cliphist", "dgop":
if err := d.installGo(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install Go: %w", err)
}
}
}
if len(buildDeps) == 0 {
return nil
}
depList := make([]string, 0, len(buildDeps))
for dep := range buildDeps {
depList = append(depList, dep)
}
args := []string{"apt-get", "install", "-y"}
args = append(args, depList...)
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
return d.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.80, 0.82)
}
func (d *DebianDistribution) installRust(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if d.commandExists("cargo") {
return nil
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.82,
Step: "Installing rustup...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo apt-get install rustup",
}
rustupInstallCmd := ExecSudoCommand(ctx, sudoPassword, "apt-get install -y rustup")
if err := d.runWithProgress(rustupInstallCmd, progressChan, PhaseSystemPackages, 0.82, 0.83); err != nil {
return fmt.Errorf("failed to install rustup: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.83,
Step: "Installing stable Rust toolchain...",
IsComplete: false,
CommandInfo: "rustup install stable",
}
rustInstallCmd := exec.CommandContext(ctx, "bash", "-c", "rustup install stable && rustup default stable")
if err := d.runWithProgress(rustInstallCmd, progressChan, PhaseSystemPackages, 0.83, 0.84); err != nil {
return fmt.Errorf("failed to install Rust toolchain: %w", err)
}
if !d.commandExists("cargo") {
d.log("Warning: cargo not found in PATH after Rust installation, trying to source environment")
}
return nil
}
func (d *DebianDistribution) installGo(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if d.commandExists("go") {
return nil
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.87,
Step: "Installing Go...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo apt-get install golang-go",
}
installCmd := ExecSudoCommand(ctx, sudoPassword, "apt-get install -y golang-go")
return d.runWithProgress(installCmd, progressChan, PhaseSystemPackages, 0.87, 0.90)
}
func (d *DebianDistribution) installGhosttyDebian(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
d.log("Installing Ghostty using Debian installer script...")
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.1,
Step: "Running Ghostty Debian installer...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "curl -fsSL https://raw.githubusercontent.com/mkasberg/ghostty-ubuntu/HEAD/install.sh | sudo bash",
LogOutput: "Installing Ghostty using pre-built Debian package",
}
installCmd := ExecSudoCommand(ctx, sudoPassword,
"/bin/bash -c \"$(curl -fsSL https://raw.githubusercontent.com/mkasberg/ghostty-ubuntu/HEAD/install.sh)\"")
if err := d.runWithProgress(installCmd, progressChan, PhaseSystemPackages, 0.1, 0.9); err != nil {
return fmt.Errorf("failed to install Ghostty: %w", err)
}
d.log("Ghostty installed successfully using Debian installer")
return nil
}
func (d *DebianDistribution) InstallManualPackages(ctx context.Context, packages []string, variantMap map[string]deps.PackageVariant, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if len(packages) == 0 {
return nil
}
d.log(fmt.Sprintf("Installing manual packages: %s", strings.Join(packages, ", ")))
for _, pkg := range packages {
switch pkg {
case "ghostty":
if err := d.installGhosttyDebian(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install ghostty: %w", err)
}
default:
if err := d.ManualPackageInstaller.InstallManualPackages(ctx, []string{pkg}, variantMap, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install %s: %w", pkg, err)
}
}
}
return nil
}

View File

@@ -0,0 +1,19 @@
package distros
import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
)
// NewDependencyDetector creates a DependencyDetector for the specified distribution
func NewDependencyDetector(distribution string, logChan chan<- string) (deps.DependencyDetector, error) {
distro, err := NewDistribution(distribution, logChan)
if err != nil {
return nil, err
}
return distro, nil
}
// NewPackageInstaller creates a Distribution for package installation
func NewPackageInstaller(distribution string, logChan chan<- string) (Distribution, error) {
return NewDistribution(distribution, logChan)
}

View File

@@ -0,0 +1,553 @@
package distros
import (
"context"
"fmt"
"os/exec"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
)
func init() {
Register("fedora", "#0B57A4", FamilyFedora, func(config DistroConfig, logChan chan<- string) Distribution {
return NewFedoraDistribution(config, logChan)
})
Register("nobara", "#0B57A4", FamilyFedora, func(config DistroConfig, logChan chan<- string) Distribution {
return NewFedoraDistribution(config, logChan)
})
Register("fedora-asahi-remix", "#0B57A4", FamilyFedora, func(config DistroConfig, logChan chan<- string) Distribution {
return NewFedoraDistribution(config, logChan)
})
Register("bluefin", "#0B57A4", FamilyFedora, func(config DistroConfig, logChan chan<- string) Distribution {
return NewFedoraDistribution(config, logChan)
})
}
type FedoraDistribution struct {
*BaseDistribution
*ManualPackageInstaller
config DistroConfig
}
func NewFedoraDistribution(config DistroConfig, logChan chan<- string) *FedoraDistribution {
base := NewBaseDistribution(logChan)
return &FedoraDistribution{
BaseDistribution: base,
ManualPackageInstaller: &ManualPackageInstaller{BaseDistribution: base},
config: config,
}
}
func (f *FedoraDistribution) GetID() string {
return f.config.ID
}
func (f *FedoraDistribution) GetColorHex() string {
return f.config.ColorHex
}
func (f *FedoraDistribution) GetFamily() DistroFamily {
return f.config.Family
}
func (f *FedoraDistribution) GetPackageManager() PackageManagerType {
return PackageManagerDNF
}
func (f *FedoraDistribution) DetectDependencies(ctx context.Context, wm deps.WindowManager) ([]deps.Dependency, error) {
return f.DetectDependenciesWithTerminal(ctx, wm, deps.TerminalGhostty)
}
func (f *FedoraDistribution) DetectDependenciesWithTerminal(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal) ([]deps.Dependency, error) {
var dependencies []deps.Dependency
// DMS at the top (shell is prominent)
dependencies = append(dependencies, f.detectDMS())
// Terminal with choice support
dependencies = append(dependencies, f.detectSpecificTerminal(terminal))
// Common detections using base methods
dependencies = append(dependencies, f.detectGit())
dependencies = append(dependencies, f.detectWindowManager(wm))
dependencies = append(dependencies, f.detectQuickshell())
dependencies = append(dependencies, f.detectXDGPortal())
dependencies = append(dependencies, f.detectPolkitAgent())
dependencies = append(dependencies, f.detectAccountsService())
// Hyprland-specific tools
if wm == deps.WindowManagerHyprland {
dependencies = append(dependencies, f.detectHyprlandTools()...)
}
// Niri-specific tools
if wm == deps.WindowManagerNiri {
dependencies = append(dependencies, f.detectXwaylandSatellite())
}
// Base detections (common across distros)
dependencies = append(dependencies, f.detectMatugen())
dependencies = append(dependencies, f.detectDgop())
dependencies = append(dependencies, f.detectHyprpicker())
dependencies = append(dependencies, f.detectClipboardTools()...)
return dependencies, nil
}
func (f *FedoraDistribution) detectXDGPortal() deps.Dependency {
status := deps.StatusMissing
if f.packageInstalled("xdg-desktop-portal-gtk") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "xdg-desktop-portal-gtk",
Status: status,
Description: "Desktop integration portal for GTK",
Required: true,
}
}
func (f *FedoraDistribution) detectPolkitAgent() deps.Dependency {
status := deps.StatusMissing
if f.packageInstalled("mate-polkit") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "mate-polkit",
Status: status,
Description: "PolicyKit authentication agent",
Required: true,
}
}
func (f *FedoraDistribution) packageInstalled(pkg string) bool {
cmd := exec.Command("rpm", "-q", pkg)
err := cmd.Run()
return err == nil
}
func (f *FedoraDistribution) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping {
return f.GetPackageMappingWithVariants(wm, make(map[string]deps.PackageVariant))
}
func (f *FedoraDistribution) GetPackageMappingWithVariants(wm deps.WindowManager, variants map[string]deps.PackageVariant) map[string]PackageMapping {
packages := map[string]PackageMapping{
// Standard DNF packages
"git": {Name: "git", Repository: RepoTypeSystem},
"ghostty": {Name: "ghostty", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
"kitty": {Name: "kitty", Repository: RepoTypeSystem},
"alacritty": {Name: "alacritty", Repository: RepoTypeSystem},
"wl-clipboard": {Name: "wl-clipboard", Repository: RepoTypeSystem},
"xdg-desktop-portal-gtk": {Name: "xdg-desktop-portal-gtk", Repository: RepoTypeSystem},
"mate-polkit": {Name: "mate-polkit", Repository: RepoTypeSystem},
"accountsservice": {Name: "accountsservice", Repository: RepoTypeSystem},
"hyprpicker": f.getHyprpickerMapping(variants["hyprland"]),
// COPR packages
"quickshell": f.getQuickshellMapping(variants["quickshell"]),
"matugen": {Name: "matugen", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
"cliphist": {Name: "cliphist", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
"dms (DankMaterialShell)": f.getDmsMapping(variants["dms (DankMaterialShell)"]),
"dgop": {Name: "dgop", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
}
switch wm {
case deps.WindowManagerHyprland:
packages["hyprland"] = f.getHyprlandMapping(variants["hyprland"])
packages["grim"] = PackageMapping{Name: "grim", Repository: RepoTypeSystem}
packages["slurp"] = PackageMapping{Name: "slurp", Repository: RepoTypeSystem}
packages["hyprctl"] = f.getHyprlandMapping(variants["hyprland"])
packages["grimblast"] = PackageMapping{Name: "grimblast", Repository: RepoTypeManual, BuildFunc: "installGrimblast"}
packages["jq"] = PackageMapping{Name: "jq", Repository: RepoTypeSystem}
case deps.WindowManagerNiri:
packages["niri"] = f.getNiriMapping(variants["niri"])
packages["xwayland-satellite"] = PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeSystem}
}
return packages
}
func (f *FedoraDistribution) getQuickshellMapping(variant deps.PackageVariant) PackageMapping {
if forceQuickshellGit || variant == deps.VariantGit {
return PackageMapping{Name: "quickshell-git", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"}
}
return PackageMapping{Name: "quickshell", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"}
}
func (f *FedoraDistribution) getDmsMapping(variant deps.PackageVariant) PackageMapping {
if variant == deps.VariantGit {
return PackageMapping{Name: "dms", Repository: RepoTypeCOPR, RepoURL: "avengemedia/dms-git"}
}
return PackageMapping{Name: "dms", Repository: RepoTypeCOPR, RepoURL: "avengemedia/dms"}
}
func (f *FedoraDistribution) getHyprlandMapping(variant deps.PackageVariant) PackageMapping {
if variant == deps.VariantGit {
return PackageMapping{Name: "hyprland-git", Repository: RepoTypeCOPR, RepoURL: "solopasha/hyprland"}
}
return PackageMapping{Name: "hyprland", Repository: RepoTypeCOPR, RepoURL: "solopasha/hyprland"}
}
func (f *FedoraDistribution) getHyprpickerMapping(variant deps.PackageVariant) PackageMapping {
if variant == deps.VariantGit {
return PackageMapping{Name: "hyprpicker-git", Repository: RepoTypeCOPR, RepoURL: "solopasha/hyprland"}
}
return PackageMapping{Name: "hyprpicker", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"}
}
func (f *FedoraDistribution) getNiriMapping(variant deps.PackageVariant) PackageMapping {
if variant == deps.VariantGit {
return PackageMapping{Name: "niri", Repository: RepoTypeCOPR, RepoURL: "yalter/niri-git"}
}
return PackageMapping{Name: "niri", Repository: RepoTypeSystem}
}
func (f *FedoraDistribution) detectXwaylandSatellite() deps.Dependency {
status := deps.StatusMissing
if f.commandExists("xwayland-satellite") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "xwayland-satellite",
Status: status,
Description: "Xwayland support",
Required: true,
}
}
func (f *FedoraDistribution) detectAccountsService() deps.Dependency {
status := deps.StatusMissing
if f.packageInstalled("accountsservice") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "accountsservice",
Status: status,
Description: "D-Bus interface for user account query and manipulation",
Required: true,
}
}
func (f *FedoraDistribution) getPrerequisites() []string {
return []string{
"dnf-plugins-core",
"make",
"unzip",
"libwayland-server",
}
}
func (f *FedoraDistribution) InstallPrerequisites(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
prerequisites := f.getPrerequisites()
var missingPkgs []string
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.06,
Step: "Checking prerequisites...",
IsComplete: false,
LogOutput: "Checking prerequisite packages",
}
for _, pkg := range prerequisites {
checkCmd := exec.CommandContext(ctx, "rpm", "-q", pkg)
if err := checkCmd.Run(); err != nil {
missingPkgs = append(missingPkgs, pkg)
}
}
_, err := exec.LookPath("go")
if err != nil {
f.log("go not found in PATH, will install golang-bin")
missingPkgs = append(missingPkgs, "golang-bin")
} else {
f.log("go already available in PATH")
}
if len(missingPkgs) == 0 {
f.log("All prerequisites already installed")
return nil
}
f.log(fmt.Sprintf("Installing prerequisites: %s", strings.Join(missingPkgs, ", ")))
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.08,
Step: fmt.Sprintf("Installing %d prerequisites...", len(missingPkgs)),
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo dnf install -y %s", strings.Join(missingPkgs, " ")),
LogOutput: fmt.Sprintf("Installing prerequisites: %s", strings.Join(missingPkgs, ", ")),
}
args := []string{"dnf", "install", "-y"}
args = append(args, missingPkgs...)
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
output, err := cmd.CombinedOutput()
if err != nil {
f.logError("failed to install prerequisites", err)
f.log(fmt.Sprintf("Prerequisites command output: %s", string(output)))
return fmt.Errorf("failed to install prerequisites: %w", err)
}
f.log(fmt.Sprintf("Prerequisites install output: %s", string(output)))
return nil
}
func (f *FedoraDistribution) InstallPackages(ctx context.Context, dependencies []deps.Dependency, wm deps.WindowManager, sudoPassword string, reinstallFlags map[string]bool, disabledFlags map[string]bool, skipGlobalUseFlags bool, progressChan chan<- InstallProgressMsg) error {
// Phase 1: Check Prerequisites
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.05,
Step: "Checking system prerequisites...",
IsComplete: false,
LogOutput: "Starting prerequisite check...",
}
if err := f.InstallPrerequisites(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install prerequisites: %w", err)
}
dnfPkgs, coprPkgs, manualPkgs, variantMap := f.categorizePackages(dependencies, wm, reinstallFlags, disabledFlags)
// Phase 2: Enable COPR repositories
if len(coprPkgs) > 0 {
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.15,
Step: "Enabling COPR repositories...",
IsComplete: false,
LogOutput: "Setting up COPR repositories for additional packages",
}
if err := f.enableCOPRRepos(ctx, coprPkgs, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to enable COPR repositories: %w", err)
}
}
// Phase 3: System Packages (DNF)
if len(dnfPkgs) > 0 {
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.35,
Step: fmt.Sprintf("Installing %d system packages...", len(dnfPkgs)),
IsComplete: false,
NeedsSudo: true,
LogOutput: fmt.Sprintf("Installing system packages: %s", strings.Join(dnfPkgs, ", ")),
}
if err := f.installDNFPackages(ctx, dnfPkgs, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install DNF packages: %w", err)
}
}
// Phase 4: COPR Packages
coprPkgNames := f.extractPackageNames(coprPkgs)
if len(coprPkgNames) > 0 {
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages, // Reusing AUR phase for COPR
Progress: 0.65,
Step: fmt.Sprintf("Installing %d COPR packages...", len(coprPkgNames)),
IsComplete: false,
LogOutput: fmt.Sprintf("Installing COPR packages: %s", strings.Join(coprPkgNames, ", ")),
}
if err := f.installCOPRPackages(ctx, coprPkgNames, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install COPR packages: %w", err)
}
}
// Phase 5: Manual Builds
if len(manualPkgs) > 0 {
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.85,
Step: fmt.Sprintf("Building %d packages from source...", len(manualPkgs)),
IsComplete: false,
LogOutput: fmt.Sprintf("Building from source: %s", strings.Join(manualPkgs, ", ")),
}
if err := f.InstallManualPackages(ctx, manualPkgs, variantMap, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install manual packages: %w", err)
}
}
// Phase 6: Configuration
progressChan <- InstallProgressMsg{
Phase: PhaseConfiguration,
Progress: 0.90,
Step: "Configuring system...",
IsComplete: false,
LogOutput: "Starting post-installation configuration...",
}
// Phase 7: Complete
progressChan <- InstallProgressMsg{
Phase: PhaseComplete,
Progress: 1.0,
Step: "Installation complete!",
IsComplete: true,
LogOutput: "All packages installed and configured successfully",
}
return nil
}
func (f *FedoraDistribution) categorizePackages(dependencies []deps.Dependency, wm deps.WindowManager, reinstallFlags map[string]bool, disabledFlags map[string]bool) ([]string, []PackageMapping, []string, map[string]deps.PackageVariant) {
dnfPkgs := []string{}
coprPkgs := []PackageMapping{}
manualPkgs := []string{}
variantMap := make(map[string]deps.PackageVariant)
for _, dep := range dependencies {
variantMap[dep.Name] = dep.Variant
}
packageMap := f.GetPackageMappingWithVariants(wm, variantMap)
for _, dep := range dependencies {
if disabledFlags[dep.Name] {
continue
}
if dep.Status == deps.StatusInstalled && !reinstallFlags[dep.Name] {
continue
}
pkgInfo, exists := packageMap[dep.Name]
if !exists {
f.log(fmt.Sprintf("Warning: No package mapping for %s", dep.Name))
continue
}
switch pkgInfo.Repository {
case RepoTypeSystem:
dnfPkgs = append(dnfPkgs, pkgInfo.Name)
case RepoTypeCOPR:
coprPkgs = append(coprPkgs, pkgInfo)
case RepoTypeManual:
manualPkgs = append(manualPkgs, dep.Name)
}
}
return dnfPkgs, coprPkgs, manualPkgs, variantMap
}
func (f *FedoraDistribution) extractPackageNames(packages []PackageMapping) []string {
names := make([]string, len(packages))
for i, pkg := range packages {
names[i] = pkg.Name
}
return names
}
func (f *FedoraDistribution) enableCOPRRepos(ctx context.Context, coprPkgs []PackageMapping, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
enabledRepos := make(map[string]bool)
for _, pkg := range coprPkgs {
if pkg.RepoURL != "" && !enabledRepos[pkg.RepoURL] {
f.log(fmt.Sprintf("Enabling COPR repository: %s", pkg.RepoURL))
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.20,
Step: fmt.Sprintf("Enabling COPR repo %s...", pkg.RepoURL),
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo dnf copr enable -y %s", pkg.RepoURL),
}
cmd := ExecSudoCommand(ctx, sudoPassword,
fmt.Sprintf("dnf copr enable -y %s 2>&1", pkg.RepoURL))
output, err := cmd.CombinedOutput()
if err != nil {
f.logError(fmt.Sprintf("failed to enable COPR repo %s", pkg.RepoURL), err)
f.log(fmt.Sprintf("COPR enable command output: %s", string(output)))
return fmt.Errorf("failed to enable COPR repo %s: %w", pkg.RepoURL, err)
}
f.log(fmt.Sprintf("COPR repo %s enabled successfully: %s", pkg.RepoURL, string(output)))
enabledRepos[pkg.RepoURL] = true
// Special handling for niri COPR repo - set priority=1
if pkg.RepoURL == "yalter/niri-git" {
f.log("Setting priority=1 for niri-git COPR repo...")
repoFile := "/etc/yum.repos.d/_copr:copr.fedorainfracloud.org:yalter:niri-git.repo"
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.22,
Step: "Setting niri COPR repo priority...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("echo \"priority=1\" | sudo tee -a %s", repoFile),
}
priorityCmd := ExecSudoCommand(ctx, sudoPassword,
fmt.Sprintf("bash -c 'echo \"priority=1\" | tee -a %s'", repoFile))
priorityOutput, err := priorityCmd.CombinedOutput()
if err != nil {
f.logError("failed to set niri COPR repo priority", err)
f.log(fmt.Sprintf("Priority command output: %s", string(priorityOutput)))
return fmt.Errorf("failed to set niri COPR repo priority: %w", err)
}
f.log(fmt.Sprintf("niri COPR repo priority set successfully: %s", string(priorityOutput)))
}
}
}
return nil
}
func (f *FedoraDistribution) installDNFPackages(ctx context.Context, packages []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if len(packages) == 0 {
return nil
}
f.log(fmt.Sprintf("Installing DNF packages: %s", strings.Join(packages, ", ")))
args := []string{"dnf", "install", "-y"}
args = append(args, packages...)
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.40,
Step: "Installing system packages...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
}
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
return f.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.40, 0.60)
}
func (f *FedoraDistribution) installCOPRPackages(ctx context.Context, packages []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if len(packages) == 0 {
return nil
}
f.log(fmt.Sprintf("Installing COPR packages: %s", strings.Join(packages, ", ")))
args := []string{"dnf", "install", "-y"}
for _, pkg := range packages {
if pkg == "niri" || pkg == "niri-git" {
args = append(args, "--setopt=install_weak_deps=False")
break
}
}
args = append(args, packages...)
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages,
Progress: 0.70,
Step: "Installing COPR packages...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
}
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
return f.runWithProgress(cmd, progressChan, PhaseAURPackages, 0.70, 0.85)
}

View File

@@ -0,0 +1,749 @@
package distros
import (
"context"
"fmt"
"os/exec"
"runtime"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
)
var GentooGlobalUseFlags = []string{
"dbus",
"udev",
"alsa",
"policykit",
"jpeg",
"png",
"webp",
"gif",
"tiff",
"svg",
"brotli",
"gdbm",
"accessibility",
"gtk",
"qt6",
"egl",
"gbm",
}
func init() {
Register("gentoo", "#54487A", FamilyGentoo, func(config DistroConfig, logChan chan<- string) Distribution {
return NewGentooDistribution(config, logChan)
})
}
type GentooDistribution struct {
*BaseDistribution
*ManualPackageInstaller
config DistroConfig
skipGlobalUseFlags bool
}
func NewGentooDistribution(config DistroConfig, logChan chan<- string) *GentooDistribution {
base := NewBaseDistribution(logChan)
return &GentooDistribution{
BaseDistribution: base,
ManualPackageInstaller: &ManualPackageInstaller{BaseDistribution: base},
config: config,
}
}
func (g *GentooDistribution) getArchKeyword() string {
arch := runtime.GOARCH
switch arch {
case "amd64":
return "~amd64"
case "arm64":
return "~arm64"
default:
return "~amd64"
}
}
func (g *GentooDistribution) GetID() string {
return g.config.ID
}
func (g *GentooDistribution) GetColorHex() string {
return g.config.ColorHex
}
func (g *GentooDistribution) GetFamily() DistroFamily {
return g.config.Family
}
func (g *GentooDistribution) GetPackageManager() PackageManagerType {
return PackageManagerPortage
}
func (g *GentooDistribution) DetectDependencies(ctx context.Context, wm deps.WindowManager) ([]deps.Dependency, error) {
return g.DetectDependenciesWithTerminal(ctx, wm, deps.TerminalGhostty)
}
func (g *GentooDistribution) DetectDependenciesWithTerminal(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal) ([]deps.Dependency, error) {
var dependencies []deps.Dependency
dependencies = append(dependencies, g.detectDMS())
dependencies = append(dependencies, g.detectSpecificTerminal(terminal))
dependencies = append(dependencies, g.detectGit())
dependencies = append(dependencies, g.detectWindowManager(wm))
dependencies = append(dependencies, g.detectQuickshell())
dependencies = append(dependencies, g.detectXDGPortal())
dependencies = append(dependencies, g.detectPolkitAgent())
dependencies = append(dependencies, g.detectAccountsService())
if wm == deps.WindowManagerHyprland {
dependencies = append(dependencies, g.detectHyprlandTools()...)
}
if wm == deps.WindowManagerNiri {
dependencies = append(dependencies, g.detectXwaylandSatellite())
}
dependencies = append(dependencies, g.detectMatugen())
dependencies = append(dependencies, g.detectDgop())
dependencies = append(dependencies, g.detectHyprpicker())
dependencies = append(dependencies, g.detectClipboardTools()...)
return dependencies, nil
}
func (g *GentooDistribution) detectXDGPortal() deps.Dependency {
status := deps.StatusMissing
if g.packageInstalled("sys-apps/xdg-desktop-portal-gtk") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "xdg-desktop-portal-gtk",
Status: status,
Description: "Desktop integration portal for GTK",
Required: true,
}
}
func (g *GentooDistribution) detectPolkitAgent() deps.Dependency {
status := deps.StatusMissing
if g.packageInstalled("mate-extra/mate-polkit") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "mate-polkit",
Status: status,
Description: "PolicyKit authentication agent",
Required: true,
}
}
func (g *GentooDistribution) detectXwaylandSatellite() deps.Dependency {
status := deps.StatusMissing
if g.packageInstalled("gui-apps/xwayland-satellite") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "xwayland-satellite",
Status: status,
Description: "Xwayland support",
Required: true,
}
}
func (g *GentooDistribution) detectAccountsService() deps.Dependency {
status := deps.StatusMissing
if g.packageInstalled("sys-apps/accountsservice") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "accountsservice",
Status: status,
Description: "D-Bus interface for user account query and manipulation",
Required: true,
}
}
func (g *GentooDistribution) packageInstalled(pkg string) bool {
cmd := exec.Command("qlist", "-I", pkg)
err := cmd.Run()
return err == nil
}
func (g *GentooDistribution) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping {
return g.GetPackageMappingWithVariants(wm, make(map[string]deps.PackageVariant))
}
func (g *GentooDistribution) GetPackageMappingWithVariants(wm deps.WindowManager, variants map[string]deps.PackageVariant) map[string]PackageMapping {
archKeyword := g.getArchKeyword()
packages := map[string]PackageMapping{
"git": {Name: "dev-vcs/git", Repository: RepoTypeSystem},
"kitty": {Name: "x11-terms/kitty", Repository: RepoTypeSystem, UseFlags: "X wayland"},
"alacritty": {Name: "x11-terms/alacritty", Repository: RepoTypeSystem, UseFlags: "X wayland"},
"wl-clipboard": {Name: "gui-apps/wl-clipboard", Repository: RepoTypeSystem},
"xdg-desktop-portal-gtk": {Name: "sys-apps/xdg-desktop-portal-gtk", Repository: RepoTypeSystem, UseFlags: "wayland X"},
"mate-polkit": {Name: "mate-extra/mate-polkit", Repository: RepoTypeSystem},
"accountsservice": {Name: "sys-apps/accountsservice", Repository: RepoTypeSystem},
"hyprpicker": g.getHyprpickerMapping(variants["hyprland"]),
"qtbase": {Name: "dev-qt/qtbase", Repository: RepoTypeSystem, UseFlags: "wayland opengl vulkan widgets"},
"qtdeclarative": {Name: "dev-qt/qtdeclarative", Repository: RepoTypeSystem, UseFlags: "opengl vulkan"},
"qtwayland": {Name: "dev-qt/qtwayland", Repository: RepoTypeSystem},
"mesa": {Name: "media-libs/mesa", Repository: RepoTypeSystem, UseFlags: "opengl vulkan"},
"quickshell": g.getQuickshellMapping(variants["quickshell"]),
"matugen": {Name: "x11-misc/matugen", Repository: RepoTypeGURU, AcceptKeywords: archKeyword},
"cliphist": {Name: "app-misc/cliphist", Repository: RepoTypeGURU, AcceptKeywords: archKeyword},
"dms (DankMaterialShell)": g.getDmsMapping(variants["dms (DankMaterialShell)"]),
"dgop": {Name: "dgop", Repository: RepoTypeManual, BuildFunc: "installDgop"},
}
switch wm {
case deps.WindowManagerHyprland:
packages["hyprland"] = g.getHyprlandMapping(variants["hyprland"])
packages["grim"] = PackageMapping{Name: "gui-apps/grim", Repository: RepoTypeSystem}
packages["slurp"] = PackageMapping{Name: "gui-apps/slurp", Repository: RepoTypeSystem}
packages["hyprctl"] = g.getHyprlandMapping(variants["hyprland"])
packages["grimblast"] = PackageMapping{Name: "gui-wm/hyprland-contrib", Repository: RepoTypeGURU, AcceptKeywords: archKeyword}
packages["jq"] = PackageMapping{Name: "app-misc/jq", Repository: RepoTypeSystem}
case deps.WindowManagerNiri:
packages["niri"] = g.getNiriMapping(variants["niri"])
packages["xwayland-satellite"] = PackageMapping{Name: "gui-apps/xwayland-satellite", Repository: RepoTypeGURU, AcceptKeywords: archKeyword}
}
return packages
}
func (g *GentooDistribution) getQuickshellMapping(_ deps.PackageVariant) PackageMapping {
return PackageMapping{Name: "gui-apps/quickshell", Repository: RepoTypeGURU, UseFlags: "breakpad jemalloc sockets wayland layer-shell session-lock toplevel-management screencopy X pipewire tray mpris pam hyprland hyprland-global-shortcuts hyprland-focus-grab i3 i3-ipc bluetooth", AcceptKeywords: "**"}
}
func (g *GentooDistribution) getDmsMapping(_ deps.PackageVariant) PackageMapping {
return PackageMapping{Name: "dms", Repository: RepoTypeManual, BuildFunc: "installDankMaterialShell"}
}
func (g *GentooDistribution) getHyprlandMapping(variant deps.PackageVariant) PackageMapping {
archKeyword := g.getArchKeyword()
if variant == deps.VariantGit {
return PackageMapping{Name: "gui-wm/hyprland", Repository: RepoTypeGURU, UseFlags: "X", AcceptKeywords: archKeyword}
}
return PackageMapping{Name: "gui-wm/hyprland", Repository: RepoTypeSystem, UseFlags: "X", AcceptKeywords: archKeyword}
}
func (g *GentooDistribution) getHyprpickerMapping(_ deps.PackageVariant) PackageMapping {
return PackageMapping{Name: "gui-apps/hyprpicker", Repository: RepoTypeGURU, AcceptKeywords: g.getArchKeyword()}
}
func (g *GentooDistribution) getNiriMapping(_ deps.PackageVariant) PackageMapping {
return PackageMapping{Name: "gui-wm/niri", Repository: RepoTypeGURU, UseFlags: "dbus screencast", AcceptKeywords: g.getArchKeyword()}
}
func (g *GentooDistribution) getPrerequisites() []string {
return []string{
"app-eselect/eselect-repository",
"dev-vcs/git",
"dev-build/make",
"app-arch/unzip",
"dev-util/pkgconf",
"dev-qt/qtdeclarative",
}
}
func (g *GentooDistribution) setGlobalUseFlags(ctx context.Context, sudoPassword string) error {
useFlags := strings.Join(GentooGlobalUseFlags, " ")
checkCmd := exec.CommandContext(ctx, "grep", "-q", "^USE=", "/etc/portage/make.conf")
hasUse := checkCmd.Run() == nil
var cmd *exec.Cmd
if hasUse {
cmd = ExecSudoCommand(ctx, sudoPassword, fmt.Sprintf("sed -i 's/^USE=\"\\(.*\\)\"/USE=\"\\1 %s\"/' /etc/portage/make.conf", useFlags))
} else {
cmd = ExecSudoCommand(ctx, sudoPassword, fmt.Sprintf("bash -c \"echo 'USE=\\\"%s\\\"' >> /etc/portage/make.conf\"", useFlags))
}
output, err := cmd.CombinedOutput()
if err != nil {
g.log(fmt.Sprintf("Failed to set global USE flags: %s", string(output)))
return err
}
g.log(fmt.Sprintf("Set global USE flags: %s", useFlags))
return nil
}
func (g *GentooDistribution) InstallPrerequisites(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
prerequisites := g.getPrerequisites()
var missingPkgs []string
if !g.skipGlobalUseFlags {
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.05,
Step: "Setting global USE flags...",
IsComplete: false,
LogOutput: "Configuring global USE flags in /etc/portage/make.conf",
}
if err := g.setGlobalUseFlags(ctx, sudoPassword); err != nil {
g.logError("failed to set global USE flags", err)
return fmt.Errorf("failed to set global USE flags: %w", err)
}
} else {
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.05,
Step: "Skipping global USE flags...",
IsComplete: false,
LogOutput: "Skipping global USE flags configuration (using existing configuration)",
}
}
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.06,
Step: "Checking prerequisites...",
IsComplete: false,
LogOutput: "Checking prerequisite packages",
}
for _, pkg := range prerequisites {
checkCmd := exec.CommandContext(ctx, "qlist", "-I", pkg)
if err := checkCmd.Run(); err != nil {
missingPkgs = append(missingPkgs, pkg)
}
}
_, err := exec.LookPath("go")
if err != nil {
g.log("go not found in PATH, will install dev-lang/go")
missingPkgs = append(missingPkgs, "dev-lang/go")
} else {
g.log("go already available in PATH")
}
if len(missingPkgs) == 0 {
g.log("All prerequisites already installed")
return nil
}
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.07,
Step: "Syncing Portage tree...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo emerge --sync",
LogOutput: "Syncing Portage tree with emerge --sync",
}
syncCmd := ExecSudoCommand(ctx, sudoPassword, "emerge --sync --quiet")
syncOutput, syncErr := syncCmd.CombinedOutput()
if syncErr != nil {
g.log(fmt.Sprintf("emerge --sync output: %s", string(syncOutput)))
return fmt.Errorf("failed to sync Portage tree: %w\nOutput: %s", syncErr, string(syncOutput))
}
g.log("Portage tree synced successfully")
g.log(fmt.Sprintf("Installing prerequisites: %s", strings.Join(missingPkgs, ", ")))
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.08,
Step: fmt.Sprintf("Installing %d prerequisites...", len(missingPkgs)),
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo emerge --ask=n %s", strings.Join(missingPkgs, " ")),
LogOutput: fmt.Sprintf("Installing prerequisites: %s", strings.Join(missingPkgs, ", ")),
}
args := []string{"emerge", "--ask=n", "--quiet"}
args = append(args, missingPkgs...)
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
output, err := cmd.CombinedOutput()
if err != nil {
g.logError("failed to install prerequisites", err)
g.log(fmt.Sprintf("Prerequisites command output: %s", string(output)))
return fmt.Errorf("failed to install prerequisites: %w\nOutput: %s", err, string(output))
}
g.log(fmt.Sprintf("Prerequisites install output: %s", string(output)))
return nil
}
func (g *GentooDistribution) InstallPackages(ctx context.Context, dependencies []deps.Dependency, wm deps.WindowManager, sudoPassword string, reinstallFlags map[string]bool, disabledFlags map[string]bool, skipGlobalUseFlags bool, progressChan chan<- InstallProgressMsg) error {
g.skipGlobalUseFlags = skipGlobalUseFlags
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.05,
Step: "Checking system prerequisites...",
IsComplete: false,
LogOutput: "Starting prerequisite check...",
}
if err := g.InstallPrerequisites(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install prerequisites: %w", err)
}
systemPkgs, guruPkgs, manualPkgs, variantMap := g.categorizePackages(dependencies, wm, reinstallFlags, disabledFlags)
g.log(fmt.Sprintf("CATEGORIZED PACKAGES: system=%d, guru=%d, manual=%d", len(systemPkgs), len(guruPkgs), len(manualPkgs)))
if len(systemPkgs) > 0 {
systemPkgNames := g.extractPackageNames(systemPkgs)
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.35,
Step: fmt.Sprintf("Installing %d system packages...", len(systemPkgs)),
IsComplete: false,
NeedsSudo: true,
LogOutput: fmt.Sprintf("Installing system packages: %s", strings.Join(systemPkgNames, ", ")),
}
if err := g.installPortagePackages(ctx, systemPkgs, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install Portage packages: %w", err)
}
}
if len(guruPkgs) > 0 {
g.log(fmt.Sprintf("FOUND %d GURU PACKAGES - WILL SYNC GURU REPO", len(guruPkgs)))
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages,
Progress: 0.60,
Step: "Syncing GURU repository...",
IsComplete: false,
LogOutput: "Syncing GURU repository to fetch latest ebuilds",
}
g.log("ABOUT TO CALL syncGURURepo")
if err := g.syncGURURepo(ctx, sudoPassword, progressChan); err != nil {
g.log(fmt.Sprintf("syncGURURepo RETURNED ERROR: %v", err))
return fmt.Errorf("failed to sync GURU repository: %w", err)
}
g.log("syncGURURepo COMPLETED SUCCESSFULLY")
guruPkgNames := g.extractPackageNames(guruPkgs)
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages,
Progress: 0.65,
Step: fmt.Sprintf("Installing %d GURU packages...", len(guruPkgs)),
IsComplete: false,
LogOutput: fmt.Sprintf("Installing GURU packages: %s", strings.Join(guruPkgNames, ", ")),
}
if err := g.installGURUPackages(ctx, guruPkgs, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install GURU packages: %w", err)
}
}
if len(manualPkgs) > 0 {
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.85,
Step: fmt.Sprintf("Building %d packages from source...", len(manualPkgs)),
IsComplete: false,
LogOutput: fmt.Sprintf("Building from source: %s", strings.Join(manualPkgs, ", ")),
}
if err := g.InstallManualPackages(ctx, manualPkgs, variantMap, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install manual packages: %w", err)
}
}
progressChan <- InstallProgressMsg{
Phase: PhaseConfiguration,
Progress: 0.90,
Step: "Configuring system...",
IsComplete: false,
LogOutput: "Starting post-installation configuration...",
}
progressChan <- InstallProgressMsg{
Phase: PhaseComplete,
Progress: 1.0,
Step: "Installation complete!",
IsComplete: true,
LogOutput: "All packages installed and configured successfully",
}
return nil
}
func (g *GentooDistribution) categorizePackages(dependencies []deps.Dependency, wm deps.WindowManager, reinstallFlags map[string]bool, disabledFlags map[string]bool) ([]PackageMapping, []PackageMapping, []string, map[string]deps.PackageVariant) {
systemPkgs := []PackageMapping{}
guruPkgs := []PackageMapping{}
manualPkgs := []string{}
variantMap := make(map[string]deps.PackageVariant)
for _, dep := range dependencies {
variantMap[dep.Name] = dep.Variant
}
packageMap := g.GetPackageMappingWithVariants(wm, variantMap)
for _, dep := range dependencies {
if disabledFlags[dep.Name] {
continue
}
if dep.Status == deps.StatusInstalled && !reinstallFlags[dep.Name] {
continue
}
pkgInfo, exists := packageMap[dep.Name]
if !exists {
g.log(fmt.Sprintf("Warning: No package mapping for %s", dep.Name))
continue
}
switch pkgInfo.Repository {
case RepoTypeSystem:
systemPkgs = append(systemPkgs, pkgInfo)
case RepoTypeGURU:
guruPkgs = append(guruPkgs, pkgInfo)
case RepoTypeManual:
manualPkgs = append(manualPkgs, dep.Name)
}
}
return systemPkgs, guruPkgs, manualPkgs, variantMap
}
func (g *GentooDistribution) extractPackageNames(packages []PackageMapping) []string {
names := make([]string, len(packages))
for i, pkg := range packages {
names[i] = pkg.Name
}
return names
}
func (g *GentooDistribution) installPortagePackages(ctx context.Context, packages []PackageMapping, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if len(packages) == 0 {
return nil
}
packageNames := g.extractPackageNames(packages)
g.log(fmt.Sprintf("Installing Portage packages: %s", strings.Join(packageNames, ", ")))
for _, pkg := range packages {
if pkg.AcceptKeywords != "" {
if err := g.setPackageAcceptKeywords(ctx, pkg.Name, pkg.AcceptKeywords, sudoPassword); err != nil {
return fmt.Errorf("failed to set accept keywords for %s: %w", pkg.Name, err)
}
}
if pkg.UseFlags != "" {
if err := g.setPackageUseFlags(ctx, pkg.Name, pkg.UseFlags, sudoPassword); err != nil {
return fmt.Errorf("failed to set USE flags for %s: %w", pkg.Name, err)
}
}
}
args := []string{"emerge", "--ask=n", "--quiet"}
args = append(args, packageNames...)
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.40,
Step: "Installing system packages...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
}
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
return g.runWithProgressTimeout(cmd, progressChan, PhaseSystemPackages, 0.40, 0.60, 0)
}
func (g *GentooDistribution) setPackageUseFlags(ctx context.Context, packageName, useFlags, sudoPassword string) error {
packageUseDir := "/etc/portage/package.use"
mkdirCmd := ExecSudoCommand(ctx, sudoPassword,
fmt.Sprintf("mkdir -p %s", packageUseDir))
if output, err := mkdirCmd.CombinedOutput(); err != nil {
g.log(fmt.Sprintf("mkdir output: %s", string(output)))
return fmt.Errorf("failed to create package.use directory: %w", err)
}
useFlagLine := fmt.Sprintf("%s %s", packageName, useFlags)
checkExistingCmd := exec.CommandContext(ctx, "bash", "-c",
fmt.Sprintf("grep -q '^%s ' %s/danklinux 2>/dev/null", packageName, packageUseDir))
if checkExistingCmd.Run() == nil {
g.log(fmt.Sprintf("Updating USE flags for %s from existing entry", packageName))
escapedPkg := strings.ReplaceAll(packageName, "/", "\\/")
replaceCmd := ExecSudoCommand(ctx, sudoPassword,
fmt.Sprintf("sed -i '/^%s /d' %s/danklinux; exit_code=$?; exit $exit_code", escapedPkg, packageUseDir))
if output, err := replaceCmd.CombinedOutput(); err != nil {
g.log(fmt.Sprintf("sed delete output: %s", string(output)))
return fmt.Errorf("failed to remove old USE flags: %w", err)
}
}
appendCmd := ExecSudoCommand(ctx, sudoPassword,
fmt.Sprintf("bash -c \"echo '%s' >> %s/danklinux\"", useFlagLine, packageUseDir))
output, err := appendCmd.CombinedOutput()
if err != nil {
g.log(fmt.Sprintf("append output: %s", string(output)))
return fmt.Errorf("failed to write USE flags to package.use: %w", err)
}
g.log(fmt.Sprintf("Set USE flags for %s: %s", packageName, useFlags))
return nil
}
func (g *GentooDistribution) syncGURURepo(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.55,
Step: "Enabling GURU repository...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo eselect repository enable guru",
LogOutput: "Enabling GURU repository with eselect",
}
// Enable GURU repository
enableCmd := ExecSudoCommand(ctx, sudoPassword,
"eselect repository enable guru 2>&1; exit_code=$?; exit $exit_code")
output, err := enableCmd.CombinedOutput()
g.log(fmt.Sprintf("eselect repository enable guru output:\n%s", string(output)))
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.55,
LogOutput: "GURU repository enabled",
}
if err != nil {
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.55,
LogOutput: fmt.Sprintf("ERROR enabling GURU: %v", err),
Error: err,
}
return fmt.Errorf("failed to enable GURU repository: %w\nOutput: %s", err, string(output))
}
// Sync GURU repository
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.57,
Step: "Syncing GURU repository...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo emaint sync --repo guru",
LogOutput: "Syncing GURU repository",
}
syncCmd := ExecSudoCommand(ctx, sudoPassword,
"emaint sync --repo guru 2>&1; exit_code=$?; exit $exit_code")
syncOutput, syncErr := syncCmd.CombinedOutput()
g.log(fmt.Sprintf("emaint sync --repo guru output:\n%s", string(syncOutput)))
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.57,
LogOutput: "GURU repository synced",
}
if syncErr != nil {
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.57,
LogOutput: fmt.Sprintf("ERROR syncing GURU: %v", syncErr),
Error: syncErr,
}
return fmt.Errorf("failed to sync GURU repository: %w\nOutput: %s", syncErr, string(syncOutput))
}
return nil
}
func (g *GentooDistribution) setPackageAcceptKeywords(ctx context.Context, packageName, keywords, sudoPassword string) error {
checkCmd := exec.CommandContext(ctx, "portageq", "match", "/", packageName)
if output, err := checkCmd.CombinedOutput(); err == nil && len(output) > 0 {
g.log(fmt.Sprintf("Package %s is already available (may already be unmasked)", packageName))
return nil
}
acceptKeywordsDir := "/etc/portage/package.accept_keywords"
mkdirCmd := ExecSudoCommand(ctx, sudoPassword,
fmt.Sprintf("mkdir -p %s", acceptKeywordsDir))
if output, err := mkdirCmd.CombinedOutput(); err != nil {
g.log(fmt.Sprintf("mkdir output: %s", string(output)))
return fmt.Errorf("failed to create package.accept_keywords directory: %w", err)
}
keywordLine := fmt.Sprintf("%s %s", packageName, keywords)
checkExistingCmd := exec.CommandContext(ctx, "bash", "-c",
fmt.Sprintf("grep -q '^%s ' %s/danklinux 2>/dev/null", packageName, acceptKeywordsDir))
if checkExistingCmd.Run() == nil {
g.log(fmt.Sprintf("Updating accept keywords for %s from existing entry", packageName))
escapedPkg := strings.ReplaceAll(packageName, "/", "\\/")
replaceCmd := ExecSudoCommand(ctx, sudoPassword,
fmt.Sprintf("sed -i '/^%s /d' %s/danklinux; exit_code=$?; exit $exit_code", escapedPkg, acceptKeywordsDir))
if output, err := replaceCmd.CombinedOutput(); err != nil {
g.log(fmt.Sprintf("sed delete output: %s", string(output)))
return fmt.Errorf("failed to remove old accept keywords: %w", err)
}
}
appendCmd := ExecSudoCommand(ctx, sudoPassword,
fmt.Sprintf("bash -c \"echo '%s' >> %s/danklinux\"", keywordLine, acceptKeywordsDir))
output, err := appendCmd.CombinedOutput()
if err != nil {
g.log(fmt.Sprintf("append output: %s", string(output)))
return fmt.Errorf("failed to write accept keywords: %w", err)
}
g.log(fmt.Sprintf("Set accept keywords for %s: %s", packageName, keywords))
return nil
}
func (g *GentooDistribution) installGURUPackages(ctx context.Context, packages []PackageMapping, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if len(packages) == 0 {
return nil
}
packageNames := g.extractPackageNames(packages)
g.log(fmt.Sprintf("Installing GURU packages: %s", strings.Join(packageNames, ", ")))
for _, pkg := range packages {
if pkg.AcceptKeywords != "" {
if err := g.setPackageAcceptKeywords(ctx, pkg.Name, pkg.AcceptKeywords, sudoPassword); err != nil {
return fmt.Errorf("failed to set accept keywords for %s: %w", pkg.Name, err)
}
}
if pkg.UseFlags != "" {
if err := g.setPackageUseFlags(ctx, pkg.Name, pkg.UseFlags, sudoPassword); err != nil {
return fmt.Errorf("failed to set USE flags for %s: %w", pkg.Name, err)
}
}
}
guruPackages := make([]string, len(packageNames))
for i, pkg := range packageNames {
guruPackages[i] = pkg + "::guru"
}
args := []string{"emerge", "--ask=n", "--quiet"}
args = append(args, guruPackages...)
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages,
Progress: 0.70,
Step: "Installing GURU packages...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
}
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
return g.runWithProgressTimeout(cmd, progressChan, PhaseAURPackages, 0.70, 0.85, 0)
}

View File

@@ -0,0 +1,156 @@
package distros
import (
"context"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
)
// DistroFamily represents a family of related distributions
type DistroFamily string
const (
FamilyArch DistroFamily = "arch"
FamilyFedora DistroFamily = "fedora"
FamilySUSE DistroFamily = "suse"
FamilyUbuntu DistroFamily = "ubuntu"
FamilyDebian DistroFamily = "debian"
FamilyNix DistroFamily = "nix"
FamilyGentoo DistroFamily = "gentoo"
)
// PackageManagerType defines the package manager a distro uses
type PackageManagerType string
const (
PackageManagerPacman PackageManagerType = "pacman"
PackageManagerDNF PackageManagerType = "dnf"
PackageManagerAPT PackageManagerType = "apt"
PackageManagerZypper PackageManagerType = "zypper"
PackageManagerNix PackageManagerType = "nix"
PackageManagerPortage PackageManagerType = "portage"
)
// RepositoryType defines the type of repository for a package
type RepositoryType string
const (
RepoTypeSystem RepositoryType = "system" // Standard system repo (pacman, dnf, apt)
RepoTypeAUR RepositoryType = "aur" // Arch User Repository
RepoTypeCOPR RepositoryType = "copr" // Fedora COPR
RepoTypePPA RepositoryType = "ppa" // Ubuntu PPA
RepoTypeFlake RepositoryType = "flake" // Nix flake
RepoTypeGURU RepositoryType = "guru" // Gentoo GURU
RepoTypeManual RepositoryType = "manual" // Manual build from source
)
// InstallPhase represents the current phase of installation
type InstallPhase int
const (
PhasePrerequisites InstallPhase = iota
PhaseAURHelper
PhaseSystemPackages
PhaseAURPackages
PhaseCursorTheme
PhaseConfiguration
PhaseComplete
)
// InstallProgressMsg represents progress during package installation
type InstallProgressMsg struct {
Phase InstallPhase
Progress float64
Step string
IsComplete bool
NeedsSudo bool
CommandInfo string
LogOutput string
Error error
}
// PackageMapping defines how to install a package on a specific distro
type PackageMapping struct {
Name string // Package name to install
Repository RepositoryType // Repository type
RepoURL string // Repository URL if needed (e.g., COPR repo, PPA)
BuildFunc string // Name of manual build function if RepoTypeManual
UseFlags string // USE flags for Gentoo packages
AcceptKeywords string // Accept keywords for Gentoo packages (e.g., "~amd64")
}
// Distribution defines a Linux distribution with all its specific configurations
type Distribution interface {
// Metadata
GetID() string
GetColorHex() string
GetFamily() DistroFamily
GetPackageManager() PackageManagerType
// Dependency Detection
DetectDependencies(ctx context.Context, wm deps.WindowManager) ([]deps.Dependency, error)
DetectDependenciesWithTerminal(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal) ([]deps.Dependency, error)
// Package Installation
InstallPackages(ctx context.Context, dependencies []deps.Dependency, wm deps.WindowManager, sudoPassword string, reinstallFlags map[string]bool, disabledFlags map[string]bool, skipGlobalUseFlags bool, progressChan chan<- InstallProgressMsg) error
// Package Mapping
GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping
// Prerequisites
InstallPrerequisites(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error
}
// DistroConfig holds configuration for a distribution
type DistroConfig struct {
ID string
ColorHex string
Family DistroFamily
Constructor func(config DistroConfig, logChan chan<- string) Distribution
}
// Registry holds all supported distributions
var Registry = make(map[string]DistroConfig)
// Register adds a distribution to the registry
func Register(id, colorHex string, family DistroFamily, constructor func(config DistroConfig, logChan chan<- string) Distribution) {
Registry[id] = DistroConfig{
ID: id,
ColorHex: colorHex,
Family: family,
Constructor: constructor,
}
}
// GetSupportedDistros returns a list of all supported distribution IDs
func GetSupportedDistros() []string {
ids := make([]string, 0, len(Registry))
for id := range Registry {
ids = append(ids, id)
}
return ids
}
// IsDistroSupported checks if a distribution ID is supported
func IsDistroSupported(id string) bool {
_, exists := Registry[id]
return exists
}
// NewDistribution creates a distribution instance by ID
func NewDistribution(id string, logChan chan<- string) (Distribution, error) {
config, exists := Registry[id]
if !exists {
return nil, &UnsupportedDistributionError{ID: id}
}
return config.Constructor(config, logChan), nil
}
// UnsupportedDistributionError is returned when a distribution is not supported
type UnsupportedDistributionError struct {
ID string
}
func (e *UnsupportedDistributionError) Error() string {
return "unsupported distribution: " + e.ID
}

View File

@@ -0,0 +1,955 @@
package distros
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
)
// ManualPackageInstaller provides methods for installing packages from source
type ManualPackageInstaller struct {
*BaseDistribution
}
// parseLatestTagFromGitOutput parses git ls-remote output and returns the latest tag
func (m *ManualPackageInstaller) parseLatestTagFromGitOutput(output string) string {
lines := strings.Split(output, "\n")
for _, line := range lines {
if strings.Contains(line, "refs/tags/") && !strings.Contains(line, "^{}") {
parts := strings.Split(line, "refs/tags/")
if len(parts) > 1 {
latestTag := strings.TrimSpace(parts[1])
return latestTag
}
}
}
return ""
}
// getLatestQuickshellTag fetches the latest tag from the quickshell repository
func (m *ManualPackageInstaller) getLatestQuickshellTag(ctx context.Context) string {
tagCmd := exec.CommandContext(ctx, "git", "ls-remote", "--tags", "--sort=-v:refname",
"https://github.com/quickshell-mirror/quickshell.git")
tagOutput, err := tagCmd.Output()
if err != nil {
m.log(fmt.Sprintf("Warning: failed to fetch quickshell tags: %v", err))
return ""
}
return m.parseLatestTagFromGitOutput(string(tagOutput))
}
func (m *ManualPackageInstaller) InstallManualPackages(ctx context.Context, packages []string, variantMap map[string]deps.PackageVariant, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if len(packages) == 0 {
return nil
}
m.log(fmt.Sprintf("Installing manual packages: %s", strings.Join(packages, ", ")))
for _, pkg := range packages {
variant := variantMap[pkg]
switch pkg {
case "dms (DankMaterialShell)", "dms":
if err := m.installDankMaterialShell(ctx, variant, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install DankMaterialShell: %w", err)
}
case "dgop":
if err := m.installDgop(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install dgop: %w", err)
}
case "grimblast":
if err := m.installGrimblast(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install grimblast: %w", err)
}
case "niri":
if err := m.installNiri(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install niri: %w", err)
}
case "quickshell":
if err := m.installQuickshell(ctx, variant, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install quickshell: %w", err)
}
case "hyprland":
if err := m.installHyprland(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install hyprland: %w", err)
}
case "hyprpicker":
if err := m.installHyprpicker(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install hyprpicker: %w", err)
}
case "ghostty":
if err := m.installGhostty(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install ghostty: %w", err)
}
case "matugen":
if err := m.installMatugen(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install matugen: %w", err)
}
case "cliphist":
if err := m.installCliphist(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install cliphist: %w", err)
}
case "xwayland-satellite":
if err := m.installXwaylandSatellite(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install xwayland-satellite: %w", err)
}
default:
m.log(fmt.Sprintf("Warning: No manual build method for %s", pkg))
}
}
return nil
}
func (m *ManualPackageInstaller) installDgop(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
m.log("Installing dgop from source...")
homeDir := os.Getenv("HOME")
if homeDir == "" {
return fmt.Errorf("HOME environment variable not set")
}
cacheDir := filepath.Join(homeDir, ".cache", "dankinstall")
if err := os.MkdirAll(cacheDir, 0755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err)
}
tmpDir := filepath.Join(cacheDir, "dgop-build")
if err := os.MkdirAll(tmpDir, 0755); err != nil {
return fmt.Errorf("failed to create temp directory: %w", err)
}
defer os.RemoveAll(tmpDir)
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.1,
Step: "Cloning dgop repository...",
IsComplete: false,
CommandInfo: "git clone https://github.com/AvengeMedia/dgop.git",
}
cloneCmd := exec.CommandContext(ctx, "git", "clone", "https://github.com/AvengeMedia/dgop.git", tmpDir)
if err := cloneCmd.Run(); err != nil {
m.logError("failed to clone dgop repository", err)
return fmt.Errorf("failed to clone dgop repository: %w", err)
}
buildCmd := exec.CommandContext(ctx, "make")
buildCmd.Dir = tmpDir
buildCmd.Env = append(os.Environ(), "TMPDIR="+cacheDir)
if err := m.runWithProgressStep(buildCmd, progressChan, PhaseSystemPackages, 0.4, 0.7, "Building dgop..."); err != nil {
return fmt.Errorf("failed to build dgop: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.7,
Step: "Installing dgop...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo make install",
}
installCmd := ExecSudoCommand(ctx, sudoPassword, "make install")
installCmd.Dir = tmpDir
if err := installCmd.Run(); err != nil {
m.logError("failed to install dgop", err)
return fmt.Errorf("failed to install dgop: %w", err)
}
m.log("dgop installed successfully from source")
return nil
}
func (m *ManualPackageInstaller) installGrimblast(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
m.log("Installing grimblast script for Hyprland...")
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.1,
Step: "Downloading grimblast script...",
IsComplete: false,
CommandInfo: "curl grimblast script",
}
grimblastURL := "https://raw.githubusercontent.com/hyprwm/contrib/refs/heads/main/grimblast/grimblast"
tmpPath := filepath.Join(os.TempDir(), "grimblast")
downloadCmd := exec.CommandContext(ctx, "curl", "-L", "-o", tmpPath, grimblastURL)
if err := downloadCmd.Run(); err != nil {
m.logError("failed to download grimblast", err)
return fmt.Errorf("failed to download grimblast: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.5,
Step: "Making grimblast executable...",
IsComplete: false,
CommandInfo: "chmod +x grimblast",
}
chmodCmd := exec.CommandContext(ctx, "chmod", "+x", tmpPath)
if err := chmodCmd.Run(); err != nil {
m.logError("failed to make grimblast executable", err)
return fmt.Errorf("failed to make grimblast executable: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.8,
Step: "Installing grimblast to /usr/local/bin...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo cp grimblast /usr/local/bin/",
}
installCmd := ExecSudoCommand(ctx, sudoPassword,
fmt.Sprintf("cp %s /usr/local/bin/grimblast", tmpPath))
if err := installCmd.Run(); err != nil {
m.logError("failed to install grimblast", err)
return fmt.Errorf("failed to install grimblast: %w", err)
}
os.Remove(tmpPath)
m.log("grimblast installed successfully to /usr/local/bin")
return nil
}
func (m *ManualPackageInstaller) installNiri(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
m.log("Installing niri from source...")
homeDir, _ := os.UserHomeDir()
buildDir := filepath.Join(homeDir, ".cache", "dankinstall", "niri-build")
tmpDir := filepath.Join(homeDir, ".cache", "dankinstall", "tmp")
if err := os.MkdirAll(buildDir, 0755); err != nil {
return fmt.Errorf("failed to create build directory: %w", err)
}
if err := os.MkdirAll(tmpDir, 0755); err != nil {
return fmt.Errorf("failed to create temp directory: %w", err)
}
defer func() {
os.RemoveAll(buildDir)
os.RemoveAll(tmpDir)
}()
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.2,
Step: "Cloning niri repository...",
IsComplete: false,
CommandInfo: "git clone https://github.com/YaLTeR/niri.git",
}
cloneCmd := exec.CommandContext(ctx, "git", "clone", "https://github.com/YaLTeR/niri.git", buildDir)
if err := cloneCmd.Run(); err != nil {
return fmt.Errorf("failed to clone niri: %w", err)
}
checkoutCmd := exec.CommandContext(ctx, "git", "-C", buildDir, "checkout", "v25.08")
if err := checkoutCmd.Run(); err != nil {
m.log(fmt.Sprintf("Warning: failed to checkout v25.08, using main: %v", err))
}
if !m.commandExists("cargo-deb") {
cargoDebInstallCmd := exec.CommandContext(ctx, "cargo", "install", "cargo-deb")
cargoDebInstallCmd.Env = append(os.Environ(), "TMPDIR="+tmpDir)
if err := m.runWithProgressStep(cargoDebInstallCmd, progressChan, PhaseSystemPackages, 0.3, 0.35, "Installing cargo-deb..."); err != nil {
return fmt.Errorf("failed to install cargo-deb: %w", err)
}
}
buildDebCmd := exec.CommandContext(ctx, "cargo", "deb")
buildDebCmd.Dir = buildDir
buildDebCmd.Env = append(os.Environ(), "TMPDIR="+tmpDir)
if err := m.runWithProgressStep(buildDebCmd, progressChan, PhaseSystemPackages, 0.35, 0.95, "Building niri deb package..."); err != nil {
return fmt.Errorf("failed to build niri deb: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.95,
Step: "Installing niri deb package...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "dpkg -i niri.deb",
}
installDebCmd := ExecSudoCommand(ctx, sudoPassword,
fmt.Sprintf("dpkg -i %s/target/debian/niri_*.deb", buildDir))
output, err := installDebCmd.CombinedOutput()
if err != nil {
m.log(fmt.Sprintf("dpkg install failed. Output:\n%s", string(output)))
return fmt.Errorf("failed to install niri deb package: %w\nOutput:\n%s", err, string(output))
}
m.log(fmt.Sprintf("dpkg install successful. Output:\n%s", string(output)))
m.log("niri installed successfully from source")
return nil
}
func (m *ManualPackageInstaller) installQuickshell(ctx context.Context, variant deps.PackageVariant, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
m.log("Installing quickshell from source...")
homeDir := os.Getenv("HOME")
if homeDir == "" {
return fmt.Errorf("HOME environment variable not set")
}
cacheDir := filepath.Join(homeDir, ".cache", "dankinstall")
if err := os.MkdirAll(cacheDir, 0755); 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 {
return fmt.Errorf("failed to create temp directory: %w", err)
}
defer os.RemoveAll(tmpDir)
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.1,
Step: "Cloning quickshell repository...",
IsComplete: false,
CommandInfo: "git clone https://github.com/quickshell-mirror/quickshell.git",
}
var cloneCmd *exec.Cmd
if forceQuickshellGit || variant == deps.VariantGit {
cloneCmd = exec.CommandContext(ctx, "git", "clone", "https://github.com/quickshell-mirror/quickshell.git", tmpDir)
} else {
latestTag := m.getLatestQuickshellTag(ctx)
if latestTag != "" {
m.log(fmt.Sprintf("Using latest quickshell tag: %s", latestTag))
cloneCmd = exec.CommandContext(ctx, "git", "clone", "--branch", latestTag, "https://github.com/quickshell-mirror/quickshell.git", tmpDir)
} else {
m.log("Warning: failed to fetch latest tag, using default branch")
cloneCmd = exec.CommandContext(ctx, "git", "clone", "https://github.com/quickshell-mirror/quickshell.git", tmpDir)
}
}
if err := cloneCmd.Run(); err != nil {
return fmt.Errorf("failed to clone quickshell: %w", err)
}
buildDir := tmpDir + "/build"
if err := os.MkdirAll(buildDir, 0755); err != nil {
return fmt.Errorf("failed to create build directory: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.3,
Step: "Configuring quickshell build...",
IsComplete: false,
CommandInfo: "cmake -B build -S . -G Ninja",
}
configureCmd := exec.CommandContext(ctx, "cmake", "-GNinja", "-B", "build",
"-DCMAKE_BUILD_TYPE=RelWithDebInfo",
"-DCRASH_REPORTER=off",
"-DCMAKE_CXX_STANDARD=20")
configureCmd.Dir = tmpDir
configureCmd.Env = append(os.Environ(), "TMPDIR="+cacheDir)
output, err := configureCmd.CombinedOutput()
if err != nil {
m.log(fmt.Sprintf("cmake configure failed. Output:\n%s", string(output)))
return fmt.Errorf("failed to configure quickshell: %w\nCMake output:\n%s", err, string(output))
}
m.log(fmt.Sprintf("cmake configure successful. Output:\n%s", string(output)))
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.4,
Step: "Building quickshell (this may take a while)...",
IsComplete: false,
CommandInfo: "cmake --build build",
}
buildCmd := exec.CommandContext(ctx, "cmake", "--build", "build")
buildCmd.Dir = tmpDir
buildCmd.Env = append(os.Environ(), "TMPDIR="+cacheDir)
if err := m.runWithProgressStep(buildCmd, progressChan, PhaseSystemPackages, 0.4, 0.8, "Building quickshell..."); err != nil {
return fmt.Errorf("failed to build quickshell: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.8,
Step: "Installing quickshell...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo cmake --install build",
}
installCmd := ExecSudoCommand(ctx, sudoPassword, "cmake --install build")
installCmd.Dir = tmpDir
if err := installCmd.Run(); err != nil {
return fmt.Errorf("failed to install quickshell: %w", err)
}
m.log("quickshell installed successfully from source")
return nil
}
func (m *ManualPackageInstaller) installHyprland(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
m.log("Installing Hyprland from source...")
homeDir := os.Getenv("HOME")
if homeDir == "" {
return fmt.Errorf("HOME environment variable not set")
}
cacheDir := filepath.Join(homeDir, ".cache", "dankinstall")
if err := os.MkdirAll(cacheDir, 0755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err)
}
tmpDir := filepath.Join(cacheDir, "hyprland-build")
if err := os.MkdirAll(tmpDir, 0755); err != nil {
return fmt.Errorf("failed to create temp directory: %w", err)
}
defer os.RemoveAll(tmpDir)
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.1,
Step: "Cloning Hyprland repository...",
IsComplete: false,
CommandInfo: "git clone --recursive https://github.com/hyprwm/Hyprland.git",
}
cloneCmd := exec.CommandContext(ctx, "git", "clone", "--recursive", "https://github.com/hyprwm/Hyprland.git", tmpDir)
if err := cloneCmd.Run(); err != nil {
return fmt.Errorf("failed to clone Hyprland: %w", err)
}
checkoutCmd := exec.CommandContext(ctx, "git", "-C", tmpDir, "checkout", "v0.50.1")
if err := checkoutCmd.Run(); err != nil {
m.log(fmt.Sprintf("Warning: failed to checkout v0.50.1, using main: %v", err))
}
buildCmd := exec.CommandContext(ctx, "make", "all")
buildCmd.Dir = tmpDir
buildCmd.Env = append(os.Environ(), "TMPDIR="+cacheDir)
if err := m.runWithProgressStep(buildCmd, progressChan, PhaseSystemPackages, 0.2, 0.8, "Building Hyprland..."); err != nil {
return fmt.Errorf("failed to build Hyprland: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.8,
Step: "Installing Hyprland...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo make install",
}
installCmd := ExecSudoCommand(ctx, sudoPassword, "make install")
installCmd.Dir = tmpDir
if err := installCmd.Run(); err != nil {
return fmt.Errorf("failed to install Hyprland: %w", err)
}
m.log("Hyprland installed successfully from source")
return nil
}
func (m *ManualPackageInstaller) installHyprpicker(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
m.log("Installing hyprpicker from source...")
homeDir := os.Getenv("HOME")
if homeDir == "" {
return fmt.Errorf("HOME environment variable not set")
}
cacheDir := filepath.Join(homeDir, ".cache", "dankinstall")
if err := os.MkdirAll(cacheDir, 0755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err)
}
// Install hyprutils first
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.05,
Step: "Building hyprutils dependency...",
IsComplete: false,
CommandInfo: "git clone https://github.com/hyprwm/hyprutils.git",
}
hyprutilsDir := filepath.Join(cacheDir, "hyprutils-build")
if err := os.MkdirAll(hyprutilsDir, 0755); err != nil {
return fmt.Errorf("failed to create hyprutils directory: %w", err)
}
defer os.RemoveAll(hyprutilsDir)
cloneUtilsCmd := exec.CommandContext(ctx, "git", "clone", "https://github.com/hyprwm/hyprutils.git", hyprutilsDir)
if err := cloneUtilsCmd.Run(); err != nil {
return fmt.Errorf("failed to clone hyprutils: %w", err)
}
configureUtilsCmd := exec.CommandContext(ctx, "cmake",
"--no-warn-unused-cli",
"-DCMAKE_BUILD_TYPE:STRING=Release",
"-DCMAKE_INSTALL_PREFIX:PATH=/usr",
"-DBUILD_TESTING=off",
"-S", ".",
"-B", "./build")
configureUtilsCmd.Dir = hyprutilsDir
configureUtilsCmd.Env = append(os.Environ(), "TMPDIR="+cacheDir)
if err := m.runWithProgressStep(configureUtilsCmd, progressChan, PhaseSystemPackages, 0.05, 0.1, "Configuring hyprutils..."); err != nil {
return fmt.Errorf("failed to configure hyprutils: %w", err)
}
buildUtilsCmd := exec.CommandContext(ctx, "cmake", "--build", "./build", "--config", "Release", "--target", "all")
buildUtilsCmd.Dir = hyprutilsDir
buildUtilsCmd.Env = append(os.Environ(), "TMPDIR="+cacheDir)
if err := m.runWithProgressStep(buildUtilsCmd, progressChan, PhaseSystemPackages, 0.1, 0.2, "Building hyprutils..."); err != nil {
return fmt.Errorf("failed to build hyprutils: %w", err)
}
installUtilsCmd := ExecSudoCommand(ctx, sudoPassword, "cmake --install ./build")
installUtilsCmd.Dir = hyprutilsDir
if err := installUtilsCmd.Run(); err != nil {
return fmt.Errorf("failed to install hyprutils: %w", err)
}
// Install hyprwayland-scanner
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.2,
Step: "Building hyprwayland-scanner dependency...",
IsComplete: false,
CommandInfo: "git clone https://github.com/hyprwm/hyprwayland-scanner.git",
}
scannerDir := filepath.Join(cacheDir, "hyprwayland-scanner-build")
if err := os.MkdirAll(scannerDir, 0755); err != nil {
return fmt.Errorf("failed to create scanner directory: %w", err)
}
defer os.RemoveAll(scannerDir)
cloneScannerCmd := exec.CommandContext(ctx, "git", "clone", "https://github.com/hyprwm/hyprwayland-scanner.git", scannerDir)
if err := cloneScannerCmd.Run(); err != nil {
return fmt.Errorf("failed to clone hyprwayland-scanner: %w", err)
}
configureScannerCmd := exec.CommandContext(ctx, "cmake",
"-DCMAKE_INSTALL_PREFIX=/usr",
"-B", "build")
configureScannerCmd.Dir = scannerDir
configureScannerCmd.Env = append(os.Environ(), "TMPDIR="+cacheDir)
if err := m.runWithProgressStep(configureScannerCmd, progressChan, PhaseSystemPackages, 0.2, 0.25, "Configuring hyprwayland-scanner..."); err != nil {
return fmt.Errorf("failed to configure hyprwayland-scanner: %w", err)
}
buildScannerCmd := exec.CommandContext(ctx, "cmake", "--build", "build", "-j")
buildScannerCmd.Dir = scannerDir
buildScannerCmd.Env = append(os.Environ(), "TMPDIR="+cacheDir)
if err := m.runWithProgressStep(buildScannerCmd, progressChan, PhaseSystemPackages, 0.25, 0.35, "Building hyprwayland-scanner..."); err != nil {
return fmt.Errorf("failed to build hyprwayland-scanner: %w", err)
}
installScannerCmd := ExecSudoCommand(ctx, sudoPassword, "cmake --install build")
installScannerCmd.Dir = scannerDir
if err := installScannerCmd.Run(); err != nil {
return fmt.Errorf("failed to install hyprwayland-scanner: %w", err)
}
// Now build hyprpicker
tmpDir := filepath.Join(cacheDir, "hyprpicker-build")
if err := os.MkdirAll(tmpDir, 0755); err != nil {
return fmt.Errorf("failed to create temp directory: %w", err)
}
defer os.RemoveAll(tmpDir)
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.35,
Step: "Cloning hyprpicker repository...",
IsComplete: false,
CommandInfo: "git clone https://github.com/hyprwm/hyprpicker.git",
}
cloneCmd := exec.CommandContext(ctx, "git", "clone", "https://github.com/hyprwm/hyprpicker.git", tmpDir)
if err := cloneCmd.Run(); err != nil {
return fmt.Errorf("failed to clone hyprpicker: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.45,
Step: "Configuring hyprpicker build...",
IsComplete: false,
CommandInfo: "cmake -B build -S . -DCMAKE_BUILD_TYPE=Release",
}
configureCmd := exec.CommandContext(ctx, "cmake",
"--no-warn-unused-cli",
"-DCMAKE_BUILD_TYPE:STRING=Release",
"-DCMAKE_INSTALL_PREFIX:PATH=/usr",
"-S", ".",
"-B", "./build")
configureCmd.Dir = tmpDir
configureCmd.Env = append(os.Environ(), "TMPDIR="+cacheDir)
output, err := configureCmd.CombinedOutput()
if err != nil {
m.log(fmt.Sprintf("cmake configure failed. Output:\n%s", string(output)))
return fmt.Errorf("failed to configure hyprpicker: %w\nCMake output:\n%s", err, string(output))
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.55,
Step: "Building hyprpicker...",
IsComplete: false,
CommandInfo: "cmake --build build --target hyprpicker",
}
buildCmd := exec.CommandContext(ctx, "cmake", "--build", "./build", "--config", "Release", "--target", "hyprpicker")
buildCmd.Dir = tmpDir
buildCmd.Env = append(os.Environ(), "TMPDIR="+cacheDir)
if err := m.runWithProgressStep(buildCmd, progressChan, PhaseSystemPackages, 0.55, 0.8, "Building hyprpicker..."); err != nil {
return fmt.Errorf("failed to build hyprpicker: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.8,
Step: "Installing hyprpicker...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo cmake --install build",
}
installCmd := ExecSudoCommand(ctx, sudoPassword, "cmake --install ./build")
installCmd.Dir = tmpDir
if err := installCmd.Run(); err != nil {
return fmt.Errorf("failed to install hyprpicker: %w", err)
}
m.log("hyprpicker installed successfully from source")
return nil
}
func (m *ManualPackageInstaller) installGhostty(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
m.log("Installing Ghostty from source...")
homeDir := os.Getenv("HOME")
if homeDir == "" {
return fmt.Errorf("HOME environment variable not set")
}
cacheDir := filepath.Join(homeDir, ".cache", "dankinstall")
if err := os.MkdirAll(cacheDir, 0755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err)
}
tmpDir := filepath.Join(cacheDir, "ghostty-build")
if err := os.MkdirAll(tmpDir, 0755); err != nil {
return fmt.Errorf("failed to create temp directory: %w", err)
}
defer os.RemoveAll(tmpDir)
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.1,
Step: "Cloning Ghostty repository...",
IsComplete: false,
CommandInfo: "git clone https://github.com/ghostty-org/ghostty.git",
}
cloneCmd := exec.CommandContext(ctx, "git", "clone", "https://github.com/ghostty-org/ghostty.git", tmpDir)
if err := cloneCmd.Run(); err != nil {
return fmt.Errorf("failed to clone Ghostty: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.2,
Step: "Building Ghostty (this may take a while)...",
IsComplete: false,
CommandInfo: "zig build -Doptimize=ReleaseFast",
}
buildCmd := exec.CommandContext(ctx, "zig", "build", "-Doptimize=ReleaseFast")
buildCmd.Dir = tmpDir
buildCmd.Env = append(os.Environ(), "TMPDIR="+cacheDir)
if err := buildCmd.Run(); err != nil {
return fmt.Errorf("failed to build Ghostty: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.8,
Step: "Installing Ghostty...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo cp zig-out/bin/ghostty /usr/local/bin/",
}
installCmd := ExecSudoCommand(ctx, sudoPassword,
fmt.Sprintf("cp %s/zig-out/bin/ghostty /usr/local/bin/", tmpDir))
if err := installCmd.Run(); err != nil {
return fmt.Errorf("failed to install Ghostty: %w", err)
}
m.log("Ghostty installed successfully from source")
return nil
}
func (m *ManualPackageInstaller) installMatugen(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
m.log("Installing matugen from source...")
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.1,
Step: "Installing matugen via cargo...",
IsComplete: false,
CommandInfo: "cargo install matugen",
}
installCmd := exec.CommandContext(ctx, "cargo", "install", "matugen")
if err := m.runWithProgressStep(installCmd, progressChan, PhaseSystemPackages, 0.1, 0.7, "Building matugen..."); err != nil {
return fmt.Errorf("failed to install matugen: %w", err)
}
homeDir := os.Getenv("HOME")
sourcePath := filepath.Join(homeDir, ".cargo", "bin", "matugen")
targetPath := "/usr/local/bin/matugen"
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.7,
Step: "Installing matugen binary to system...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo cp %s %s", sourcePath, targetPath),
}
copyCmd := exec.CommandContext(ctx, "sudo", "-S", "cp", sourcePath, targetPath)
copyCmd.Stdin = strings.NewReader(sudoPassword + "\n")
if err := copyCmd.Run(); err != nil {
return fmt.Errorf("failed to copy matugen to /usr/local/bin: %w", err)
}
// Make it executable
chmodCmd := exec.CommandContext(ctx, "sudo", "-S", "chmod", "+x", targetPath)
chmodCmd.Stdin = strings.NewReader(sudoPassword + "\n")
if err := chmodCmd.Run(); err != nil {
return fmt.Errorf("failed to make matugen executable: %w", err)
}
m.log("matugen installed successfully from source")
return nil
}
func (m *ManualPackageInstaller) installDankMaterialShell(ctx context.Context, variant deps.PackageVariant, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
m.log("Installing DankMaterialShell (DMS)...")
if err := m.installDMSBinary(ctx, sudoPassword, progressChan); err != nil {
m.logError("Failed to install DMS binary", err)
}
dmsPath := filepath.Join(os.Getenv("HOME"), ".config/quickshell/dms")
if _, err := os.Stat(dmsPath); os.IsNotExist(err) {
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.90,
Step: "Cloning DankMaterialShell...",
IsComplete: false,
CommandInfo: "git clone https://github.com/AvengeMedia/DankMaterialShell.git",
}
configDir := filepath.Dir(dmsPath)
if err := os.MkdirAll(configDir, 0755); err != nil {
return fmt.Errorf("failed to create quickshell config directory: %w", err)
}
cloneCmd := exec.CommandContext(ctx, "git", "clone",
"https://github.com/AvengeMedia/DankMaterialShell.git", dmsPath)
if err := cloneCmd.Run(); err != nil {
return fmt.Errorf("failed to clone DankMaterialShell: %w", err)
}
if forceDMSGit || variant == deps.VariantGit {
m.log("Using git variant (master branch)")
return nil
}
tagCmd := exec.CommandContext(ctx, "git", "-C", dmsPath, "describe", "--tags", "--abbrev=0", "origin/master")
tagOutput, err := tagCmd.Output()
if err != nil {
m.log("Using default branch (no tags found)")
return nil
}
latestTag := strings.TrimSpace(string(tagOutput))
checkoutCmd := exec.CommandContext(ctx, "git", "-C", dmsPath, "checkout", latestTag)
if err := checkoutCmd.Run(); err != nil {
m.logError(fmt.Sprintf("Failed to checkout tag %s", latestTag), err)
return nil
}
m.log(fmt.Sprintf("Checked out latest tag: %s", latestTag))
m.log("DankMaterialShell cloned successfully")
return nil
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.90,
Step: "Updating DankMaterialShell...",
IsComplete: false,
CommandInfo: "Updating ~/.config/quickshell/dms",
}
fetchCmd := exec.CommandContext(ctx, "git", "-C", dmsPath, "fetch", "origin", "--tags", "--force")
if err := fetchCmd.Run(); err != nil {
m.logError("Failed to fetch updates", err)
return nil
}
if forceDMSGit || variant == deps.VariantGit {
branchCmd := exec.CommandContext(ctx, "git", "-C", dmsPath, "rev-parse", "--abbrev-ref", "HEAD")
branchOutput, err := branchCmd.Output()
if err != nil {
m.logError("Failed to get current branch", err)
return nil
}
branch := strings.TrimSpace(string(branchOutput))
if branch == "" {
branch = "master"
}
pullCmd := exec.CommandContext(ctx, "git", "-C", dmsPath, "pull", "origin", branch)
if err := pullCmd.Run(); err != nil {
m.logError("Failed to pull updates", err)
return nil
}
m.log("DankMaterialShell updated successfully (git variant)")
return nil
}
latestTagCmd := exec.CommandContext(ctx, "git", "-C", dmsPath, "describe", "--tags", "--abbrev=0", "origin/master")
tagOutput, err := latestTagCmd.Output()
if err != nil {
m.logError("Failed to get latest tag", err)
return nil
}
latestTag := strings.TrimSpace(string(tagOutput))
checkoutCmd := exec.CommandContext(ctx, "git", "-C", dmsPath, "checkout", latestTag)
if err := checkoutCmd.Run(); err != nil {
m.logError(fmt.Sprintf("Failed to checkout tag %s", latestTag), err)
return nil
}
m.log(fmt.Sprintf("Updated to tag: %s", latestTag))
return nil
}
func (m *ManualPackageInstaller) installCliphist(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
m.log("Installing cliphist from source...")
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.1,
Step: "Installing cliphist via go install...",
IsComplete: false,
CommandInfo: "go install go.senan.xyz/cliphist@latest",
}
installCmd := exec.CommandContext(ctx, "go", "install", "go.senan.xyz/cliphist@latest")
if err := m.runWithProgressStep(installCmd, progressChan, PhaseSystemPackages, 0.1, 0.7, "Building cliphist..."); err != nil {
return fmt.Errorf("failed to install cliphist: %w", err)
}
homeDir := os.Getenv("HOME")
sourcePath := filepath.Join(homeDir, "go", "bin", "cliphist")
targetPath := "/usr/local/bin/cliphist"
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.7,
Step: "Installing cliphist binary to system...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo cp %s %s", sourcePath, targetPath),
}
copyCmd := exec.CommandContext(ctx, "sudo", "-S", "cp", sourcePath, targetPath)
copyCmd.Stdin = strings.NewReader(sudoPassword + "\n")
if err := copyCmd.Run(); err != nil {
return fmt.Errorf("failed to copy cliphist to /usr/local/bin: %w", err)
}
// Make it executable
chmodCmd := exec.CommandContext(ctx, "sudo", "-S", "chmod", "+x", targetPath)
chmodCmd.Stdin = strings.NewReader(sudoPassword + "\n")
if err := chmodCmd.Run(); err != nil {
return fmt.Errorf("failed to make cliphist executable: %w", err)
}
m.log("cliphist installed successfully from source")
return nil
}
func (m *ManualPackageInstaller) installXwaylandSatellite(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
m.log("Installing xwayland-satellite from source...")
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.1,
Step: "Installing xwayland-satellite via cargo...",
IsComplete: false,
CommandInfo: "cargo install --git https://github.com/Supreeeme/xwayland-satellite --tag v0.7",
}
installCmd := exec.CommandContext(ctx, "cargo", "install", "--git", "https://github.com/Supreeeme/xwayland-satellite", "--tag", "v0.7")
if err := m.runWithProgressStep(installCmd, progressChan, PhaseSystemPackages, 0.1, 0.7, "Building xwayland-satellite..."); err != nil {
return fmt.Errorf("failed to install xwayland-satellite: %w", err)
}
homeDir := os.Getenv("HOME")
sourcePath := filepath.Join(homeDir, ".cargo", "bin", "xwayland-satellite")
targetPath := "/usr/local/bin/xwayland-satellite"
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.7,
Step: "Installing xwayland-satellite binary to system...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo cp %s %s", sourcePath, targetPath),
}
copyCmd := exec.CommandContext(ctx, "sudo", "-S", "cp", sourcePath, targetPath)
copyCmd.Stdin = strings.NewReader(sudoPassword + "\n")
if err := copyCmd.Run(); err != nil {
return fmt.Errorf("failed to copy xwayland-satellite to /usr/local/bin: %w", err)
}
chmodCmd := exec.CommandContext(ctx, "sudo", "-S", "chmod", "+x", targetPath)
chmodCmd.Stdin = strings.NewReader(sudoPassword + "\n")
if err := chmodCmd.Run(); err != nil {
return fmt.Errorf("failed to make xwayland-satellite executable: %w", err)
}
m.log("xwayland-satellite installed successfully from source")
return nil
}

View File

@@ -0,0 +1,122 @@
package distros
import (
"testing"
)
func TestManualPackageInstaller_parseLatestTagFromGitOutput(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "normal tag output",
input: `a1a150fab00a93ea983aaca5df55304bc837f51b refs/tags/v0.2.1
a5431dd02dc23d9ef1680e67777fed00fe5f7cda refs/tags/v0.2.0
703a3789083d2f990c4e99cd25c97c2a4cccbd81 refs/tags/v0.1.0`,
expected: "v0.2.1",
},
{
name: "annotated tags with ^{}",
input: `a1a150fab00a93ea983aaca5df55304bc837f51b refs/tags/v0.2.1
b1b150fab00a93ea983aaca5df55304bc837f51c refs/tags/v0.2.1^{}
a5431dd02dc23d9ef1680e67777fed00fe5f7cda refs/tags/v0.2.0`,
expected: "v0.2.1",
},
{
name: "mixed tags",
input: `a1a150fab00a93ea983aaca5df55304bc837f51b refs/tags/v0.3.0
b1b150fab00a93ea983aaca5df55304bc837f51c refs/tags/v0.3.0^{}
a5431dd02dc23d9ef1680e67777fed00fe5f7cda refs/tags/v0.2.0
c1c150fab00a93ea983aaca5df55304bc837f51d refs/tags/beta-1`,
expected: "v0.3.0",
},
{
name: "empty output",
input: "",
expected: "",
},
{
name: "no tags",
input: "some other output\nwithout tags",
expected: "",
},
{
name: "only annotated tags",
input: `a1a150fab00a93ea983aaca5df55304bc837f51b refs/tags/v0.2.1^{}
a5431dd02dc23d9ef1680e67777fed00fe5f7cda refs/tags/v0.2.0^{}`,
expected: "",
},
{
name: "single tag",
input: `a1a150fab00a93ea983aaca5df55304bc837f51b refs/tags/v1.0.0`,
expected: "v1.0.0",
},
{
name: "tag with extra whitespace",
input: `a1a150fab00a93ea983aaca5df55304bc837f51b refs/tags/v0.2.1
a5431dd02dc23d9ef1680e67777fed00fe5f7cda refs/tags/v0.2.0`,
expected: "v0.2.1",
},
{
name: "beta and rc tags",
input: `a1a150fab00a93ea983aaca5df55304bc837f51b refs/tags/v0.3.0-beta.1
a5431dd02dc23d9ef1680e67777fed00fe5f7cda refs/tags/v0.2.0`,
expected: "v0.3.0-beta.1",
},
{
name: "tags without v prefix",
input: `a1a150fab00a93ea983aaca5df55304bc837f51b refs/tags/0.2.1
a5431dd02dc23d9ef1680e67777fed00fe5f7cda refs/tags/0.2.0`,
expected: "0.2.1",
},
{
name: "multiple lines with spaces",
input: `
a1a150fab00a93ea983aaca5df55304bc837f51b refs/tags/v1.2.3
a5431dd02dc23d9ef1680e67777fed00fe5f7cda refs/tags/v1.2.2
`,
expected: "v1.2.3",
},
{
name: "tag at end of line",
input: `a1a150fab00a93ea983aaca5df55304bc837f51b refs/tags/v0.2.1`,
expected: "v0.2.1",
},
}
logChan := make(chan string, 100)
defer close(logChan)
base := NewBaseDistribution(logChan)
installer := &ManualPackageInstaller{BaseDistribution: base}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := installer.parseLatestTagFromGitOutput(tt.input)
if result != tt.expected {
t.Errorf("parseLatestTagFromGitOutput() = %q, expected %q", result, tt.expected)
}
})
}
}
func TestManualPackageInstaller_parseLatestTagFromGitOutput_EmptyInstaller(t *testing.T) {
// Test that parsing works even with a minimal installer setup
logChan := make(chan string, 10)
defer close(logChan)
base := NewBaseDistribution(logChan)
installer := &ManualPackageInstaller{BaseDistribution: base}
input := `abc123 refs/tags/v1.0.0
def456 refs/tags/v0.9.0`
result := installer.parseLatestTagFromGitOutput(input)
if result != "v1.0.0" {
t.Errorf("Expected v1.0.0, got %s", result)
}
}

View File

@@ -0,0 +1,466 @@
package distros
import (
"context"
"fmt"
"os/exec"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
)
func init() {
Register("nixos", "#7EBAE4", FamilyNix, func(config DistroConfig, logChan chan<- string) Distribution {
return NewNixOSDistribution(config, logChan)
})
}
type NixOSDistribution struct {
*BaseDistribution
config DistroConfig
}
func NewNixOSDistribution(config DistroConfig, logChan chan<- string) *NixOSDistribution {
base := NewBaseDistribution(logChan)
return &NixOSDistribution{
BaseDistribution: base,
config: config,
}
}
func (n *NixOSDistribution) GetID() string {
return n.config.ID
}
func (n *NixOSDistribution) GetColorHex() string {
return n.config.ColorHex
}
func (n *NixOSDistribution) GetFamily() DistroFamily {
return n.config.Family
}
func (n *NixOSDistribution) GetPackageManager() PackageManagerType {
return PackageManagerNix
}
func (n *NixOSDistribution) DetectDependencies(ctx context.Context, wm deps.WindowManager) ([]deps.Dependency, error) {
return n.DetectDependenciesWithTerminal(ctx, wm, deps.TerminalGhostty)
}
func (n *NixOSDistribution) DetectDependenciesWithTerminal(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal) ([]deps.Dependency, error) {
var dependencies []deps.Dependency
// DMS at the top (shell is prominent)
dependencies = append(dependencies, n.detectDMS())
// Terminal with choice support
dependencies = append(dependencies, n.detectSpecificTerminal(terminal))
// Common detections using base methods
dependencies = append(dependencies, n.detectGit())
dependencies = append(dependencies, n.detectWindowManager(wm))
dependencies = append(dependencies, n.detectQuickshell())
dependencies = append(dependencies, n.detectXDGPortal())
dependencies = append(dependencies, n.detectPolkitAgent())
dependencies = append(dependencies, n.detectAccountsService())
// Hyprland-specific tools
if wm == deps.WindowManagerHyprland {
dependencies = append(dependencies, n.detectHyprlandTools()...)
}
// Niri-specific tools
if wm == deps.WindowManagerNiri {
dependencies = append(dependencies, n.detectXwaylandSatellite())
}
// Base detections (common across distros)
dependencies = append(dependencies, n.detectMatugen())
dependencies = append(dependencies, n.detectDgop())
dependencies = append(dependencies, n.detectHyprpicker())
dependencies = append(dependencies, n.detectClipboardTools()...)
return dependencies, nil
}
func (n *NixOSDistribution) detectDMS() deps.Dependency {
status := deps.StatusMissing
// For NixOS, check if quickshell can find the dms config
cmd := exec.Command("qs", "-c", "dms", "--list")
if err := cmd.Run(); err == nil {
status = deps.StatusInstalled
} else if n.packageInstalled("DankMaterialShell") {
// Fallback: check if flake is in profile
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "dms (DankMaterialShell)",
Status: status,
Description: "Desktop Management System configuration (installed as flake)",
Required: true,
}
}
func (n *NixOSDistribution) detectXDGPortal() deps.Dependency {
status := deps.StatusMissing
if n.packageInstalled("xdg-desktop-portal-gtk") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "xdg-desktop-portal-gtk",
Status: status,
Description: "Desktop integration portal for GTK",
Required: true,
}
}
func (n *NixOSDistribution) detectWindowManager(wm deps.WindowManager) deps.Dependency {
switch wm {
case deps.WindowManagerHyprland:
status := deps.StatusMissing
description := "Dynamic tiling Wayland compositor"
if n.commandExists("hyprland") || n.commandExists("Hyprland") {
status = deps.StatusInstalled
} else {
description = "Install system-wide: programs.hyprland.enable = true; in configuration.nix"
}
return deps.Dependency{
Name: "hyprland",
Status: status,
Description: description,
Required: true,
}
case deps.WindowManagerNiri:
status := deps.StatusMissing
description := "Scrollable-tiling Wayland compositor"
if n.commandExists("niri") {
status = deps.StatusInstalled
} else {
description = "Install system-wide: environment.systemPackages = [ pkgs.niri ]; in configuration.nix"
}
return deps.Dependency{
Name: "niri",
Status: status,
Description: description,
Required: true,
}
default:
return deps.Dependency{
Name: "unknown-wm",
Status: deps.StatusMissing,
Description: "Unknown window manager",
Required: true,
}
}
}
func (n *NixOSDistribution) detectHyprlandTools() []deps.Dependency {
var dependencies []deps.Dependency
tools := []struct {
name string
description string
}{
{"grim", "Screenshot utility for Wayland"},
{"slurp", "Region selection utility for Wayland"},
{"hyprctl", "Hyprland control utility (comes with system Hyprland)"},
{"hyprpicker", "Color picker for Hyprland"},
{"grimblast", "Screenshot script for Hyprland"},
{"jq", "JSON processor"},
}
for _, tool := range tools {
status := deps.StatusMissing
// Special handling for hyprctl - it comes with system hyprland
if tool.name == "hyprctl" {
if n.commandExists("hyprctl") {
status = deps.StatusInstalled
}
} else {
if n.commandExists(tool.name) {
status = deps.StatusInstalled
}
}
dependencies = append(dependencies, deps.Dependency{
Name: tool.name,
Status: status,
Description: tool.description,
Required: true,
})
}
return dependencies
}
func (n *NixOSDistribution) detectXwaylandSatellite() deps.Dependency {
status := deps.StatusMissing
if n.commandExists("xwayland-satellite") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "xwayland-satellite",
Status: status,
Description: "Xwayland support",
Required: true,
}
}
func (n *NixOSDistribution) detectPolkitAgent() deps.Dependency {
status := deps.StatusMissing
if n.packageInstalled("mate-polkit") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "mate-polkit",
Status: status,
Description: "PolicyKit authentication agent",
Required: true,
}
}
func (n *NixOSDistribution) detectAccountsService() deps.Dependency {
status := deps.StatusMissing
if n.packageInstalled("accountsservice") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "accountsservice",
Status: status,
Description: "D-Bus interface for user account query and manipulation",
Required: true,
}
}
func (n *NixOSDistribution) packageInstalled(pkg string) bool {
cmd := exec.Command("nix", "profile", "list")
output, err := cmd.Output()
if err != nil {
return false
}
return strings.Contains(string(output), pkg)
}
func (n *NixOSDistribution) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping {
packages := map[string]PackageMapping{
"git": {Name: "nixpkgs#git", Repository: RepoTypeSystem},
"quickshell": {Name: "github:quickshell-mirror/quickshell", Repository: RepoTypeFlake},
"matugen": {Name: "github:InioX/matugen", Repository: RepoTypeFlake},
"dgop": {Name: "github:AvengeMedia/dgop", Repository: RepoTypeFlake},
"dms (DankMaterialShell)": {Name: "github:AvengeMedia/DankMaterialShell", Repository: RepoTypeFlake},
"ghostty": {Name: "nixpkgs#ghostty", Repository: RepoTypeSystem},
"alacritty": {Name: "nixpkgs#alacritty", Repository: RepoTypeSystem},
"cliphist": {Name: "nixpkgs#cliphist", Repository: RepoTypeSystem},
"wl-clipboard": {Name: "nixpkgs#wl-clipboard", Repository: RepoTypeSystem},
"xdg-desktop-portal-gtk": {Name: "nixpkgs#xdg-desktop-portal-gtk", Repository: RepoTypeSystem},
"mate-polkit": {Name: "nixpkgs#mate.mate-polkit", Repository: RepoTypeSystem},
"accountsservice": {Name: "nixpkgs#accountsservice", Repository: RepoTypeSystem},
"hyprpicker": {Name: "nixpkgs#hyprpicker", Repository: RepoTypeSystem},
}
// Note: Window managers (hyprland/niri) should be installed system-wide on NixOS
// We only install the tools here
switch wm {
case deps.WindowManagerHyprland:
// Skip hyprland itself - should be installed system-wide
packages["grim"] = PackageMapping{Name: "nixpkgs#grim", Repository: RepoTypeSystem}
packages["slurp"] = PackageMapping{Name: "nixpkgs#slurp", Repository: RepoTypeSystem}
packages["grimblast"] = PackageMapping{Name: "github:hyprwm/contrib#grimblast", Repository: RepoTypeFlake}
packages["jq"] = PackageMapping{Name: "nixpkgs#jq", Repository: RepoTypeSystem}
case deps.WindowManagerNiri:
// Skip niri itself - should be installed system-wide
packages["xwayland-satellite"] = PackageMapping{Name: "nixpkgs#xwayland-satellite", Repository: RepoTypeFlake}
}
return packages
}
func (n *NixOSDistribution) InstallPrerequisites(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.10,
Step: "NixOS prerequisites ready",
IsComplete: false,
LogOutput: "NixOS package manager is ready to use",
}
return nil
}
func (n *NixOSDistribution) InstallPackages(ctx context.Context, dependencies []deps.Dependency, wm deps.WindowManager, sudoPassword string, reinstallFlags map[string]bool, disabledFlags map[string]bool, skipGlobalUseFlags bool, progressChan chan<- InstallProgressMsg) error {
// Phase 1: Check Prerequisites
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.05,
Step: "Checking system prerequisites...",
IsComplete: false,
LogOutput: "Starting prerequisite check...",
}
if err := n.InstallPrerequisites(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install prerequisites: %w", err)
}
nixpkgsPkgs, flakePkgs, _ := n.categorizePackages(dependencies, wm, reinstallFlags, disabledFlags)
// Phase 2: Nixpkgs Packages
if len(nixpkgsPkgs) > 0 {
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.35,
Step: fmt.Sprintf("Installing %d packages from nixpkgs...", len(nixpkgsPkgs)),
IsComplete: false,
LogOutput: fmt.Sprintf("Installing nixpkgs packages: %s", strings.Join(nixpkgsPkgs, ", ")),
}
if err := n.installNixpkgsPackages(ctx, nixpkgsPkgs, progressChan); err != nil {
return fmt.Errorf("failed to install nixpkgs packages: %w", err)
}
}
// Phase 3: Flake Packages
if len(flakePkgs) > 0 {
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages,
Progress: 0.65,
Step: fmt.Sprintf("Installing %d packages from flakes...", len(flakePkgs)),
IsComplete: false,
LogOutput: fmt.Sprintf("Installing flake packages: %s", strings.Join(flakePkgs, ", ")),
}
if err := n.installFlakePackages(ctx, flakePkgs, progressChan); err != nil {
return fmt.Errorf("failed to install flake packages: %w", err)
}
}
// Phase 4: Configuration
progressChan <- InstallProgressMsg{
Phase: PhaseConfiguration,
Progress: 0.90,
Step: "Configuring system...",
IsComplete: false,
LogOutput: "Starting post-installation configuration...",
}
if err := n.postInstallConfig(progressChan); err != nil {
return fmt.Errorf("failed to configure system: %w", err)
}
// Phase 5: Complete
progressChan <- InstallProgressMsg{
Phase: PhaseComplete,
Progress: 1.0,
Step: "Installation complete!",
IsComplete: true,
LogOutput: "All packages installed and configured successfully",
}
return nil
}
func (n *NixOSDistribution) categorizePackages(dependencies []deps.Dependency, wm deps.WindowManager, reinstallFlags map[string]bool, disabledFlags map[string]bool) ([]string, []string, map[string]deps.PackageVariant) {
nixpkgsPkgs := []string{}
flakePkgs := []string{}
variantMap := make(map[string]deps.PackageVariant)
for _, dep := range dependencies {
variantMap[dep.Name] = dep.Variant
}
packageMap := n.GetPackageMapping(wm)
for _, dep := range dependencies {
if disabledFlags[dep.Name] {
continue
}
if dep.Status == deps.StatusInstalled && !reinstallFlags[dep.Name] {
continue
}
pkgInfo, exists := packageMap[dep.Name]
if !exists {
n.log(fmt.Sprintf("Warning: No package mapping found for %s", dep.Name))
continue
}
switch pkgInfo.Repository {
case RepoTypeSystem:
nixpkgsPkgs = append(nixpkgsPkgs, pkgInfo.Name)
case RepoTypeFlake:
flakePkgs = append(flakePkgs, pkgInfo.Name)
}
}
return nixpkgsPkgs, flakePkgs, variantMap
}
func (n *NixOSDistribution) installNixpkgsPackages(ctx context.Context, packages []string, progressChan chan<- InstallProgressMsg) error {
if len(packages) == 0 {
return nil
}
n.log(fmt.Sprintf("Installing nixpkgs packages: %s", strings.Join(packages, ", ")))
args := []string{"profile", "install"}
args = append(args, packages...)
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.40,
Step: "Installing nixpkgs packages...",
IsComplete: false,
CommandInfo: fmt.Sprintf("nix %s", strings.Join(args, " ")),
}
cmd := exec.CommandContext(ctx, "nix", args...)
return n.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.40, 0.60)
}
func (n *NixOSDistribution) installFlakePackages(ctx context.Context, packages []string, progressChan chan<- InstallProgressMsg) error {
if len(packages) == 0 {
return nil
}
n.log(fmt.Sprintf("Installing flake packages: %s", strings.Join(packages, ", ")))
baseProgress := 0.65
progressStep := 0.20 / float64(len(packages))
for i, pkg := range packages {
currentProgress := baseProgress + (float64(i) * progressStep)
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages,
Progress: currentProgress,
Step: fmt.Sprintf("Installing flake package %s (%d/%d)...", pkg, i+1, len(packages)),
IsComplete: false,
CommandInfo: fmt.Sprintf("nix profile install %s", pkg),
}
cmd := exec.CommandContext(ctx, "nix", "profile", "install", pkg)
if err := n.runWithProgress(cmd, progressChan, PhaseAURPackages, currentProgress, currentProgress+progressStep); err != nil {
return fmt.Errorf("failed to install flake package %s: %w", pkg, err)
}
}
return nil
}
func (n *NixOSDistribution) postInstallConfig(progressChan chan<- InstallProgressMsg) error {
// For NixOS, DMS is installed as a flake package, so we skip both the binary installation and git clone
// The flake installation handles both the binary and config files correctly
progressChan <- InstallProgressMsg{
Phase: PhaseConfiguration,
Progress: 0.95,
Step: "NixOS configuration complete",
IsComplete: false,
LogOutput: "DMS installed via flake - binary and config handled by Nix",
}
return nil
}

View File

@@ -0,0 +1,604 @@
package distros
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
)
func init() {
Register("opensuse-tumbleweed", "#73BA25", FamilySUSE, func(config DistroConfig, logChan chan<- string) Distribution {
return NewOpenSUSEDistribution(config, logChan)
})
}
type OpenSUSEDistribution struct {
*BaseDistribution
*ManualPackageInstaller
config DistroConfig
}
func NewOpenSUSEDistribution(config DistroConfig, logChan chan<- string) *OpenSUSEDistribution {
base := NewBaseDistribution(logChan)
return &OpenSUSEDistribution{
BaseDistribution: base,
ManualPackageInstaller: &ManualPackageInstaller{BaseDistribution: base},
config: config,
}
}
func (o *OpenSUSEDistribution) GetID() string {
return o.config.ID
}
func (o *OpenSUSEDistribution) GetColorHex() string {
return o.config.ColorHex
}
func (o *OpenSUSEDistribution) GetFamily() DistroFamily {
return o.config.Family
}
func (o *OpenSUSEDistribution) GetPackageManager() PackageManagerType {
return PackageManagerZypper
}
func (o *OpenSUSEDistribution) DetectDependencies(ctx context.Context, wm deps.WindowManager) ([]deps.Dependency, error) {
return o.DetectDependenciesWithTerminal(ctx, wm, deps.TerminalGhostty)
}
func (o *OpenSUSEDistribution) DetectDependenciesWithTerminal(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal) ([]deps.Dependency, error) {
var dependencies []deps.Dependency
// DMS at the top (shell is prominent)
dependencies = append(dependencies, o.detectDMS())
// Terminal with choice support
dependencies = append(dependencies, o.detectSpecificTerminal(terminal))
// Common detections using base methods
dependencies = append(dependencies, o.detectGit())
dependencies = append(dependencies, o.detectWindowManager(wm))
dependencies = append(dependencies, o.detectQuickshell())
dependencies = append(dependencies, o.detectXDGPortal())
dependencies = append(dependencies, o.detectPolkitAgent())
dependencies = append(dependencies, o.detectAccountsService())
// Hyprland-specific tools
if wm == deps.WindowManagerHyprland {
dependencies = append(dependencies, o.detectHyprlandTools()...)
}
// Niri-specific tools
if wm == deps.WindowManagerNiri {
dependencies = append(dependencies, o.detectXwaylandSatellite())
}
// Base detections (common across distros)
dependencies = append(dependencies, o.detectMatugen())
dependencies = append(dependencies, o.detectDgop())
dependencies = append(dependencies, o.detectHyprpicker())
dependencies = append(dependencies, o.detectClipboardTools()...)
return dependencies, nil
}
func (o *OpenSUSEDistribution) detectXDGPortal() deps.Dependency {
status := deps.StatusMissing
if o.packageInstalled("xdg-desktop-portal-gtk") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "xdg-desktop-portal-gtk",
Status: status,
Description: "Desktop integration portal for GTK",
Required: true,
}
}
func (o *OpenSUSEDistribution) detectPolkitAgent() deps.Dependency {
status := deps.StatusMissing
if o.packageInstalled("mate-polkit") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "mate-polkit",
Status: status,
Description: "PolicyKit authentication agent",
Required: true,
}
}
func (o *OpenSUSEDistribution) packageInstalled(pkg string) bool {
cmd := exec.Command("rpm", "-q", pkg)
err := cmd.Run()
return err == nil
}
func (o *OpenSUSEDistribution) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping {
return o.GetPackageMappingWithVariants(wm, make(map[string]deps.PackageVariant))
}
func (o *OpenSUSEDistribution) GetPackageMappingWithVariants(wm deps.WindowManager, variants map[string]deps.PackageVariant) map[string]PackageMapping {
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},
"wl-clipboard": {Name: "wl-clipboard", Repository: RepoTypeSystem},
"xdg-desktop-portal-gtk": {Name: "xdg-desktop-portal-gtk", Repository: RepoTypeSystem},
"mate-polkit": {Name: "mate-polkit", Repository: RepoTypeSystem},
"accountsservice": {Name: "accountsservice", Repository: RepoTypeSystem},
"cliphist": {Name: "cliphist", Repository: RepoTypeSystem},
"hyprpicker": {Name: "hyprpicker", Repository: RepoTypeSystem},
// Manual builds
"dms (DankMaterialShell)": {Name: "dms", Repository: RepoTypeManual, BuildFunc: "installDankMaterialShell"},
"dgop": {Name: "dgop", Repository: RepoTypeManual, BuildFunc: "installDgop"},
"quickshell": {Name: "quickshell", Repository: RepoTypeManual, BuildFunc: "installQuickshell"},
"matugen": {Name: "matugen", Repository: RepoTypeManual, BuildFunc: "installMatugen"},
}
switch wm {
case deps.WindowManagerHyprland:
packages["hyprland"] = PackageMapping{Name: "hyprland", Repository: RepoTypeSystem}
packages["grim"] = PackageMapping{Name: "grim", Repository: RepoTypeSystem}
packages["slurp"] = PackageMapping{Name: "slurp", Repository: RepoTypeSystem}
packages["hyprctl"] = PackageMapping{Name: "hyprland", Repository: RepoTypeSystem}
packages["grimblast"] = PackageMapping{Name: "grimblast", Repository: RepoTypeManual, BuildFunc: "installGrimblast"}
packages["jq"] = PackageMapping{Name: "jq", Repository: RepoTypeSystem}
case deps.WindowManagerNiri:
packages["niri"] = PackageMapping{Name: "niri", Repository: RepoTypeSystem}
packages["xwayland-satellite"] = PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeSystem}
}
return packages
}
func (o *OpenSUSEDistribution) detectXwaylandSatellite() deps.Dependency {
status := deps.StatusMissing
if o.commandExists("xwayland-satellite") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "xwayland-satellite",
Status: status,
Description: "Xwayland support",
Required: true,
}
}
func (o *OpenSUSEDistribution) detectAccountsService() deps.Dependency {
status := deps.StatusMissing
if o.packageInstalled("accountsservice") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "accountsservice",
Status: status,
Description: "D-Bus interface for user account query and manipulation",
Required: true,
}
}
func (o *OpenSUSEDistribution) getPrerequisites() []string {
return []string{
"make",
"unzip",
"gcc",
"gcc-c++",
"cmake",
"ninja",
"pkgconf-pkg-config",
"git",
"qt6-base-devel",
"qt6-declarative-devel",
"qt6-declarative-private-devel",
"qt6-shadertools",
"qt6-shadertools-devel",
"qt6-wayland-devel",
"qt6-waylandclient-private-devel",
"spirv-tools-devel",
"cli11-devel",
"wayland-protocols-devel",
"libgbm-devel",
"libdrm-devel",
"pipewire-devel",
"jemalloc-devel",
"wayland-utils",
"Mesa-libGLESv3-devel",
"pam-devel",
"glib2-devel",
"polkit-devel",
}
}
func (o *OpenSUSEDistribution) InstallPrerequisites(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
prerequisites := o.getPrerequisites()
var missingPkgs []string
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.06,
Step: "Checking prerequisites...",
IsComplete: false,
LogOutput: "Checking prerequisite packages",
}
for _, pkg := range prerequisites {
checkCmd := exec.CommandContext(ctx, "rpm", "-q", pkg)
if err := checkCmd.Run(); err != nil {
missingPkgs = append(missingPkgs, pkg)
}
}
_, err := exec.LookPath("go")
if err != nil {
o.log("go not found in PATH, will install go")
missingPkgs = append(missingPkgs, "go")
} else {
o.log("go already available in PATH")
}
if len(missingPkgs) == 0 {
o.log("All prerequisites already installed")
return nil
}
o.log(fmt.Sprintf("Installing prerequisites: %s", strings.Join(missingPkgs, ", ")))
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.08,
Step: fmt.Sprintf("Installing %d prerequisites...", len(missingPkgs)),
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo zypper install -y %s", strings.Join(missingPkgs, " ")),
LogOutput: fmt.Sprintf("Installing prerequisites: %s", strings.Join(missingPkgs, ", ")),
}
args := []string{"zypper", "install", "-y"}
args = append(args, missingPkgs...)
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
output, err := cmd.CombinedOutput()
if err != nil {
o.logError("failed to install prerequisites", err)
o.log(fmt.Sprintf("Prerequisites command output: %s", string(output)))
return fmt.Errorf("failed to install prerequisites: %w", err)
}
o.log(fmt.Sprintf("Prerequisites install output: %s", string(output)))
return nil
}
func (o *OpenSUSEDistribution) InstallPackages(ctx context.Context, dependencies []deps.Dependency, wm deps.WindowManager, sudoPassword string, reinstallFlags map[string]bool, disabledFlags map[string]bool, skipGlobalUseFlags bool, progressChan chan<- InstallProgressMsg) error {
// Phase 1: Check Prerequisites
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.05,
Step: "Checking system prerequisites...",
IsComplete: false,
LogOutput: "Starting prerequisite check...",
}
if err := o.InstallPrerequisites(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install prerequisites: %w", err)
}
systemPkgs, manualPkgs, variantMap := o.categorizePackages(dependencies, wm, reinstallFlags, disabledFlags)
// Phase 2: System Packages (Zypper)
if len(systemPkgs) > 0 {
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.35,
Step: fmt.Sprintf("Installing %d system packages...", len(systemPkgs)),
IsComplete: false,
NeedsSudo: true,
LogOutput: fmt.Sprintf("Installing system packages: %s", strings.Join(systemPkgs, ", ")),
}
if err := o.installZypperPackages(ctx, systemPkgs, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install zypper packages: %w", err)
}
}
// Phase 3: Manual Builds
if len(manualPkgs) > 0 {
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.85,
Step: fmt.Sprintf("Building %d packages from source...", len(manualPkgs)),
IsComplete: false,
LogOutput: fmt.Sprintf("Building from source: %s", strings.Join(manualPkgs, ", ")),
}
if err := o.InstallManualPackages(ctx, manualPkgs, variantMap, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install manual packages: %w", err)
}
}
// Phase 4: Configuration
progressChan <- InstallProgressMsg{
Phase: PhaseConfiguration,
Progress: 0.90,
Step: "Configuring system...",
IsComplete: false,
LogOutput: "Starting post-installation configuration...",
}
// Phase 5: Complete
progressChan <- InstallProgressMsg{
Phase: PhaseComplete,
Progress: 1.0,
Step: "Installation complete!",
IsComplete: true,
LogOutput: "All packages installed and configured successfully",
}
return nil
}
func (o *OpenSUSEDistribution) categorizePackages(dependencies []deps.Dependency, wm deps.WindowManager, reinstallFlags map[string]bool, disabledFlags map[string]bool) ([]string, []string, map[string]deps.PackageVariant) {
systemPkgs := []string{}
manualPkgs := []string{}
variantMap := make(map[string]deps.PackageVariant)
for _, dep := range dependencies {
variantMap[dep.Name] = dep.Variant
}
packageMap := o.GetPackageMappingWithVariants(wm, variantMap)
for _, dep := range dependencies {
if disabledFlags[dep.Name] {
continue
}
if dep.Status == deps.StatusInstalled && !reinstallFlags[dep.Name] {
continue
}
pkgInfo, exists := packageMap[dep.Name]
if !exists {
o.log(fmt.Sprintf("Warning: No package mapping for %s", dep.Name))
continue
}
switch pkgInfo.Repository {
case RepoTypeSystem:
systemPkgs = append(systemPkgs, pkgInfo.Name)
case RepoTypeManual:
manualPkgs = append(manualPkgs, dep.Name)
}
}
return systemPkgs, manualPkgs, variantMap
}
func (o *OpenSUSEDistribution) installZypperPackages(ctx context.Context, packages []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if len(packages) == 0 {
return nil
}
o.log(fmt.Sprintf("Installing zypper packages: %s", strings.Join(packages, ", ")))
args := []string{"zypper", "install", "-y"}
args = append(args, packages...)
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.40,
Step: "Installing system packages...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
}
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
return o.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.40, 0.60)
}
func (o *OpenSUSEDistribution) installQuickshell(ctx context.Context, variant deps.PackageVariant, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
o.log("Installing quickshell from source (with openSUSE-specific build flags)...")
homeDir := os.Getenv("HOME")
if homeDir == "" {
return fmt.Errorf("HOME environment variable not set")
}
cacheDir := filepath.Join(homeDir, ".cache", "dankinstall")
if err := os.MkdirAll(cacheDir, 0755); 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 {
return fmt.Errorf("failed to create temp directory: %w", err)
}
defer os.RemoveAll(tmpDir)
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.1,
Step: "Cloning quickshell repository...",
IsComplete: false,
CommandInfo: "git clone https://github.com/quickshell-mirror/quickshell.git",
}
var cloneCmd *exec.Cmd
if forceQuickshellGit || variant == deps.VariantGit {
cloneCmd = exec.CommandContext(ctx, "git", "clone", "https://github.com/quickshell-mirror/quickshell.git", tmpDir)
} else {
latestTag := o.getLatestQuickshellTag(ctx)
if latestTag != "" {
o.log(fmt.Sprintf("Using latest quickshell tag: %s", latestTag))
cloneCmd = exec.CommandContext(ctx, "git", "clone", "--branch", latestTag, "https://github.com/quickshell-mirror/quickshell.git", tmpDir)
} else {
o.log("Warning: failed to fetch latest tag, using default branch")
cloneCmd = exec.CommandContext(ctx, "git", "clone", "https://github.com/quickshell-mirror/quickshell.git", tmpDir)
}
}
if err := cloneCmd.Run(); err != nil {
return fmt.Errorf("failed to clone quickshell: %w", err)
}
buildDir := tmpDir + "/build"
if err := os.MkdirAll(buildDir, 0755); err != nil {
return fmt.Errorf("failed to create build directory: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.3,
Step: "Configuring quickshell build (with openSUSE flags)...",
IsComplete: false,
CommandInfo: "cmake -B build -S . -G Ninja",
}
// Get optflags from rpm
optflagsCmd := exec.CommandContext(ctx, "rpm", "--eval", "%{optflags}")
optflagsOutput, err := optflagsCmd.Output()
optflags := strings.TrimSpace(string(optflagsOutput))
if err != nil || optflags == "" {
o.log("Warning: Could not get optflags from rpm, using default -O2 -g")
optflags = "-O2 -g"
}
// Set openSUSE-specific CFLAGS
customCFLAGS := fmt.Sprintf("%s -I/usr/include/wayland", optflags)
configureCmd := exec.CommandContext(ctx, "cmake", "-GNinja", "-B", "build",
"-DCMAKE_BUILD_TYPE=RelWithDebInfo",
"-DCRASH_REPORTER=off",
"-DCMAKE_CXX_STANDARD=20")
configureCmd.Dir = tmpDir
configureCmd.Env = append(os.Environ(),
"TMPDIR="+cacheDir,
"CFLAGS="+customCFLAGS,
"CXXFLAGS="+customCFLAGS)
o.log(fmt.Sprintf("Using CFLAGS: %s", customCFLAGS))
output, err := configureCmd.CombinedOutput()
if err != nil {
o.log(fmt.Sprintf("cmake configure failed. Output:\n%s", string(output)))
return fmt.Errorf("failed to configure quickshell: %w\nCMake output:\n%s", err, string(output))
}
o.log(fmt.Sprintf("cmake configure successful. Output:\n%s", string(output)))
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.4,
Step: "Building quickshell (this may take a while)...",
IsComplete: false,
CommandInfo: "cmake --build build",
}
buildCmd := exec.CommandContext(ctx, "cmake", "--build", "build")
buildCmd.Dir = tmpDir
buildCmd.Env = append(os.Environ(),
"TMPDIR="+cacheDir,
"CFLAGS="+customCFLAGS,
"CXXFLAGS="+customCFLAGS)
if err := o.runWithProgressStep(buildCmd, progressChan, PhaseSystemPackages, 0.4, 0.8, "Building quickshell..."); err != nil {
return fmt.Errorf("failed to build quickshell: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.8,
Step: "Installing quickshell...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo cmake --install build",
}
installCmd := ExecSudoCommand(ctx, sudoPassword, "cmake --install build")
installCmd.Dir = tmpDir
if err := installCmd.Run(); err != nil {
return fmt.Errorf("failed to install quickshell: %w", err)
}
o.log("quickshell installed successfully from source")
return nil
}
func (o *OpenSUSEDistribution) installRust(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if o.commandExists("cargo") {
return nil
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.82,
Step: "Installing rustup...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo zypper install rustup",
}
rustupInstallCmd := ExecSudoCommand(ctx, sudoPassword, "zypper install -y rustup")
if err := o.runWithProgress(rustupInstallCmd, progressChan, PhaseSystemPackages, 0.82, 0.83); err != nil {
return fmt.Errorf("failed to install rustup: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.83,
Step: "Installing stable Rust toolchain...",
IsComplete: false,
CommandInfo: "rustup install stable",
}
rustInstallCmd := exec.CommandContext(ctx, "bash", "-c", "rustup install stable && rustup default stable")
if err := o.runWithProgress(rustInstallCmd, progressChan, PhaseSystemPackages, 0.83, 0.84); err != nil {
return fmt.Errorf("failed to install Rust toolchain: %w", err)
}
if !o.commandExists("cargo") {
o.log("Warning: cargo not found in PATH after Rust installation, trying to source environment")
}
return nil
}
func (o *OpenSUSEDistribution) InstallManualPackages(ctx context.Context, packages []string, variantMap map[string]deps.PackageVariant, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if len(packages) == 0 {
return nil
}
o.log(fmt.Sprintf("Installing manual packages: %s", strings.Join(packages, ", ")))
for _, pkg := range packages {
if pkg == "matugen" {
if err := o.installRust(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install Rust: %w", err)
}
break
}
}
for _, pkg := range packages {
variant := variantMap[pkg]
if pkg == "quickshell" {
if err := o.installQuickshell(ctx, variant, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install quickshell: %w", err)
}
} else {
if err := o.ManualPackageInstaller.InstallManualPackages(ctx, []string{pkg}, variantMap, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install %s: %w", pkg, err)
}
}
}
return nil
}

View File

@@ -0,0 +1,115 @@
package distros
import (
"bufio"
"fmt"
"os"
"runtime"
"strconv"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs"
)
// DistroInfo contains basic information about a distribution
type DistroInfo struct {
ID string
HexColorCode string
}
// OSInfo contains complete OS information
type OSInfo struct {
Distribution DistroInfo
Version string
VersionID string
PrettyName string
Architecture string
}
// GetOSInfo detects the current OS and returns information about it
func GetOSInfo() (*OSInfo, error) {
if runtime.GOOS != "linux" {
return nil, errdefs.NewCustomError(errdefs.ErrTypeNotLinux, fmt.Sprintf("Only linux is supported, but I found %s", runtime.GOOS))
}
if runtime.GOARCH != "amd64" && runtime.GOARCH != "arm64" {
return nil, errdefs.NewCustomError(errdefs.ErrTypeInvalidArchitecture, fmt.Sprintf("Only amd64 and arm64 are supported, but I found %s", runtime.GOARCH))
}
info := &OSInfo{
Architecture: runtime.GOARCH,
}
file, err := os.Open("/etc/os-release")
if err != nil {
return nil, err
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
continue
}
key := parts[0]
value := strings.Trim(parts[1], "\"")
switch key {
case "ID":
config, exists := Registry[value]
if !exists {
return nil, errdefs.NewCustomError(errdefs.ErrTypeUnsupportedDistribution, fmt.Sprintf("Unsupported distribution: %s", value))
}
info.Distribution = DistroInfo{
ID: value, // Use the actual ID from os-release
HexColorCode: config.ColorHex,
}
case "VERSION_ID", "BUILD_ID":
info.VersionID = value
case "VERSION":
info.Version = value
case "PRETTY_NAME":
info.PrettyName = value
}
}
return info, scanner.Err()
}
// IsUnsupportedDistro checks if a distribution/version combination is supported
func IsUnsupportedDistro(distroID, versionID string) bool {
if !IsDistroSupported(distroID) {
return true
}
if distroID == "ubuntu" {
parts := strings.Split(versionID, ".")
if len(parts) >= 2 {
major, err1 := strconv.Atoi(parts[0])
minor, err2 := strconv.Atoi(parts[1])
if err1 == nil && err2 == nil {
return major < 25 || (major == 25 && minor < 4)
}
}
return true
}
if distroID == "debian" {
if versionID == "" {
// debian testing/sid have no version ID
return false
}
versionNum, err := strconv.Atoi(versionID)
if err == nil {
return versionNum < 12
}
return true
}
return false
}

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