mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-10-22 11:14:01 -04:00
Compare commits
637
Commits
v1.6.2
...
stable-2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ab57f14b8 | ||
|
|
ce81dfc95a | ||
|
|
a5511fe8d7 | ||
|
|
7d930f8f6c | ||
|
|
11ef76b9d0 | ||
|
|
e48de4cc2a | ||
|
|
1683fceac5 | ||
|
|
2a83c5c69b | ||
|
|
1acfae1755 | ||
|
|
1e1dbebb70 | ||
|
|
53412d1f22 | ||
|
|
9ece8f26fa | ||
|
|
fd1295f959 | ||
|
|
1e3f16ac9d | ||
|
|
4fa6f58720 | ||
|
|
ff45b3b4b2 | ||
|
|
12e54e0225 | ||
|
|
f63d8bf4fb | ||
|
|
1e8423992d | ||
|
|
c4ecb988b4 | ||
|
|
9b413296b1 | ||
|
|
d04eae4210 | ||
|
|
5ec42c91dc | ||
|
|
b97fda0639 | ||
|
|
5aa26677c1 | ||
|
|
b94fb6a2c8 | ||
|
|
516be7ca89 | ||
|
|
7c39d251b7 | ||
|
|
cff4496ec5 | ||
|
|
9e177f2293 | ||
|
|
807853c9a4 | ||
|
|
789a968cde | ||
|
|
788b722702 | ||
|
|
bbb91a5a72 | ||
|
|
8a30a83896 | ||
|
|
572cbcd9d3 | ||
|
|
bdc6a5d214 | ||
|
|
8be08999ca | ||
|
|
806799614c | ||
|
|
9b62944e9f | ||
|
|
7fafc08196 | ||
|
|
f43e3705b8 | ||
|
|
bb3e3adaab | ||
|
|
d04525b2bd | ||
|
|
2b9e48d750 | ||
|
|
fdb9410bc4 | ||
|
|
078e3bfbcd | ||
|
|
beab7d86e0 | ||
|
|
115ed9f338 | ||
|
|
4155804e57 | ||
|
|
7bcbc34c57 | ||
|
|
fcb74f5884 | ||
|
|
317d9399c7 | ||
|
|
d6a362d6b3 | ||
|
|
8628daac31 | ||
|
|
6a2b4378d6 | ||
|
|
a9dd3be408 | ||
|
|
880135da67 | ||
|
|
e6f215b91d | ||
|
|
602c20e6a0 | ||
|
|
35aff6b0f6 | ||
|
|
a72f2ac43d | ||
|
|
e4f0c5335a | ||
|
|
5a480101c6 | ||
|
|
2592e2f33e | ||
|
|
207cbe5aae | ||
|
|
47885e4981 | ||
|
|
2ad442fb37 | ||
|
|
c0c054552e | ||
|
|
ae361e37fc | ||
|
|
d8a87a8e13 | ||
|
|
ba3574efaa | ||
|
|
da3b658b2f | ||
|
|
8e690022f1 | ||
|
|
e9ce1a9d46 | ||
|
|
506c6f5504 | ||
|
|
5f87b7ceea | ||
|
|
5c40670a6d | ||
|
|
cd42276870 | ||
|
|
b08f3f8ea1 | ||
|
|
a5435c585f | ||
|
|
806b86754c | ||
|
|
9d5c02002f | ||
|
|
e2ecc1f2a9 | ||
|
|
27ee4f505a | ||
|
|
d14f80ec32 | ||
|
|
549cbf6d08 | ||
|
|
6b40abe4b9 | ||
|
|
1184b9ca7b | ||
|
|
0983c2267a | ||
|
|
eb837255e0 | ||
|
|
879533440e | ||
|
|
0f33c4b830 | ||
|
|
290a1a085b | ||
|
|
c4aea85296 | ||
|
|
87ce92703c | ||
|
|
ad964512a9 | ||
|
|
3c13700fc5 | ||
|
|
8ff7ce2cea | ||
|
|
699eb23775 | ||
|
|
57cfb5e493 | ||
|
|
66ffc133ca | ||
|
|
35041d03e1 | ||
|
|
c18d989fc5 | ||
|
|
39b41fb880 | ||
|
|
12feb8068b | ||
|
|
e23bd4073f | ||
|
|
f03ae838c9 | ||
|
|
f4e3697803 | ||
|
|
5f31b2e3d5 | ||
|
|
ba8c4e60a1 | ||
|
|
c3e93ee39a | ||
|
|
d8a4ffe47b | ||
|
|
eda2f4459b | ||
|
|
d041ffccb0 | ||
|
|
14de920cd2 | ||
|
|
80711f1c21 | ||
|
|
593046064b | ||
|
|
4a8f1062be | ||
|
|
e5bb8f610a | ||
|
|
0699bc1c97 | ||
|
|
9433bf7500 | ||
|
|
93bd3eecd1 | ||
|
|
566ccf2fde | ||
|
|
eefc823de3 | ||
|
|
8cf89f17c0 | ||
|
|
718ba6b118 | ||
|
|
3ff724e4ea | ||
|
|
10eb5f6ec3 | ||
|
|
a8bb04a2b2 | ||
|
|
fb1686888d | ||
|
|
3e5cfe650c | ||
|
|
ffcaad73b8 | ||
|
|
0fb2297c15 | ||
|
|
ce389d2e87 | ||
|
|
80ca0f0ee5 | ||
|
|
132dca18ae | ||
|
|
4eda5ccd8f | ||
|
|
6640a943b3 | ||
|
|
36ff537e9a | ||
|
|
846a0ff22f | ||
|
|
40b6eaa4fe | ||
|
|
60736a7381 | ||
|
|
1966c8760d | ||
|
|
b988cb14b7 | ||
|
|
002ab6592d | ||
|
|
adaa5e2bb0 | ||
|
|
a1e6f6d6ac | ||
|
|
3a48f59067 | ||
|
|
f372777829 | ||
|
|
d7d7f73420 | ||
|
|
f1ea842bca | ||
|
|
2cc720cf2d | ||
|
|
d8aca6ab48 | ||
|
|
3246138096 | ||
|
|
6f0674ff72 | ||
|
|
3e2d969e5d | ||
|
|
583333dfb5 | ||
|
|
5193fcd473 | ||
|
|
68043277c6 | ||
|
|
b0b14700e8 | ||
|
|
6d331b8fde | ||
|
|
0444835016 | ||
|
|
938971e758 | ||
|
|
400f62316c | ||
|
|
be26a06c2a | ||
|
|
3aa8cd91e8 | ||
|
|
26301462b7 | ||
|
|
8163b4654b | ||
|
|
834806a9ce | ||
|
|
e60b038f09 | ||
|
|
d0f9954361 | ||
|
|
f988a57494 | ||
|
|
e2a0b37d03 | ||
|
|
aef801fc4b | ||
|
|
534913dc0b | ||
|
|
a0ea04178f | ||
|
|
c417cd10ea | ||
|
|
4dd159c7b2 | ||
|
|
5113441810 | ||
|
|
e10102edc0 | ||
|
|
ea68399ef3 | ||
|
|
2747a75198 | ||
|
|
f9574ef9ae | ||
|
|
39b0d40877 | ||
|
|
b53f7c0633 | ||
|
|
7f2ab8e012 | ||
|
|
b55d5b428b | ||
|
|
1dc0790d77 | ||
|
|
9073de5286 | ||
|
|
e985408a2e | ||
|
|
5f9f66ca59 | ||
|
|
b938dd76f0 | ||
|
|
474b723faf | ||
|
|
f19632eba5 | ||
|
|
2c77b9d477 | ||
|
|
fa6927de3b | ||
|
|
1a0e4b9979 | ||
|
|
f61a5d1211 | ||
|
|
1d120f94bf | ||
|
|
97004ffd6e | ||
|
|
aa5610dd49 | ||
|
|
2fd472b89a | ||
|
|
c1006f6b34 | ||
|
|
8b47d3cf70 | ||
|
|
2bf7f7c556 | ||
|
|
4dc45185ba | ||
|
|
c0f08f08cb | ||
|
|
f9d91cd21f | ||
|
|
b1dde74e4d | ||
|
|
f2c06a3cc7 | ||
|
|
ed43b07d5e | ||
|
|
4b0dfa67a0 | ||
|
|
72c23161e7 | ||
|
|
7c6e9ed514 | ||
|
|
66e37ff843 | ||
|
|
6f43ece99f | ||
|
|
e9f66d6256 | ||
|
|
b42fe6643f | ||
|
|
8fedfae1dc | ||
|
|
a8146d2ca5 | ||
|
|
8e59341c50 | ||
|
|
650b731164 | ||
|
|
07740cc2d5 | ||
|
|
7ffd640ef9 | ||
|
|
98bf56cbcd | ||
|
|
8299d8fa77 | ||
|
|
fd52e0af59 | ||
|
|
5eb78f1f12 | ||
|
|
769e4455f8 | ||
|
|
54eb0ccaed | ||
|
|
089dc98166 | ||
|
|
d838d15e69 | ||
|
|
f886789835 | ||
|
|
9cceff29dd | ||
|
|
507b818a55 | ||
|
|
069fa318d6 | ||
|
|
766708c551 | ||
|
|
ebf9c5ba7b | ||
|
|
5ee570557f | ||
|
|
ca93837149 | ||
|
|
7b144ddac1 | ||
|
|
9d938cfed0 | ||
|
|
1276418f6e | ||
|
|
c8d9a8617e | ||
|
|
4265ea1620 | ||
|
|
ff50b492b1 | ||
|
|
b81373aae8 | ||
|
|
87a4da9e13 | ||
|
|
2c584c8bf7 | ||
|
|
eeea1a633c | ||
|
|
9ec48c4f13 | ||
|
|
f8b25bc43c | ||
|
|
ccbf1d38ad | ||
|
|
ba50299b2f | ||
|
|
f6d0701505 | ||
|
|
4b285323a1 | ||
|
|
0ac7233c4d | ||
|
|
2f5a438e1e | ||
|
|
9b7c0eddd7 | ||
|
|
d11075d154 | ||
|
|
7eee3dcbe7 | ||
|
|
d8113d2a02 | ||
|
|
74628450fc | ||
|
|
dfa19b9237 | ||
|
|
131906d6a5 | ||
|
|
f4785763a1 | ||
|
|
5972dd188b | ||
|
|
1f8e7a69a0 | ||
|
|
235297898b | ||
|
|
9d3599c51a | ||
|
|
002c4eb549 | ||
|
|
076288604e | ||
|
|
e26d0294c8 | ||
|
|
4bd3e091fe | ||
|
|
30dc1122c5 | ||
|
|
d2942a4dbf | ||
|
|
2b01926e31 | ||
|
|
18610b8972 | ||
|
|
c2962f8239 | ||
|
|
d3936e1742 | ||
|
|
23e8bb5935 | ||
|
|
d7dc8ae5d2 | ||
|
|
375aa2f492 | ||
|
|
30c7171836 | ||
|
|
27f7a1b67b | ||
|
|
d8fa3f609d | ||
|
|
4ca2a8ef99 | ||
|
|
c79127c1bb | ||
|
|
b48fce0447 | ||
|
|
585827e7e4 | ||
|
|
d025ccdfc8 | ||
|
|
184a16c2b4 | ||
|
|
19abc7dc96 | ||
|
|
51f115f11e | ||
|
|
27989967b8 | ||
|
|
2a8368a8a4 | ||
|
|
49d7d6b2fe | ||
|
|
48de42b57c | ||
|
|
28c76ddb66 | ||
|
|
6a320399c9 | ||
|
|
497e80732f | ||
|
|
57de2083ad | ||
|
|
204aa1735b | ||
|
|
d9d9dc21d2 | ||
|
|
0bff4524e2 | ||
|
|
136e1f21cd | ||
|
|
4395c97a6a | ||
|
|
1e1abb0206 | ||
|
|
300544bc8c | ||
|
|
340f2d179c | ||
|
|
5318c43f3a | ||
|
|
850d7a6071 | ||
|
|
1d789892f7 | ||
|
|
1492b649e6 | ||
|
|
c559a5250c | ||
|
|
eda8b26f6e | ||
|
|
e4ec3582b4 | ||
|
|
af27f0bac0 | ||
|
|
e473c92825 | ||
|
|
de1c5c99fa | ||
|
|
c668ab9fdf | ||
|
|
e61fa45444 | ||
|
|
ea6c52b5e8 | ||
|
|
b9c8faa60f | ||
|
|
d77c4f6298 | ||
|
|
a348d8bc5e | ||
|
|
0b9d931ea3 | ||
|
|
b54fa3a584 | ||
|
|
22fa08f0ff | ||
|
|
738e19715e | ||
|
|
a8623e3ccb | ||
|
|
5b4faef744 | ||
|
|
c4ea1a1dbd | ||
|
|
d84037066e | ||
|
|
ad25687d30 | ||
|
|
3bbf02f502 | ||
|
|
9e7f35de0c | ||
|
|
8e9970efee | ||
|
|
adccc05397 | ||
|
|
73759d5752 | ||
|
|
db903801c3 | ||
|
|
219a95d869 | ||
|
|
d5a684ce52 | ||
|
|
5cfda4ebe7 | ||
|
|
f025a8d7b4 | ||
|
|
ea70863fd7 | ||
|
|
34e0ed2e90 | ||
|
|
c1818dee2c | ||
|
|
765ffe8564 | ||
|
|
53b8de2714 | ||
|
|
3081941592 | ||
|
|
c205358b79 | ||
|
|
ce8db05cea | ||
|
|
13034cb85c | ||
|
|
f482642880 | ||
|
|
aced05157b | ||
|
|
2fb0cfee60 | ||
|
|
182e08b1a4 | ||
|
|
00952d4d51 | ||
|
|
35faa3130c | ||
|
|
97ec64ff97 | ||
|
|
3fb27d93de | ||
|
|
8173dd336e | ||
|
|
eda77f7816 | ||
|
|
33bc0a0dff | ||
|
|
e372c48009 | ||
|
|
f9de6b86ea | ||
|
|
ffe2da7e99 | ||
|
|
1142390724 | ||
|
|
4ca4e2586e | ||
|
|
a609b5f999 | ||
|
|
f6bb4b1727 | ||
|
|
c45579746b | ||
|
|
64b4d0d235 | ||
|
|
a4e500bc00 | ||
|
|
d5c96a1756 | ||
|
|
734314d09f | ||
|
|
e4fb86be91 | ||
|
|
77a6cdfc00 | ||
|
|
f27627ead2 | ||
|
|
814620a934 | ||
|
|
2a5e9d59df | ||
|
|
980d37c9da | ||
|
|
0717908d53 | ||
|
|
4b00c047da | ||
|
|
5623476126 | ||
|
|
9dc5b2c5af | ||
|
|
09e7ea0221 | ||
|
|
1fc4537894 | ||
|
|
e796f9d1ed | ||
|
|
a48f41b749 | ||
|
|
3247476368 | ||
|
|
3b1b63c93f | ||
|
|
909c0055ea | ||
|
|
c7ab8993e5 | ||
|
|
05377e690a | ||
|
|
65dbb9fc58 | ||
|
|
cc45f8b445 | ||
|
|
8da739a3ea | ||
|
|
3b529b3a97 | ||
|
|
60342b7416 | ||
|
|
d7074a53eb | ||
|
|
7c2140dd60 | ||
|
|
3c14a41624 | ||
|
|
b1b6a3d686 | ||
|
|
361e9da26a | ||
|
|
890166e384 | ||
|
|
6cb8a3b67a | ||
|
|
10e8086874 | ||
|
|
9a1c622492 | ||
|
|
82fd310732 | ||
|
|
ec6407fb1e | ||
|
|
e76d989656 | ||
|
|
7975136c10 | ||
|
|
8508121f4a | ||
|
|
b97a8828eb | ||
|
|
ace13d7c18 | ||
|
|
4554c9c894 | ||
|
|
4949c2fbbe | ||
|
|
1c61c26d46 | ||
|
|
e3b5cde72e | ||
|
|
d750c173ba | ||
|
|
6e9bbc15a6 | ||
|
|
878aa69fac | ||
|
|
145b9bbc18 | ||
|
|
c5029444ff | ||
|
|
4b926be08f | ||
|
|
4d31c84188 | ||
|
|
eb0b56e578 | ||
|
|
eb0cb80884 | ||
|
|
7e8c556ab6 | ||
|
|
ccd6da3b43 | ||
|
|
f97e7f7fe9 | ||
|
|
6ab3755405 | ||
|
|
fb973951e3 | ||
|
|
f22ad2c333 | ||
|
|
4bdfcfc0cf | ||
|
|
9d1884fb19 | ||
|
|
80060ee925 | ||
|
|
c66ca00062 | ||
|
|
62098c8add | ||
|
|
4cb2be5fa4 | ||
|
|
edf936973f | ||
|
|
a96dea2019 | ||
|
|
7ba4bccf6c | ||
|
|
860542314e | ||
|
|
89484b5d85 | ||
|
|
27434cf016 | ||
|
|
abd51287f3 | ||
|
|
17d52d3c88 | ||
|
|
4eb163538c | ||
|
|
2d1b4f905d | ||
|
|
e9498716f9 | ||
|
|
8538b303e3 | ||
|
|
75b8cfff4f | ||
|
|
b41f0f2c4e | ||
|
|
f3af870d93 | ||
|
|
a84e84c39b | ||
|
|
74e146a1e8 | ||
|
|
8266e078e7 | ||
|
|
c9032926d7 | ||
|
|
bb20528f68 | ||
|
|
17ecaad8dd | ||
|
|
cc8a6053c5 | ||
|
|
e3b4efc498 | ||
|
|
a3f1a3d620 | ||
|
|
0bd3e85150 | ||
|
|
1c683e932f | ||
|
|
21cd0a70ce | ||
|
|
cb037e2543 | ||
|
|
567c14f6d9 | ||
|
|
dacffb16b3 | ||
|
|
64ba6163e2 | ||
|
|
4d6de9cf96 | ||
|
|
d9d9e6b9cf | ||
|
|
9b6c9a4b36 | ||
|
|
2486adb226 | ||
|
|
427da94090 | ||
|
|
b56a53c03a | ||
|
|
c65cda2273 | ||
|
|
03a3ef28d2 | ||
|
|
5d120d5a66 | ||
|
|
879e8f5791 | ||
|
|
0c36f17896 | ||
|
|
367f9f1e28 | ||
|
|
df71c65478 | ||
|
|
c1feab857a | ||
|
|
b84fa410ce | ||
|
|
5d65deeb37 | ||
|
|
a68344df60 | ||
|
|
5409c8738a | ||
|
|
60e79909c0 | ||
|
|
84594e6449 | ||
|
|
c2c423e68e | ||
|
|
2b55d81bb6 | ||
|
|
19b4593bfe | ||
|
|
67e5f24761 | ||
|
|
a5087735b5 | ||
|
|
aea35afdc7 | ||
|
|
4c65efc40b | ||
|
|
f102a83d9f | ||
|
|
f8f1bfaaaa | ||
|
|
b5415caa7f | ||
|
|
c2dcee49a8 | ||
|
|
617bfcaca9 | ||
|
|
77e60313ef | ||
|
|
79abcdfd7b | ||
|
|
2447ebec4f | ||
|
|
3d1873c46e | ||
|
|
10e9c070df | ||
|
|
7faed137f2 | ||
|
|
81d63410ed | ||
|
|
0293891f41 | ||
|
|
ed77fea62e | ||
|
|
a8e8a22e43 | ||
|
|
a3a9fe13eb | ||
|
|
f0040ffcba | ||
|
|
d036a3eba2 | ||
|
|
0784bdb87e | ||
|
|
014c69b09a | ||
|
|
e5d8d03114 | ||
|
|
292a9595b3 | ||
|
|
a534c37fcc | ||
|
|
f6c53993ac | ||
|
|
c2c36d7e21 | ||
|
|
de8d16bba5 | ||
|
|
29463bbf27 | ||
|
|
c4b1f4b880 | ||
|
|
bffeb252ee | ||
|
|
39d42f850d | ||
|
|
eec0209415 | ||
|
|
8334ed3c19 | ||
|
|
1bb13d8bcf | ||
|
|
c5a76d9e46 | ||
|
|
a4b575c41a | ||
|
|
72326ec541 | ||
|
|
4df80fa057 | ||
|
|
dcc7164b77 | ||
|
|
b47e1d9bf1 | ||
|
|
8e9cd3f4a5 | ||
|
|
9d5114d295 | ||
|
|
8c3d3c653c | ||
|
|
0d81d0fc2e | ||
|
|
36bb62ac66 | ||
|
|
49a639a358 | ||
|
|
579d6f7b5c | ||
|
|
1bf35426ea | ||
|
|
81d5962650 | ||
|
|
641d65cbd9 | ||
|
|
8c975630ef | ||
|
|
c9506bc847 | ||
|
|
6ecdd6d45d | ||
|
|
447997b9ec | ||
|
|
2c136b6c10 | ||
|
|
6e9a657132 | ||
|
|
1ef38adddf | ||
|
|
e809733e3a | ||
|
|
c94ce9b49f | ||
|
|
534003209c | ||
|
|
05c807b7cd | ||
|
|
218f7f752f | ||
|
|
6af2b1fbc3 | ||
|
|
ef86e0c54f | ||
|
|
0133962936 | ||
|
|
6b50696b8a | ||
|
|
d7b5812561 | ||
|
|
4141089e63 | ||
|
|
b4f6da25ee | ||
|
|
f864dc2233 | ||
|
|
13192370f5 | ||
|
|
09ddd6da82 | ||
|
|
b3e146b310 | ||
|
|
4d5bac28c9 | ||
|
|
5e873170ee | ||
|
|
af866ba661 | ||
|
|
990fa17841 | ||
|
|
8c0f34232b | ||
|
|
3adfa918c1 | ||
|
|
cf74c10561 | ||
|
|
60675ecb39 | ||
|
|
ce930f003d | ||
|
|
65fb97d247 | ||
|
|
ba2b4a4d5d | ||
|
|
4b04af121f | ||
|
|
8fe918ff24 | ||
|
|
2bf2754ad2 | ||
|
|
0f058468a5 | ||
|
|
b9365610c8 | ||
|
|
f16fc1d42e | ||
|
|
8232144bb7 | ||
|
|
fb07a91108 | ||
|
|
24e17a5957 | ||
|
|
17d4ea6f88 | ||
|
|
8c42700a4c | ||
|
|
9bf8174984 | ||
|
|
0f56536142 | ||
|
|
de27853c46 | ||
|
|
0890db93db | ||
|
|
a9fce46fec | ||
|
|
d0f47bb316 | ||
|
|
c1e53dbc33 | ||
|
|
62ad4c2cb7 | ||
|
|
c76c29139f | ||
|
|
654c6dce8f | ||
|
|
8188f53da5 | ||
|
|
f3d9d73db1 | ||
|
|
f9eb2381b0 | ||
|
|
5baef07048 | ||
|
|
f402731ef2 | ||
|
|
59f6f1fa71 | ||
|
|
c0f775ae49 | ||
|
|
3d6e45f430 | ||
|
|
85bc7604c2 | ||
|
|
3674d0f480 | ||
|
|
5f124de8d4 | ||
|
|
59a03f450d | ||
|
|
3797422832 | ||
|
|
e4793327b8 | ||
|
|
e18931353b | ||
|
|
cf3c76a79f | ||
|
|
7e18a68b82 | ||
|
|
96a21fbe08 | ||
|
|
fbffa410af | ||
|
|
936b42b4e7 | ||
|
|
eb53d8983e | ||
|
|
4e2afb53cc | ||
|
|
a665ba8ca5 | ||
|
|
d57e32c5cb | ||
|
|
07d8e28668 | ||
|
|
ec22e9e594 | ||
|
|
433b13cb0e | ||
|
|
cbe572eea6 | ||
|
|
c1f1da1d26 | ||
|
|
2008c39af3 | ||
|
|
1f22ad4b33 | ||
|
|
8a871aafac |
@@ -8,7 +8,7 @@ Each skill is a directory with a `SKILL.md` entrypoint, optional reference docs,
|
||||
|
||||
| Skill | Description |
|
||||
|-------|-------------|
|
||||
| [dms-plugin-dev](dms-plugin-dev/) | Develop plugins for DankMaterialShell - covers all 4 plugin types (widget, daemon, launcher, desktop), manifest creation, QML components, settings UI, data persistence, theme integration, and PopoutService usage. |
|
||||
| [dms-plugin-dev](dms-plugin-dev/) | Develop plugins for Dux Shell - covers all 4 plugin types (widget, daemon, launcher, desktop), manifest creation, QML components, settings UI, data persistence, theme integration, and PopoutService usage. |
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -28,7 +28,7 @@ ln -s ../../.agents/skills/dms-plugin-dev .claude/skills/dms-plugin-dev
|
||||
**Personal** (all your projects):
|
||||
|
||||
```bash
|
||||
ln -s /path/to/DankMaterialShell/.agents/skills/dms-plugin-dev ~/.claude/skills/dms-plugin-dev
|
||||
ln -s /path/to/dms/.agents/skills/dms-plugin-dev ~/.claude/skills/dms-plugin-dev
|
||||
```
|
||||
|
||||
After linking, the skill appears in Claude Code's `/` menu as `/dms-plugin-dev`, and Claude loads it automatically when you ask about DMS plugin development.
|
||||
|
||||
@@ -1,65 +1,71 @@
|
||||
---
|
||||
name: dms-plugin-dev
|
||||
description: >
|
||||
Develop plugins for DankMaterialShell (DMS), a QML-based Linux desktop shell built on
|
||||
Quickshell. Supports five plugin types: widget (bar + Control Center), daemon (background
|
||||
service), launcher (search + actions), desktop (draggable desktop widgets), and composite
|
||||
(multi-surface). Covers manifest creation, QML component development, startup checks,
|
||||
Develop plugins for Dux Shell (DMS), a QML-based Linux desktop shell built on
|
||||
Quickshell. Supports widget (bar, dock + Control Center), daemon (background service),
|
||||
launcher (search + actions), desktop (draggable desktop widgets), dash (dash tab),
|
||||
dashCard (dash overview card), and composite (multi-surface) plugins. Covers manifest
|
||||
creation, QML component development, startup checks,
|
||||
settings UI, data persistence, theme integration, PopoutService usage, IPC runtime
|
||||
discovery, and external command execution. Use when the user wants to create, modify,
|
||||
or debug a DMS plugin, or asks about the DMS plugin API.
|
||||
compatibility: Designed for Claude Code (or similar products)
|
||||
metadata:
|
||||
author: DankMaterialShell
|
||||
version: "1.1"
|
||||
author: Dux Shell
|
||||
version: "1.2"
|
||||
domain: qml-desktop-development
|
||||
framework: DankMaterialShell
|
||||
framework: Dux Shell
|
||||
languages: qml, javascript
|
||||
allowed-tools: Bash Read Write Edit
|
||||
---
|
||||
|
||||
# DankMaterialShell Plugin Development
|
||||
# Dux Shell Plugin Development
|
||||
|
||||
## Overview
|
||||
|
||||
DMS plugins extend the desktop shell with custom widgets, background services, launcher
|
||||
integrations, and desktop widgets. Plugins are QML components discovered from
|
||||
`~/.config/DankMaterialShell/plugins/`.
|
||||
DMS plugins extend the desktop shell with bar and dock widgets, background services, launcher
|
||||
integrations, desktop widgets, and dash tabs and cards. Plugins are QML components discovered from
|
||||
`~/.config/dms/plugins/`.
|
||||
|
||||
**Minimum plugin structure:**
|
||||
|
||||
```
|
||||
~/.config/DankMaterialShell/plugins/YourPlugin/
|
||||
~/.config/dms/plugins/YourPlugin/
|
||||
plugin.json # Required: manifest with metadata
|
||||
YourComponent.qml # Required: main QML component
|
||||
YourSettings.qml # Optional: settings UI
|
||||
*.js # Optional: JavaScript utilities
|
||||
```
|
||||
|
||||
**Plugin registry:** Community plugins are available at https://plugins.danklinux.com/
|
||||
**Plugin registry:** Community plugins are available at https://plugins.duxlabs.dev/
|
||||
|
||||
**Five plugin types:**
|
||||
**Plugin types:**
|
||||
|
||||
| Type | Purpose | Base Component | Bar pills | CC integration |
|
||||
|-------------|--------------------------------|----------------------------|-----------|----------------|
|
||||
| `widget` | Bar widget + popout | `PluginComponent` | Yes | Yes |
|
||||
| `daemon` | Background service | `PluginComponent` (no UI) | No | Optional |
|
||||
| `launcher` | Searchable items in launcher | `Item` | No | No |
|
||||
| `desktop` | Draggable desktop widget | `DesktopPluginComponent` | No | No |
|
||||
| `composite` | Multi-surface plugin | One component per surface | Optional | Optional |
|
||||
| Type | Purpose | Base Component | Bar/dock pills | CC integration |
|
||||
|-------------|--------------------------------|----------------------------|----------------|----------------|
|
||||
| `widget` | Bar or dock widget + popout | `PluginComponent` | Yes | Yes |
|
||||
| `daemon` | Background service | `PluginComponent` (no UI) | No | Optional |
|
||||
| `launcher` | Searchable items in launcher | `Item` | No | No |
|
||||
| `desktop` | Draggable desktop widget | `DesktopPluginComponent` | No | No |
|
||||
| `dash` | Tab in the dash popout | `DashTabComponent` | No | No |
|
||||
| `dashCard` | Card in the dash overview grid | `DashCardComponent` | No | No |
|
||||
| `composite` | Multi-surface plugin | One component per surface | Optional | Optional |
|
||||
|
||||
## Step 1: Determine Plugin Type
|
||||
|
||||
Choose the type based on what the plugin does:
|
||||
|
||||
- **Shows in the bar?** - Use `widget`. Displays a pill in DankBar, optionally opens a popout,
|
||||
optionally integrates with Control Center.
|
||||
- **Shows in the bar or a dock?** - Use `widget`. Displays a pill in DankBar or a dock (same
|
||||
component), optionally opens a popout or an attached dock panel, optionally integrates with
|
||||
Control Center.
|
||||
- **Runs in background only?** - Use `daemon`. No visible UI, reacts to events (wallpaper
|
||||
changes, notifications, battery level, etc.).
|
||||
- **Provides searchable/actionable items?** - Use `launcher`. Items appear in the DMS launcher
|
||||
with trigger-based filtering (e.g., type `=` for calculator, `:` for emoji).
|
||||
- **Shows on the desktop background?** - Use `desktop`. Draggable, resizable widget on the
|
||||
desktop layer.
|
||||
- **Full page in the dash?** - Use `dash`. A tab in the dash popout, id `plugin_<pluginId>`.
|
||||
- **Small tile on the dash overview?** - Use `dashCard`. A resizable card in the overview grid.
|
||||
- **Needs multiple surfaces?** - Use `composite`. A single plugin that registers any combination
|
||||
of the above (e.g., a daemon + bar widget + desktop widget). Each surface gets its own
|
||||
QML component file.
|
||||
@@ -126,6 +132,11 @@ Create `plugin.json` in your plugin directory. See [plugin-manifest-reference.md
|
||||
}
|
||||
```
|
||||
|
||||
**Standalone dash tab or card:** same single file form, `"type": "dash"` or `"type": "dashCard"`
|
||||
plus `"component"`. A tab and a card together (or with a bar widget) use `components` with the
|
||||
keys `dash` and `dashCard`. Both accept an optional `dash` block for labels, card size and
|
||||
options. See [dash-plugin-guide.md](references/dash-plugin-guide.md).
|
||||
|
||||
**Key rules:**
|
||||
- `id` must be camelCase, matching pattern `^[a-zA-Z][a-zA-Z0-9]*$`
|
||||
- `version` must be semver (e.g., `1.0.0`)
|
||||
@@ -280,6 +291,62 @@ PluginComponent {
|
||||
|
||||
See [daemon-plugin-guide.md](references/daemon-plugin-guide.md) for event-driven patterns and process execution.
|
||||
|
||||
### Dash Tab
|
||||
|
||||
```qml
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
import qs.Widgets
|
||||
import qs.Modules.Plugins
|
||||
import qs.Modules.DankDash
|
||||
|
||||
DashTabComponent {
|
||||
id: root
|
||||
|
||||
property date now: new Date()
|
||||
|
||||
implicitHeight: DashMetrics.tabMinHeight
|
||||
|
||||
Timer {
|
||||
interval: 1000
|
||||
repeat: true
|
||||
running: root.live
|
||||
onTriggered: root.now = new Date()
|
||||
}
|
||||
|
||||
StyledText {
|
||||
anchors.centerIn: parent
|
||||
text: root.now.toLocaleTimeString(I18n.locale())
|
||||
color: Theme.surfaceText
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Dash Card
|
||||
|
||||
```qml
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
import qs.Widgets
|
||||
import qs.Modules.Plugins
|
||||
|
||||
DashCardComponent {
|
||||
id: root
|
||||
|
||||
tone: options.tone ?? ""
|
||||
|
||||
StyledText {
|
||||
anchors.centerIn: parent
|
||||
text: I18n.trFor("myPlugin", "Hello")
|
||||
color: root.contentColor
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Gate timers and animations on `live`; the dash content stays alive after the popout closes.
|
||||
Cards must lay out for every size in their manifest range. Use the card colors (`accentColor`,
|
||||
`contentColor`, `mutedColor`) so tones work. See [dash-plugin-guide.md](references/dash-plugin-guide.md).
|
||||
|
||||
### Composite
|
||||
|
||||
For composite plugins, create a separate QML file per surface. Each surface uses the same
|
||||
@@ -295,8 +362,7 @@ MyCompositePlugin/
|
||||
Settings.qml # Shared settings for all surfaces
|
||||
```
|
||||
|
||||
Use `pluginService.pluginHasSurface(pluginId, "widget")` to check whether a specific surface
|
||||
is registered for a plugin at runtime.
|
||||
`pluginService.availablePlugins[pluginId].surfaces` lists the surfaces a plugin registered.
|
||||
|
||||
## Step 4: Add Startup Check (Optional)
|
||||
|
||||
@@ -414,7 +480,7 @@ Always use `Theme.*` properties from `qs.Common` - never hardcode colors or size
|
||||
- Radius: `Theme.cornerRadius`, `Theme.cornerRadiusSmall`, `Theme.cornerRadiusLarge`
|
||||
- Icons: `Theme.iconSizeSmall` (16), `Theme.iconSize` (24), `Theme.iconSizeLarge` (32)
|
||||
|
||||
**Common widgets from `qs.Widgets`:** `StyledText`, `StyledRect`, `DankIcon`, `DankButton`, `DankToggle`, `DankTextField`, `DankSlider`, `DankGridView`, `CachingImage`.
|
||||
**Common widgets from `qs.DCommon.Widgets`:** `StyledText`, `StyledRect`, `DIcon`, `DButton`, `DToggle`, `DTextField`, `DSlider`, `DGridView`, `CachingImage`. The old `Dank*` names in `qs.Widgets` are deprecated.
|
||||
|
||||
See [theme-reference.md](references/theme-reference.md) for the complete property list.
|
||||
|
||||
@@ -431,7 +497,6 @@ PluginComponent {
|
||||
PopoutComponent {
|
||||
headerText: "My Plugin"
|
||||
detailsText: "Optional subtitle"
|
||||
showCloseButton: true
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
@@ -450,7 +515,7 @@ PluginComponent {
|
||||
}
|
||||
```
|
||||
|
||||
**PopoutComponent properties:** `headerText`, `detailsText`, `showCloseButton`, `closePopout()` (auto-injected), `headerHeight` (readonly), `detailsHeight` (readonly).
|
||||
**PopoutComponent properties:** `headerText`, `detailsText`, `closePopout()` (auto-injected), `headerHeight` (readonly), `detailsHeight` (readonly).
|
||||
|
||||
Calculate available content height: `popoutHeight - headerHeight - detailsHeight - spacing`
|
||||
|
||||
@@ -550,7 +615,7 @@ Lookup order: plugin file, then the global DMS catalog, then the English term. `
|
||||
1. Validate `plugin.json` against the schema at [assets/plugin-schema.json](assets/plugin-schema.json)
|
||||
2. Run the shell with verbose output: `qs -v -p $CONFIGPATH/quickshell/dms/shell.qml`
|
||||
3. Open Settings > Plugins > Scan for Plugins
|
||||
4. Enable your plugin and add it to the DankBar layout
|
||||
4. Enable your plugin and add it to the DankBar layout, a dock, or the dash (tabs show up on their own; add cards from the dash edit mode)
|
||||
|
||||
**Runtime plugin discovery via IPC:**
|
||||
|
||||
@@ -588,6 +653,10 @@ Plugin IDs are validated against `^[a-zA-Z0-9_\-:]{1,64}$`.
|
||||
11. **Using `requires` instead of `dependencies`** - `requires` is deprecated; use `dependencies`
|
||||
12. **Providing both `component` and `components`** - Use one or the other, not both
|
||||
13. **Missing `trigger` on composite with launcher surface** - Still required when `components` has a `launcher` key
|
||||
14. **Using `"type": "widget"` for a dash tab** - Dash surfaces need `"type": "dash"` / `"dashCard"` or a `components` key
|
||||
15. **Timers in dash tabs or cards that ignore `live`** - The dash content outlives the popout; gate on `live`
|
||||
16. **Reading `dash.options` from a bar widget** - `options` only reach dash surfaces; share values through `settings` and `pluginData`
|
||||
17. **Raw theme colors in a dash card** - Use `accentColor` / `contentColor` / `mutedColor` so the card follows its tone
|
||||
|
||||
## Quick Reference: Imports
|
||||
|
||||
@@ -611,6 +680,16 @@ import QtQuick
|
||||
import qs.Common
|
||||
```
|
||||
|
||||
**Dash tab / card:**
|
||||
```qml
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
import qs.Widgets
|
||||
import qs.Modules.Plugins
|
||||
import qs.Modules.DankDash // DashMetrics, DashWidgetGrid
|
||||
import qs.Modules.DankDash.Overview // Card, for tiles inside a tab
|
||||
```
|
||||
|
||||
**For clipboard/exec:** `import Quickshell`
|
||||
**For processes:** `import Quickshell.Io`
|
||||
**For networking:** `import Quickshell.Networking`
|
||||
@@ -629,7 +708,8 @@ import qs.Common
|
||||
Load these on demand for detailed API documentation:
|
||||
|
||||
- [plugin-manifest-reference.md](references/plugin-manifest-reference.md) - Complete plugin.json field reference and JSON schema
|
||||
- [widget-plugin-guide.md](references/widget-plugin-guide.md) - PluginComponent, bar pills, popouts, click actions, CC integration
|
||||
- [widget-plugin-guide.md](references/widget-plugin-guide.md) - PluginComponent, bar and dock pills, attached dock panels, popouts, click actions, CC integration
|
||||
- [dash-plugin-guide.md](references/dash-plugin-guide.md) - DashTabComponent, DashCardComponent, DashWidgetGrid, dash options, card sizing
|
||||
- [launcher-plugin-guide.md](references/launcher-plugin-guide.md) - getItems/executeItem, triggers, icon types, context menus, tile view
|
||||
- [desktop-plugin-guide.md](references/desktop-plugin-guide.md) - DesktopPluginComponent, sizing, edit mode, position persistence
|
||||
- [daemon-plugin-guide.md](references/daemon-plugin-guide.md) - Event-driven background services, process execution
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "https://danklinux.com/schemas/plugin.json",
|
||||
"title": "DankMaterialShell Plugin Manifest",
|
||||
"description": "Schema for DankMaterialShell plugin.json manifest files",
|
||||
"$id": "https://duxlabs.dev/schemas/plugin.json",
|
||||
"title": "Dux Shell Plugin Manifest",
|
||||
"description": "Schema for Dux Shell plugin.json manifest files",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
@@ -42,7 +42,7 @@
|
||||
"type": {
|
||||
"type": "string",
|
||||
"description": "Plugin type. Use 'composite' (or any value) together with 'components' to provide multiple surfaces from one plugin.",
|
||||
"enum": ["widget", "daemon", "launcher", "desktop", "composite"]
|
||||
"enum": ["widget", "daemon", "launcher", "desktop", "dash", "dashCard", "composite"]
|
||||
},
|
||||
"capabilities": {
|
||||
"type": "array",
|
||||
@@ -80,11 +80,83 @@
|
||||
"type": "string",
|
||||
"description": "Launcher provider component (requires 'trigger')",
|
||||
"pattern": "^\\./.*\\.qml$"
|
||||
},
|
||||
"dash": {
|
||||
"type": "string",
|
||||
"description": "Dashboard tab component (DashTabComponent)",
|
||||
"pattern": "^\\./.*\\.qml$"
|
||||
},
|
||||
"dashCard": {
|
||||
"type": "string",
|
||||
"description": "Dashboard overview card component (DashCardComponent)",
|
||||
"pattern": "^\\./.*\\.qml$"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"minProperties": 1
|
||||
},
|
||||
"dash": {
|
||||
"type": "object",
|
||||
"description": "Presentation for the dashboard surfaces. Falls back to 'icon' and 'name'.",
|
||||
"properties": {
|
||||
"icon": {
|
||||
"type": "string",
|
||||
"description": "Material icon for the dashboard tab"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Tab label"
|
||||
},
|
||||
"card": {
|
||||
"type": "object",
|
||||
"description": "Overview card presentation and default size in grid units. Widths are clamped to the user's column count (3 to 8).",
|
||||
"properties": {
|
||||
"icon": { "type": "string" },
|
||||
"title": { "type": "string" },
|
||||
"w": { "type": "integer", "minimum": 1, "maximum": 8 },
|
||||
"h": { "type": "integer", "minimum": 1 },
|
||||
"minW": { "type": "integer", "minimum": 1, "maximum": 8 },
|
||||
"minH": { "type": "integer", "minimum": 1 },
|
||||
"maxW": { "type": "integer", "minimum": 1, "maximum": 8 },
|
||||
"maxH": { "type": "integer", "minimum": 1 }
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"options": {
|
||||
"type": "array",
|
||||
"description": "User options shown in the dashboard options sheet and Settings > Dashboard; values reach the tab and card as `options.<key>`",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["key", "type"],
|
||||
"properties": {
|
||||
"key": { "type": "string", "not": { "const": "widgets" }, "description": "Option key, read as options.<key>. 'widgets' is reserved for DashWidgetGrid layouts" },
|
||||
"text": { "type": "string", "description": "Row label, translated through the plugin catalog" },
|
||||
"description": { "type": "string", "description": "Secondary text under a toggle row, translated through the plugin catalog" },
|
||||
"type": { "type": "string", "enum": ["toggle", "choice", "number"] },
|
||||
"def": { "description": "Default value: boolean for toggle, choice value for choice, number for number" },
|
||||
"choices": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["value"],
|
||||
"properties": {
|
||||
"value": { "type": "string" },
|
||||
"text": { "type": "string" }
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"min": { "type": "number" },
|
||||
"max": { "type": "number" },
|
||||
"step": { "type": "number", "exclusiveMinimum": 0 },
|
||||
"unit": { "type": "string" }
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"trigger": {
|
||||
"type": "string",
|
||||
"description": "Trigger string for launcher activation (required for launcher type)"
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
import qs.Widgets
|
||||
import qs.Modules.Plugins
|
||||
import qs.Modules.DankDash
|
||||
|
||||
DashCardComponent {
|
||||
id: root
|
||||
|
||||
readonly property bool wide: width >= DashMetrics.gridRowUnit * 2
|
||||
|
||||
tone: options.tone ?? ""
|
||||
|
||||
Row {
|
||||
anchors.centerIn: parent
|
||||
spacing: Theme.spacingS
|
||||
|
||||
DankIcon {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
name: "extension"
|
||||
size: Theme.iconSizeLarge
|
||||
color: root.accentColor
|
||||
}
|
||||
|
||||
StyledText {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: root.wide
|
||||
text: I18n.trFor("myDashCard", "Hello")
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
color: root.contentColor
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"id": "myDashCard",
|
||||
"name": "My Dash Card",
|
||||
"description": "A custom dash overview card",
|
||||
"version": "1.0.0",
|
||||
"author": "Your Name",
|
||||
"type": "dashCard",
|
||||
"capabilities": ["dash-card"],
|
||||
"component": "./Card.qml",
|
||||
"icon": "extension",
|
||||
"dash": {
|
||||
"card": { "w": 2, "h": 1, "minW": 1, "minH": 1, "maxW": 3, "maxH": 2 },
|
||||
"options": [
|
||||
{
|
||||
"key": "tone",
|
||||
"text": "Tone",
|
||||
"type": "choice",
|
||||
"def": "",
|
||||
"choices": [
|
||||
{ "value": "", "text": "Surface" },
|
||||
{ "value": "primary", "text": "Primary" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"requires_dms": ">=1.7.0"
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
import qs.Widgets
|
||||
import qs.Modules.Plugins
|
||||
import qs.Modules.DankDash
|
||||
|
||||
DashTabComponent {
|
||||
id: root
|
||||
|
||||
focusTarget: button
|
||||
implicitHeight: Math.max(DashMetrics.tabMinHeight, content.implicitHeight + Theme.spacingL * 2)
|
||||
|
||||
function restoreFocus() {
|
||||
button.forceActiveFocus(Qt.OtherFocusReason);
|
||||
}
|
||||
|
||||
Column {
|
||||
id: content
|
||||
anchors.centerIn: parent
|
||||
spacing: Theme.spacingM
|
||||
|
||||
StyledText {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
visible: !root.options.compact
|
||||
text: I18n.trFor("myDashTab", "Hello from a dash tab")
|
||||
font.pixelSize: Theme.fontSizeXLarge
|
||||
color: Theme.surfaceText
|
||||
}
|
||||
|
||||
DankButton {
|
||||
id: button
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: I18n.trFor("myDashTab", "Open overview")
|
||||
onClicked: root.tabRequested("overview")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"id": "myDashTab",
|
||||
"name": "My Dash Tab",
|
||||
"description": "A custom dash tab",
|
||||
"version": "1.0.0",
|
||||
"author": "Your Name",
|
||||
"type": "dash",
|
||||
"capabilities": ["dash-tab"],
|
||||
"component": "./Tab.qml",
|
||||
"icon": "extension",
|
||||
"dash": {
|
||||
"title": "My Tab",
|
||||
"options": [
|
||||
{ "key": "compact", "text": "Compact layout", "type": "toggle", "def": false }
|
||||
]
|
||||
},
|
||||
"requires_dms": ">=1.7.0"
|
||||
}
|
||||
@@ -59,7 +59,6 @@ PluginComponent {
|
||||
// popoutContent: Component {
|
||||
// PopoutComponent {
|
||||
// headerText: "My Widget"
|
||||
// showCloseButton: true
|
||||
//
|
||||
// Column {
|
||||
// width: parent.width
|
||||
|
||||
@@ -43,7 +43,7 @@ PluginSettings {
|
||||
pluginId: "exampleVariants"
|
||||
|
||||
// Variant creation UI
|
||||
DankButton {
|
||||
DButton {
|
||||
text: "Add New Instance"
|
||||
onClicked: {
|
||||
var id = "variant_" + Date.now()
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
# Dash Plugin Guide
|
||||
|
||||
The dash popout (`dms ipc call dash open`) holds tabs and an overview grid of cards. A plugin can add a tab (`dash` surface), a card (`dashCard` surface), or both. Built-in tabs and cards use the same registry (`quickshell/Modules/DankDash/DashRegistry.qml`).
|
||||
|
||||
Requires DMS 1.7 or later: set `"requires_dms": ">=1.7.0"`.
|
||||
|
||||
## Picking the manifest form
|
||||
|
||||
| Plugin provides | Manifest |
|
||||
|-----------------|----------|
|
||||
| Tab only | `"type": "dash"`, `"component": "./Tab.qml"` |
|
||||
| Card only | `"type": "dashCard"`, `"component": "./Card.qml"` |
|
||||
| Tab and card | `"type": "composite"`, `"components": { "dash": ..., "dashCard": ... }` |
|
||||
| Dash plus bar/dock widget, daemon, ... | `"type": "composite"` with every surface in `components` |
|
||||
|
||||
Add the optional `dash` block for labels, card size and options (see plugin-manifest-reference.md, Dash Block).
|
||||
|
||||
## Rules that trip people up
|
||||
|
||||
- Only enabled plugins appear in the dash. There is no "disabled" placeholder.
|
||||
- A plugin tab shows in the tab bar as soon as the plugin loads. Cards are never placed automatically; users add them in edit mode (three-dot menu, Edit, Add widget).
|
||||
- Tab id is `plugin_<pluginId>`. Use it with `dms ipc call dash open plugin_<pluginId>` and `popoutService.toggleDankDash("plugin_" + pluginId, x, y, width, section, screen)`.
|
||||
- A tab is created when it becomes current and destroyed when the user leaves it. The dash content stays alive after the popout closes, so gate every Timer, animation and service ref on `live`.
|
||||
- Widths are grid columns (user picks 3 to 8, default 6). Rows are 96 px (`DashMetrics.gridRowUnit`). Cards must lay out for every size in `minW..maxW` x `minH..maxH`.
|
||||
- `dash.options` values reach only the dash surfaces as `options.<key>`. A bar widget or daemon in the same plugin cannot read them; use a `settings` component and `pluginData` for values every surface needs.
|
||||
- `widgets` is a reserved option key.
|
||||
- Use `I18n.trFor("<plugin id literal>", "...")` for every string.
|
||||
|
||||
## Tab: DashTabComponent
|
||||
|
||||
```qml
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
import qs.Widgets
|
||||
import qs.DCommon.Widgets
|
||||
import qs.Modules.Plugins
|
||||
import qs.Modules.DankDash
|
||||
|
||||
DashTabComponent {
|
||||
id: root
|
||||
|
||||
focusTarget: button
|
||||
implicitHeight: Math.max(DashMetrics.tabMinHeight, content.implicitHeight + Theme.spacingL * 2)
|
||||
menuActions: [
|
||||
{
|
||||
label: I18n.trFor("myPlugin", "Refresh"),
|
||||
iconName: "refresh",
|
||||
action: () => root.refresh()
|
||||
}
|
||||
]
|
||||
|
||||
function refresh() {
|
||||
}
|
||||
|
||||
function restoreFocus() {
|
||||
button.forceActiveFocus(Qt.OtherFocusReason);
|
||||
}
|
||||
|
||||
function handleKeyEvent(event) {
|
||||
if (event.key !== Qt.Key_R)
|
||||
return false;
|
||||
refresh();
|
||||
return true;
|
||||
}
|
||||
|
||||
Timer {
|
||||
interval: 1000
|
||||
repeat: true
|
||||
running: root.live
|
||||
onTriggered: root.refresh()
|
||||
}
|
||||
|
||||
Column {
|
||||
id: content
|
||||
anchors.centerIn: parent
|
||||
spacing: Theme.spacingM
|
||||
|
||||
StyledText {
|
||||
text: root.options.compact ? "" : I18n.trFor("myPlugin", "Hello")
|
||||
color: Theme.surfaceText
|
||||
}
|
||||
|
||||
DButton {
|
||||
id: button
|
||||
text: I18n.trFor("myPlugin", "Refresh")
|
||||
onClicked: root.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Injected: `pluginId`, `pluginService`, `popoutService`, `entryId`, `dashHost` (`dashVisible`, `requestTab(id)`, `editMode`), `editMode`, `live`. Also `targetScreen`, `active` and `rowBudget` when the tab declares them.
|
||||
|
||||
Base provides: `options`, `pluginData`, `getData(key, def)`, `setData(key, value)`.
|
||||
|
||||
Optional: `implicitHeight`, `focusTarget`, `restoreFocus()`, `handleKeyEvent(event)` (return true to consume; Ctrl+Tab is reserved), `blocksTabNavigation`, `menuActions`, `signal tabRequested(string id)`, `signal navFocusRequested`.
|
||||
|
||||
For tiles inside a tab, use `Card` from `qs.Modules.DankDash.Overview` so colors, radius and tones match the overview.
|
||||
|
||||
### Editable widgets in a tab
|
||||
|
||||
Assign a `DashWidgetGrid` (from `qs.Modules.DankDash`) to `widgetGrid` to get Edit, Add widget, Reset and Clear All:
|
||||
|
||||
```qml
|
||||
DashTabComponent {
|
||||
id: tab
|
||||
widgetGrid: grid
|
||||
implicitHeight: grid.implicitHeight
|
||||
focusTarget: grid.focusTarget
|
||||
|
||||
DashWidgetGrid {
|
||||
id: grid
|
||||
width: parent.width
|
||||
entryId: tab.entryId
|
||||
live: tab.live
|
||||
editMode: tab.editMode
|
||||
definitions: [
|
||||
{ id: "main", text: I18n.trFor("myPlugin", "Main"), icon: "star", component: mainWidget, w: 2, h: 2, minW: 1, minH: 1, maxW: 4, maxH: 3 }
|
||||
]
|
||||
}
|
||||
|
||||
Component {
|
||||
id: mainWidget
|
||||
Card {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The grid is 4 columns (2 when narrower than `Theme.smallBreakpoint`). `enabled: false` on a definition leaves it in the Add menu. The layout is saved in `dashOptions[entryId].widgets`.
|
||||
|
||||
## Card: DashCardComponent
|
||||
|
||||
```qml
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.Common
|
||||
import qs.Widgets
|
||||
import qs.Modules.Plugins
|
||||
import qs.Modules.DankDash
|
||||
|
||||
DashCardComponent {
|
||||
id: root
|
||||
|
||||
readonly property bool wide: width >= DashMetrics.gridRowUnit * 2
|
||||
readonly property bool tall: height >= DashMetrics.gridRowUnit * 2
|
||||
|
||||
tone: options.tone ?? ""
|
||||
|
||||
SystemClock {
|
||||
id: clock
|
||||
enabled: root.live
|
||||
precision: SystemClock.Minutes
|
||||
}
|
||||
|
||||
StyledText {
|
||||
anchors.centerIn: parent
|
||||
text: clock.date.toLocaleTimeString(I18n.locale(), "hh:mm")
|
||||
font.pixelSize: root.tall ? Theme.fontSizeDisplay : Theme.fontSizeXLarge
|
||||
color: root.accentColor
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Base (`Card`) provides: `pad`, `title`, `tone` (`""`, `"primary"`, `"secondary"`, `"tertiary"`), `containerColor`, `contentColor`, `accentColor`, `onAccentColor`, `mutedColor`, `chipColor`, `tinted`, `clickable`, `signal clicked`, `options`, `pluginData`, `getData`, `setData`.
|
||||
|
||||
Injected: `pluginId`, `pluginService`, `popoutService`, `entryId`; bound: `live` (overview visible), `interactive` (false in edit mode).
|
||||
|
||||
Clicks: when the plugin also has a tab, a click on a `clickable` card opens it. Set `opensTab: false` to handle the click yourself. Non-clickable cards are skipped by keyboard navigation unless `focusTarget` points at a child control.
|
||||
|
||||
Use the card colors, never raw `Theme.primary` or `Theme.surfaceText`, so tones work.
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
dms ipc call plugins reload myPlugin
|
||||
dms ipc call dash open plugin_myPlugin
|
||||
```
|
||||
|
||||
Working examples in `quickshell/PLUGINS/`: `DashTabExample` (standalone tab), `DashCardExample` (standalone card), `DashCounterExample` (tab + card + bar/dock widget).
|
||||
@@ -174,7 +174,7 @@ Retrieve a plugin's installation directory at runtime:
|
||||
var dir = pluginService.getPluginPath(pluginId)
|
||||
```
|
||||
|
||||
Returns the absolute path to the plugin's directory (e.g., `~/.config/DankMaterialShell/plugins/MyPlugin`), or an empty string if the plugin is not found. Useful for loading bundled assets (images, data files) relative to the plugin's location.
|
||||
Returns the absolute path to the plugin's directory (e.g., `~/.config/dms/plugins/MyPlugin`), or an empty string if the plugin is not found. Useful for loading bundled assets (images, data files) relative to the plugin's location.
|
||||
|
||||
## Important Notes
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
| `description` | string | Short description (shown in UI) | Non-empty |
|
||||
| `version` | string | Semantic version | Pattern `^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$` |
|
||||
| `author` | string | Creator name or email | Non-empty |
|
||||
| `type` | string | Plugin type | One of: `widget`, `daemon`, `launcher`, `desktop`, `composite` |
|
||||
| `type` | string | Plugin type | One of: `widget`, `daemon`, `launcher`, `desktop`, `dash`, `dashCard`, `composite` |
|
||||
| `capabilities` | array | Plugin capabilities | At least 1 string item |
|
||||
|
||||
One of `component` or `components` is required (not both):
|
||||
@@ -17,7 +17,7 @@ One of `component` or `components` is required (not both):
|
||||
| Field | Type | Description | Validation |
|
||||
|-------|------|-------------|------------|
|
||||
| `component` | string | Path to main QML file (single-surface plugins) | Must start with `./`, end with `.qml` |
|
||||
| `components` | object | Map of surface name to QML path (multi-surface plugins) | At least 1 entry; keys: `widget`, `desktop`, `daemon`, `launcher` |
|
||||
| `components` | object | Map of surface name to QML path (multi-surface plugins) | At least 1 entry; keys: `widget`, `desktop`, `daemon`, `launcher`, `dash`, `dashCard` |
|
||||
|
||||
## Conditional Requirements
|
||||
|
||||
@@ -38,6 +38,7 @@ One of `component` or `components` is required (not both):
|
||||
| `requires` | array | Deprecated alias for `dependencies` |
|
||||
| `permissions` | array | Required permissions |
|
||||
| `trigger` | string | Launcher trigger string (required for launcher type) |
|
||||
| `dash` | object | Dash tab label, overview card size range and dash options. See Dash Block below. |
|
||||
|
||||
## Permissions
|
||||
|
||||
@@ -59,6 +60,8 @@ Capabilities are free-form strings that describe what the plugin does. Common va
|
||||
- `monitoring` - system/service monitoring
|
||||
- `launcher` - launcher search provider
|
||||
- `desktop-widget` - desktop background widget
|
||||
- `dash-tab` - dash tab
|
||||
- `dash-card` - dash overview card
|
||||
- `ai` - AI/LLM integration
|
||||
- `slideout` - uses slideout panel
|
||||
|
||||
@@ -119,7 +122,68 @@ The `components` field maps surface names to QML paths, allowing a single plugin
|
||||
}
|
||||
```
|
||||
|
||||
Valid surface keys: `widget`, `desktop`, `daemon`, `launcher`. Provide any subset. Each surface is loaded independently in the appropriate registry.
|
||||
Valid surface keys: `widget`, `desktop`, `daemon`, `launcher`, `dash`, `dashCard`. Provide any subset. Each surface is loaded independently in the appropriate registry.
|
||||
|
||||
## Dash Block
|
||||
|
||||
Plugins with a `dash` or `dashCard` surface can describe them in a `dash` object. Everything is optional.
|
||||
|
||||
```json
|
||||
"dash": {
|
||||
"icon": "timer",
|
||||
"title": "Stopwatch",
|
||||
"card": { "title": "Progress", "icon": "hourglass_bottom", "w": 2, "h": 1, "minW": 1, "minH": 1, "maxW": 4, "maxH": 2 },
|
||||
"options": [
|
||||
{ "key": "compact", "text": "Compact layout", "type": "toggle", "def": false, "description": "Hide the secondary line" },
|
||||
{ "key": "style", "text": "Style", "type": "choice", "def": "bars", "choices": [{ "value": "bars", "text": "Bars" }, { "value": "dots", "text": "Dots" }] },
|
||||
{ "key": "limit", "text": "Items", "type": "number", "def": 5, "min": 1, "max": 20, "step": 1 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `icon`, `title` | Tab icon and label. Fall back to the top-level `icon` and `name` |
|
||||
| `card.title`, `card.icon` | Card name and icon in the Add widget menu. Fall back to the tab label and icon |
|
||||
| `card.w`, `card.h` | Default size in grid cells. Columns are 3 to 8 (default 6), rows are 96 px |
|
||||
| `card.minW` .. `card.maxH` | Resize range. Widths clamp to the user's column count; heights have no fixed limit |
|
||||
| `options` | Rows in the dash options sheet and Settings > Dashboard. Read as `options.<key>` in the tab and card. Types: `toggle`, `choice`, `number`. `widgets` is a reserved key |
|
||||
|
||||
## Dash Examples
|
||||
|
||||
Standalone tab and standalone card use the single file form:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "dashTabExample",
|
||||
"name": "Stopwatch Example",
|
||||
"description": "A standalone dash tab",
|
||||
"version": "1.0.0",
|
||||
"author": "Developer Name",
|
||||
"type": "dash",
|
||||
"capabilities": ["dash-tab"],
|
||||
"component": "./StopwatchTab.qml",
|
||||
"dash": { "title": "Stopwatch" },
|
||||
"requires_dms": ">=1.7.0"
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "dashCardExample",
|
||||
"name": "Progress Example",
|
||||
"description": "A standalone overview card",
|
||||
"version": "1.0.0",
|
||||
"author": "Developer Name",
|
||||
"type": "dashCard",
|
||||
"capabilities": ["dash-card"],
|
||||
"component": "./ProgressCard.qml",
|
||||
"dash": { "card": { "w": 2, "h": 1, "maxW": 4, "maxH": 2 } },
|
||||
"requires_dms": ">=1.7.0"
|
||||
}
|
||||
```
|
||||
|
||||
A tab plus a card, or dash surfaces plus a bar widget, use `components` with `"type": "composite"`.
|
||||
|
||||
## Complete Example
|
||||
|
||||
|
||||
@@ -83,11 +83,12 @@ Theme.popupBackground() // Popup background with proper opacity
|
||||
|
||||
```qml
|
||||
import qs.Widgets
|
||||
import qs.DCommon.Widgets
|
||||
|
||||
Row {
|
||||
spacing: Theme.spacingS
|
||||
|
||||
DankIcon {
|
||||
DIcon {
|
||||
name: "icon_name"
|
||||
color: Theme.onSurface
|
||||
font.pixelSize: Theme.iconSize
|
||||
@@ -151,7 +152,7 @@ StyledRect {
|
||||
anchors.centerIn: parent
|
||||
spacing: Theme.spacingS
|
||||
|
||||
DankIcon {
|
||||
DIcon {
|
||||
name: "star"
|
||||
color: Theme.surfaceText
|
||||
font.pixelSize: Theme.iconSize
|
||||
@@ -191,17 +192,17 @@ font.pixelSize: 14 // Use Theme.fontSizeMedium
|
||||
|--------|-------------|
|
||||
| `StyledText` | Themed text with proper color defaults |
|
||||
| `StyledRect` | Themed rectangle |
|
||||
| `DankIcon` | Material Symbols icon renderer |
|
||||
| `DIcon` | Material Symbols icon renderer |
|
||||
| `DankNFIcon` | Nerd Font icon renderer |
|
||||
| `DankButton` | Themed button |
|
||||
| `DankToggle` | Toggle switch |
|
||||
| `DankTextField` | Text input field |
|
||||
| `DankSlider` | Slider control |
|
||||
| `DankDropdown` | Dropdown menu |
|
||||
| `DankGridView` | Grid layout view |
|
||||
| `DankListView` | List layout view |
|
||||
| `DankFlickable` | Scrollable container |
|
||||
| `DankTabBar` | Tab bar navigation |
|
||||
| `DButton` | Themed button |
|
||||
| `DToggle` | Toggle switch |
|
||||
| `DTextField` | Text input field |
|
||||
| `DSlider` | Slider control |
|
||||
| `DDropdown` | Dropdown menu |
|
||||
| `DGridView` | Grid layout view |
|
||||
| `DListView` | List layout view |
|
||||
| `DFlickable` | Scrollable container |
|
||||
| `DTabBar` | Tab bar navigation |
|
||||
| `DankCollapsibleSection` | Collapsible content section |
|
||||
| `DankTooltip` | Hover tooltip |
|
||||
| `DankNumberStepper` | Number +/- control |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Widget Plugin Guide
|
||||
|
||||
Widgets are bar plugins that display pills in DankBar, optionally open popouts, and can integrate with the Control Center.
|
||||
Widgets display pills in DankBar and in docks (same component, no extra code), optionally open popouts, and can integrate with the Control Center.
|
||||
|
||||
## Base Component
|
||||
|
||||
@@ -37,6 +37,8 @@ These are automatically set by the plugin host:
|
||||
| `pluginId` | string | This plugin's ID |
|
||||
| `pluginService` | object | PluginService reference |
|
||||
| `pluginData` | object | Reactive plugin settings data |
|
||||
| `surfaceContext` | object | The host surface: `kind` (`"bar"` or `"dock"`), `id`, `screen`, `isVertical`, `thickness`, `live`, `editMode`, `inlineExpansion` |
|
||||
| `widgetInstanceId` | string | This occurrence; the same widget can appear on several bars and docks |
|
||||
|
||||
## Bar Pills
|
||||
|
||||
@@ -57,7 +59,7 @@ horizontalBarPill: Component {
|
||||
anchors.centerIn: parent
|
||||
spacing: Theme.spacingS
|
||||
|
||||
DankIcon {
|
||||
DIcon {
|
||||
name: "star"
|
||||
color: Theme.surfaceText
|
||||
font.pixelSize: Theme.iconSize
|
||||
@@ -90,7 +92,7 @@ verticalBarPill: Component {
|
||||
anchors.centerIn: parent
|
||||
spacing: Theme.spacingS
|
||||
|
||||
DankIcon {
|
||||
DIcon {
|
||||
name: "star"
|
||||
color: Theme.surfaceText
|
||||
font.pixelSize: Theme.iconSizeSmall
|
||||
@@ -115,7 +117,6 @@ PluginComponent {
|
||||
PopoutComponent {
|
||||
headerText: "My Plugin"
|
||||
detailsText: "Optional subtitle"
|
||||
showCloseButton: true
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
@@ -138,7 +139,6 @@ PluginComponent {
|
||||
|----------|------|---------|-------------|
|
||||
| `headerText` | string | `""` | Main header (bold, large). Hidden if empty. |
|
||||
| `detailsText` | string | `""` | Subtitle below header. Hidden if empty. |
|
||||
| `showCloseButton` | bool | `false` | Show X button in top-right corner. |
|
||||
| `closePopout` | function | (injected) | Call to close the popout programmatically. |
|
||||
| `headerHeight` | int | (readonly) | Height of header area (0 if hidden). |
|
||||
| `detailsHeight` | int | (readonly) | Height of details area (0 if hidden). |
|
||||
@@ -241,16 +241,49 @@ PluginComponent {
|
||||
|
||||
**Bar reveal optimization:** The visibility timer automatically pauses while the bar is hidden (auto-hide mode) and resumes checks when the bar is revealed. This is handled via the internal `_barRevealed` property - no plugin code needed. Plugins using `visibilityCommand` with `visibilityInterval` benefit from this automatically.
|
||||
|
||||
## Docks
|
||||
|
||||
Users add any loaded widget plugin to a dock from the dock's edit mode or Settings > Dock > Apps & widgets. Nothing in the manifest is needed.
|
||||
|
||||
- Define both `horizontalBarPill` and `verticalBarPill`; docks sit on any edge.
|
||||
- Size icons with `root.iconSize` and text with `Theme.barTextSize(barThickness, barConfig?.fontScale, barConfig?.maximizeWidgetText)`. The dock hands its own thickness and scale in those properties.
|
||||
- Gate timers and animations on `surfaceLive` (host visible and `visibilityCommand` passing). Docks auto-hide.
|
||||
- Don't assume one instance. Use `widgetInstanceId` if an instance needs its own state.
|
||||
|
||||
Optional attached panel: set `attachedContent: Component { ... }`. When the dock's "Open widgets" setting is Inline (`surfaceContext.inlineExpansion`), a click grows the dock and shows the panel next to the apps. With Popout (the default) the normal `popoutContent` opens instead; a widget with no popout still expands inline. Bars always use `popoutContent`. A `pillClickAction` always wins over both.
|
||||
|
||||
```qml
|
||||
PluginComponent {
|
||||
id: root
|
||||
|
||||
attachedContent: Component {
|
||||
Column {
|
||||
padding: Theme.spacingM
|
||||
spacing: Theme.spacingM
|
||||
|
||||
DButton {
|
||||
text: I18n.trFor("myPlugin", "Close")
|
||||
onClicked: root.surfaceContext.dismissExpansion()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The panel spans the dock's length and grows up to 320 px away from the edge (at most half the screen). The plugin cannot size it. `attachedActive` is true while this widget owns the panel. Escape closes it. See `quickshell/PLUGINS/AttachedPanelExample` and `quickshell/PLUGINS/SURFACE_WIDGETS.md`.
|
||||
|
||||
## Popout Namespace
|
||||
|
||||
For plugins with multiple popout instances, use `layerNamespacePlugin` to isolate popout state:
|
||||
|
||||
```qml
|
||||
PluginComponent {
|
||||
layerNamespacePlugin: true
|
||||
layerNamespacePlugin: "my-plugin"
|
||||
}
|
||||
```
|
||||
|
||||
The shell prefixes it, so the popout's namespace becomes `dms:plugins:my-plugin`.
|
||||
|
||||
## Reading Plugin Data
|
||||
|
||||
Access saved settings reactively via the injected `pluginData`:
|
||||
@@ -278,6 +311,7 @@ import QtQuick
|
||||
import Quickshell
|
||||
import qs.Common
|
||||
import qs.Widgets
|
||||
import qs.DCommon.Widgets
|
||||
import qs.Services
|
||||
import qs.Modules.Plugins
|
||||
|
||||
@@ -333,9 +367,8 @@ PluginComponent {
|
||||
popoutContent: Component {
|
||||
PopoutComponent {
|
||||
headerText: "Emoji Picker"
|
||||
showCloseButton: true
|
||||
|
||||
DankGridView {
|
||||
DGridView {
|
||||
width: parent.width
|
||||
height: 300
|
||||
cellWidth: 50
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
github: [avengemedia]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: danklinux
|
||||
ko_fi: duxlabs
|
||||
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
|
||||
|
||||
@@ -6,7 +6,7 @@ body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## DankMaterialShell Bug Report
|
||||
## Dux Shell Bug Report
|
||||
Limit your report to one issue per submission unless similarly related
|
||||
- type: dropdown
|
||||
id: compositor
|
||||
|
||||
@@ -6,7 +6,7 @@ body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## DankMaterialShell Feature Request
|
||||
## Dux Shell Feature Request
|
||||
- type: textarea
|
||||
id: feature_description
|
||||
attributes:
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
name: Support Request
|
||||
description: Help with installation, usage, or general questions about DankMaterialShell
|
||||
description: Help with installation, usage, or general questions about Dux Shell
|
||||
labels:
|
||||
- support
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## DankMaterialShell Support Request
|
||||
## Dux Shell Support Request
|
||||
- type: dropdown
|
||||
id: compositor
|
||||
attributes:
|
||||
|
||||
@@ -28,4 +28,4 @@
|
||||
- [ ] New user-facing strings are wrapped in `I18n.tr()` with translator context, reusing existing terms where possible
|
||||
- [ ] Go changes: ran `make fmt`, added/updated tests, `make test` passes, and `go mod tidy` is clean
|
||||
- [ ] QML changes: ran `make lint-qml` with no new warnings
|
||||
- [ ] I have opened a corresponding pull request in dlx-docs to document any new behaviors: https://github.com/AvengeMedia/DankLinux-Docs
|
||||
- [ ] I have opened a corresponding pull request in dlx-docs to document any new behaviors: https://github.com/duxlabs-dev/dux-docs
|
||||
|
||||
@@ -64,7 +64,14 @@ jobs:
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
|
||||
- uses: anthropics/claude-code-action@v1
|
||||
- name: Resolve base branch
|
||||
id: pr
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: echo "base=$(gh pr view ${{ github.event.issue.number }} --json baseRefName --jq .baseRefName)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: anthropics/claude-code-action@9ca9355b36297178e28d37c799d1c9c8a28e6507
|
||||
id: claude
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
prompt: |
|
||||
@@ -75,12 +82,13 @@ jobs:
|
||||
---
|
||||
|
||||
Text after `/claude review` is an optional focus. Honor it but still report anything serious outside it.
|
||||
The PR head is checked out. Use `gh pr view ${{ github.event.issue.number }}` and `gh pr diff ${{ github.event.issue.number }}`, read surrounding code as needed.
|
||||
The PR head is checked out and the base branch is fetched as `origin/${{ steps.pr.outputs.base }}`. Use `gh pr view ${{ github.event.issue.number }}` for title and description.
|
||||
Get the diff from git, never `gh pr diff` (GitHub refuses diffs over 20000 lines): `git diff --stat origin/${{ steps.pr.outputs.base }}...HEAD`, then `git diff origin/${{ steps.pr.outputs.base }}...HEAD -- <path>` per file or directory. Read surrounding code as needed. Split a large PR across Task agents by area.
|
||||
|
||||
Steps:
|
||||
1. Read CONTRIBUTING.md.
|
||||
2. Read the PR title, description and diff.
|
||||
3. Check existing comments so you don't repeat them: `gh api repos/${{ github.repository }}/pulls/${{ github.event.issue.number }}/comments` and `gh pr view ${{ github.event.issue.number }} --comments`. Skip findings already covered by a comment that still applies.
|
||||
3. Check existing comments so you don't repeat them: `gh api --method GET repos/${{ github.repository }}/pulls/${{ github.event.issue.number }}/comments` and `gh pr view ${{ github.event.issue.number }} --comments`. Skip findings already covered by a comment that still applies.
|
||||
4. Verify each candidate finding against the code. Drop anything you can't confirm.
|
||||
5. Post one inline comment per confirmed finding with `mcp__github_inline_comment__create_inline_comment` (`confirmed: true`). Say what's wrong, why, and the fix. Add a committable suggestion only if it alone fully fixes the issue.
|
||||
6. Post one new summary comment with `gh pr comment ${{ github.event.issue.number }} --body ...`. Never edit earlier comments. Format:
|
||||
@@ -88,26 +96,43 @@ jobs:
|
||||
## Claude review
|
||||
<one line verdict>
|
||||
- <finding, file:line> per inline comment posted this run
|
||||
Checked: <one line>. Model: claude-opus-5.
|
||||
Checked: <one line>. Model: claude-opus-5-5.
|
||||
|
||||
No findings: "No issues found." plus what was checked.
|
||||
|
||||
Flag only issues introduced by this PR:
|
||||
- Bugs: wrong logic, crashes, nil/undefined access, races, leaks (timers, connections, processes, handles), bad Go error handling, edge cases that will clearly be hit.
|
||||
- QML: undefined refs, missing imports, binding loops, bindings that never update, handlers on the wrong object, raw Flickable/ListView/ScrollView instead of the Dank* wrappers.
|
||||
- QML: undefined refs, missing imports, binding loops, bindings that never update, handlers on the wrong object, raw Flickable/ListView/ScrollView instead of the D* wrappers (DFlickable, DListView, DGridView).
|
||||
- Deprecated shims: added code that uses a deprecated type or imports a deprecated module. Every shim calls `Deprecation.type(` or `Deprecation.module(`; list them with `git grep -h -e "Deprecation.type(" -e "Deprecation.module(" origin/${{ steps.pr.outputs.base }} -- quickshell`, which gives the old and new name. Grep the base, not HEAD, since a PR branched before the rename has no shims. Flag it even when the rest of the file still uses old names on the PR's base; suggest the new name and a rebase if the base predates it.
|
||||
- Security: command injection, path traversal, unsafe file permissions, secrets in code.
|
||||
- CONTRIBUTING.md rules: user-facing strings in `I18n.tr()` with translator context, reuse existing terms from quickshell/translations/en.json; en.json and template.json must not be touched in a PR; mocks under core/internal/mocks/ are regenerated not hand-edited; new dank-qml-common contract properties need the matching library change.
|
||||
- Go: unformatted code, go.mod/go.sum out of sync with imports, tests that no longer compile.
|
||||
- CONTRIBUTING.md rules: user-facing strings in `I18n.tr()` with translator context, reuse existing terms from quickshell/translations/en.json; en.json and template.json must not be touched in a PR; mocks under core/internal/mocks/ are regenerated not hand-edited; new dux-qml-common contract properties need the matching library change.
|
||||
|
||||
Don't flag style or naming, pre-existing issues, anything a linter or compiler catches, speculative input-dependent problems, missing tests or docs, or refactor ideas. Not sure it's real: don't post it.
|
||||
Don't flag style or naming (deprecated shims are not naming), pre-existing issues, anything a linter or compiler catches, speculative input-dependent problems, missing tests or docs, or refactor ideas. Not sure it's real: don't post it.
|
||||
|
||||
Review only. Never edit files, commit, push, approve or request changes. Post each finding once. Keep comments short.
|
||||
Review only. Never edit files, commit, push, approve or request changes. Do not build, vet, format or run tests; CI already does that and the tools are not available here. Post each finding once. Keep comments short.
|
||||
claude_args: |
|
||||
--model claude-opus-5
|
||||
--max-turns 80
|
||||
--allowedTools "Read,Grep,Glob,LS,Task,mcp__github_inline_comment__create_inline_comment,Bash(gh pr view:*),Bash(gh pr diff:*),Bash(gh pr comment:*),Bash(gh api repos/${{ github.repository }}/pulls/${{ github.event.issue.number }}/comments:*),Bash(git diff:*),Bash(git log:*),Bash(git show:*),Bash(git blame:*)"
|
||||
--model claude-opus-5-5
|
||||
--max-turns 120
|
||||
--allowedTools "Read,Grep,Glob,LS,Task,mcp__github_inline_comment__create_inline_comment,Bash(gh pr view:*),Bash(gh pr comment:*),Bash(gh api --method GET repos/${{ github.repository }}/pulls/${{ github.event.issue.number }}/comments:*),Bash(git diff:*),Bash(git log:*),Bash(git show:*),Bash(git blame:*),Bash(git merge-base:*),Bash(git grep:*)"
|
||||
--disallowedTools "Edit,Write,MultiEdit,NotebookEdit,WebFetch,WebSearch"
|
||||
|
||||
- name: Upload transcript
|
||||
if: always() && steps.claude.outputs.execution_file != ''
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: claude-review-${{ github.event.issue.number }}-${{ github.run_id }}
|
||||
path: ${{ steps.claude.outputs.execution_file }}
|
||||
retention-days: 7
|
||||
if-no-files-found: ignore
|
||||
|
||||
- name: Require a summary comment
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SINCE: ${{ github.event.comment.created_at }}
|
||||
run: |
|
||||
gh api "repos/${{ github.repository }}/issues/${{ github.event.issue.number }}/comments?since=$SINCE&per_page=100" \
|
||||
--jq '[.[] | select(.body | startswith("## Claude review"))] | length' | grep -qv '^0$'
|
||||
|
||||
- if: failure()
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
name: Sync flake.lock to dank-qml-common
|
||||
name: Sync flake.lock to dux-qml-common
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
paths:
|
||||
- "dank-qml-common"
|
||||
- "dux-qml-common"
|
||||
branches:
|
||||
- master
|
||||
- "stable-*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -17,7 +18,7 @@ jobs:
|
||||
steps:
|
||||
- name: Create GitHub App token
|
||||
id: app_token
|
||||
uses: actions/create-github-app-token@v1
|
||||
uses: actions/create-github-app-token@v3
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
@@ -34,10 +35,10 @@ jobs:
|
||||
- name: Point flake input at the submodule commit
|
||||
run: |
|
||||
set -euo pipefail
|
||||
submodule_rev=$(git ls-tree HEAD dank-qml-common --object-only)
|
||||
submodule_rev=$(git ls-tree HEAD dux-qml-common --object-only)
|
||||
flake_rev=$(python3 -c "import json; print(json.load(open('flake.lock'))['nodes']['dank-qml-common']['locked']['rev'])")
|
||||
[ "$submodule_rev" = "$flake_rev" ] && { echo "flake.lock already matches $submodule_rev"; exit 0; }
|
||||
nix flake lock --override-input dank-qml-common "github:AvengeMedia/dank-qml-common/$submodule_rev"
|
||||
nix flake lock --override-input dank-qml-common "github:duxlabs-dev/dux-qml-common/$submodule_rev"
|
||||
|
||||
- name: Commit and push flake.lock update
|
||||
env:
|
||||
@@ -48,9 +49,9 @@ jobs:
|
||||
echo "No changes to flake.lock"
|
||||
exit 0
|
||||
fi
|
||||
git config user.name "dms-ci[bot]"
|
||||
git config user.email "dms-ci[bot]@users.noreply.github.com"
|
||||
git config user.name "dux-ci[bot]"
|
||||
git config user.email "344434142+dux-ci[bot]@users.noreply.github.com"
|
||||
git add flake.lock
|
||||
git commit -m "nix: sync flake.lock to dank-qml-common submodule"
|
||||
git commit -m "nix: sync flake.lock to dux-qml-common submodule"
|
||||
git pull --rebase origin ${{ github.ref_name }}
|
||||
git push https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git HEAD:${{ github.ref_name }}
|
||||
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
cache-dependency-path: ./core/go.sum
|
||||
|
||||
- name: Test
|
||||
run: go test -v ./...
|
||||
run: go test -race -v ./...
|
||||
|
||||
- name: Build dms
|
||||
run: go build -v ./cmd/dms
|
||||
|
||||
@@ -81,8 +81,8 @@ jobs:
|
||||
if git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/dev/null 2>&1; then
|
||||
echo "::error::tag ${TAG} already exists"; exit 1
|
||||
fi
|
||||
git config user.name "dms-ci[bot]"
|
||||
git config user.email "dms-ci[bot]@users.noreply.github.com"
|
||||
git config user.name "dux-ci[bot]"
|
||||
git config user.email "344434142+dux-ci[bot]@users.noreply.github.com"
|
||||
|
||||
echo "${TAG}" > quickshell/VERSION
|
||||
git add quickshell/VERSION
|
||||
|
||||
@@ -7,7 +7,7 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
target:
|
||||
description: "Release branch to audit (default: newest release/*)"
|
||||
description: "Release branch to audit (default: newest stable-*)"
|
||||
required: false
|
||||
type: string
|
||||
|
||||
|
||||
@@ -48,8 +48,8 @@ jobs:
|
||||
COMMITS: ${{ toJSON(github.event.commits) }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "dms-ci[bot]"
|
||||
git config user.email "dms-ci[bot]@users.noreply.github.com"
|
||||
git config user.name "dux-ci[bot]"
|
||||
git config user.email "344434142+dux-ci[bot]@users.noreply.github.com"
|
||||
|
||||
for sha in $(jq -r '.[].id' <<<"$COMMITS"); do
|
||||
git cat-file -e "$sha" 2>/dev/null || continue
|
||||
@@ -86,8 +86,8 @@ jobs:
|
||||
PORT_SOURCE_PR: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "dms-ci[bot]"
|
||||
git config user.email "dms-ci[bot]@users.noreply.github.com"
|
||||
git config user.name "dux-ci[bot]"
|
||||
git config user.email "344434142+dux-ci[bot]@users.noreply.github.com"
|
||||
|
||||
targets=$(jq -r '.[] | select(startswith("port ")) | sub("^port +"; "")' <<<"$LABELS" |
|
||||
sed -E 's,^stable-,,' | sort -u)
|
||||
|
||||
@@ -29,3 +29,5 @@ jobs:
|
||||
|
||||
- name: run pre-commit hooks
|
||||
uses: j178/prek-action@v2
|
||||
env:
|
||||
SKIP: qml-tests
|
||||
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
prune:
|
||||
name: Prune archive snapshots
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository == 'AvengeMedia/DankMaterialShell'
|
||||
if: github.repository == 'duxlabs-dev/dux-shell'
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
name: QML tests
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- "quickshell/**"
|
||||
- "dux-qml-common"
|
||||
- "Makefile"
|
||||
- "core/Makefile"
|
||||
- "scripts/shell-test-excludes.txt"
|
||||
- "flake.nix"
|
||||
- ".pre-commit-config.yaml"
|
||||
- ".github/workflows/qml-tests.yml"
|
||||
- ".github/workflows/prek.yml"
|
||||
pull_request:
|
||||
branches: [master, main, "stable-*"]
|
||||
paths:
|
||||
- "quickshell/**"
|
||||
- "dux-qml-common"
|
||||
- "Makefile"
|
||||
- "core/Makefile"
|
||||
- "scripts/shell-test-excludes.txt"
|
||||
- "flake.nix"
|
||||
- ".pre-commit-config.yaml"
|
||||
- ".github/workflows/qml-tests.yml"
|
||||
- ".github/workflows/prek.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test-qml:
|
||||
runs-on: ubuntu-latest
|
||||
container: archlinux:base
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Install test dependencies
|
||||
run: |
|
||||
pacman-key --init
|
||||
pacman -Syu --noconfirm --needed git make nodejs python lua quickshell qt6-declarative ttf-dejavu
|
||||
|
||||
- name: Record runtime versions
|
||||
run: |
|
||||
pacman -Q quickshell qt6-declarative
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run QML pre-commit hook
|
||||
uses: j178/prek-action@5337cb91e0fa35a7ff31b9ca345126d8bbbcdf16
|
||||
with:
|
||||
prek-version: "0.5.5"
|
||||
extra-args: qml-tests --all-files
|
||||
cache: ${{ github.event_name != 'pull_request' }}
|
||||
env:
|
||||
GIT_CONFIG_COUNT: "1"
|
||||
GIT_CONFIG_KEY_0: safe.directory
|
||||
GIT_CONFIG_VALUE_0: ${{ github.workspace }}
|
||||
@@ -207,8 +207,8 @@ jobs:
|
||||
# GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||
# run: |
|
||||
# set -euo pipefail
|
||||
# git config user.name "dms-ci[bot]"
|
||||
# git config user.email "dms-ci[bot]@users.noreply.github.com"
|
||||
# git config user.name "dux-ci[bot]"
|
||||
# git config user.email "344434142+dux-ci[bot]@users.noreply.github.com"
|
||||
|
||||
# version="${GITHUB_REF#refs/tags/}"
|
||||
# echo "Updating to version: $version"
|
||||
@@ -280,7 +280,7 @@ jobs:
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
curl -fsSL https://install.danklinux.com | sh
|
||||
curl -fsSL https://install.duxlabs.dev | sh
|
||||
```
|
||||
|
||||
## Assets
|
||||
@@ -304,7 +304,7 @@ jobs:
|
||||
- **`dankinstall-arm64.gz`** - Installer binary for ARM64 systems
|
||||
- **`dms-cli-<version>.tar.gz`** - Go source code with vendored modules (for distro packaging)
|
||||
- **`dms-qml.tar.gz`** - QML source code only
|
||||
- **`dms-source.tar.gz`** - Full repository source with bundled DankCommon (for distro packaging)
|
||||
- **`dms-source.tar.gz`** - Full repository source with bundled DCommon (for distro packaging)
|
||||
|
||||
### Checksums
|
||||
- **`*.sha256`** - SHA256 checksums for verifying download integrity
|
||||
@@ -352,15 +352,15 @@ jobs:
|
||||
# Copy completions
|
||||
cp _core_assets/completion.* _release_assets/ 2>/dev/null || true
|
||||
|
||||
# Replace the DankCommon symlink with real submodule content for packaging
|
||||
rm quickshell/DankCommon
|
||||
cp -r dank-qml-common/DankCommon quickshell/DankCommon
|
||||
# Replace the DCommon symlink with real submodule content for packaging
|
||||
rm quickshell/DCommon
|
||||
cp -r dux-qml-common/DCommon quickshell/DCommon
|
||||
|
||||
# Create full source tarball (GitHub tag archives never contain submodule content)
|
||||
VERSION_NUM=${TAG#v}
|
||||
tar --exclude='.git' \
|
||||
--exclude='.github' \
|
||||
--exclude='dank-qml-common' \
|
||||
--exclude='dux-qml-common' \
|
||||
--exclude='_release_assets' \
|
||||
--exclude='_core_assets' \
|
||||
--exclude='RELEASE_BODY.md' \
|
||||
@@ -378,7 +378,9 @@ jobs:
|
||||
# Tar the CONTENTS of quickshell/, not the directory itself
|
||||
(cd quickshell && tar --exclude='.git' \
|
||||
--exclude='.github' \
|
||||
--exclude='./tests' \
|
||||
--exclude='*.tar.gz' \
|
||||
--exclude-from=../scripts/shell-test-excludes.txt \
|
||||
-czf ../_release_assets/dms-qml.tar.gz .)
|
||||
|
||||
# Generate checksum for QML package
|
||||
@@ -418,7 +420,7 @@ jobs:
|
||||
|
||||
# Create installation guide
|
||||
cat > _temp_full/INSTALL.md << 'EOFINSTALL'
|
||||
# DankMaterialShell Installation
|
||||
# Dux Shell Installation
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -456,8 +458,8 @@ jobs:
|
||||
|
||||
## Configuration
|
||||
|
||||
- Settings are stored in `~/.config/DankMaterialShell/settings.json`
|
||||
- Plugins go in `~/.config/DankMaterialShell/plugins/`
|
||||
- Settings are stored in `~/.config/dms/settings.json`
|
||||
- Plugins go in `~/.config/dms/plugins/`
|
||||
- See the documentation in the `dms/` directory for more details
|
||||
|
||||
## Troubleshooting
|
||||
@@ -518,8 +520,8 @@ jobs:
|
||||
bash scripts/notify-issues.sh "$PREVIOUS_TAG" "$TAG"
|
||||
|
||||
# Pin the satellite repos to this release's version. Each dispatch target
|
||||
# decides for itself whether to tag: X.Y.0 always tags, X.Y.Z skips when the
|
||||
# repo has no commits of its own since its last tag.
|
||||
# decides for itself whether to tag: X.Y.0 always tags master and cuts
|
||||
# stable-X.Y, X.Y.Z tags stable-X.Y and skips when it has nothing new.
|
||||
fanout:
|
||||
name: Pin ${{ matrix.repo }}
|
||||
runs-on: ubuntu-latest
|
||||
@@ -529,12 +531,13 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
repo:
|
||||
- dgop
|
||||
- dankgo
|
||||
- dank-qml-common
|
||||
- dankcalendar
|
||||
- danksearch
|
||||
- dank-greeter
|
||||
- dux-gop
|
||||
- dux-go
|
||||
- dux-qml-common
|
||||
- dux-calendar
|
||||
- dux-search
|
||||
- dux-greeter
|
||||
- dux-files
|
||||
steps:
|
||||
- name: Create GitHub App token
|
||||
id: app_token
|
||||
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
echo "📦 Downloading DMS QML source for v${VERSION}..."
|
||||
|
||||
# Download DMS QML source
|
||||
wget "https://github.com/AvengeMedia/DankMaterialShell/releases/download/v${VERSION}/dms-qml.tar.gz" || {
|
||||
wget "https://github.com/duxlabs-dev/dux-shell/releases/download/v${VERSION}/dms-qml.tar.gz" || {
|
||||
echo "❌ Failed to download dms-qml.tar.gz for v${VERSION}"
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
check-updates:
|
||||
name: Check for updates
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository == 'AvengeMedia/DankMaterialShell'
|
||||
if: github.repository == 'duxlabs-dev/dux-shell'
|
||||
|
||||
outputs:
|
||||
has_updates: ${{ steps.check.outputs.has_updates }}
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
# Helper function to check dms stable tag
|
||||
# Sets LATEST_TAG variable in parent scope if update needed
|
||||
check_dms_stable() {
|
||||
LATEST_TAG=$(curl -s https://api.github.com/repos/AvengeMedia/DankMaterialShell/releases/latest | grep '"tag_name"' | sed 's/.*"tag_name": "\([^"]*\)".*/\1/' || echo "")
|
||||
LATEST_TAG=$(curl -s https://api.github.com/repos/duxlabs-dev/dux-shell/releases/latest | grep '"tag_name"' | sed 's/.*"tag_name": "\([^"]*\)".*/\1/' || echo "")
|
||||
local OBS_SPEC=$(curl -s -u "$OBS_USERNAME:$OBS_PASSWORD" "https://api.opensuse.org/source/home:AvengeMedia:dms/dms/dms.spec" 2>/dev/null || echo "")
|
||||
local OBS_VERSION=$(echo "$OBS_SPEC" | grep "^Version:" | awk '{print $2}' | xargs || echo "")
|
||||
|
||||
@@ -192,7 +192,7 @@ jobs:
|
||||
# Determine version for dms stable using the API
|
||||
# GITHUB_REF is unreliable when "Use workflow from" a tag; API works from any ref
|
||||
if [[ "${{ github.event.inputs.package }}" == "dms" ]] || [[ "${{ github.event.inputs.package }}" == "all" ]]; then
|
||||
LATEST_TAG=$(curl -s https://api.github.com/repos/AvengeMedia/DankMaterialShell/releases/latest | grep '"tag_name"' | sed 's/.*"tag_name": "\([^"]*\)".*/\1/' || echo "")
|
||||
LATEST_TAG=$(curl -s https://api.github.com/repos/duxlabs-dev/dux-shell/releases/latest | grep '"tag_name"' | sed 's/.*"tag_name": "\([^"]*\)".*/\1/' || echo "")
|
||||
if [[ -n "$LATEST_TAG" ]]; then
|
||||
echo "version=$LATEST_TAG" >> $GITHUB_OUTPUT
|
||||
echo "Using latest release from API: $LATEST_TAG"
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
check-updates:
|
||||
name: Check package/series updates
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository == 'AvengeMedia/DankMaterialShell'
|
||||
if: github.repository == 'duxlabs-dev/dux-shell'
|
||||
|
||||
outputs:
|
||||
has_updates: ${{ steps.check.outputs.has_updates }}
|
||||
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
name: Build & Deploy XBPS packages
|
||||
runs-on: ubuntu-latest
|
||||
needs: preflight
|
||||
if: github.repository == 'AvengeMedia/DankMaterialShell' && needs.preflight.outputs.should_build == 'true'
|
||||
if: github.repository == 'duxlabs-dev/dux-shell' && needs.preflight.outputs.should_build == 'true'
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
|
||||
@@ -8,6 +8,7 @@ on:
|
||||
- "core/go.sum"
|
||||
branches:
|
||||
- master
|
||||
- "stable-*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -33,21 +34,7 @@ jobs:
|
||||
uses: cachix/install-nix-action@v31
|
||||
|
||||
- name: Update vendorHash in flake.nix
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "Attempting nix build to get new vendorHash..."
|
||||
if output=$(nix build .#dms-shell 2>&1); then
|
||||
echo "Build succeeded, no hash update needed"
|
||||
exit 0
|
||||
fi
|
||||
new_hash=$(echo "$output" | grep -oP "got:\s+\K\S+" | head -n1 || true)
|
||||
[ -n "$new_hash" ] || { echo "Could not extract new vendorHash"; echo "$output"; exit 1; }
|
||||
current_hash=$(grep -oP 'vendorHash = "\K[^"]+' flake.nix)
|
||||
[ "$current_hash" = "$new_hash" ] && { echo "vendorHash already up to date"; exit 0; }
|
||||
sed -i "s|vendorHash = \"$current_hash\"|vendorHash = \"$new_hash\"|" flake.nix
|
||||
echo "Verifying build with new vendorHash..."
|
||||
nix build .#dms-shell
|
||||
echo "vendorHash updated successfully!"
|
||||
run: scripts/update-vendor-hash.sh
|
||||
|
||||
- name: Commit and push vendorHash update
|
||||
env:
|
||||
@@ -55,8 +42,8 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if ! git diff --quiet flake.nix; then
|
||||
git config user.name "dms-ci[bot]"
|
||||
git config user.email "dms-ci[bot]@users.noreply.github.com"
|
||||
git config user.name "dux-ci[bot]"
|
||||
git config user.email "344434142+dux-ci[bot]@users.noreply.github.com"
|
||||
git add flake.nix
|
||||
git commit -m "nix: update vendorHash for go.mod changes" || exit 0
|
||||
git pull --rebase origin ${{ github.ref_name }}
|
||||
|
||||
+2
-1
@@ -64,7 +64,7 @@ AGENTS-temp.md
|
||||
niri-colors.generated.kdl
|
||||
ghostty-colors.generated.conf
|
||||
|
||||
# Notepad files (should be in ~/.local/state/DankMaterialShell/)
|
||||
# Notepad files (should be in ~/.local/state/dms/)
|
||||
untitled-*.txt
|
||||
file:*
|
||||
notepad-files/
|
||||
@@ -130,5 +130,6 @@ distro/void/masterdir*/
|
||||
|
||||
# Often gets built
|
||||
core/dms
|
||||
core/dankinstall
|
||||
|
||||
core/internal/shellembed/dist
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
[submodule "dank-qml-common"]
|
||||
path = dank-qml-common
|
||||
url = https://github.com/AvengeMedia/dank-qml-common.git
|
||||
path = dux-qml-common
|
||||
url = https://github.com/duxlabs-dev/dux-qml-common.git
|
||||
branch = master
|
||||
|
||||
+17
-1
@@ -5,6 +5,16 @@ repos:
|
||||
- id: trailing-whitespace
|
||||
- id: check-yaml
|
||||
- id: end-of-file-fixer
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: qml-tests
|
||||
name: shell logic and QML behavior tests
|
||||
entry: python3 quickshell/tests/run-qml.py
|
||||
language: system
|
||||
types: []
|
||||
files: ^(quickshell/|dux-qml-common$|Makefile$|core/Makefile$|scripts/shell-test-excludes\.txt$|flake\.nix$|\.pre-commit-config\.yaml$|\.github/workflows/(qml-tests|prek)\.yml$)
|
||||
pass_filenames: false
|
||||
require_serial: true
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: shellcheck
|
||||
@@ -20,6 +30,12 @@ repos:
|
||||
language: system
|
||||
files: ^core/.*\.(go|mod|sum)$
|
||||
pass_filenames: false
|
||||
- id: nix-vendor-hash
|
||||
name: update Nix vendor hash
|
||||
entry: bash -c 'command -v nix >/dev/null 2>&1 || exit 0; scripts/update-vendor-hash.sh --stage'
|
||||
language: system
|
||||
files: ^core/go\.(mod|sum)$
|
||||
pass_filenames: false
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: settings-search-index
|
||||
@@ -51,4 +67,4 @@ repos:
|
||||
entry: bash -c 'if grep -nE "console\.(log|error|info|warn|debug)" "$@"; then echo "Use the Log service (log.info/warn/error/debug/fatal) instead of console.*" >&2; exit 1; fi' --
|
||||
language: system
|
||||
files: ^quickshell/.*\.qml$
|
||||
exclude: ^quickshell/(Services/Log\.qml$|dms-plugins/|PLUGINS/)
|
||||
exclude: ^quickshell/(Services/Log\.qml$|dms-plugins/|PLUGINS/|tests/)
|
||||
|
||||
@@ -1,62 +1,46 @@
|
||||
## What is this?
|
||||
|
||||
DMS is an open-source, MIT-licensed desktop shell for Wayland compositors on linux. Like KWin, gnome-shell, or cosmic-shell - but designed to work with many different compositors.
|
||||
DMS is an open-source desktop shell for Wayland compositors on linux, built to work with many compositors.
|
||||
|
||||
## Repo Structure:
|
||||
## Repo Structure
|
||||
|
||||
There are 2 main apps in the repo, they are designed to operate together. Additionally there is a dank-qml-common submodule which contains common QML widgets, like a shared library, that is leveraged by other quickshell apps in the Dank Desktop suite.
|
||||
- core/ - Go. `cmd/dms` runs quickshell, CLI utilities, and the unix socket server the UI depends on. `cmd/dankinstall` is a separate installer TUI.
|
||||
- quickshell/ - the entire UI: Common/ (singletons, Theme, settings), Services/ (system and core access), Modules/ (bar, dock, lock, ...), Modals/, Widgets/ (shell-specific widgets and deprecated Dank* shims), PLUGINS/, translations/
|
||||
- dux-qml-common/ - submodule of shared QML widgets
|
||||
- docs/, distro/ - IPC and theme docs, packaging
|
||||
|
||||
There is a GO module (dankgo) that is used by core for certain common go functionality.
|
||||
## Rules
|
||||
|
||||
### 1- core:
|
||||
- Keep it simple.
|
||||
- Do not edit code in response to a question.
|
||||
- Never commit, push, or open a PR unless asked.
|
||||
- Point out glaring issues we missed.
|
||||
- The shell runs 24/7. Audit every change for idle CPU, extra processes, timers, and retained memory.
|
||||
- `core` and `quickshell` share the socket protocol. Changing one means checking the other.
|
||||
- QML uses Theme tokens, never hardcoded colors, spacing, or constants.
|
||||
- Use the D* widgets from qs.DCommon.Widgets (DListView, DFlickable, and so on) instead of raw ListView/Flickable/ScrollView. The Dank* names in Widgets/ are deprecated shims.
|
||||
- User-facing text goes through I18n.tr(). Reuse catalog terms. Never edit translation catalogs.
|
||||
- Guard clauses, early returns.
|
||||
- Comment only what the code cannot say: a non-obvious why, a trap, a workaround. Never narrate what the code does or restate a name. Keep it to a line or two, and delete useless or paragraph comments you come across.
|
||||
- Do not edit generated code. Mocks come from mockery.
|
||||
- No library for a simple problem. Use a modern one for a complex, common problem.
|
||||
|
||||
Contains 2 apps:
|
||||
## Tests
|
||||
|
||||
1. cmd/dms - The parent process for quickshell (shell runner), and general purpose tools and utilities (such as screenshot, clipboard). It also runs the unix socket server the UI uses for functionality quickshell doesn't provide.
|
||||
2. cmd/dankinstall - An entirely separate app, a TUI for quickly installing DMS
|
||||
- A test must fail on the broken code. Otherwise do not write it.
|
||||
- No useless tests: "main.go contains func main()", defaults, "it instantiates", behavior the change didn't touch.
|
||||
- QML tests must be fast. Call logic directly, load the smallest component, no fixed delays or animation waits, few meaningful assertions over a matrix.
|
||||
- Tests cover `.js` modules, functions of `Services/` and `Common/` singletons, and IPC handlers. Never load, slice or mock anything under `Modules/`, `Modals/` or `Widgets/`, and never spawn a compositor, display server or shell. Those tests only ever broke on UI tweaks.
|
||||
|
||||
### 2- quickshell:
|
||||
## Connections vs bound properties
|
||||
|
||||
A quickshell application, the entirety of the UI for DMS. It uses quickshells own services supplemented by the core unix socket server for additional functionality.
|
||||
|
||||
Layout:
|
||||
|
||||
- Common/ - app-wide singletons and helpers (Theme, SettingsData, SessionData, I18n, PopoutManager)
|
||||
- Services/ - headless singletons that talk to the system and to core
|
||||
- Modules/ - the visible shell: DankBar, ControlCenter, Dock, Lock, Notifications, OSD, and so on
|
||||
- Modals/ - large standalone surfaces: Settings, DankLauncherV2, Clipboard, Greeter
|
||||
- Widgets/ - reusable Dank* components (DankListView, DankIcon, DankToggle, ...)
|
||||
- PLUGINS/ - the plugin system; third-party plugins load from the user's config dir
|
||||
- translations/ - POEditor-synced catalogs
|
||||
|
||||
### Other directories:
|
||||
|
||||
- docs/ - IPC reference, custom theme docs
|
||||
- distro/ - packaging for debian, fedora, nix, opensuse, ubuntu, void
|
||||
|
||||
## General Rules:
|
||||
|
||||
- Keep it simple. Do not overcomplicate things.
|
||||
- Follow each apps' own conventions. For example, QML widgets use Theme tokens instead of hardcoding colors, spacing, or other constants.
|
||||
- Resource usage is extremely important to DMS. The shell runs 24/7 on every machine it's installed on - audit any change for idle CPU cost, extra processes, timers, and retained memory.
|
||||
- `core` and `quickshell` are tightly coupled through the unix socket protocol. When you are changing one, be mindful of the consequences for the other.
|
||||
- Use the Dank* wrappers in Widgets/ (DankListView, DankFlickable, and so on) instead of raw ListView/Flickable/ScrollView.
|
||||
- All user-facing text goes through I18n.tr(). Prefer reusing existing catalog terms over adding new ones. Never edit the translation catalogs by hand, they are synced with POEditor.
|
||||
- Do not start editing code in response to a question. We'll tell you when to edit code.
|
||||
- Do not leave paragraphs of comments on top of the code. You should try to avoid them as much as possible with understandable function names and code. If they are necessary even then, make them concise. Remove such comments when you come by them in the codebase. Comments should always move with code, not be left behind.
|
||||
- Use guard statement patterns in any code you write.
|
||||
- Do not write any useless tests, tests should cover input output validation - not useless things like "main.go contains func main()"
|
||||
- Do not edit generated code directly, mocks are generated with mockery. Not edited by hand
|
||||
- If we are missing a glaring issue when we ask you to do something, do not hesitate to point it out.
|
||||
- Reinvent the wheel but do not reinvent the car. If you are solving a simple problem do not introduce a library. If you are solving a complex but a common problem, there is likely a modern library for it, if so, use it.
|
||||
- Never commit or push code unless explicitly asked to do so.
|
||||
- Never make a PR unless explicitly asked to do so.
|
||||
- In per-item or per-screen components (delegates, bar widgets, popout and modal hosts, OSDs), no `Connections` for `on<Prop>Changed` handlers.
|
||||
- Source in the same file: put the handler on it.
|
||||
- Source outside (singleton, loader item, delegate parent): bind to a typed readonly property, never `var`, and handle that.
|
||||
- Keep `Connections` for plain signals, hand-emitted change signals (`SettingsData.barConfigsChanged()`), constant-pointer targets, reassignable targets, and per-frame `var` sources.
|
||||
- A bound handler fires once at creation if the initial value differs from the type default, and never on a same-value write. Convert only idempotent bodies. `enabled:` conditions become a guard inside the handler.
|
||||
- Singletons under Services/ and Common/ keep their `Connections`.
|
||||
|
||||
## Commit Messages
|
||||
|
||||
Commit messages start with the part of the system they touched, followed by a short lowercase explanation of the work:
|
||||
|
||||
network: report wifi state from the associated adapter
|
||||
doctor: flag config files that fail to parse
|
||||
|
||||
The title should be concise. Description should explain the work in more detail (only if required) while still being concise. Use simple language, do not try to sound smart.
|
||||
`area: short lowercase explanation`, for example `network: report wifi state from the associated adapter`. Description only if required, in simple language.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
This file is more of a quick reference so I know what to account for before next releases.
|
||||
|
||||
# Next
|
||||
- notifications: timed per-app popup mutes (1h / 4h) from the notification context menu
|
||||
- Go core migrated to dankgo (shared log/paths/errdefs/ipc/shellapp)
|
||||
- Quickshell UI embedded in the dms binary, -c / DMS_SHELL_DIR override - breaking for setups relying on ~/.config/quickshell/dms auto-discovery (XDG search removed)
|
||||
- release binaries + all -git packages build with withshell; sockets/pidfiles/env names unchanged, IPC APIVersion 28 wire-compatible
|
||||
|
||||
+27
-18
@@ -6,10 +6,10 @@ To contribute fork this repository, make your changes, and open a pull request.
|
||||
|
||||
## Setup
|
||||
|
||||
Clone with submodules — the shared widget library ([dank-qml-common](https://github.com/AvengeMedia/dank-qml-common)) is vendored at `dank-qml-common/` and symlinked into `quickshell/DankCommon`:
|
||||
Clone with submodules — the shared widget library ([dux-qml-common](https://github.com/duxlabs-dev/dux-qml-common)) is vendored at `dux-qml-common/` and symlinked into `quickshell/DCommon`:
|
||||
|
||||
```bash
|
||||
git clone --recurse-submodules https://github.com/AvengeMedia/DankMaterialShell.git
|
||||
git clone --recurse-submodules https://github.com/duxlabs-dev/dux-shell.git
|
||||
# or, in an existing clone:
|
||||
git submodule update --init
|
||||
```
|
||||
@@ -54,34 +54,29 @@ make run # dev build, then launch against the live quickshell/ tree
|
||||
|
||||
The UI config dir resolves in order: `-c <dir>`, `DMS_SHELL_DIR`, the dir a running instance is using, then the embedded UI. Each candidate must contain `shell.qml`. `make run` uses `-c $(pwd)/quickshell`, so QML edits hot-reload from the working tree.
|
||||
|
||||
The Go core depends on [dankgo](https://github.com/AvengeMedia/dankgo) for logging, XDG paths, the IPC transport, and the quickshell process lifecycle. To develop against a local dankgo checkout, create a gitignored `go.work` at the repo root:
|
||||
The Go core depends on [dankgo](https://github.com/duxlabs-dev/dux-go) for logging, XDG paths, the IPC transport, and the quickshell process lifecycle. To develop against a local dankgo checkout, create a gitignored `go.work` at the repo root:
|
||||
|
||||
```
|
||||
go 1.26.1
|
||||
|
||||
use (
|
||||
./core
|
||||
../dankgo
|
||||
)
|
||||
go work init ./core ../dankgo
|
||||
```
|
||||
|
||||
## Shared widgets (dank-qml-common)
|
||||
## Shared widgets (dux-qml-common)
|
||||
|
||||
Everything under `quickshell/DankCommon/` (core widgets, the file browser, scroll physics, bundled fonts) is shared across the DMS suite and lives in the `dank-qml-common` submodule. It is a normal git worktree:
|
||||
Everything under `quickshell/DCommon/` (core widgets, the file browser, scroll physics, bundled fonts) is shared across the DMS suite and lives in the `dux-qml-common` submodule. It is a normal git worktree:
|
||||
|
||||
1. Edit files under `dank-qml-common/` (or through the `quickshell/DankCommon` symlink — same files) and test in the running shell; hot reload works as usual. For isolated widget work, the library is its own runnable config with a gallery: `qs -c dank-qml-common`.
|
||||
2. Commit and PR those changes in the `dank-qml-common` repo: `cd dank-qml-common && git switch -c my-change`, push, open the PR there.
|
||||
1. Edit files under `dux-qml-common/` (or through the `quickshell/DCommon` symlink — same files) and test in the running shell; hot reload works as usual. For isolated widget work, the library is its own runnable config with a gallery: `qs -c dux-qml-common`.
|
||||
2. Commit and PR those changes in the `dux-qml-common` repo: `cd dux-qml-common && git switch -c my-change`, push, open the PR there.
|
||||
3. Once merged, bump the pointer here: `make update-common` (updates the submodule and the nix flake input together), then commit alongside any DMS-side changes. If you only bump the submodule, CI syncs `flake.lock` to it automatically on master.
|
||||
|
||||
The submodule URL in `.gitmodules` is HTTPS so CI and anonymous clones keep working. To push over SSH instead of being prompted for credentials, add a push rewrite to your git config — fetches stay HTTPS, pushes use SSH:
|
||||
|
||||
```bash
|
||||
git config --global url."[email protected]:AvengeMedia/".pushInsteadOf "https://github.com/AvengeMedia/"
|
||||
git config --global url."[email protected]:duxlabs-dev/".pushInsteadOf "https://github.com/duxlabs-dev/"
|
||||
```
|
||||
|
||||
Shared widgets read app-provided singletons (`Theme`, `SettingsData`, ...) through a documented contract — see the dank-qml-common README. If your change needs a new contract property, add it to the library's stub singletons in the same PR, then to `quickshell/Common/` here when you bump.
|
||||
Shared widgets read app-provided singletons (`Theme`, `SettingsData`, ...) through a documented contract — see the dux-qml-common README. If your change needs a new contract property, add it to the library's stub singletons in the same PR, then to `quickshell/Common/` here when you bump.
|
||||
|
||||
Files in `quickshell/Widgets/`, `quickshell/Common/`, and `quickshell/Modals/FileBrowser/` that moved to the library remain in place as thin wrappers, so `import qs.Widgets`, `qs.Common`, and `qs.Modals.FileBrowser` keep working for the shell and for plugins.
|
||||
Files in `quickshell/Widgets/`, `quickshell/Common/`, and `quickshell/Modals/FileBrowser/` that moved to the library remain in place as thin wrappers, so existing `qs.Widgets`, `qs.Common`, and `qs.Modals.FileBrowser` imports keep working for the shell and for plugins. The `Dank*` widget names are deprecated: each one logs a warning naming its replacement. New code imports `qs.DCommon.Widgets` and uses the `D*` names (`DButton`, `DIcon`, `DListView`, and so on).
|
||||
|
||||
## VSCode Setup
|
||||
|
||||
@@ -143,7 +138,9 @@ touch .qmlls.ini
|
||||
|
||||
5. Run `make lint-qml` from the repo root to lint QML entrypoints (requires the `.qmlls.ini` generated above). The script needs the **Qt 6** `qmllint`; it checks `qmllint6`, Fedora's `qmllint-qt6`, `/usr/lib/qt6/bin/qmllint`, then `qmllint` in `PATH`. If your Qt 6 binary lives elsewhere, set `QMLLINT=/path/to/qmllint`.
|
||||
|
||||
6. Make your changes, test, and open a pull request.
|
||||
6. Run `make test-qml` for QML unit tests (`nix develop --command make test-qml` with Nix). These use Qt 6 `qmltestrunner` offscreen and do not require a running shell. Set `QMLTESTRUNNER=/path/to/qmltestrunner` if needed.
|
||||
|
||||
7. Make your changes, test, and open a pull request.
|
||||
|
||||
### I18n/Localization
|
||||
|
||||
@@ -161,7 +158,7 @@ Preferably, try to keep new terms to a minimum and re-use existing terms where p
|
||||
|
||||
Don't re-extract the translations. `en.json` and `template.json` are synced with POEditor by a maintainer script, so running `extract_translations.py` in your PR just makes a diff that fights the next sync. Add your `I18n.tr()` calls and leave the catalogs alone. (`settings_search_index.json` is the exception, the pre-commit hook regenerates that one when you touch settings QML.)
|
||||
|
||||
Strings inside `quickshell/DankCommon/` are owned by the dank-qml-common repo but stay in the DMS POEditor project — extraction here deliberately skips them, and `scripts/i18nsync.py sync` uploads the union of app terms and the submodule's terms instead (common terms carry the `dank-qml-common` tag). On download the sync splits the exports: app translations go to `quickshell/translations/poexports/`, common translations go to `dank-qml-common/DankCommon/translations/poexports/` for you to commit in that repo and bump. At runtime `I18n` merges both catalogs (app terms win). Other apps (dankcalendar) keep their own POEditor projects and merge the `dank-qml-common`-tagged terms from the DMS project.
|
||||
Strings inside `quickshell/DCommon/` are owned by the dux-qml-common repo but stay in the DMS POEditor project — extraction here deliberately skips them, and `scripts/i18nsync.py sync` uploads the union of app terms and the submodule's terms instead (common terms carry the `dux-qml-common` tag). On download the sync splits the exports: app translations go to `quickshell/translations/poexports/`, common translations go to `dux-qml-common/DCommon/translations/poexports/` for you to commit in that repo and bump. At runtime `I18n` merges both catalogs (app terms win). Other apps (dankcalendar) keep their own POEditor projects and merge the `dux-qml-common`-tagged terms from the DMS project.
|
||||
|
||||
### GO (`core` directory)
|
||||
|
||||
@@ -186,6 +183,18 @@ mockery
|
||||
|
||||
`core/.mockery.yml` lists every mocked interface and where its mock goes (e.g. `network.Backend` -> `internal/mocks/network/mock_Backend.go`). To mock a new interface, add it there under its package and regenerate.
|
||||
|
||||
## Generative AI
|
||||
|
||||
Using an LLM to help write code is fine. Submitting its output unread is not.
|
||||
|
||||
- You are responsible for every line you submit. You have read it, tested it, and can explain it in review.
|
||||
- Say in the PR when a meaningful part of the code was AI generated.
|
||||
- Write PR descriptions, issues, and comments yourself. No AI-written or AI-rewritten text. A few plain sentences in your own words beat a page of generated summary. PRs and issues with AI-written text get closed without review.
|
||||
- You can use AI to troubleshoot, gather data, and information for new issues. The description should be written by a human.
|
||||
- If you are not an english-native speaker you can use AI to translate your request or PR description
|
||||
- Do not file issues or leave comments you have not verified yourself. Reports that do not reproduce get closed.
|
||||
- PRs that read like unreviewed output, with narrating comments, invented APIs, or style that ignores the file they are in, get closed without review.
|
||||
|
||||
## Pull request
|
||||
|
||||
Include screenshots/video if applicable in your pull request if applicable, to visualize what your change is affecting.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Root Makefile for DankMaterialShell (DMS)
|
||||
# Root Makefile for Dux Shell (DMS)
|
||||
# Orchestrates building, installation, and systemd management
|
||||
|
||||
# Build configuration
|
||||
@@ -14,11 +14,12 @@ USER_HOME := $(if $(SUDO_USER),$(shell getent passwd $(SUDO_USER) | cut -d: -f6)
|
||||
SYSTEMD_USER_DIR=$(USER_HOME)/.config/systemd/user
|
||||
|
||||
SHELL_DIR=quickshell
|
||||
QMLTESTRUNNER ?=
|
||||
SHELL_INSTALL_DIR=$(DATA_DIR)/quickshell/dms
|
||||
ASSETS_DIR=assets
|
||||
APPLICATIONS_DIR=$(DATA_DIR)/applications
|
||||
|
||||
.PHONY: all build dev run clean lint-qml install install-bin install-shell install-completions install-systemd install-icon install-desktop uninstall uninstall-bin uninstall-shell uninstall-completions uninstall-systemd uninstall-icon uninstall-desktop help
|
||||
.PHONY: all build dev run clean lint-qml install install-bin install-completions install-systemd install-icon install-desktop uninstall uninstall-bin uninstall-shell uninstall-completions uninstall-systemd uninstall-icon uninstall-desktop help
|
||||
|
||||
all: build
|
||||
|
||||
@@ -41,10 +42,14 @@ clean:
|
||||
lint-qml:
|
||||
@./quickshell/scripts/qmllint-entrypoints.sh
|
||||
|
||||
# Pull the latest dank-qml-common and pin it everywhere it is consumed
|
||||
.PHONY: test-qml
|
||||
test-qml:
|
||||
QMLTESTRUNNER="$(QMLTESTRUNNER)" python3 quickshell/tests/run-qml.py
|
||||
|
||||
# Pull the latest dux-qml-common and pin it everywhere it is consumed
|
||||
# (submodule pointer + nix flake input). Commit both in one change.
|
||||
update-common:
|
||||
git submodule update --remote --merge dank-qml-common
|
||||
git submodule update --remote --merge dux-qml-common
|
||||
nix --extra-experimental-features 'nix-command flakes' flake update dank-qml-common
|
||||
|
||||
# Installation targets
|
||||
@@ -53,14 +58,6 @@ install-bin:
|
||||
@install -D -m 755 $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_DIR)/$(BINARY_NAME)
|
||||
@echo "Binary installed"
|
||||
|
||||
install-shell:
|
||||
@echo "Installing shell files to $(SHELL_INSTALL_DIR)..."
|
||||
@test -e $(SHELL_DIR)/DankCommon/Widgets/DankIcon.qml || { echo "DankCommon missing: run git submodule update --init"; exit 1; }
|
||||
@mkdir -p $(SHELL_INSTALL_DIR)
|
||||
@cp -rL $(SHELL_DIR)/* $(SHELL_INSTALL_DIR)/
|
||||
@rm -rf $(SHELL_INSTALL_DIR)/.git* $(SHELL_INSTALL_DIR)/.github
|
||||
@echo "Shell files installed"
|
||||
|
||||
install-completions:
|
||||
@echo "Installing shell completions..."
|
||||
@mkdir -p $(DATA_DIR)/bash-completion/completions
|
||||
@@ -86,19 +83,21 @@ endif
|
||||
|
||||
install-icon:
|
||||
@echo "Installing icon..."
|
||||
@install -D -m 644 $(ASSETS_DIR)/danklogo.svg $(ICON_DIR)/danklogo.svg
|
||||
@install -D -m 644 $(ASSETS_DIR)/dev.duxlabs.shell.svg $(ICON_DIR)/dev.duxlabs.shell.svg
|
||||
@rm -f $(ICON_DIR)/com.danklinux.dms.svg
|
||||
@gtk-update-icon-cache -q $(DATA_DIR)/icons/hicolor 2>/dev/null || true
|
||||
@echo "Icon installed"
|
||||
|
||||
install-desktop:
|
||||
@echo "Installing desktop entries..."
|
||||
@install -D -m 644 $(ASSETS_DIR)/dms-open.desktop $(APPLICATIONS_DIR)/dms-open.desktop
|
||||
@install -D -m 644 $(ASSETS_DIR)/com.danklinux.dms.desktop $(APPLICATIONS_DIR)/com.danklinux.dms.desktop
|
||||
@install -D -m 644 $(ASSETS_DIR)/com.danklinux.dms.notepad.desktop $(APPLICATIONS_DIR)/com.danklinux.dms.notepad.desktop
|
||||
@install -D -m 644 $(ASSETS_DIR)/dev.duxlabs.shell.desktop $(APPLICATIONS_DIR)/dev.duxlabs.shell.desktop
|
||||
@install -D -m 644 $(ASSETS_DIR)/dev.duxlabs.shell.notepad.desktop $(APPLICATIONS_DIR)/dev.duxlabs.shell.notepad.desktop
|
||||
@rm -f $(APPLICATIONS_DIR)/com.danklinux.dms.desktop $(APPLICATIONS_DIR)/com.danklinux.dms.notepad.desktop
|
||||
@update-desktop-database -q $(APPLICATIONS_DIR) 2>/dev/null || true
|
||||
@echo "Desktop entries installed"
|
||||
|
||||
install: install-bin install-shell install-completions install-systemd install-icon install-desktop
|
||||
install: install-bin install-completions install-systemd install-icon install-desktop
|
||||
@echo ""
|
||||
@echo "Installation complete!"
|
||||
@echo ""
|
||||
@@ -111,7 +110,7 @@ uninstall-bin:
|
||||
@echo "Binary removed"
|
||||
|
||||
uninstall-shell:
|
||||
@echo "Removing shell files from $(SHELL_INSTALL_DIR)..."
|
||||
@echo "Removing pre-1.6 shell files from $(SHELL_INSTALL_DIR)..."
|
||||
@rm -rf $(SHELL_INSTALL_DIR)
|
||||
@echo "Shell files removed"
|
||||
|
||||
@@ -130,6 +129,8 @@ uninstall-systemd:
|
||||
|
||||
uninstall-icon:
|
||||
@echo "Removing icon..."
|
||||
@rm -f $(ICON_DIR)/dev.duxlabs.shell.svg
|
||||
@rm -f $(ICON_DIR)/com.danklinux.dms.svg
|
||||
@rm -f $(ICON_DIR)/danklogo.svg
|
||||
@gtk-update-icon-cache -q $(DATA_DIR)/icons/hicolor 2>/dev/null || true
|
||||
@echo "Icon removed"
|
||||
@@ -137,8 +138,9 @@ uninstall-icon:
|
||||
uninstall-desktop:
|
||||
@echo "Removing desktop entries..."
|
||||
@rm -f $(APPLICATIONS_DIR)/dms-open.desktop
|
||||
@rm -f $(APPLICATIONS_DIR)/com.danklinux.dms.desktop
|
||||
@rm -f $(APPLICATIONS_DIR)/com.danklinux.dms.notepad.desktop
|
||||
@rm -f $(APPLICATIONS_DIR)/dev.duxlabs.shell.desktop
|
||||
@rm -f $(APPLICATIONS_DIR)/dev.duxlabs.shell.notepad.desktop
|
||||
@rm -f $(APPLICATIONS_DIR)/com.danklinux.dms.desktop $(APPLICATIONS_DIR)/com.danklinux.dms.notepad.desktop
|
||||
@update-desktop-database -q $(APPLICATIONS_DIR) 2>/dev/null || true
|
||||
@echo "Desktop entries removed"
|
||||
|
||||
@@ -155,11 +157,11 @@ help:
|
||||
@echo " build - Same as 'all'"
|
||||
@echo " clean - Clean build artifacts"
|
||||
@echo " lint-qml - Run qmllint on shell entrypoints using the Quickshell tooling VFS"
|
||||
@echo " test-qml - Run shell logic and Qt unit tests"
|
||||
@echo ""
|
||||
@echo "Install:"
|
||||
@echo " install - Build and install everything (requires sudo)"
|
||||
@echo " install-bin - Install only the binary"
|
||||
@echo " install-shell - Install only shell files"
|
||||
@echo " install-completions - Install only shell completions"
|
||||
@echo " install-systemd - Install only systemd service"
|
||||
@echo " install-icon - Install only icon"
|
||||
@@ -168,7 +170,7 @@ help:
|
||||
@echo "Uninstall:"
|
||||
@echo " uninstall - Remove everything (requires sudo)"
|
||||
@echo " uninstall-bin - Remove only the binary"
|
||||
@echo " uninstall-shell - Remove only shell files"
|
||||
@echo " uninstall-shell - Remove shell files left by pre-1.6 installs"
|
||||
@echo " uninstall-completions - Remove only shell completions"
|
||||
@echo " uninstall-systemd - Remove only systemd service"
|
||||
@echo " uninstall-icon - Remove only icon"
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
# DankMaterialShell
|
||||
# Dux Shell
|
||||
|
||||
<div align="center">
|
||||
<a href="https://danklinux.com">
|
||||
<img src="assets/danklogo.svg" alt="DankMaterialShell" width="200">
|
||||
<a href="https://duxlabs.dev">
|
||||
<img src="assets/danklogo.svg" alt="Dux Shell" width="200">
|
||||
</a>
|
||||
|
||||
### A modern desktop shell for Wayland
|
||||
|
||||
Built with [Quickshell](https://quickshell.org/) and [Go](https://go.dev/)
|
||||
|
||||
[](https://danklinux.com/docs)
|
||||
[](https://github.com/AvengeMedia/DankMaterialShell/stargazers)
|
||||
[](https://github.com/AvengeMedia/DankMaterialShell/blob/master/LICENSE)
|
||||
[](https://github.com/AvengeMedia/DankMaterialShell/releases)
|
||||
[](https://duxlabs.dev/docs)
|
||||
[](https://github.com/duxlabs-dev/dux-shell/stargazers)
|
||||
[](https://github.com/duxlabs-dev/dux-shell/blob/master/LICENSE)
|
||||
[](https://github.com/duxlabs-dev/dux-shell/releases)
|
||||
[](https://archlinux.org/packages/extra/x86_64/dms-shell/)
|
||||
[>)](https://aur.archlinux.org/packages/dms-shell-git)
|
||||
[](https://ko-fi.com/danklinux)
|
||||
[](https://ko-fi.com/duxlabs)
|
||||
|
||||
</div>
|
||||
|
||||
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), [labwc](https://labwc.github.io/), [Scroll](https://github.com/dawsers/scroll), [Miracle WM](https://github.com/miracle-wm-org/miracle-wm), and other Wayland compositors. It replaces waybar, swaylock, swayidle, mako, fuzzel, polkit, and everything else you'd normally stitch together to make a desktop.
|
||||
Dux Shell 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), [labwc](https://labwc.github.io/), [Scroll](https://github.com/dawsers/scroll), [Miracle WM](https://github.com/miracle-wm-org/miracle-wm), and other Wayland compositors. It replaces waybar, swaylock, swayidle, mako, fuzzel, polkit, and everything else you'd normally stitch together to make a desktop.
|
||||
|
||||
## Repository Structure
|
||||
|
||||
This is a monorepo containing both the shell interface and the core backend services:
|
||||
|
||||
```
|
||||
DankMaterialShell/
|
||||
dux-shell/
|
||||
├── quickshell/ # QML-based shell interface
|
||||
│ ├── Modules/ # UI components (panels, widgets, overlays)
|
||||
│ ├── Services/ # System integration (audio, network, bluetooth)
|
||||
@@ -70,12 +70,14 @@ https://github.com/user-attachments/assets/d310418b-cc44-4ace-ac0d-2fc0c851291d
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
curl -fsSL https://install.danklinux.com | sh
|
||||
curl -fsSL https://install.duxlabs.dev | sh
|
||||
```
|
||||
|
||||
One command installs DMS and all dependencies on Arch, Fedora, Debian, Ubuntu, openSUSE, or Gentoo.
|
||||
|
||||
**[Manual installation guide](https://danklinux.com/docs/dankmaterialshell/installation)**
|
||||
**FreeBSD:** native support is included in-tree; see [distro/freebsd/README.md](distro/freebsd/README.md) for supported integrations and setup notes.
|
||||
|
||||
**[Manual installation guide](https://duxlabs.dev/docs/dms/installation)**
|
||||
|
||||
## Features
|
||||
|
||||
@@ -83,10 +85,10 @@ One command installs DMS and all dependencies on Arch, Fedora, Debian, Ubuntu, o
|
||||
Wallpaper-based color schemes that automatically theme GTK, Qt, terminals, editors (vscode, vscodium), and more using [matugen](https://github.com/InioX/matugen) and dank16.
|
||||
|
||||
**System Monitoring**
|
||||
Real-time CPU, RAM, GPU metrics and temperatures built into the dms daemon (powered by [dgop](https://github.com/AvengeMedia/dgop)). Process list with search and management.
|
||||
Real-time CPU, RAM, GPU metrics and temperatures built into the dms daemon (powered by [dgop](https://github.com/duxlabs-dev/dux-gop)). Process list with search and management.
|
||||
|
||||
**Powerful Launcher**
|
||||
Spotlight-style search for applications, files ([dsearch](https://github.com/AvengeMedia/danksearch)), emojis, running windows, calculator, and commands. Extensible with plugins.
|
||||
Spotlight-style search for applications, files ([dsearch](https://github.com/duxlabs-dev/dux-search)), emojis, running windows, calculator, and commands. Extensible with plugins.
|
||||
|
||||
**Control Center**
|
||||
Unified interface for network, Bluetooth, audio devices, display settings, and night mode.
|
||||
@@ -98,18 +100,18 @@ Notification center with grouping, rich text support, and keyboard navigation.
|
||||
MPRIS player controls, calendar sync, weather widgets, and clipboard history with image previews.
|
||||
|
||||
**Session Management**
|
||||
Lock screen, idle detection, auto-lock/suspend with separate AC/battery settings, and a settings front-end for [dank-greeter](https://github.com/AvengeMedia/dank-greeter).
|
||||
Lock screen, idle detection, auto-lock/suspend with separate AC/battery settings, and a settings front-end for [dux-greeter](https://github.com/duxlabs-dev/dux-greeter).
|
||||
|
||||
**Plugin System**
|
||||
Extend functionality with the [plugin registry](https://plugins.danklinux.com). DMS keeps
|
||||
`~/.config/DankMaterialShell/plugins.lock.json` synchronized with managed plugin installs and
|
||||
Extend functionality with the [plugin registry](https://plugins.duxlabs.dev). DMS keeps
|
||||
`~/.config/dms/plugins.lock.json` synchronized with managed plugin installs and
|
||||
their exact Git commits, so the same plugins can be reproduced on another machine.
|
||||
|
||||
## Supported Compositors
|
||||
|
||||
Works best with [niri](https://github.com/YaLTeR/niri), [Hyprland](https://hyprland.org/), [Sway](https://swaywm.org/), [MangoWC](https://github.com/DreamMaoMao/mangowc), [labwc](https://labwc.github.io/), [Scroll](https://github.com/dawsers/scroll), and [Miracle WM](https://github.com/miracle-wm-org/miracle-wm) with full workspace switching, overview integration, and monitor management. Other Wayland compositors work with reduced features.
|
||||
|
||||
[Compositor configuration guide](https://danklinux.com/docs/dankmaterialshell/compositors)
|
||||
[Compositor configuration guide](https://duxlabs.dev/docs/dms/compositors)
|
||||
|
||||
## Command Line Interface
|
||||
|
||||
@@ -126,26 +128,26 @@ dms plugins lock # Refresh the portable plugin lockfile
|
||||
dms plugins restore ~/plugins.lock.json
|
||||
```
|
||||
|
||||
[Full CLI and IPC documentation](https://danklinux.com/docs/dankmaterialshell/keybinds-ipc)
|
||||
[Full CLI and IPC documentation](https://duxlabs.dev/docs/dms/keybinds-ipc)
|
||||
|
||||
## Documentation
|
||||
|
||||
- **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)
|
||||
- **Website:** [duxlabs.dev](https://duxlabs.dev)
|
||||
- **Docs:** [duxlabs.dev/docs](https://duxlabs.dev/docs/)
|
||||
- **Theming:** [Application themes](https://duxlabs.dev/docs/dms/application-themes) | [Custom themes](https://duxlabs.dev/docs/dms/custom-themes)
|
||||
- **Plugins:** [Development guide](https://duxlabs.dev/docs/dms/plugins-overview)
|
||||
- **Support:** [Ko-fi](https://ko-fi.com/duxlabs)
|
||||
|
||||
## Dank Projects
|
||||
## Dux Projects
|
||||
|
||||
DMS is one piece of the suite. The rest lives in its own repos:
|
||||
|
||||
- [dank-greeter](https://github.com/AvengeMedia/dank-greeter) - greetd login screen with the Dank Material aesthetic. The Greeter tab in DMS settings is the front-end for it.
|
||||
- [dankcalendar](https://github.com/AvengeMedia/dankcalendar) - Local, Google, Microsoft, and CalDAV calendars for the dank desktop.
|
||||
- [dgop](https://github.com/AvengeMedia/dgop) - System monitoring TUI and Go library; its library powers the process list and dashboard widgets inside the dms daemon.
|
||||
- [dsearch](https://github.com/AvengeMedia/danksearch) - Fast file search that powers file results in the launcher.
|
||||
- [dank-qml-common](https://github.com/AvengeMedia/dank-qml-common) - Shared QML widgets and components used by DMS, dank-greeter, and dankcalendar.
|
||||
- [dankgo](https://github.com/AvengeMedia/dankgo) - Common Go modules behind the single binary apps.
|
||||
- [dux-greeter](https://github.com/duxlabs-dev/dux-greeter) - greetd login screen with the Dux Shell aesthetic. The Greeter tab in DMS settings is the front-end for it.
|
||||
- [dux-calendar](https://github.com/duxlabs-dev/dux-calendar) - Local, Google, Microsoft, and CalDAV calendars for the Dux desktop.
|
||||
- [dgop](https://github.com/duxlabs-dev/dux-gop) - System monitoring TUI and Go library; its library powers the process list and dashboard widgets inside the dms daemon.
|
||||
- [dsearch](https://github.com/duxlabs-dev/dux-search) - Fast file search that powers file results in the launcher.
|
||||
- [dux-qml-common](https://github.com/duxlabs-dev/dux-qml-common) - Shared QML widgets and components used by DMS, dux-greeter, and dux-calendar.
|
||||
- [dux-go](https://github.com/duxlabs-dev/dux-go) - Common Go modules behind the single binary apps.
|
||||
|
||||
## Development
|
||||
|
||||
@@ -175,7 +177,7 @@ quickshell -p quickshell/
|
||||
|
||||
```nix
|
||||
{
|
||||
inputs.dms.url = "github:AvengeMedia/DankMaterialShell";
|
||||
inputs.dms.url = "github:duxlabs-dev/dux-shell";
|
||||
|
||||
# Use in home-manager or NixOS configuration
|
||||
imports = [ inputs.dms.homeModules.dank-material-shell ];
|
||||
@@ -191,7 +193,7 @@ Contributions welcome. Bug fixes, widgets, features, documentation, and plugins
|
||||
3. Test thoroughly
|
||||
4. Open a pull request
|
||||
|
||||
For documentation contributions, see [DankLinux-Docs](https://github.com/AvengeMedia/DankLinux-Docs).
|
||||
For documentation contributions, see [dux-docs](https://github.com/duxlabs-dev/dux-docs).
|
||||
|
||||
## Credits
|
||||
|
||||
@@ -200,10 +202,11 @@ For documentation contributions, see [DankLinux-Docs](https://github.com/AvengeM
|
||||
- [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
|
||||
- [Reverier-Xu](https://github.com/Reverier-Xu) - [Ori-fcitx5](https://github.com/Reverier-Xu/Ori-fcitx5) inspiration for Fcitx5 theme
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.dera.page/#AvengeMedia/DankMaterialShell&type=date&legend=top-left)
|
||||
[](https://star-history.dera.page/#duxlabs-dev/dux-shell&type=date&legend=top-left)
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=DankMaterialShell
|
||||
Name=Dux Shell
|
||||
GenericName=Desktop Shell
|
||||
Comment=Material 3 inspired shell for Wayland compositors
|
||||
Exec=dms run
|
||||
Icon=danklogo
|
||||
Icon=dev.duxlabs.shell
|
||||
Terminal=false
|
||||
NoDisplay=true
|
||||
StartupNotify=false
|
||||
@@ -2,9 +2,9 @@
|
||||
Type=Application
|
||||
Name=DMS Notepad
|
||||
GenericName=Text Editor
|
||||
Comment=Quick notes and text files in the dank desktop shell
|
||||
Comment=Quick notes and text files in Dux Shell
|
||||
Exec=dms ipc call notepad openFile %f
|
||||
Icon=danklogo
|
||||
Icon=dev.duxlabs.shell
|
||||
Terminal=false
|
||||
NoDisplay=true
|
||||
Categories=Utility;TextEditor;
|
||||
@@ -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 |
@@ -3,7 +3,7 @@ Type=Application
|
||||
Name=DMS
|
||||
Comment=Select an application to open links and files
|
||||
Exec=dms open %u
|
||||
Icon=danklogo
|
||||
Icon=dev.duxlabs.shell
|
||||
Terminal=false
|
||||
NoDisplay=true
|
||||
MimeType=x-scheme-handler/http;x-scheme-handler/https;x-scheme-handler/dms;text/html;application/xhtml+xml;
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
[Unit]
|
||||
Description=Dank Material Shell (DMS)
|
||||
Description=Dux Shell (DMS)
|
||||
PartOf=graphical-session.target
|
||||
After=graphical-session.target
|
||||
Requisite=graphical-session.target
|
||||
|
||||
[Service]
|
||||
Type=dbus
|
||||
BusName=org.freedesktop.Notifications
|
||||
Type=notify
|
||||
NotifyAccess=main
|
||||
Environment=QS_DISABLE_CRASH_HANDLER=1
|
||||
ExecStart=/usr/bin/dms run --session
|
||||
ExecReload=/bin/kill -USR1 $MAINPID
|
||||
LimitNOFILE=16384
|
||||
LimitNOFILE=16384:infinity
|
||||
Restart=on-failure
|
||||
RestartForceExitStatus=TEMPFAIL
|
||||
SuccessExitStatus=TEMPFAIL
|
||||
|
||||
+10
-10
@@ -6,13 +6,13 @@ template: testify
|
||||
template-data:
|
||||
unroll-variadic: true
|
||||
packages:
|
||||
github.com/AvengeMedia/DankMaterialShell/core/internal/geolocation:
|
||||
github.com/duxlabs-dev/dux-shell/core/internal/geolocation:
|
||||
config:
|
||||
dir: internal/mocks/geolocation
|
||||
pkgname: mocks_geolocation
|
||||
interfaces:
|
||||
Client: {}
|
||||
github.com/AvengeMedia/DankMaterialShell/core/internal/server/cups:
|
||||
github.com/duxlabs-dev/dux-shell/core/internal/server/cups:
|
||||
config:
|
||||
dir: internal/mocks/cups
|
||||
pkgname: mocks_cups
|
||||
@@ -22,46 +22,46 @@ packages:
|
||||
config:
|
||||
dir: internal/mocks/cups_pkhelper
|
||||
pkgname: mocks_cups_pkhelper
|
||||
github.com/AvengeMedia/DankMaterialShell/core/internal/server/evdev:
|
||||
github.com/duxlabs-dev/dux-shell/core/internal/server/evdev:
|
||||
config:
|
||||
dir: internal/mocks/evdev
|
||||
pkgname: mocks_evdev
|
||||
interfaces:
|
||||
EvdevDevice: {}
|
||||
github.com/AvengeMedia/DankMaterialShell/core/internal/server/network:
|
||||
github.com/duxlabs-dev/dux-shell/core/internal/server/network:
|
||||
config:
|
||||
dir: internal/mocks/network
|
||||
pkgname: mocks_network
|
||||
interfaces:
|
||||
Backend: {}
|
||||
github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext:
|
||||
github.com/duxlabs-dev/dux-shell/core/internal/server/wlcontext:
|
||||
config:
|
||||
dir: internal/mocks/wlcontext
|
||||
pkgname: mocks_wlcontext
|
||||
interfaces:
|
||||
WaylandContext: {}
|
||||
github.com/AvengeMedia/DankMaterialShell/core/internal/utils:
|
||||
github.com/duxlabs-dev/dux-shell/core/internal/utils:
|
||||
config:
|
||||
dir: internal/mocks/utils
|
||||
pkgname: mocks_utils
|
||||
interfaces:
|
||||
AppChecker: {}
|
||||
github.com/AvengeMedia/DankMaterialShell/core/internal/version:
|
||||
github.com/duxlabs-dev/dux-shell/core/internal/version:
|
||||
config:
|
||||
dir: internal/mocks/version
|
||||
pkgname: mocks_version
|
||||
interfaces:
|
||||
VersionFetcher: {}
|
||||
github.com/AvengeMedia/dankgo/wayland/client:
|
||||
github.com/duxlabs-dev/dux-go/wayland/client:
|
||||
config:
|
||||
dir: internal/mocks/wlclient
|
||||
pkgname: mocks_wlclient
|
||||
interfaces:
|
||||
WaylandDisplay: {}
|
||||
github.com/AvengeMedia/DankMaterialShell/core/internal/plugins:
|
||||
github.com/duxlabs-dev/dux-shell/core/internal/plugins:
|
||||
interfaces:
|
||||
GitClient: {}
|
||||
github.com/AvengeMedia/DankMaterialShell/core/internal/server/brightness:
|
||||
github.com/duxlabs-dev/dux-shell/core/internal/server/brightness:
|
||||
config:
|
||||
dir: internal/mocks/brightness
|
||||
pkgname: mocks_brightness
|
||||
|
||||
@@ -1,13 +1,26 @@
|
||||
repos:
|
||||
- repo: https://github.com/golangci/golangci-lint
|
||||
rev: v2.13.1
|
||||
hooks:
|
||||
- id: golangci-lint-fmt
|
||||
require_serial: true
|
||||
- id: golangci-lint-full
|
||||
- id: golangci-lint-config-verify
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: golangci-lint-fmt
|
||||
name: golangci-lint-fmt
|
||||
entry: go run github.com/golangci/golangci-lint/v2/cmd/[email protected] fmt
|
||||
language: system
|
||||
types: [go]
|
||||
require_serial: true
|
||||
pass_filenames: false
|
||||
- id: golangci-lint-full
|
||||
name: golangci-lint-full
|
||||
entry: go run github.com/golangci/golangci-lint/v2/cmd/[email protected] run --fix
|
||||
language: system
|
||||
types: [go]
|
||||
require_serial: true
|
||||
pass_filenames: false
|
||||
- id: golangci-lint-config-verify
|
||||
name: golangci-lint-config-verify
|
||||
entry: go run github.com/golangci/golangci-lint/v2/cmd/[email protected] config verify
|
||||
language: system
|
||||
files: '\.golangci\.(?:yml|yaml|toml|json)'
|
||||
pass_filenames: false
|
||||
- id: go-test
|
||||
name: go test
|
||||
entry: go test ./...
|
||||
|
||||
+3
-10
@@ -37,20 +37,13 @@ endif
|
||||
# Default target
|
||||
all: build
|
||||
|
||||
# Copy the quickshell UI into the embed dir (gitignored) so tagged builds
|
||||
# can bake it into the binary. Dev-only files are stripped; scripts/ is kept
|
||||
# minus its dev entries, since Theme.qml and BluetoothService.qml run
|
||||
# gtk.sh/qt.sh/bluez-card-profile.lua out of the resolved shell dir.
|
||||
# tar -h dereferences the DankCommon submodule symlink; go:embed rejects
|
||||
# symlinks. .qmlls.ini is excluded at copy time: it's a symlink into the
|
||||
# quickshell runtime VFS and dereferencing it fails whenever the shell isn't
|
||||
# running.
|
||||
sync-shell:
|
||||
@test -e $(SHELL_SRC)/DankCommon/Widgets/DankIcon.qml || { echo "DankCommon missing: run git submodule update --init"; exit 1; }
|
||||
@test -e $(SHELL_SRC)/DCommon/Widgets/DIcon.qml || { echo "DCommon missing: run git submodule update --init"; exit 1; }
|
||||
@rm -rf $(EMBED_DIR)
|
||||
@mkdir -p $(EMBED_DIR)
|
||||
@tar -C $(SHELL_SRC) --exclude=.qmlls.ini -chf - . | tar -C $(EMBED_DIR) -xf -
|
||||
@tar -C $(SHELL_SRC) --exclude=.qmlls.ini --exclude-from=../scripts/shell-test-excludes.txt -chf - . | tar -C $(EMBED_DIR) -xf -
|
||||
@rm -rf $(EMBED_DIR)/.git* $(EMBED_DIR)/.github
|
||||
@rm -rf $(EMBED_DIR)/tests
|
||||
@find $(EMBED_DIR) -type d \( -name .claude -o -name .vscode \) -prune -exec rm -rf {} +
|
||||
@rm -f $(EMBED_DIR)/AGENTS.md $(EMBED_DIR)/qmlformat-all.sh
|
||||
@rm -f $(EMBED_DIR)/scripts/i18nsync.py $(EMBED_DIR)/scripts/build-vscode-vsix.sh $(EMBED_DIR)/scripts/qmllint-entrypoints.sh
|
||||
|
||||
+9
-9
@@ -1,6 +1,6 @@
|
||||
# DMS Backend & CLI
|
||||
|
||||
Go-based backend for DankMaterialShell providing system integration, IPC, and installation tools.
|
||||
Go-based backend for Dux Shell providing system integration, IPC, and installation tools.
|
||||
|
||||
**See [root README](../README.md) for project overview and installation.**
|
||||
|
||||
@@ -78,7 +78,7 @@ Custom IPC via unix socket (JSON API) for shell communication.
|
||||
- `dms brightness [list|set]` - Control display/monitor brightness
|
||||
- `dms color pick` - Native color picker (see below)
|
||||
- `dms update` - Update DMS and dependencies (disabled in distro packages)
|
||||
- `dms greeter` - Deprecated; forwards to the standalone [dms-greeter](https://github.com/AvengeMedia/dank-greeter) binary
|
||||
- `dms greeter` - Deprecated; forwards to the standalone [dms-greeter](https://github.com/duxlabs-dev/dux-greeter) binary
|
||||
|
||||
### Color Picker
|
||||
|
||||
@@ -132,10 +132,10 @@ This runs gofmt, golangci-lint, tests, and builds before each commit when `core/
|
||||
|
||||
**Regenerating Wayland Protocol Bindings:**
|
||||
|
||||
Protocol XML files live in `internal/proto/xml/`. Bindings are generated with the [dankgo](https://github.com/AvengeMedia/dankgo) `go-wayland-scanner`:
|
||||
Protocol XML files live in `internal/proto/xml/`. Bindings are generated with the [dankgo](https://github.com/duxlabs-dev/dux-go) `go-wayland-scanner`:
|
||||
|
||||
```bash
|
||||
go run github.com/AvengeMedia/dankgo/cmd/go-wayland-scanner@latest \
|
||||
go run github.com/duxlabs-dev/dux-go/cmd/go-wayland-scanner@latest \
|
||||
-i internal/proto/xml/wlr-gamma-control-unstable-v1.xml \
|
||||
-pkg wlr_gamma_control -o internal/proto/wlr_gamma_control/gamma_control.go
|
||||
```
|
||||
@@ -152,7 +152,7 @@ go run github.com/AvengeMedia/dankgo/cmd/go-wayland-scanner@latest \
|
||||
**Interactive (TUI):**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://install.danklinux.com | sh
|
||||
curl -fsSL https://install.duxlabs.dev | sh
|
||||
```
|
||||
|
||||
**Headless (unattended):**
|
||||
@@ -160,9 +160,9 @@ curl -fsSL https://install.danklinux.com | sh
|
||||
Headless mode requires cached credentials or a passwordless rule for your privilege escalation tool (sudo, doas, or run0). With sudo, run `sudo -v` first:
|
||||
|
||||
```bash
|
||||
sudo -v && curl -fsSL https://install.danklinux.com | sh -s -- -c niri -t ghostty -y
|
||||
sudo -v && curl -fsSL https://install.danklinux.com | sh -s -- -c hyprland -t kitty --dms-greeter -y
|
||||
sudo -v && curl -fsSL https://install.danklinux.com | sh -s -- -c niri -t ghostty --git-deps niri,quickshell --all-features -y
|
||||
sudo -v && curl -fsSL https://install.duxlabs.dev | sh -s -- -c niri -t ghostty -y
|
||||
sudo -v && curl -fsSL https://install.duxlabs.dev | sh -s -- -c hyprland -t kitty --dms-greeter -y
|
||||
sudo -v && curl -fsSL https://install.duxlabs.dev | sh -s -- -c niri -t ghostty --git-deps niri,quickshell --all-features -y
|
||||
```
|
||||
|
||||
| Flag | Short | Description |
|
||||
@@ -225,6 +225,6 @@ Most packages available in standard repos. Minimal building required.
|
||||
Uses Portage with GURU overlay. Automatically configures USE flags. Variable success depending on system configuration.
|
||||
|
||||
**Void Linux**
|
||||
Uses XBPS with the DMS and DankLinux self-hosted repositories.
|
||||
Uses XBPS with the DMS and Dux Labs self-hosted repositories.
|
||||
|
||||
See installer output for distribution-specific details during installation.
|
||||
|
||||
@@ -5,10 +5,10 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/headless"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/tui"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/headless"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/log"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/tui"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -33,8 +33,8 @@ var (
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "dankinstall",
|
||||
Short: "Install DankMaterialShell and its dependencies",
|
||||
Long: `dankinstall sets up DankMaterialShell with your chosen compositor and terminal.
|
||||
Short: "Install Dux Shell and its dependencies",
|
||||
Long: `dankinstall sets up Dux Shell with your chosen compositor and terminal.
|
||||
|
||||
Without flags, it launches an interactive TUI. Providing either --compositor
|
||||
or --term activates headless (unattended) mode, which requires both flags.
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
sharedpam "github.com/AvengeMedia/DankMaterialShell/core/internal/pam"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/log"
|
||||
sharedpam "github.com/duxlabs-dev/dux-shell/core/internal/pam"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/backup"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/backup"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -17,7 +17,7 @@ var backupCmd = &cobra.Command{
|
||||
|
||||
var backupCreateCmd = &cobra.Command{
|
||||
Use: "create",
|
||||
Short: "Create a tar.gz backup of ~/.config/DankMaterialShell (settings, plugins, themes)",
|
||||
Short: "Create a tar.gz backup of ~/.config/dms (settings, plugins, themes)",
|
||||
Args: cobra.NoArgs,
|
||||
Run: runBackupCreate,
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/blur"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/blur"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/brightness"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/log"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/server/brightness"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
|
||||
@@ -8,11 +8,11 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/highlight"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/highlight/lexers"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/highlight/styles"
|
||||
"github.com/alecthomas/chroma/v2"
|
||||
"github.com/alecthomas/chroma/v2/formatters/html"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/highlight"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/highlight/lexers"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/highlight/styles"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
|
||||
@@ -28,10 +28,10 @@ import (
|
||||
_ "golang.org/x/image/tiff"
|
||||
_ "golang.org/x/image/webp"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||
"github.com/AvengeMedia/dankgo/wlclipboard"
|
||||
"github.com/duxlabs-dev/dux-go/ipc"
|
||||
"github.com/duxlabs-dev/dux-go/wlclipboard"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/clipboard"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/log"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -430,7 +430,7 @@ func runCommand(args []string, stdin []byte) {
|
||||
}
|
||||
|
||||
func runClipHistory(cmd *cobra.Command, args []string) {
|
||||
req := models.Request{
|
||||
req := ipc.Request{
|
||||
ID: 1,
|
||||
Method: "clipboard.getHistory",
|
||||
}
|
||||
@@ -501,7 +501,7 @@ func runClipGet(cmd *cobra.Command, args []string) {
|
||||
}
|
||||
|
||||
if clipGetCopy {
|
||||
req := models.Request{
|
||||
req := ipc.Request{
|
||||
ID: 1,
|
||||
Method: "clipboard.copyEntry",
|
||||
Params: map[string]any{"id": id},
|
||||
@@ -519,7 +519,7 @@ func runClipGet(cmd *cobra.Command, args []string) {
|
||||
return
|
||||
}
|
||||
|
||||
req := models.Request{
|
||||
req := ipc.Request{
|
||||
ID: 1,
|
||||
Method: "clipboard.getEntry",
|
||||
Params: map[string]any{
|
||||
@@ -565,7 +565,7 @@ func runClipDelete(cmd *cobra.Command, args []string) {
|
||||
log.Fatalf("Invalid ID: %v", err)
|
||||
}
|
||||
|
||||
req := models.Request{
|
||||
req := ipc.Request{
|
||||
ID: 1,
|
||||
Method: "clipboard.deleteEntry",
|
||||
Params: map[string]any{
|
||||
@@ -586,7 +586,7 @@ func runClipDelete(cmd *cobra.Command, args []string) {
|
||||
}
|
||||
|
||||
func runClipClear(cmd *cobra.Command, args []string) {
|
||||
req := models.Request{
|
||||
req := ipc.Request{
|
||||
ID: 1,
|
||||
Method: "clipboard.clearHistory",
|
||||
}
|
||||
@@ -621,7 +621,7 @@ func runClipSearch(cmd *cobra.Command, args []string) {
|
||||
params["isImage"] = false
|
||||
}
|
||||
|
||||
req := models.Request{
|
||||
req := ipc.Request{
|
||||
ID: 1,
|
||||
Method: "clipboard.search",
|
||||
Params: params,
|
||||
@@ -688,7 +688,7 @@ func runClipSearch(cmd *cobra.Command, args []string) {
|
||||
}
|
||||
|
||||
func runClipConfigGet(cmd *cobra.Command, args []string) {
|
||||
req := models.Request{
|
||||
req := ipc.Request{
|
||||
ID: 1,
|
||||
Method: "clipboard.getConfig",
|
||||
}
|
||||
@@ -742,7 +742,7 @@ func runClipConfigSet(cmd *cobra.Command, args []string) {
|
||||
return
|
||||
}
|
||||
|
||||
req := models.Request{
|
||||
req := ipc.Request{
|
||||
ID: 1,
|
||||
Method: "clipboard.setConfig",
|
||||
Params: params,
|
||||
@@ -761,7 +761,7 @@ func runClipConfigSet(cmd *cobra.Command, args []string) {
|
||||
}
|
||||
|
||||
func runClipExport(cmd *cobra.Command, args []string) {
|
||||
req := models.Request{
|
||||
req := ipc.Request{
|
||||
ID: 1,
|
||||
Method: "clipboard.getHistory",
|
||||
}
|
||||
@@ -1004,19 +1004,25 @@ func downloadToTempFile(rawURL string) (string, error) {
|
||||
return filePath, nil
|
||||
}
|
||||
|
||||
// copyFileToClipboard copies through the server so the entry lands in
|
||||
// history, and serves the same offers itself when the server is down or
|
||||
// refuses (e.g. file over the history size limit).
|
||||
func copyFileToClipboard(filePath string) error {
|
||||
req := models.Request{
|
||||
if abs, err := filepath.Abs(filePath); err == nil {
|
||||
filePath = abs
|
||||
}
|
||||
resp, ok := tryServerRequest(ipc.Request{
|
||||
ID: 1,
|
||||
Method: "clipboard.copyFile",
|
||||
Params: map[string]any{"filePath": filePath},
|
||||
})
|
||||
if ok && resp.Error == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
resp, err := sendServerRequest(req)
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("server request: %w", err)
|
||||
return fmt.Errorf("read file: %w", err)
|
||||
}
|
||||
if resp.Error != "" {
|
||||
return fmt.Errorf("server error: %s", resp.Error)
|
||||
}
|
||||
return nil
|
||||
return clipboard.CopyMulti(clipboard.FileOffers(filePath, filePath, data), false, false)
|
||||
}
|
||||
|
||||
@@ -5,16 +5,18 @@ import (
|
||||
"os"
|
||||
"runtime/debug"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/colorpicker"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/clipboard"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/colorpicker"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/site"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
colorOutputFmt string
|
||||
colorAutocopy bool
|
||||
colorNotify bool
|
||||
colorLowercase bool
|
||||
colorOutputFmt string
|
||||
colorAutocopy bool
|
||||
colorNotify bool
|
||||
colorLowercase bool
|
||||
colorAllowMulti bool
|
||||
)
|
||||
|
||||
var colorCmd = &cobra.Command{
|
||||
@@ -25,7 +27,7 @@ var colorCmd = &cobra.Command{
|
||||
This is the screen eyedropper CLI. To open the in-shell color modal, use:
|
||||
dms ipc call color-picker toggle
|
||||
|
||||
See: https://danklinux.com/docs/dankmaterialshell/keybinds-ipc`,
|
||||
See: ` + site.Docs + "/dms/keybinds-ipc",
|
||||
}
|
||||
|
||||
var colorPickCmd = &cobra.Command{
|
||||
@@ -54,7 +56,8 @@ Examples:
|
||||
dms color pick --rgb # Output as RGB
|
||||
dms color pick --json # Output all formats as JSON
|
||||
dms color pick --hex -l # Output hex in lowercase
|
||||
dms color pick -a # Auto-copy result to clipboard`,
|
||||
dms color pick -a # Auto-copy result to clipboard
|
||||
dms color pick --allow-multiple # Skip the one-picker-at-a-time guard`,
|
||||
Run: runColorPick,
|
||||
}
|
||||
|
||||
@@ -69,6 +72,7 @@ func init() {
|
||||
colorPickCmd.Flags().StringVarP(&colorOutputFmt, "output-format", "o", "", "Custom output format template")
|
||||
colorPickCmd.Flags().BoolVarP(&colorAutocopy, "autocopy", "a", false, "Copy result to clipboard")
|
||||
colorPickCmd.Flags().BoolVarP(&colorLowercase, "lowercase", "l", false, "Output hex in lowercase")
|
||||
colorPickCmd.Flags().BoolVar(&colorAllowMulti, "allow-multiple", false, "Open a picker even when another selection overlay is already open")
|
||||
|
||||
colorPickCmd.MarkFlagsMutuallyExclusive("hex", "rgb", "hsl", "hsv", "cmyk", "json")
|
||||
|
||||
@@ -90,11 +94,12 @@ func runColorPick(cmd *cobra.Command, args []string) {
|
||||
}
|
||||
|
||||
config := colorpicker.Config{
|
||||
Format: format,
|
||||
CustomFormat: colorOutputFmt,
|
||||
Lowercase: colorLowercase,
|
||||
Autocopy: colorAutocopy,
|
||||
Notify: colorNotify,
|
||||
Format: format,
|
||||
CustomFormat: colorOutputFmt,
|
||||
Lowercase: colorLowercase,
|
||||
Autocopy: colorAutocopy,
|
||||
Notify: colorNotify,
|
||||
AllowMultiple: colorAllowMulti,
|
||||
}
|
||||
|
||||
// One-shot process holding a few screen-sized buffers: skip GC cycles while it runs.
|
||||
|
||||
@@ -6,10 +6,12 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/plugins"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/shellembed"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/log"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/plugins"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/server"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/server/sysupdate"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/shellembed"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/site"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -27,7 +29,7 @@ var ipcCmd = &cobra.Command{
|
||||
dms ipc call <target> <function> [args...] invoke a command
|
||||
dms ipc list list all targets and functions
|
||||
|
||||
Full reference: https://danklinux.com/docs/dankmaterialshell/keybinds-ipc`,
|
||||
Full reference: ` + site.Docs + "/dms/keybinds-ipc",
|
||||
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
return getShellIPCCompletions(args, toComplete), cobra.ShellCompDirectiveNoFileComp
|
||||
},
|
||||
@@ -217,21 +219,17 @@ func runVersion(cmd *cobra.Command, args []string) {
|
||||
// Git builds: dms (git) v0.6.2-XXXX
|
||||
// Stable releases: dms v0.6.2
|
||||
func formatVersion(version string) string {
|
||||
// Arch/Debian/Ubuntu/OpenSUSE git format: 0.6.2+git2264.c5c5ce84
|
||||
re := regexp.MustCompile(`^([\d.]+)\+git(\d+)\.`)
|
||||
if matches := re.FindStringSubmatch(version); matches != nil {
|
||||
return fmt.Sprintf("dms (git) v%s-%s", matches[1], matches[2])
|
||||
}
|
||||
|
||||
// Fedora COPR git format: 0.0.git.2267.d430cae9
|
||||
re = regexp.MustCompile(`^[\d.]+\.git\.(\d+)\.`)
|
||||
if matches := re.FindStringSubmatch(version); matches != nil {
|
||||
baseVersion := getBaseVersion()
|
||||
return fmt.Sprintf("dms (git) v%s-%s", baseVersion, matches[1])
|
||||
if n := sysupdate.GitBuildCount(version); n > 0 {
|
||||
// Fedora COPR (0.0.git.2267.d430cae9) carries no real base version.
|
||||
base, _, ok := strings.Cut(version, "+git")
|
||||
if !ok {
|
||||
base = getBaseVersion()
|
||||
}
|
||||
return fmt.Sprintf("dms (git) v%s-%d", strings.TrimPrefix(base, "v"), n)
|
||||
}
|
||||
|
||||
// Stable release format: 0.6.2
|
||||
re = regexp.MustCompile(`^([\d.]+)$`)
|
||||
re := regexp.MustCompile(`^([\d.]+)$`)
|
||||
if matches := re.FindStringSubmatch(version); matches != nil {
|
||||
return fmt.Sprintf("dms v%s", matches[1])
|
||||
}
|
||||
@@ -773,6 +771,7 @@ func getCommonCommands() []*cobra.Command {
|
||||
registryCmd,
|
||||
dank16Cmd,
|
||||
brightnessCmd,
|
||||
iccCmd,
|
||||
dpmsCmd,
|
||||
keybindsCmd,
|
||||
greeterCmd,
|
||||
@@ -793,6 +792,9 @@ func getCommonCommands() []*cobra.Command {
|
||||
blurCmd,
|
||||
trashCmd,
|
||||
systemCmd,
|
||||
systemdCmd,
|
||||
switchUserCmd,
|
||||
switchCmd,
|
||||
dmenuCmd,
|
||||
}...)
|
||||
}
|
||||
|
||||
@@ -7,9 +7,10 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/luaconfig"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/log"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/luaconfig"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/mangoconf"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/utils"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -35,6 +36,7 @@ var resolveIncludeCmd = &cobra.Command{
|
||||
"layout.lua",
|
||||
"outputs.lua",
|
||||
"cursor.lua",
|
||||
"input.lua",
|
||||
"windowrules.lua",
|
||||
"cursor.kdl",
|
||||
"layout.kdl",
|
||||
@@ -42,6 +44,7 @@ var resolveIncludeCmd = &cobra.Command{
|
||||
"binds.kdl",
|
||||
"input.kdl",
|
||||
"cursor.conf",
|
||||
"input.conf",
|
||||
"layout.conf",
|
||||
"outputs.conf",
|
||||
"binds.conf",
|
||||
@@ -52,8 +55,32 @@ var resolveIncludeCmd = &cobra.Command{
|
||||
Run: runResolveInclude,
|
||||
}
|
||||
|
||||
var mangoMigrateMain bool
|
||||
|
||||
var mangoMigrateCmd = &cobra.Command{
|
||||
Use: "mango-migrate",
|
||||
Short: "Respell Mango config keys for the installed Mango",
|
||||
Long: "Detects whether the installed Mango uses legacy or snake_case config keys, rewrites the DMS fragments in mango/dms to match, and prints the dialect on the last line. --main also rewrites config.conf after a backup.",
|
||||
Args: cobra.NoArgs,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
dialect := mangoconf.Detect()
|
||||
changed, err := dialect.Migrate(mangoconf.Dir(), mangoMigrateMain)
|
||||
for _, path := range changed {
|
||||
fmt.Fprintf(os.Stderr, "migrated %s\n", path)
|
||||
}
|
||||
// Printed before any error: the shell needs the dialect even when a fragment failed.
|
||||
fmt.Println(dialect)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
configCmd.AddCommand(resolveIncludeCmd)
|
||||
mangoMigrateCmd.Flags().BoolVar(&mangoMigrateMain, "main", false, "also rewrite config.conf (a backup is kept)")
|
||||
configCmd.AddCommand(mangoMigrateCmd)
|
||||
}
|
||||
|
||||
type IncludeResult struct {
|
||||
@@ -104,32 +131,21 @@ func checkHyprlandInclude(filename string) (IncludeResult, error) {
|
||||
return result, err
|
||||
}
|
||||
|
||||
targetRel := filepath.ToSlash(filepath.Join("dms", filename))
|
||||
|
||||
mainLua := filepath.Join(configDir, "hyprland.lua")
|
||||
if _, err := os.Stat(mainLua); err == nil {
|
||||
result.ConfigFormat = "lua"
|
||||
result.ReadOnly = false
|
||||
processedLua := make(map[string]bool)
|
||||
if luaconfig.RequiresTarget(mainLua, targetAbs, processedLua) {
|
||||
result.Included = true
|
||||
return result, nil
|
||||
}
|
||||
result.Included = luaconfig.RequiresTarget(mainLua, targetAbs, make(map[string]bool))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// 0.55/0.56 installs still on hyprland.conf: read-only until migrated.
|
||||
mainConf := filepath.Join(configDir, "hyprland.conf")
|
||||
if _, err := os.Stat(mainConf); err == nil {
|
||||
if result.ConfigFormat == "" {
|
||||
result.ConfigFormat = "hyprlang"
|
||||
result.ReadOnly = true
|
||||
}
|
||||
processed := make(map[string]bool)
|
||||
if hyprlandFindIncludeHyprlang(mainConf, targetRel, processed) {
|
||||
result.Included = true
|
||||
return result, nil
|
||||
}
|
||||
if _, err := os.Stat(mainConf); err != nil {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
result.ConfigFormat = "hyprlang"
|
||||
result.ReadOnly = true
|
||||
result.Included = hyprlandFindIncludeHyprlang(mainConf, targetAbs, make(map[string]bool))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -150,14 +166,8 @@ func hyprlandFindIncludeHyprlang(filePath, target string, processed map[string]b
|
||||
}
|
||||
|
||||
baseDir := filepath.Dir(absPath)
|
||||
lines := strings.SplitSeq(string(data), "\n")
|
||||
|
||||
for line := range lines {
|
||||
for line := range strings.SplitSeq(string(data), "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "#") || trimmed == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(trimmed, "source") {
|
||||
continue
|
||||
}
|
||||
@@ -167,22 +177,12 @@ func hyprlandFindIncludeHyprlang(filePath, target string, processed map[string]b
|
||||
continue
|
||||
}
|
||||
|
||||
sourcePath := strings.TrimSpace(parts[1])
|
||||
if matchesTarget(sourcePath, target) {
|
||||
return true
|
||||
}
|
||||
|
||||
fullPath := sourcePath
|
||||
if !filepath.IsAbs(sourcePath) {
|
||||
fullPath = filepath.Join(baseDir, sourcePath)
|
||||
}
|
||||
|
||||
expanded, err := utils.ExpandPath(fullPath)
|
||||
fullPath, err := resolveSourcePath(baseDir, strings.TrimSpace(parts[1]))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if hyprlandFindIncludeHyprlang(expanded, target, processed) {
|
||||
if fullPath == target || hyprlandFindIncludeHyprlang(fullPath, target, processed) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -205,8 +205,13 @@ func checkNiriInclude(filename string) (IncludeResult, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
targetAbs, err := filepath.Abs(targetPath)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
processed := make(map[string]bool)
|
||||
result.Included = niriFindInclude(mainConfig, "dms/"+filename, processed)
|
||||
result.Included = niriFindInclude(mainConfig, targetAbs, processed)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -248,17 +253,12 @@ func niriFindInclude(filePath, target string, processed map[string]bool) bool {
|
||||
continue
|
||||
}
|
||||
|
||||
includePath := trimmed[startQuote+1 : endQuote]
|
||||
if matchesTarget(includePath, target) {
|
||||
return true
|
||||
fullPath, err := resolveSourcePath(baseDir, trimmed[startQuote+1:endQuote])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
fullPath := includePath
|
||||
if !filepath.IsAbs(includePath) {
|
||||
fullPath = filepath.Join(baseDir, includePath)
|
||||
}
|
||||
|
||||
if niriFindInclude(fullPath, target, processed) {
|
||||
if fullPath == target || niriFindInclude(fullPath, target, processed) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -267,7 +267,7 @@ func niriFindInclude(filePath, target string, processed map[string]bool) bool {
|
||||
}
|
||||
|
||||
func checkMangoWCInclude(filename string) (IncludeResult, error) {
|
||||
configDir := filepath.Join(utils.XDGConfigHome(), "mango")
|
||||
configDir := mangoconf.Dir()
|
||||
|
||||
targetPath := filepath.Join(configDir, "dms", filename)
|
||||
result := IncludeResult{}
|
||||
@@ -284,8 +284,13 @@ func checkMangoWCInclude(filename string) (IncludeResult, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
targetAbs, err := filepath.Abs(targetPath)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
processed := make(map[string]bool)
|
||||
result.Included = mangowcFindInclude(mainConfig, "dms/"+filename, processed)
|
||||
result.Included = mangowcFindInclude(mainConfig, targetAbs, processed)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -323,22 +328,12 @@ func mangowcFindInclude(filePath, target string, processed map[string]bool) bool
|
||||
continue
|
||||
}
|
||||
|
||||
sourcePath := strings.TrimSpace(parts[1])
|
||||
if matchesTarget(sourcePath, target) {
|
||||
return true
|
||||
}
|
||||
|
||||
fullPath := sourcePath
|
||||
if !filepath.IsAbs(sourcePath) {
|
||||
fullPath = filepath.Join(baseDir, sourcePath)
|
||||
}
|
||||
|
||||
expanded, err := utils.ExpandPath(fullPath)
|
||||
fullPath, err := resolveSourcePath(baseDir, strings.TrimSpace(parts[1]))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if mangowcFindInclude(expanded, target, processed) {
|
||||
if fullPath == target || mangowcFindInclude(fullPath, target, processed) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -346,8 +341,13 @@ func mangowcFindInclude(filePath, target string, processed map[string]bool) bool
|
||||
return false
|
||||
}
|
||||
|
||||
func matchesTarget(path, target string) bool {
|
||||
path = strings.TrimPrefix(path, "./")
|
||||
target = strings.TrimPrefix(target, "./")
|
||||
return path == target || strings.HasSuffix(path, "/"+target)
|
||||
func resolveSourcePath(baseDir, raw string) (string, error) {
|
||||
expanded, err := utils.ExpandPath(raw)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !filepath.IsAbs(expanded) {
|
||||
expanded = filepath.Join(baseDir, expanded)
|
||||
}
|
||||
return filepath.Abs(expanded)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func writeConfigFile(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNiriFindIncludeNestedRelativeToIncludingFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeConfigFile(t, filepath.Join(dir, "config.kdl"), "include \"dms/dms.kdl\"\n")
|
||||
writeConfigFile(t, filepath.Join(dir, "dms", "dms.kdl"), "include \"binds.kdl\"\ninclude \"./outputs.kdl\"\n")
|
||||
|
||||
for _, name := range []string{"binds.kdl", "outputs.kdl"} {
|
||||
target := filepath.Join(dir, "dms", name)
|
||||
if !niriFindInclude(filepath.Join(dir, "config.kdl"), target, map[string]bool{}) {
|
||||
t.Errorf("%s included through dms/dms.kdl not detected", name)
|
||||
}
|
||||
}
|
||||
|
||||
if niriFindInclude(filepath.Join(dir, "config.kdl"), filepath.Join(dir, "dms", "layout.kdl"), map[string]bool{}) {
|
||||
t.Error("layout.kdl reported included without an include line")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMangowcFindIncludeNestedRelativeToIncludingFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeConfigFile(t, filepath.Join(dir, "config.conf"), "source=./dms/dms.conf\n")
|
||||
writeConfigFile(t, filepath.Join(dir, "dms", "dms.conf"), "source = binds.conf\n")
|
||||
|
||||
if !mangowcFindInclude(filepath.Join(dir, "config.conf"), filepath.Join(dir, "dms", "binds.conf"), map[string]bool{}) {
|
||||
t.Error("binds.conf sourced through dms/dms.conf not detected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckHyprlandIncludeFormat(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
files map[string]string
|
||||
wantFormat string
|
||||
wantReadOnly bool
|
||||
wantIncluded bool
|
||||
}{
|
||||
{"hyprlang sourced", map[string]string{"hyprland.conf": "source = ./dms/colors.conf\n"}, "hyprlang", true, true},
|
||||
{"hyprlang not sourced", map[string]string{"hyprland.conf": "bind = SUPER, Q, killactive\n"}, "hyprlang", true, false},
|
||||
{"lua wins over leftover conf", map[string]string{"hyprland.conf": "source = ./dms/colors.conf\n", "hyprland.lua": "\n"}, "lua", false, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
xdg := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", xdg)
|
||||
for name, content := range tt.files {
|
||||
writeConfigFile(t, filepath.Join(xdg, "hypr", name), content)
|
||||
}
|
||||
got, err := checkHyprlandInclude("colors.conf")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.ConfigFormat != tt.wantFormat || got.ReadOnly != tt.wantReadOnly || got.Included != tt.wantIncluded {
|
||||
t.Fatalf("got %+v, want format %q readOnly %v included %v", got, tt.wantFormat, tt.wantReadOnly, tt.wantIncluded)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/dank16"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/dank16"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/log"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -28,9 +28,13 @@ func init() {
|
||||
dank16Cmd.Flags().Bool("wezterm", false, "Output in Wezterm terminal format")
|
||||
dank16Cmd.Flags().String("background", "", "Custom background color")
|
||||
dank16Cmd.Flags().String("contrast", "dps", "Contrast algorithm: dps (Delta Phi Star, default) or wcag")
|
||||
dank16Cmd.Flags().String("profile", "default", "Palette: default, low, medium or high")
|
||||
dank16Cmd.Flags().Bool("variants", false, "Output all variants (dark/light/default) in JSON")
|
||||
dank16Cmd.Flags().String("primary-dark", "", "Primary color for dark mode (use with --variants)")
|
||||
dank16Cmd.Flags().String("primary-light", "", "Primary color for light mode (use with --variants)")
|
||||
_ = dank16Cmd.RegisterFlagCompletionFunc("profile", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"default", "low", "medium", "high"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
_ = dank16Cmd.RegisterFlagCompletionFunc("contrast", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"dps", "wcag"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
@@ -47,6 +51,7 @@ func runDank16(cmd *cobra.Command, args []string) {
|
||||
isWezterm, _ := cmd.Flags().GetBool("wezterm")
|
||||
background, _ := cmd.Flags().GetString("background")
|
||||
contrastAlgo, _ := cmd.Flags().GetString("contrast")
|
||||
profile, _ := cmd.Flags().GetString("profile")
|
||||
useVariants, _ := cmd.Flags().GetBool("variants")
|
||||
primaryDark, _ := cmd.Flags().GetString("primary-dark")
|
||||
primaryLight, _ := cmd.Flags().GetString("primary-light")
|
||||
@@ -79,11 +84,12 @@ func runDank16(cmd *cobra.Command, args []string) {
|
||||
primaryLight = primaryColor
|
||||
}
|
||||
variantOpts := dank16.VariantOptions{
|
||||
PrimaryDark: primaryDark,
|
||||
PrimaryLight: primaryLight,
|
||||
Background: background,
|
||||
UseDPS: contrastAlgo == "dps",
|
||||
IsLightMode: isLight,
|
||||
PrimaryDark: primaryDark,
|
||||
PrimaryLight: primaryLight,
|
||||
BackgroundDark: background,
|
||||
UseDPS: contrastAlgo == "dps",
|
||||
IsLightMode: isLight,
|
||||
Profile: profile,
|
||||
}
|
||||
variantColors := dank16.GenerateVariantPalette(variantOpts)
|
||||
fmt.Print(dank16.GenerateVariantJSON(variantColors))
|
||||
@@ -102,6 +108,7 @@ func runDank16(cmd *cobra.Command, args []string) {
|
||||
IsLight: isLight,
|
||||
Background: background,
|
||||
UseDPS: contrastAlgo == "dps",
|
||||
Profile: profile,
|
||||
}
|
||||
|
||||
colors := dank16.GeneratePalette(primaryColor, opts)
|
||||
|
||||
@@ -0,0 +1,643 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/dmenuipc"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/log"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/qsipc"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
const dmenuConnectTimeout = 5 * time.Second
|
||||
|
||||
var (
|
||||
dmenuPrompt string
|
||||
dmenuLines int
|
||||
dmenuPlaceholder string
|
||||
dmenuPromptOnly bool
|
||||
dmenuSep string
|
||||
dmenuActiveSpec string
|
||||
dmenuUrgentSpec string
|
||||
dmenuOnlyMatch bool
|
||||
dmenuNoCustom bool
|
||||
dmenuSelect string
|
||||
dmenuMesg string
|
||||
dmenuInput string
|
||||
dmenuPassword bool
|
||||
dmenuMarkupRows bool
|
||||
dmenuSync bool
|
||||
dmenuNoRunIfEmpty bool
|
||||
dmenuFormat string
|
||||
dmenuMultiSelect bool
|
||||
dmenuDump bool
|
||||
dmenuKeybindSpecs []string
|
||||
dmenuPrintInfo bool
|
||||
dmenuView string
|
||||
dmenuSize string
|
||||
dmenuIcon string
|
||||
dmenuCaseInsens bool
|
||||
dmenuDisableHist bool
|
||||
)
|
||||
|
||||
var dmenuCmd = &cobra.Command{
|
||||
Use: "dmenu",
|
||||
Short: "dmenu-compatible picker backed by the DMS app launcher",
|
||||
Long: `Read newline-separated items from stdin, show them in the DMS launcher,
|
||||
and print the selected item to stdout
|
||||
|
||||
Examples:
|
||||
printf '%s\n' opt1 opt2 opt3 | dms dmenu -p "Pick one:"
|
||||
dms dmenu --prompt-only -p "Rename to:"
|
||||
|
||||
Matching is always case-insensitive; -i/--case-insensitive is accepted for
|
||||
dmenu script compatibility but has no effect.
|
||||
|
||||
Most, but not all, rofi flags are implemented.
|
||||
Long-form flags also accept rofi's single-dash form
|
||||
(e.g. -only-match as well as --only-match).
|
||||
|
||||
Exit codes: 0 on accept, 1 if the user cancelled (or stdin was empty),
|
||||
2 if the shell was unreachable.`,
|
||||
Run: runDmenu,
|
||||
}
|
||||
|
||||
func init() {
|
||||
dmenuCmd.Flags().BoolVarP(&dmenuCaseInsens, "case-insensitive", "i", false, "No effect: matching is always case-insensitive. Accepted for dmenu/rofi script compatibility")
|
||||
dmenuCmd.Flags().StringVarP(&dmenuPrompt, "prompt", "p", "dmenu", "Prompt label")
|
||||
dmenuCmd.Flags().IntVarP(&dmenuLines, "lines", "l", 0, "Max visible result rows (0 = shell default)")
|
||||
dmenuCmd.Flags().StringVar(&dmenuPlaceholder, "placeholder", "", "Input placeholder text")
|
||||
dmenuCmd.Flags().BoolVar(&dmenuPromptOnly, "prompt-only", false, "No piped item list — pure free-text prompt")
|
||||
|
||||
dmenuCmd.Flags().StringVar(&dmenuSep, "sep", "\n", "Input item separator")
|
||||
dmenuCmd.Flags().StringVarP(&dmenuActiveSpec, "active", "a", "", "Mark rows active by index (e.g. \"1,3,7:11,-3:\")")
|
||||
dmenuCmd.Flags().StringVarP(&dmenuUrgentSpec, "urgent", "u", "", "Mark rows urgent by index (same spec syntax as --active)")
|
||||
dmenuCmd.Flags().BoolVar(&dmenuOnlyMatch, "only-match", false, "Reject free text; only a listed item may be returned; also blocks Escape/click-outside cancel, forcing a real pick")
|
||||
dmenuCmd.Flags().BoolVar(&dmenuNoCustom, "no-custom", false, "Reject free text; only a listed item may be returned (unlike --only-match, Escape/cancel still works normally)")
|
||||
dmenuCmd.Flags().StringVar(&dmenuSelect, "select", "", "Pre-select the first row matching this string (or, with --dump, filter to matching rows)")
|
||||
dmenuCmd.Flags().StringVar(&dmenuMesg, "mesg", "", "Extra message line shown alongside the list")
|
||||
dmenuCmd.Flags().StringVar(&dmenuInput, "input", "", "Read items from this file instead of stdin")
|
||||
dmenuCmd.Flags().BoolVar(&dmenuPassword, "password", false, "Mask typed input (cosmetic only, not a security boundary)")
|
||||
dmenuCmd.Flags().BoolVar(&dmenuMarkupRows, "markup-rows", false, "Render each row's text as rich markup")
|
||||
dmenuCmd.Flags().BoolVar(&dmenuSync, "sync", false, "Read all input to EOF before showing the picker (default: stream as items arrive)")
|
||||
dmenuCmd.Flags().BoolVar(&dmenuNoRunIfEmpty, "no-run-if-empty", false, "If stdin is empty, exit 1 immediately with no UI shown")
|
||||
dmenuCmd.Flags().StringVar(&dmenuFormat, "format", "s", "What to print on accept: s=text (default), i=0-based index, d=1-based index, q=shell-quoted, p=markup-stripped, f=typed filter text, F=quoted filter text")
|
||||
dmenuCmd.Flags().BoolVar(&dmenuMultiSelect, "multi-select", false, "Allow selecting multiple rows; each is printed on its own line on accept")
|
||||
dmenuCmd.Flags().BoolVar(&dmenuDump, "dump", false, "Apply --select against the item list and print the result immediately; no UI is ever shown")
|
||||
dmenuCmd.Flags().StringArrayVar(&dmenuKeybindSpecs, "keybind", nil, "Bind a custom accept key (repeatable): N=keyspec, e.g. --keybind 1=ctrl+e (N is 1-19); exits 9+N (10-28) instead of 0 when accepted that way")
|
||||
dmenuCmd.Flags().BoolVar(&dmenuPrintInfo, "print-info", false, "Also print an accepted row's info metadata (the per-row \\0info\\x1f... field, see the per-row metadata protocol) on a second line")
|
||||
dmenuCmd.Flags().BoolVar(&dmenuDisableHist, "disable-history", false, "Don't add this session's search query to the launcher's persistent history (default: recorded, like other launcher modes)")
|
||||
|
||||
dmenuCmd.Flags().StringVar(&dmenuView, "view", "", "Override the result view mode for this invocation: list, grid, or tile (default: list)")
|
||||
dmenuCmd.Flags().StringVar(&dmenuSize, "size", "", "Override the popup size preset for this invocation: 1-4, matching the Appearance settings size buttons (default: the shell's own launcher size setting)")
|
||||
dmenuCmd.Flags().StringVar(&dmenuIcon, "icon", "", "Show a Material Symbols icon next to the prompt badge (default: no icon)")
|
||||
}
|
||||
|
||||
var dmenuLongFlagToken = regexp.MustCompile(`^-([a-zA-Z][a-zA-Z0-9-]+)(=.*)?$`)
|
||||
|
||||
func dmenuAliasableLongFlags() map[string]bool {
|
||||
set := map[string]bool{}
|
||||
dmenuCmd.Flags().VisitAll(func(f *pflag.Flag) {
|
||||
if len(f.Name) > 1 {
|
||||
set[f.Name] = true
|
||||
}
|
||||
})
|
||||
return set
|
||||
}
|
||||
|
||||
func normalizeDmenuArgv(args []string) []string {
|
||||
if len(args) < 2 || args[1] != "dmenu" {
|
||||
return args
|
||||
}
|
||||
aliasable := dmenuAliasableLongFlags()
|
||||
out := make([]string, len(args))
|
||||
copy(out, args)
|
||||
for i := 2; i < len(out); i++ {
|
||||
tok := out[i]
|
||||
if tok == "--" {
|
||||
break
|
||||
}
|
||||
m := dmenuLongFlagToken.FindStringSubmatch(tok)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
name, valueSuffix := m[1], m[2]
|
||||
if aliasable[name] {
|
||||
out[i] = "--" + name + valueSuffix
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func dmenuSplitFunc(sep string) bufio.SplitFunc {
|
||||
if sep == "\n" {
|
||||
return bufio.ScanLines
|
||||
}
|
||||
sepBytes := []byte(sep)
|
||||
return func(data []byte, atEOF bool) (advance int, token []byte, err error) {
|
||||
if atEOF && len(data) == 0 {
|
||||
return 0, nil, nil
|
||||
}
|
||||
if i := bytes.Index(data, sepBytes); i >= 0 {
|
||||
return i + len(sepBytes), data[0:i], nil
|
||||
}
|
||||
if atEOF {
|
||||
return len(data), data, nil
|
||||
}
|
||||
return 0, nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
func parseIndexSpec(spec string) ([]dmenuipc.Range, error) {
|
||||
spec = strings.TrimSpace(spec)
|
||||
if spec == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var ranges []dmenuipc.Range
|
||||
for _, tok := range strings.Split(spec, ",") {
|
||||
tok = strings.TrimSpace(tok)
|
||||
if tok == "" {
|
||||
continue
|
||||
}
|
||||
if idx := strings.Index(tok, ":"); idx >= 0 {
|
||||
startStr, endStr := strings.TrimSpace(tok[:idx]), strings.TrimSpace(tok[idx+1:])
|
||||
start, end := 0, -1
|
||||
if startStr != "" {
|
||||
n, err := strconv.Atoi(startStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid range start %q in spec %q", startStr, spec)
|
||||
}
|
||||
start = n
|
||||
}
|
||||
if endStr != "" {
|
||||
n, err := strconv.Atoi(endStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid range end %q in spec %q", endStr, spec)
|
||||
}
|
||||
end = n
|
||||
}
|
||||
ranges = append(ranges, dmenuipc.Range{Start: start, End: end})
|
||||
continue
|
||||
}
|
||||
n, err := strconv.Atoi(tok)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid index %q in spec %q", tok, spec)
|
||||
}
|
||||
ranges = append(ranges, dmenuipc.Range{Start: n, End: n + 1})
|
||||
}
|
||||
return ranges, nil
|
||||
}
|
||||
|
||||
func parseDmenuKeybinds(specs []string) ([]dmenuipc.Keybind, error) {
|
||||
var out []dmenuipc.Keybind
|
||||
seen := map[int]bool{}
|
||||
for _, s := range specs {
|
||||
eq := strings.IndexByte(s, '=')
|
||||
if eq <= 0 {
|
||||
return nil, fmt.Errorf("invalid --keybind %q (expected N=keyspec, e.g. 1=ctrl+e)", s)
|
||||
}
|
||||
n, err := strconv.Atoi(strings.TrimSpace(s[:eq]))
|
||||
if err != nil || n < 1 || n > 19 {
|
||||
return nil, fmt.Errorf("invalid --keybind index in %q (expected 1-19)", s)
|
||||
}
|
||||
key := strings.TrimSpace(s[eq+1:])
|
||||
if key == "" {
|
||||
return nil, fmt.Errorf("invalid --keybind %q: empty key spec", s)
|
||||
}
|
||||
if seen[n] {
|
||||
return nil, fmt.Errorf("duplicate --keybind index %d", n)
|
||||
}
|
||||
seen[n] = true
|
||||
out = append(out, dmenuipc.Keybind{N: n, Key: key})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func dmenuParseBool(val string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(val)) {
|
||||
case "", "0", "false", "no":
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func parseDmenuRow(line string) (text string, meta *dmenuipc.ItemMeta) {
|
||||
idx := strings.IndexByte(line, 0)
|
||||
if idx < 0 {
|
||||
return line, nil
|
||||
}
|
||||
text = line[:idx]
|
||||
fields := strings.Split(line[idx+1:], "\x1f")
|
||||
m := dmenuipc.ItemMeta{}
|
||||
any := false
|
||||
for i := 0; i+1 < len(fields); i += 2 {
|
||||
key, val := fields[i], fields[i+1]
|
||||
switch key {
|
||||
case "icon":
|
||||
m.Icon = val
|
||||
case "display":
|
||||
m.Display = val
|
||||
case "meta":
|
||||
m.Meta = val
|
||||
case "info":
|
||||
m.Info = val
|
||||
case "nonselectable":
|
||||
m.NonSelectable = dmenuParseBool(val)
|
||||
case "urgent":
|
||||
m.Urgent = dmenuParseBool(val)
|
||||
case "active":
|
||||
m.Active = dmenuParseBool(val)
|
||||
case "permanent":
|
||||
m.Permanent = dmenuParseBool(val)
|
||||
case "header":
|
||||
m.Header = dmenuParseBool(val)
|
||||
default:
|
||||
continue
|
||||
}
|
||||
any = true
|
||||
}
|
||||
if !any {
|
||||
return text, nil
|
||||
}
|
||||
return text, &m
|
||||
}
|
||||
|
||||
func validateDmenuFormat(format string) error {
|
||||
switch format {
|
||||
case "", "s", "i", "d", "q", "p", "f", "F":
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("invalid --format %q (expected one of s,i,d,q,p,f,F)", format)
|
||||
}
|
||||
}
|
||||
|
||||
func validateDmenuView(view string) error {
|
||||
switch view {
|
||||
case "", "list", "grid", "tile":
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("invalid --view %q (expected one of list,grid,tile)", view)
|
||||
}
|
||||
}
|
||||
|
||||
func dmenuSizePreset(size string) (string, error) {
|
||||
switch size {
|
||||
case "":
|
||||
return "", nil
|
||||
case "1":
|
||||
return "micro", nil
|
||||
case "2":
|
||||
return "compact", nil
|
||||
case "3":
|
||||
return "medium", nil
|
||||
case "4":
|
||||
return "large", nil
|
||||
default:
|
||||
return "", fmt.Errorf("invalid --size %q (expected one of 1,2,3,4)", size)
|
||||
}
|
||||
}
|
||||
|
||||
var dmenuMarkupTagRe = regexp.MustCompile(`<[^>]*>`)
|
||||
|
||||
func dmenuShellQuote(s string) string {
|
||||
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
||||
}
|
||||
|
||||
func dmenuFormatOutput(format string, index int, text, filterText string) (string, error) {
|
||||
switch format {
|
||||
case "", "s":
|
||||
return text, nil
|
||||
case "i":
|
||||
return strconv.Itoa(index), nil
|
||||
case "d":
|
||||
if index < 0 {
|
||||
return strconv.Itoa(index), nil
|
||||
}
|
||||
return strconv.Itoa(index + 1), nil
|
||||
case "q":
|
||||
return dmenuShellQuote(text), nil
|
||||
case "p":
|
||||
return dmenuMarkupTagRe.ReplaceAllString(text, ""), nil
|
||||
case "f":
|
||||
return filterText, nil
|
||||
case "F":
|
||||
return dmenuShellQuote(filterText), nil
|
||||
default:
|
||||
return "", fmt.Errorf("invalid --format %q (expected one of s,i,d,q,p,f,F)", format)
|
||||
}
|
||||
}
|
||||
|
||||
func dmenuOpenInput() *os.File {
|
||||
if dmenuPromptOnly {
|
||||
return nil
|
||||
}
|
||||
if dmenuInput != "" {
|
||||
f, err := os.Open(dmenuInput)
|
||||
if err != nil {
|
||||
log.Fatalf("Error opening --input file: %v", err)
|
||||
}
|
||||
return f
|
||||
}
|
||||
return os.Stdin
|
||||
}
|
||||
|
||||
type dmenuRow struct {
|
||||
Text string
|
||||
Meta *dmenuipc.ItemMeta
|
||||
}
|
||||
|
||||
func dmenuReadAll(input *os.File) []dmenuRow {
|
||||
if input == nil {
|
||||
return nil
|
||||
}
|
||||
scanner := bufio.NewScanner(input)
|
||||
scanner.Buffer(make([]byte, 64*1024), 1<<20)
|
||||
scanner.Split(dmenuSplitFunc(dmenuSep))
|
||||
var rows []dmenuRow
|
||||
for scanner.Scan() {
|
||||
text, meta := parseDmenuRow(scanner.Text())
|
||||
rows = append(rows, dmenuRow{Text: text, Meta: meta})
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
log.Fatalf("Error reading items: %v", err)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func dmenuDumpLines(rows []dmenuRow, select_, format string, printInfo bool) ([]string, error) {
|
||||
var lower string
|
||||
filtering := select_ != ""
|
||||
if filtering {
|
||||
lower = strings.ToLower(select_)
|
||||
}
|
||||
var lines []string
|
||||
for i, row := range rows {
|
||||
if row.Meta != nil && (row.Meta.NonSelectable || row.Meta.Header) {
|
||||
continue
|
||||
}
|
||||
if filtering && !strings.Contains(strings.ToLower(row.Text), lower) {
|
||||
continue
|
||||
}
|
||||
out, err := dmenuFormatOutput(format, i, row.Text, select_)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lines = append(lines, out)
|
||||
if printInfo && row.Meta != nil && row.Meta.Info != "" {
|
||||
lines = append(lines, row.Meta.Info)
|
||||
}
|
||||
}
|
||||
return lines, nil
|
||||
}
|
||||
|
||||
func runDmenuDump(rows []dmenuRow, format string) {
|
||||
lines, err := dmenuDumpLines(rows, dmenuSelect, format, dmenuPrintInfo)
|
||||
if err != nil {
|
||||
log.Fatalf("Error formatting output: %v", err)
|
||||
}
|
||||
for _, l := range lines {
|
||||
fmt.Println(l)
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
func runDmenu(cmd *cobra.Command, args []string) {
|
||||
if cmd.Flags().Changed("case-insensitive") {
|
||||
fmt.Fprintln(os.Stderr, "dms dmenu: warning: -i/--case-insensitive has no effect (matching is always case-insensitive)")
|
||||
}
|
||||
os.Exit(dmenuSession())
|
||||
}
|
||||
|
||||
func dmenuSession() int {
|
||||
format := dmenuFormat
|
||||
if format == "" {
|
||||
format = "s"
|
||||
}
|
||||
if err := validateDmenuFormat(format); err != nil {
|
||||
log.Fatalf("Error in --format: %v", err)
|
||||
}
|
||||
|
||||
if dmenuDump {
|
||||
input := dmenuOpenInput()
|
||||
if input != nil && input != os.Stdin {
|
||||
defer input.Close()
|
||||
}
|
||||
runDmenuDump(dmenuReadAll(input), format)
|
||||
return 0
|
||||
}
|
||||
|
||||
activeRanges, err := parseIndexSpec(dmenuActiveSpec)
|
||||
if err != nil {
|
||||
log.Fatalf("Error parsing --active: %v", err)
|
||||
}
|
||||
urgentRanges, err := parseIndexSpec(dmenuUrgentSpec)
|
||||
if err != nil {
|
||||
log.Fatalf("Error parsing --urgent: %v", err)
|
||||
}
|
||||
keybinds, err := parseDmenuKeybinds(dmenuKeybindSpecs)
|
||||
if err != nil {
|
||||
log.Fatalf("Error parsing --keybind: %v", err)
|
||||
}
|
||||
if err := validateDmenuView(dmenuView); err != nil {
|
||||
log.Fatalf("Error in --view: %v", err)
|
||||
}
|
||||
dmenuSizePresetName, err := dmenuSizePreset(dmenuSize)
|
||||
if err != nil {
|
||||
log.Fatalf("Error in --size: %v", err)
|
||||
}
|
||||
|
||||
input := dmenuOpenInput()
|
||||
if input != nil && input != os.Stdin {
|
||||
defer input.Close()
|
||||
}
|
||||
|
||||
var scanner *bufio.Scanner
|
||||
if input != nil {
|
||||
scanner = bufio.NewScanner(input)
|
||||
scanner.Buffer(make([]byte, 64*1024), 1<<20)
|
||||
scanner.Split(dmenuSplitFunc(dmenuSep))
|
||||
}
|
||||
|
||||
var bufferedItems []string
|
||||
streaming := scanner != nil && !dmenuSync
|
||||
if scanner != nil && !streaming {
|
||||
for scanner.Scan() {
|
||||
bufferedItems = append(bufferedItems, scanner.Text())
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
log.Errorf("Error reading items: %v", err)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
var pendingFirstItem string
|
||||
havePendingFirstItem := false
|
||||
if dmenuNoRunIfEmpty {
|
||||
if streaming {
|
||||
if !scanner.Scan() {
|
||||
if err := scanner.Err(); err != nil {
|
||||
log.Errorf("Error reading items: %v", err)
|
||||
return 1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
pendingFirstItem = scanner.Text()
|
||||
havePendingFirstItem = true
|
||||
} else if len(bufferedItems) == 0 {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
release, err := dmenuipc.AcquireQueueSlot(func() {
|
||||
fmt.Fprintln(os.Stderr, "dms dmenu: waiting for another dmenu session to finish...")
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("Error acquiring dmenu queue slot: %v", err)
|
||||
}
|
||||
defer release()
|
||||
|
||||
sess, err := dmenuipc.Listen()
|
||||
if err != nil {
|
||||
log.Fatalf("Error creating dmenu socket: %v", err)
|
||||
}
|
||||
defer sess.Close()
|
||||
|
||||
pid, ok := shellApp.SessionPID()
|
||||
if !ok {
|
||||
log.Errorf("DMS shell is not running")
|
||||
return 1
|
||||
}
|
||||
|
||||
mode := "dmenu:" + sess.Path()
|
||||
if _, _, err := qsipc.Call(qsipc.SocketPathForPID(pid), "launcher", "openWith", []string{mode}); err != nil {
|
||||
log.Errorf("Error opening launcher: %v", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
conn, err := sess.Accept(dmenuConnectTimeout)
|
||||
if err != nil {
|
||||
log.Debugf("dmenu: shell never connected: %v", err)
|
||||
return 2
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
header := dmenuipc.Header{
|
||||
Prompt: dmenuPrompt,
|
||||
Placeholder: dmenuPlaceholder,
|
||||
Lines: dmenuLines,
|
||||
PromptOnly: dmenuPromptOnly,
|
||||
Select: dmenuSelect,
|
||||
Mesg: dmenuMesg,
|
||||
Password: dmenuPassword,
|
||||
MarkupRows: dmenuMarkupRows,
|
||||
OnlyMatch: dmenuOnlyMatch,
|
||||
NoCustom: dmenuNoCustom,
|
||||
MultiSelect: dmenuMultiSelect,
|
||||
Active: activeRanges,
|
||||
Urgent: urgentRanges,
|
||||
Keybinds: keybinds,
|
||||
View: dmenuView,
|
||||
Size: dmenuSizePresetName,
|
||||
Icon: dmenuIcon,
|
||||
DisableHistory: dmenuDisableHist,
|
||||
}
|
||||
if err := conn.SendHeader(header); err != nil {
|
||||
log.Debugf("dmenu: failed to send header: %v", err)
|
||||
return 2
|
||||
}
|
||||
|
||||
var sendErr error
|
||||
sendItem := func(line string) {
|
||||
text, meta := parseDmenuRow(line)
|
||||
if err := conn.SendItem(text, meta); err != nil {
|
||||
log.Debugf("dmenu: failed to send item: %v", err)
|
||||
sendErr = err
|
||||
}
|
||||
}
|
||||
|
||||
if streaming {
|
||||
if havePendingFirstItem && sendErr == nil {
|
||||
sendItem(pendingFirstItem)
|
||||
}
|
||||
for sendErr == nil && scanner.Scan() {
|
||||
sendItem(scanner.Text())
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
log.Debugf("dmenu: error reading items mid-stream: %v", err)
|
||||
return 2
|
||||
}
|
||||
} else {
|
||||
for _, item := range bufferedItems {
|
||||
if sendErr != nil {
|
||||
break
|
||||
}
|
||||
sendItem(item)
|
||||
}
|
||||
}
|
||||
if sendErr != nil {
|
||||
return 2
|
||||
}
|
||||
if err := conn.SendEnd(); err != nil {
|
||||
log.Debugf("dmenu: failed to send end marker: %v", err)
|
||||
return 2
|
||||
}
|
||||
|
||||
sel, selected, err := conn.ReadSelection()
|
||||
if err != nil {
|
||||
log.Debugf("dmenu: failed to read result: %v", err)
|
||||
return 2
|
||||
}
|
||||
if !selected {
|
||||
return 1
|
||||
}
|
||||
|
||||
switch sel.Kind {
|
||||
case "multi":
|
||||
for _, item := range sel.Items {
|
||||
out, err := dmenuFormatOutput(format, item.Index, item.Text, sel.FilterText)
|
||||
if err != nil {
|
||||
log.Errorf("Error formatting output: %v", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Println(out)
|
||||
if dmenuPrintInfo && item.Info != "" {
|
||||
fmt.Println(item.Info)
|
||||
}
|
||||
}
|
||||
case "freetext":
|
||||
out, err := dmenuFormatOutput(format, -1, sel.Text, sel.FilterText)
|
||||
if err != nil {
|
||||
log.Errorf("Error formatting output: %v", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Println(out)
|
||||
default: // "row"
|
||||
out, err := dmenuFormatOutput(format, sel.Index, sel.Text, sel.FilterText)
|
||||
if err != nil {
|
||||
log.Errorf("Error formatting output: %v", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Println(out)
|
||||
if dmenuPrintInfo && sel.Info != "" {
|
||||
fmt.Println(sel.Info)
|
||||
}
|
||||
}
|
||||
|
||||
return dmenuKeybindExitCode(sel.KeybindN)
|
||||
}
|
||||
|
||||
func dmenuKeybindExitCode(keybindN int) int {
|
||||
if keybindN <= 0 {
|
||||
return 0
|
||||
}
|
||||
return 9 + keybindN
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/dmenuipc"
|
||||
)
|
||||
|
||||
func TestParseIndexSpec(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
spec string
|
||||
want []dmenuipc.Range
|
||||
wantErr bool
|
||||
}{
|
||||
{"", nil, false},
|
||||
{"1,3", []dmenuipc.Range{{Start: 1, End: 2}, {Start: 3, End: 4}}, false},
|
||||
{"7:11", []dmenuipc.Range{{Start: 7, End: 11}}, false},
|
||||
{"-3:", []dmenuipc.Range{{Start: -3, End: -1}}, false},
|
||||
{":5", []dmenuipc.Range{{Start: 0, End: 5}}, false},
|
||||
{"1, 3 , 7:11", []dmenuipc.Range{{Start: 1, End: 2}, {Start: 3, End: 4}, {Start: 7, End: 11}}, false},
|
||||
{"1,,3", []dmenuipc.Range{{Start: 1, End: 2}, {Start: 3, End: 4}}, false},
|
||||
{"x", nil, true},
|
||||
{"1:y", nil, true},
|
||||
{"x:1", nil, true},
|
||||
} {
|
||||
got, err := parseIndexSpec(tc.spec)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("parseIndexSpec(%q): expected error, got %v", tc.spec, got)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("parseIndexSpec(%q): unexpected error: %v", tc.spec, err)
|
||||
continue
|
||||
}
|
||||
if !reflect.DeepEqual(got, tc.want) {
|
||||
t.Errorf("parseIndexSpec(%q) = %+v, want %+v", tc.spec, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDmenuKeybinds(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
specs []string
|
||||
want []dmenuipc.Keybind
|
||||
wantErr bool
|
||||
}{
|
||||
{"empty", nil, nil, false},
|
||||
{"multiple", []string{"1=ctrl+e", "2=f5"}, []dmenuipc.Keybind{{N: 1, Key: "ctrl+e"}, {N: 2, Key: "f5"}}, false},
|
||||
{"missing equals", []string{"bad"}, nil, true},
|
||||
{"zero index", []string{"0=x"}, nil, true},
|
||||
{"out of range index", []string{"20=x"}, nil, true},
|
||||
{"empty key spec", []string{"1="}, nil, true},
|
||||
{"duplicate index", []string{"1=ctrl+e", "1=f5"}, nil, true},
|
||||
{"non numeric index", []string{"a=x"}, nil, true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := parseDmenuKeybinds(tc.specs)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("parseDmenuKeybinds(%v): expected error, got %v", tc.specs, got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("parseDmenuKeybinds(%v): unexpected error: %v", tc.specs, err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, tc.want) {
|
||||
t.Fatalf("parseDmenuKeybinds(%v) = %+v, want %+v", tc.specs, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDmenuParseBool(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
val string
|
||||
want bool
|
||||
}{
|
||||
{"", false},
|
||||
{"0", false},
|
||||
{"false", false},
|
||||
{"False", false},
|
||||
{"no", false},
|
||||
{"anything-else", true},
|
||||
} {
|
||||
if got := dmenuParseBool(tc.val); got != tc.want {
|
||||
t.Errorf("dmenuParseBool(%q) = %v, want %v", tc.val, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDmenuRow(t *testing.T) {
|
||||
t.Run("plain text, no metadata", func(t *testing.T) {
|
||||
text, meta := parseDmenuRow("apple")
|
||||
if text != "apple" || meta != nil {
|
||||
t.Fatalf("parseDmenuRow(apple) = %q, %+v, want apple, nil", text, meta)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("single field", func(t *testing.T) {
|
||||
text, meta := parseDmenuRow("Firefox\x00icon\x1ffirefox")
|
||||
if text != "Firefox" {
|
||||
t.Fatalf("text = %q, want Firefox", text)
|
||||
}
|
||||
if meta == nil || meta.Icon != "firefox" {
|
||||
t.Fatalf("meta = %+v, want Icon=firefox", meta)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("all recognized fields", func(t *testing.T) {
|
||||
line := "Firefox\x00icon\x1ffirefox\x1fdisplay\x1fMozilla Firefox\x1fmeta\x1fbrowser\x1finfo\x1ffirefox.desktop" +
|
||||
"\x1fnonselectable\x1ftrue\x1furgent\x1ftrue\x1factive\x1ftrue\x1fpermanent\x1ftrue\x1fheader\x1ftrue"
|
||||
text, meta := parseDmenuRow(line)
|
||||
want := dmenuipc.ItemMeta{
|
||||
Icon: "firefox", Display: "Mozilla Firefox", Meta: "browser", Info: "firefox.desktop",
|
||||
NonSelectable: true, Urgent: true, Active: true, Permanent: true, Header: true,
|
||||
}
|
||||
if text != "Firefox" {
|
||||
t.Fatalf("text = %q, want Firefox", text)
|
||||
}
|
||||
if meta == nil || *meta != want {
|
||||
t.Fatalf("meta = %+v, want %+v", meta, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown field ignored but still produces meta", func(t *testing.T) {
|
||||
text, meta := parseDmenuRow("apple\x00bogus\x1fvalue")
|
||||
if text != "apple" {
|
||||
t.Fatalf("text = %q, want apple", text)
|
||||
}
|
||||
if meta != nil {
|
||||
t.Fatalf("meta = %+v, want nil (no recognized fields)", meta)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dangling unpaired field ignored", func(t *testing.T) {
|
||||
text, meta := parseDmenuRow("apple\x00icon\x1ffirefox\x1fdangling")
|
||||
if text != "apple" {
|
||||
t.Fatalf("text = %q, want apple", text)
|
||||
}
|
||||
if meta == nil || meta.Icon != "firefox" {
|
||||
t.Fatalf("meta = %+v, want Icon=firefox", meta)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateDmenuFormat(t *testing.T) {
|
||||
for _, f := range []string{"", "s", "i", "d", "q", "p", "f", "F"} {
|
||||
if err := validateDmenuFormat(f); err != nil {
|
||||
t.Errorf("validateDmenuFormat(%q): unexpected error: %v", f, err)
|
||||
}
|
||||
}
|
||||
for _, f := range []string{"x", "S", "ff"} {
|
||||
if err := validateDmenuFormat(f); err == nil {
|
||||
t.Errorf("validateDmenuFormat(%q): expected error, got nil", f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDmenuView(t *testing.T) {
|
||||
for _, v := range []string{"", "list", "grid", "tile"} {
|
||||
if err := validateDmenuView(v); err != nil {
|
||||
t.Errorf("validateDmenuView(%q): unexpected error: %v", v, err)
|
||||
}
|
||||
}
|
||||
for _, v := range []string{"bogus", "List"} {
|
||||
if err := validateDmenuView(v); err == nil {
|
||||
t.Errorf("validateDmenuView(%q): expected error, got nil", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDmenuShellQuote(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"banana", "'banana'"},
|
||||
{"it's", `'it'\''s'`},
|
||||
{"", "''"},
|
||||
} {
|
||||
if got := dmenuShellQuote(tc.in); got != tc.want {
|
||||
t.Errorf("dmenuShellQuote(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDmenuFormatOutput(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
format string
|
||||
index int
|
||||
text string
|
||||
filterText string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{"", 1, "banana", "ban", "banana", false},
|
||||
{"s", 1, "banana", "ban", "banana", false},
|
||||
{"i", 1, "banana", "ban", "1", false},
|
||||
{"d", 1, "banana", "ban", "2", false},
|
||||
{"d", -1, "banana", "xyz", "-1", false},
|
||||
{"q", 0, "it's", "", "'it'\\''s'", false},
|
||||
{"p", 0, "<b>Bold</b> item", "", "Bold item", false},
|
||||
{"f", 0, "banana", "ban", "ban", false},
|
||||
{"F", 0, "banana", "it's", "'it'\\''s'", false},
|
||||
{"bogus", 0, "banana", "", "", true},
|
||||
} {
|
||||
got, err := dmenuFormatOutput(tc.format, tc.index, tc.text, tc.filterText)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("dmenuFormatOutput(%q, ...): expected error, got %q", tc.format, got)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("dmenuFormatOutput(%q, ...): unexpected error: %v", tc.format, err)
|
||||
continue
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("dmenuFormatOutput(%q, %d, %q, %q) = %q, want %q", tc.format, tc.index, tc.text, tc.filterText, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDmenuDumpLines(t *testing.T) {
|
||||
rows := []dmenuRow{
|
||||
{Text: "Header", Meta: &dmenuipc.ItemMeta{Header: true}},
|
||||
{Text: "Firefox", Meta: &dmenuipc.ItemMeta{Info: "firefox.desktop"}},
|
||||
{Text: "apple"},
|
||||
{Text: "skip", Meta: &dmenuipc.ItemMeta{NonSelectable: true}},
|
||||
{Text: "banana"},
|
||||
}
|
||||
|
||||
t.Run("no filter, default format", func(t *testing.T) {
|
||||
got, err := dmenuDumpLines(rows, "", "s", false)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
want := []string{"Firefox", "apple", "banana"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("with select filter", func(t *testing.T) {
|
||||
got, err := dmenuDumpLines(rows, "an", "s", false)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
want := []string{"banana"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("print-info appends info lines", func(t *testing.T) {
|
||||
got, err := dmenuDumpLines(rows, "", "s", true)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
want := []string{"Firefox", "firefox.desktop", "apple", "banana"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid format propagates error", func(t *testing.T) {
|
||||
if _, err := dmenuDumpLines(rows, "", "bogus", false); err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDmenuKeybindExitCode(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
n int
|
||||
want int
|
||||
}{
|
||||
{0, 0},
|
||||
{1, 10},
|
||||
{19, 28},
|
||||
} {
|
||||
if got := dmenuKeybindExitCode(tc.n); got != tc.want {
|
||||
t.Errorf("dmenuKeybindExitCode(%d) = %d, want %d", tc.n, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeDmenuArgv(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
"not the dmenu subcommand: untouched",
|
||||
[]string{"dms", "ipc", "-password"},
|
||||
[]string{"dms", "ipc", "-password"},
|
||||
},
|
||||
{
|
||||
"rofi-style single-dash long flags get a second dash",
|
||||
[]string{"dms", "dmenu", "-only-match", "-password", "-sep", ","},
|
||||
[]string{"dms", "dmenu", "--only-match", "--password", "--sep", ","},
|
||||
},
|
||||
{
|
||||
"= form is preserved",
|
||||
[]string{"dms", "dmenu", "-format=i"},
|
||||
[]string{"dms", "dmenu", "--format=i"},
|
||||
},
|
||||
{
|
||||
"already-double-dash flags are untouched",
|
||||
[]string{"dms", "dmenu", "--password", "-p", "hi"},
|
||||
[]string{"dms", "dmenu", "--password", "-p", "hi"},
|
||||
},
|
||||
{
|
||||
"single-char shorthands are untouched",
|
||||
[]string{"dms", "dmenu", "-p", "hi", "-l", "5", "-i"},
|
||||
[]string{"dms", "dmenu", "-p", "hi", "-l", "5", "-i"},
|
||||
},
|
||||
{
|
||||
"negative --active/--urgent spec values are not mistaken for flag names",
|
||||
[]string{"dms", "dmenu", "--active", "-3:"},
|
||||
[]string{"dms", "dmenu", "--active", "-3:"},
|
||||
},
|
||||
{
|
||||
"DMS-native long flags also get the single-dash alias",
|
||||
[]string{"dms", "dmenu", "-icon", "star"},
|
||||
[]string{"dms", "dmenu", "--icon", "star"},
|
||||
},
|
||||
{
|
||||
"rofi's -a/-u abbreviations are real shorthands, untouched by the rewrite",
|
||||
[]string{"dms", "dmenu", "-a", "1,3", "-u", "2"},
|
||||
[]string{"dms", "dmenu", "-a", "1,3", "-u", "2"},
|
||||
},
|
||||
{
|
||||
"stops rewriting after a bare --",
|
||||
[]string{"dms", "dmenu", "--", "-password"},
|
||||
[]string{"dms", "dmenu", "--", "-password"},
|
||||
},
|
||||
} {
|
||||
got := normalizeDmenuArgv(tc.args)
|
||||
if !reflect.DeepEqual(got, tc.want) {
|
||||
t.Errorf("%s: normalizeDmenuArgv(%v) = %v, want %v", tc.name, tc.args, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+367
-140
@@ -1,10 +1,11 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"debug/elf"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -13,17 +14,20 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/blur"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/matugen"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/brightness"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/tui"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/version"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/blur"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/clipboard"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/config"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/distros"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/mangoconf"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/matugen"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/server/brightness"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/server/network"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/site"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/tui"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/utils"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/version"
|
||||
"github.com/godbus/dbus/v5"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -87,7 +91,6 @@ func (ds *DoctorStatus) OKCount() int {
|
||||
|
||||
var (
|
||||
quickshellVersionRegex = regexp.MustCompile(`(?i)quickshell (\d+\.\d+\.\d+)`)
|
||||
hyprlandVersionRegex = regexp.MustCompile(`v?(\d+\.\d+\.\d+)`)
|
||||
niriVersionRegex = regexp.MustCompile(`niri (\d+\.\d+)`)
|
||||
swayVersionRegex = regexp.MustCompile(`sway version (\d+\.\d+)`)
|
||||
riverVersionRegex = regexp.MustCompile(`river (\d+\.\d+)`)
|
||||
@@ -95,6 +98,9 @@ var (
|
||||
labwcVersionRegex = regexp.MustCompile(`labwc (\d+\.\d+\.\d+)`)
|
||||
mangowcVersionRegex = regexp.MustCompile(`mango (\d+\.\d+\.\d+)`)
|
||||
miracleVersionRegex = regexp.MustCompile(`miracle-wm v?(\d+\.\d+\.\d+)`)
|
||||
scrollVersionRegex = regexp.MustCompile(`scroll version (\d+\.\d+)`)
|
||||
aqueousVersionRegex = regexp.MustCompile(`(?i)aqueous v?(\d+\.\d+(?:\.\d+)?)`)
|
||||
umbrielVersionRegex = regexp.MustCompile(`umbriel (\d+\.\d+\.\d+)`)
|
||||
)
|
||||
|
||||
var doctorCmd = &cobra.Command{
|
||||
@@ -160,7 +166,7 @@ func (c category) String() string {
|
||||
|
||||
const (
|
||||
checkNameMaxLength = 21
|
||||
doctorDocsURL = "https://danklinux.com/docs/dankmaterialshell/cli-doctor"
|
||||
doctorDocsURL = site.Docs + "/dms/cli-doctor"
|
||||
)
|
||||
|
||||
type checkResult struct {
|
||||
@@ -218,6 +224,7 @@ func runDoctor(cmd *cobra.Command, args []string) {
|
||||
checkOptionalDependencies(),
|
||||
checkConfigurationFiles(),
|
||||
checkSystemdServices(),
|
||||
checkSettingsPortal(),
|
||||
checkEnvironmentVars(),
|
||||
checkFonts(),
|
||||
)
|
||||
@@ -323,15 +330,24 @@ func checkEnvironmentVars() []checkResult {
|
||||
var results []checkResult
|
||||
results = append(results, checkEnvVar("QT_QPA_PLATFORMTHEME")...)
|
||||
results = append(results, checkQtPlatformThemePlugin("QT_QPA_PLATFORMTHEME")...)
|
||||
results = append(results, checkEnvVar("QT_QPA_PLATFORMTHEME_QT6")...)
|
||||
if os.Getenv("QT_QPA_PLATFORMTHEME_QT6") != os.Getenv("QT_QPA_PLATFORMTHEME") {
|
||||
results = append(results, checkQtPlatformThemePlugin("QT_QPA_PLATFORMTHEME_QT6")...)
|
||||
switch {
|
||||
case os.Getenv("QT_QPA_PLATFORMTHEME_QT6") != "":
|
||||
results = append(results, checkEnvVar("QT_QPA_PLATFORMTHEME_QT6")...)
|
||||
if os.Getenv("QT_QPA_PLATFORMTHEME_QT6") != os.Getenv("QT_QPA_PLATFORMTHEME") {
|
||||
results = append(results, checkQtPlatformThemePlugin("QT_QPA_PLATFORMTHEME_QT6")...)
|
||||
}
|
||||
case os.Getenv("QT_QPA_PLATFORMTHEME") != "":
|
||||
if doctorVerbose {
|
||||
results = append(results, checkResult{catEnvironment, "QT_QPA_PLATFORMTHEME_QT6", statusInfo, "Not set, Qt6 uses QT_QPA_PLATFORMTHEME", "", doctorDocsURL + "#environment-variables"})
|
||||
}
|
||||
default:
|
||||
results = append(results, checkEnvVar("QT_QPA_PLATFORMTHEME_QT6")...)
|
||||
}
|
||||
if matugen.QtengineActive() {
|
||||
results = append(results, checkQtengineConfig()...)
|
||||
}
|
||||
results = append(results, checkEnvVar("QS_ICON_THEME")...)
|
||||
results = append(results, checkXDGMenuPrefix()...)
|
||||
if matugen.QtengineActive() {
|
||||
results = append(results, checkQtenginePlugin()...)
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
@@ -352,7 +368,7 @@ func checkXDGMenuPrefix() []checkResult {
|
||||
if checkXDGMenuFile(menuPrefix) {
|
||||
return []checkResult{{catEnvironment, "XDG_MENU_PREFIX", statusInfo, menuPrefix, "", doctorDocsURL + "#xdg-menu-prefix"}}
|
||||
}
|
||||
return []checkResult{{catEnvironment, "XDG_MENU_PREFIX", statusWarn, fmt.Sprintf("%s (menu file not found)", menuPrefix), fmt.Sprintf("Dolphin 'Open with…' dialog may be empty. Ensure /etc/xdg/menus/%sapplications.menu exists.", menuPrefix), doctorDocsURL + "#xdg-menu-prefix"}}
|
||||
return []checkResult{{catEnvironment, "XDG_MENU_PREFIX", statusWarn, fmt.Sprintf("%s (menu file not found)", menuPrefix), fmt.Sprintf("Dolphin 'Open with…' dialog may be empty. Ensure %sapplications.menu exists in ~/.config/menus or /etc/xdg/menus.", menuPrefix), doctorDocsURL + "#xdg-menu-prefix"}}
|
||||
}
|
||||
if _, err := exec.LookPath("keditfiletype"); err == nil {
|
||||
return []checkResult{{catEnvironment, "XDG_MENU_PREFIX", statusWarn, "Not set", "Dolphin file associations and 'Open with…' dialog may be empty. Set XDG_MENU_PREFIX=plasma- in your compositor's environment block.", doctorDocsURL + "#xdg-menu-prefix"}}
|
||||
@@ -378,55 +394,41 @@ var qtQueryBinaries = []struct{ bin, flag string }{
|
||||
{"qtpaths", "--query"},
|
||||
}
|
||||
|
||||
// checkQtenginePlugin verifies the qtengine platform theme plugin is installed
|
||||
// where Qt actually searches, for every Qt major that can be interrogated.
|
||||
//
|
||||
// Qt is asked where it looks rather than the filesystem searched, because a
|
||||
// package can install the Qt5 plugin under a prefix Qt5 does not search, after
|
||||
// which Qt5 apps silently fall back while Qt6 apps look correct. A search would
|
||||
// find the plugin and report OK on exactly that bug.
|
||||
func checkQtenginePlugin() []checkResult {
|
||||
// checkQtengineConfig reports whether qtengine's config points Qt apps at the
|
||||
// DMS color scheme, which DMS writes there when it generates a theme.
|
||||
func checkQtengineConfig() []checkResult {
|
||||
url := doctorDocsURL + "#environment-variables"
|
||||
path := matugen.QtengineConfigPath()
|
||||
|
||||
pluginRoots := qtPluginRoots()
|
||||
|
||||
// qmake/qtpaths ship in dev packages most users lack — never warn on a Qt
|
||||
// that could not be interrogated, and never claim it is fine either.
|
||||
if len(pluginRoots) == 0 {
|
||||
return []checkResult{{
|
||||
catEnvironment, "qtengine plugin", statusInfo, "Cannot verify (no qmake/qtpaths)",
|
||||
"Install a Qt development package to let dms doctor check the plugin against the path Qt actually searches.",
|
||||
url,
|
||||
}}
|
||||
data, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) || (err == nil && len(bytes.TrimSpace(data)) == 0) {
|
||||
return []checkResult{{catEnvironment, "qtengine config", statusInfo, "Not found", "Qt apps use qtengine's default palette until DMS generates a theme.", url}}
|
||||
}
|
||||
var cfg struct {
|
||||
Theme struct {
|
||||
ColorScheme string `json:"colorScheme"`
|
||||
} `json:"theme"`
|
||||
}
|
||||
if err == nil {
|
||||
err = json.Unmarshal(data, &cfg)
|
||||
}
|
||||
if err != nil {
|
||||
return []checkResult{{catEnvironment, "qtengine config", statusWarn, "Unreadable", fmt.Sprintf("DMS won't overwrite %s while it can't parse it, so Qt apps stop following DMS colors. Fix or remove the file.", path), url}}
|
||||
}
|
||||
|
||||
// Qt also searches QT_PLUGIN_PATH, so a plugin found there is loadable.
|
||||
envRoots := filepath.SplitList(os.Getenv("QT_PLUGIN_PATH"))
|
||||
|
||||
var results []checkResult
|
||||
for _, major := range slices.Sorted(maps.Keys(pluginRoots)) {
|
||||
// qtengine only ships Qt5 and Qt6 plugins.
|
||||
if major != "5" && major != "6" {
|
||||
continue
|
||||
}
|
||||
|
||||
root := pluginRoots[major]
|
||||
name := fmt.Sprintf("qtengine plugin (Qt%s)", major)
|
||||
|
||||
plugin := qtenginePluginPath(append([]string{root}, envRoots...), major)
|
||||
if plugin == "" {
|
||||
expected := qtenginePluginFile(root, major)
|
||||
results = append(results, checkResult{
|
||||
catEnvironment, name, statusWarn, fmt.Sprintf("Not found in Qt%s plugin path", major),
|
||||
fmt.Sprintf("Expected %s — Qt%s apps will silently fall back to an unthemed palette. The plugin is likely installed under a prefix Qt%s does not search.", expected, major, major),
|
||||
url,
|
||||
})
|
||||
continue
|
||||
}
|
||||
results = append(results, checkResult{catEnvironment, name, statusOK, "Installed", plugin, url})
|
||||
scheme := matugen.QtengineColorSchemePath()
|
||||
if cfg.Theme.ColorScheme == scheme {
|
||||
return []checkResult{{catEnvironment, "qtengine config", statusOK, "Uses DMS colors", path, url}}
|
||||
}
|
||||
|
||||
return results
|
||||
current := "No color scheme is set"
|
||||
if cfg.Theme.ColorScheme != "" {
|
||||
current = "The color scheme is " + cfg.Theme.ColorScheme
|
||||
}
|
||||
return []checkResult{{
|
||||
catEnvironment, "qtengine config", statusInfo, "Not using DMS colors",
|
||||
fmt.Sprintf("%s. DMS sets %s when it generates a theme with the qtengine and KColorScheme templates enabled.", current, scheme),
|
||||
url,
|
||||
}}
|
||||
}
|
||||
|
||||
// checkQtPlatformThemePlugin validates one of the QT_QPA_PLATFORMTHEME
|
||||
@@ -436,8 +438,8 @@ func checkQtenginePlugin() []checkResult {
|
||||
// Quickshell tray menus draw a placeholder for every icon an app publishes.
|
||||
func checkQtPlatformThemePlugin(varName string) []checkResult {
|
||||
value := os.Getenv(varName)
|
||||
// Unset is reported by checkEnvVar; qtengine has its own plugin check.
|
||||
if value == "" || value == "qtengine" {
|
||||
// Unset is reported by checkEnvVar.
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -453,46 +455,111 @@ func checkQtPlatformThemePlugin(varName string) []checkResult {
|
||||
}}
|
||||
}
|
||||
|
||||
// DMS themes Qt6 through these variables; qt5ct is the Qt5 counterpart.
|
||||
major := "6"
|
||||
if value == "qt5ct" {
|
||||
major = "5"
|
||||
roots := qtPluginRoots()
|
||||
var results []checkResult
|
||||
for _, major := range qtPlatformThemeMajors(varName, value, roots) {
|
||||
results = append(results, checkQtPlatformThemePluginFor(varName, value, major, roots[major]))
|
||||
}
|
||||
files := qtPlatformThemePluginFiles(value)
|
||||
return results
|
||||
}
|
||||
|
||||
// qtPlatformThemeMajors lists the Qt majors whose plugin a variable's value
|
||||
// must provide. DMS themes Qt6 through these variables and qt5ct is the Qt5
|
||||
// counterpart. qtengine ships both, and Qt5 reads only QT_QPA_PLATFORMTHEME, so
|
||||
// its Qt5 plugin is checked there when a Qt5 build tool can be asked.
|
||||
func qtPlatformThemeMajors(varName, value string, roots map[string]qtInstall) []string {
|
||||
switch {
|
||||
case value == "qt5ct":
|
||||
return []string{"5"}
|
||||
case value == "qtengine" && varName == "QT_QPA_PLATFORMTHEME" && roots["5"].plugins != "":
|
||||
return []string{"5", "6"}
|
||||
}
|
||||
return []string{"6"}
|
||||
}
|
||||
|
||||
func checkQtPlatformThemePluginFor(varName, value, major string, qt qtInstall) checkResult {
|
||||
url := doctorDocsURL + "#environment-variables"
|
||||
files := qtPlatformThemePluginFiles(value, major)
|
||||
name := fmt.Sprintf("%s plugin (Qt%s)", value, major)
|
||||
|
||||
root := qtPluginRoots()[major]
|
||||
// qmake/qtpaths ship in dev packages most users lack — never warn on a Qt
|
||||
// that could not be interrogated, and never claim it is fine either.
|
||||
if root == "" {
|
||||
return []checkResult{{
|
||||
if qt.plugins == "" {
|
||||
return checkResult{
|
||||
catEnvironment, name, statusInfo, "Cannot verify (no qmake/qtpaths)",
|
||||
"Install a Qt development package to let dms doctor check the plugin against the path Qt actually searches.",
|
||||
url,
|
||||
}}
|
||||
}
|
||||
}
|
||||
|
||||
// Qt also searches QT_PLUGIN_PATH, so a plugin found there is loadable.
|
||||
plugin := qtPluginPath(append([]string{root}, filepath.SplitList(os.Getenv("QT_PLUGIN_PATH"))...), files...)
|
||||
plugin := qtPluginPath(append([]string{qt.plugins}, filepath.SplitList(os.Getenv("QT_PLUGIN_PATH"))...), files...)
|
||||
if plugin == "" {
|
||||
// plasma-integration ships the kde theme under a file name that has
|
||||
// changed across versions; a miss says nothing reliable about the
|
||||
// install, so degrade to info instead of warning at a working setup.
|
||||
if value == "kde" {
|
||||
return []checkResult{{
|
||||
return checkResult{
|
||||
catEnvironment, name, statusInfo, "Cannot verify (plasma-integration)",
|
||||
"The kde theme comes from plasma-integration, whose plugin file name varies across versions. Verify visually that Qt apps follow your theme.",
|
||||
url,
|
||||
}}
|
||||
}
|
||||
}
|
||||
expected := filepath.Join(root, "platformthemes", files[0])
|
||||
return []checkResult{{
|
||||
expected := filepath.Join(qt.plugins, "platformthemes", files[0])
|
||||
return checkResult{
|
||||
catEnvironment, name, statusWarn, fmt.Sprintf("Not found in Qt%s plugin path", major),
|
||||
fmt.Sprintf("Expected %s — Qt apps silently fall back to a built-in theme, breaking icon lookups and palettes. Install the package providing it, or correct %s.", expected, varName),
|
||||
url,
|
||||
}}
|
||||
}
|
||||
}
|
||||
return []checkResult{{catEnvironment, name, statusOK, "Installed", plugin, url}}
|
||||
// Qt skips QPA plugins built against another Qt major.minor without a
|
||||
// warning, so an out-of-tree plugin silently stops loading after a Qt update.
|
||||
if built, ok := qtPluginBuildVersion(plugin); ok && built != qtMinorVersion(qt.version) {
|
||||
return checkResult{
|
||||
catEnvironment, name, statusWarn, fmt.Sprintf("Built for Qt %s, Qt is %s", built, qt.version),
|
||||
fmt.Sprintf("Qt ignores platform theme plugins built for another Qt minor version, so Qt apps silently fall back to a built-in theme, breaking icon lookups and palettes. Rebuild or reinstall the package providing %s against the installed Qt, then restart the shell.", plugin),
|
||||
url,
|
||||
}
|
||||
}
|
||||
return checkResult{catEnvironment, name, statusOK, "Installed", plugin, url}
|
||||
}
|
||||
|
||||
// qtPluginBuildVersion reads the Qt major.minor a plugin was built against
|
||||
// from the ELF note Qt 6 embeds in every plugin. Qt 5 plugins carry no such
|
||||
// note and report false.
|
||||
func qtPluginBuildVersion(path string) (string, bool) {
|
||||
f, err := elf.Open(path)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
defer f.Close()
|
||||
section := f.Section(".note.qt.metadata")
|
||||
if section == nil {
|
||||
return "", false
|
||||
}
|
||||
data, err := section.Data()
|
||||
if err != nil || len(data) < 12 {
|
||||
return "", false
|
||||
}
|
||||
nameSize := f.ByteOrder.Uint32(data[0:4])
|
||||
descSize := f.ByteOrder.Uint32(data[4:8])
|
||||
if f.ByteOrder.Uint32(data[8:12]) != 0x74510001 || descSize < 3 {
|
||||
return "", false
|
||||
}
|
||||
// The descriptor starts with the metadata format version, then Qt's major and minor.
|
||||
desc := 12 + (uint64(nameSize)+3)&^3
|
||||
if uint64(len(data)) < desc+uint64(descSize) {
|
||||
return "", false
|
||||
}
|
||||
return fmt.Sprintf("%d.%d", data[desc+1], data[desc+2]), true
|
||||
}
|
||||
|
||||
func qtMinorVersion(version string) string {
|
||||
parts := strings.SplitN(version, ".", 3)
|
||||
if len(parts) < 2 {
|
||||
return version
|
||||
}
|
||||
return parts[0] + "." + parts[1]
|
||||
}
|
||||
|
||||
// qtPlatformThemePluginFiles lists the plugin file names that can provide a
|
||||
@@ -500,7 +567,10 @@ func checkQtPlatformThemePlugin(varName string) []checkResult {
|
||||
// through plugin metadata keys rather than file names, so these are the names
|
||||
// each integration is known to ship; unmapped values fall back to Qt's
|
||||
// libq<value> naming convention.
|
||||
func qtPlatformThemePluginFiles(value string) []string {
|
||||
func qtPlatformThemePluginFiles(value, major string) []string {
|
||||
if value == "qtengine" {
|
||||
return []string{fmt.Sprintf("libqt%sengine-plugin.so", major)}
|
||||
}
|
||||
if files, ok := map[string][]string{
|
||||
"qt6ct": {"libqt6ct.so"},
|
||||
"qt5ct": {"libqt5ct.so"},
|
||||
@@ -512,22 +582,28 @@ func qtPlatformThemePluginFiles(value string) []string {
|
||||
return []string{"libq" + value + ".so", "lib" + value + ".so"}
|
||||
}
|
||||
|
||||
type qtInstall struct {
|
||||
plugins string
|
||||
version string
|
||||
}
|
||||
|
||||
// qtPluginRoots asks every available Qt build tool where its Qt looks for
|
||||
// plugins, keyed by Qt major. A binary's name does not reliably say which Qt
|
||||
// it belongs to — plain "qmake" is Qt5 on Arch and Qt6 elsewhere — so each one
|
||||
// is asked for QT_VERSION and the major is taken from the answer.
|
||||
func qtPluginRoots() map[string]string {
|
||||
pluginRoots := make(map[string]string)
|
||||
func qtPluginRoots() map[string]qtInstall {
|
||||
pluginRoots := make(map[string]qtInstall)
|
||||
for _, q := range qtQueryBinaries {
|
||||
if _, err := exec.LookPath(q.bin); err != nil {
|
||||
continue
|
||||
}
|
||||
major, _, _ := strings.Cut(qtQuery(q.bin, q.flag, "QT_VERSION"), ".")
|
||||
if major == "" || pluginRoots[major] != "" {
|
||||
version := qtQuery(q.bin, q.flag, "QT_VERSION")
|
||||
major, _, _ := strings.Cut(version, ".")
|
||||
if major == "" || pluginRoots[major].plugins != "" {
|
||||
continue
|
||||
}
|
||||
if root := qtQuery(q.bin, q.flag, "QT_INSTALL_PLUGINS"); root != "" {
|
||||
pluginRoots[major] = root
|
||||
pluginRoots[major] = qtInstall{plugins: root, version: version}
|
||||
}
|
||||
}
|
||||
return pluginRoots
|
||||
@@ -550,16 +626,6 @@ func qtPluginPath(roots []string, files ...string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// qtenginePluginPath returns the first of roots that holds the qtengine platform
|
||||
// theme plugin for the given Qt major, or "" if none does.
|
||||
func qtenginePluginPath(roots []string, major string) string {
|
||||
return qtPluginPath(roots, fmt.Sprintf("libqt%sengine-plugin.so", major))
|
||||
}
|
||||
|
||||
func qtenginePluginFile(root, major string) string {
|
||||
return filepath.Join(root, "platformthemes", fmt.Sprintf("libqt%sengine-plugin.so", major))
|
||||
}
|
||||
|
||||
// qtQuery asks a Qt build tool for a single QLibraryInfo property.
|
||||
func qtQuery(bin, flag, key string) string {
|
||||
output, err := exec.Command(bin, flag, key).Output()
|
||||
@@ -585,10 +651,23 @@ func qtQueryValue(output, key string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// checkXDGMenuFile looks for the menu file where the XDG menu spec does: the
|
||||
// user's config dir first, then each system config dir.
|
||||
func checkXDGMenuFile(prefix string) bool {
|
||||
menuPath := fmt.Sprintf("/etc/xdg/menus/%sapplications.menu", prefix)
|
||||
_, err := os.Stat(menuPath)
|
||||
return err == nil
|
||||
configDirs := os.Getenv("XDG_CONFIG_DIRS")
|
||||
if configDirs == "" {
|
||||
configDirs = "/etc/xdg"
|
||||
}
|
||||
dirs := append([]string{utils.XDGConfigHome()}, filepath.SplitList(configDirs)...)
|
||||
for _, dir := range dirs {
|
||||
if dir == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "menus", prefix+"applications.menu")); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func readOSRelease() map[string]string {
|
||||
@@ -728,17 +807,20 @@ func checkDMSInstallation() []checkResult {
|
||||
func checkWindowManagers() []checkResult {
|
||||
compositors := []struct {
|
||||
name, versionCmd, versionArg string
|
||||
versionRegex *regexp.Regexp
|
||||
parseVersion func(string) string
|
||||
commands []string
|
||||
}{
|
||||
{"Hyprland", "Hyprland", "--version", hyprlandVersionRegex, []string{"hyprland", "Hyprland"}},
|
||||
{"niri", "niri", "--version", niriVersionRegex, []string{"niri"}},
|
||||
{"Sway", "sway", "--version", swayVersionRegex, []string{"sway"}},
|
||||
{"River", "river", "-version", riverVersionRegex, []string{"river"}},
|
||||
{"Wayfire", "wayfire", "--version", wayfireVersionRegex, []string{"wayfire"}},
|
||||
{"labwc", "labwc", "--version", labwcVersionRegex, []string{"labwc"}},
|
||||
{"mangowc", "mango", "-v", mangowcVersionRegex, []string{"mango"}},
|
||||
{"Miracle WM", "miracle-wm", "--version", miracleVersionRegex, []string{"miracle-wm"}},
|
||||
{"Hyprland", "Hyprland", "--version", distros.ParseHyprlandVersion, []string{"hyprland", "Hyprland"}},
|
||||
{"niri", "niri", "--version", firstSubmatch(niriVersionRegex), []string{"niri"}},
|
||||
{"Sway", "sway", "--version", firstSubmatch(swayVersionRegex), []string{"sway"}},
|
||||
{"River", "river", "-version", firstSubmatch(riverVersionRegex), []string{"river"}},
|
||||
{"Wayfire", "wayfire", "--version", firstSubmatch(wayfireVersionRegex), []string{"wayfire"}},
|
||||
{"labwc", "labwc", "--version", firstSubmatch(labwcVersionRegex), []string{"labwc"}},
|
||||
{"mangowc", "mango", "-v", firstSubmatch(mangowcVersionRegex), []string{"mango"}},
|
||||
{"Miracle WM", "miracle-wm", "--version", firstSubmatch(miracleVersionRegex), []string{"miracle-wm"}},
|
||||
{"Scroll", "scroll", "--version", firstSubmatch(scrollVersionRegex), []string{"scroll"}},
|
||||
{"Aqueous", "aqueous", "-version", firstSubmatch(aqueousVersionRegex), []string{"aqueous"}},
|
||||
{"Umbriel", "umbriel", "--version", firstSubmatch(umbrielVersionRegex), []string{"umbriel"}},
|
||||
}
|
||||
|
||||
var results []checkResult
|
||||
@@ -760,18 +842,29 @@ func checkWindowManagers() []checkResult {
|
||||
if doctorVerbose && compositorPath != "" {
|
||||
details = compositorPath
|
||||
}
|
||||
display, ver := getVersionFromCommand(c.versionCmd, c.versionArg, c.parseVersion)
|
||||
results = append(results, checkResult{
|
||||
catCompositor, c.name, statusOK,
|
||||
getVersionFromCommand(c.versionCmd, c.versionArg, c.versionRegex), details,
|
||||
catCompositor, c.name, statusOK, display, details,
|
||||
doctorDocsURL + "#compositor-checks",
|
||||
})
|
||||
if c.name == "Hyprland" && hyprlandLacksLua(ver) {
|
||||
results = append(results, checkResult{
|
||||
catCompositor, "Hyprland Lua config", statusWarn,
|
||||
fmt.Sprintf("Hyprland %s is older than 0.55", ver),
|
||||
"DMS writes Hyprland config in Lua only, which needs Hyprland 0.55 or newer",
|
||||
doctorDocsURL + "#compositor-checks",
|
||||
})
|
||||
}
|
||||
if c.name == "Hyprland" {
|
||||
results = append(results, checkHyprlandConfigFormat(filepath.Join(utils.XDGConfigHome(), "hypr"))...)
|
||||
}
|
||||
}
|
||||
|
||||
if !foundAny {
|
||||
results = append(results, checkResult{
|
||||
catCompositor, "Compositor", statusError,
|
||||
"No supported Wayland compositor found",
|
||||
"Install Hyprland, niri, Sway, River, Wayfire, or miracle-wm",
|
||||
"Install Hyprland, niri, Sway, River, Wayfire, labwc, mangowc, miracle-wm, Scroll, Aqueous, or Umbriel",
|
||||
doctorDocsURL + "#compositor-checks",
|
||||
})
|
||||
}
|
||||
@@ -780,11 +873,67 @@ func checkWindowManagers() []checkResult {
|
||||
results = append(results, checkResult{catCompositor, "Active", statusInfo, wm, "", doctorDocsURL + "#compositor"})
|
||||
}
|
||||
|
||||
if os.Getenv("AQUEOUS_SOCKET") != "" {
|
||||
results = append(results, checkAqueousConfigHelper())
|
||||
}
|
||||
|
||||
if os.Getenv("MANGO_INSTANCE_SIGNATURE") != "" {
|
||||
results = append(results, checkMangoConfig(mangoconf.Dir(), mangoconf.Detect())...)
|
||||
}
|
||||
|
||||
results = append(results, checkCompositorBlurSupport())
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
var mangoLegacyOverviewBinds = regexp.MustCompile(`(?im)^\s*mousebind\s*=\s*none\s*,\s*(btn_left\s*,\s*toggleoverview\s*,\s*1|btn_right\s*,\s*killclient\s*,\s*0)\s*$`)
|
||||
|
||||
func checkMangoConfig(mangoDir string, dialect mangoconf.Dialect) []checkResult {
|
||||
url := doctorDocsURL + "#compositor-checks"
|
||||
paths, _ := filepath.Glob(filepath.Join(mangoDir, "dms", "*.conf"))
|
||||
paths = append([]string{filepath.Join(mangoDir, "config.conf")}, paths...)
|
||||
|
||||
var stale, overviewBinds []string
|
||||
for _, path := range paths {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if dialect.Translate(string(data)) != string(data) {
|
||||
stale = append(stale, filepath.Base(path))
|
||||
}
|
||||
if mangoLegacyOverviewBinds.Match(data) {
|
||||
overviewBinds = append(overviewBinds, filepath.Base(path))
|
||||
}
|
||||
}
|
||||
|
||||
var results []checkResult
|
||||
if len(stale) > 0 {
|
||||
results = append(results, checkResult{catCompositor, "Mango config keys", statusError,
|
||||
fmt.Sprintf("Not %s keys: %s", dialect, strings.Join(stale, ", ")),
|
||||
"This Mango rejects those lines as unknown keywords. Run `dms config mango-migrate --main` (config.conf is backed up).", url})
|
||||
}
|
||||
if len(overviewBinds) > 0 {
|
||||
results = append(results, checkResult{catCompositor, "Mango mouse binds", statusWarn,
|
||||
"Obsolete overview binds in " + strings.Join(overviewBinds, ", "),
|
||||
"mousebind=NONE,btn_left,toggleoverview,1 and mousebind=NONE,btn_right,killclient,0 swallow normal clicks; remove them.", url})
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func checkAqueousConfigHelper() checkResult {
|
||||
url := doctorDocsURL + "#compositor-checks"
|
||||
path, err := exec.LookPath("aqueous-config")
|
||||
if err != nil {
|
||||
return checkResult{catCompositor, "aqueous-config", statusWarn, "Not found", "Keybind and display settings edits from the shell need the aqueous-config helper", url}
|
||||
}
|
||||
details := "Keybind and display settings edits from the shell"
|
||||
if doctorVerbose {
|
||||
details = path
|
||||
}
|
||||
return checkResult{catCompositor, "aqueous-config", statusOK, "Installed", details, url}
|
||||
}
|
||||
|
||||
func checkCompositorBlurSupport() checkResult {
|
||||
supported, err := blur.ProbeSupport()
|
||||
if err != nil {
|
||||
@@ -798,25 +947,56 @@ func checkCompositorBlurSupport() checkResult {
|
||||
return checkResult{catCompositor, "Background Blur", statusWarn, "Unsupported", "Compositor does not support ext-background-effect-v1", doctorDocsURL + "#compositor-checks"}
|
||||
}
|
||||
|
||||
func getVersionFromCommand(cmd, arg string, regex *regexp.Regexp) string {
|
||||
// Same detection as the keybinds parser: a regular hyprland.lua wins, else hyprland.conf is read-only.
|
||||
func checkHyprlandConfigFormat(hyprDir string) []checkResult {
|
||||
if st, err := os.Stat(filepath.Join(hyprDir, "hyprland.lua")); err == nil && st.Mode().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
if st, err := os.Stat(filepath.Join(hyprDir, "hyprland.conf")); err != nil || !st.Mode().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
return []checkResult{{
|
||||
catCompositor, "Hyprland Lua config", statusWarn,
|
||||
"Hyprland config is hyprland.conf",
|
||||
"DMS keybinds, window rules and displays are read-only until you run `dms setup` to migrate to Lua. Hyprland 0.57 drops .conf support.",
|
||||
doctorDocsURL + "#compositor-checks",
|
||||
}}
|
||||
}
|
||||
|
||||
func hyprlandLacksLua(ver string) bool {
|
||||
return ver != "" && version.CompareVersions(ver, "0.55.0") < 0
|
||||
}
|
||||
|
||||
func firstSubmatch(re *regexp.Regexp) func(string) string {
|
||||
return func(s string) string {
|
||||
if m := re.FindStringSubmatch(s); len(m) > 1 {
|
||||
return m[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func getVersionFromCommand(cmd, arg string, parse func(string) string) (display, ver string) {
|
||||
output, err := exec.Command(cmd, arg).CombinedOutput()
|
||||
if err != nil && len(output) == 0 {
|
||||
return "installed"
|
||||
return "installed", ""
|
||||
}
|
||||
|
||||
outStr := string(output)
|
||||
if matches := regex.FindStringSubmatch(outStr); len(matches) > 1 {
|
||||
ver := matches[1]
|
||||
if strings.Contains(outStr, "git") || strings.Contains(outStr, "dirty") {
|
||||
return ver + " (git)"
|
||||
}
|
||||
return ver
|
||||
ver = parse(outStr)
|
||||
if ver == "" {
|
||||
return strings.TrimSpace(outStr), ""
|
||||
}
|
||||
return strings.TrimSpace(outStr)
|
||||
if strings.Contains(outStr, "git") || strings.Contains(outStr, "dirty") {
|
||||
return ver + " (git)", ver
|
||||
}
|
||||
return ver, ver
|
||||
}
|
||||
|
||||
func detectRunningWM() string {
|
||||
switch {
|
||||
case os.Getenv("AQUEOUS_SOCKET") != "":
|
||||
return "Aqueous"
|
||||
case os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") != "":
|
||||
return "Hyprland"
|
||||
case os.Getenv("NIRI_SOCKET") != "":
|
||||
@@ -825,6 +1005,8 @@ func detectRunningWM() string {
|
||||
return "MangoWC"
|
||||
case os.Getenv("MIRACLESOCK") != "":
|
||||
return "Miracle WM"
|
||||
case os.Getenv("UMBRIEL_SOCKET") != "":
|
||||
return "Umbriel"
|
||||
case os.Getenv("XDG_CURRENT_DESKTOP") != "":
|
||||
return os.Getenv("XDG_CURRENT_DESKTOP")
|
||||
}
|
||||
@@ -911,7 +1093,7 @@ ShellRoot {
|
||||
{"Polkit", "Authentication prompts"},
|
||||
{"IdleMonitor", "Idle detection"},
|
||||
{"IdleInhibitor", "Prevent idle/sleep"},
|
||||
{"ShortcutInhibitor", "Allow shortcut management (niri)"},
|
||||
{"ShortcutInhibitor", "Allow shortcut management (niri, Aqueous)"},
|
||||
{"BackgroundBlur", "Background blur API support in Quickshell"},
|
||||
}
|
||||
|
||||
@@ -1225,7 +1407,7 @@ func checkAdwGtk3() checkResult {
|
||||
func checkConfigurationFiles() []checkResult {
|
||||
configDir, _ := os.UserConfigDir()
|
||||
cacheDir, _ := os.UserCacheDir()
|
||||
dmsDir := "DankMaterialShell"
|
||||
dmsDir := "dms"
|
||||
|
||||
configFiles := []struct{ name, path string }{
|
||||
{"settings.json", filepath.Join(configDir, dmsDir, "settings.json")},
|
||||
@@ -1267,6 +1449,7 @@ func checkSystemdServices() []checkResult {
|
||||
var results []checkResult
|
||||
|
||||
dmsState := getServiceState("dms", true)
|
||||
wanted := config.SessionWanted()
|
||||
if !dmsState.exists {
|
||||
results = append(results, checkResult{catServices, "dms.service", statusInfo, "Not installed", "Optional user service", doctorDocsURL + "#services"})
|
||||
} else {
|
||||
@@ -1278,12 +1461,28 @@ func checkSystemdServices() []checkResult {
|
||||
case dmsState.active == "failed":
|
||||
status = statusError
|
||||
case dmsState.active == "active":
|
||||
case dmsState.enabled == "disabled" && len(wanted) > 0:
|
||||
message = "Started with " + strings.Join(wanted, ", ")
|
||||
if dmsState.active != "" {
|
||||
message += ", " + dmsState.active
|
||||
}
|
||||
case dmsState.enabled == "disabled" && os.Getenv("MANGO_INSTANCE_SIGNATURE") != "":
|
||||
status, message = statusInfo, "Disabled (Mango starts DMS from its config)"
|
||||
case dmsState.enabled == "disabled":
|
||||
status, message = statusWarn, "Disabled"
|
||||
case dmsState.active == "inactive":
|
||||
status = statusError
|
||||
}
|
||||
results = append(results, checkResult{catServices, "dms.service", status, message, "", doctorDocsURL + "#services"})
|
||||
details := ""
|
||||
if len(wanted) > 0 {
|
||||
details = "Wanted by " + strings.Join(wanted, ", ")
|
||||
}
|
||||
if wm, ok := config.RunningCompositor(); ok && status != statusOK {
|
||||
if unit := config.SessionUnitName(wm); !slices.Contains(wanted, unit) {
|
||||
details = fmt.Sprintf("Nothing starts dms.service with %s. Run 'dms systemd enable'.", unit)
|
||||
}
|
||||
}
|
||||
results = append(results, checkResult{catServices, "dms.service", status, message, details, doctorDocsURL + "#services"})
|
||||
}
|
||||
|
||||
if dmsState.exists && dmsState.enabled == "enabled" {
|
||||
@@ -1308,6 +1507,24 @@ func checkSystemdServices() []checkResult {
|
||||
return results
|
||||
}
|
||||
|
||||
// Chromium, Electron and libadwaita follow light/dark through the settings
|
||||
// portal; without it they fall back to GTK theme colours that never update.
|
||||
func checkSettingsPortal() []checkResult {
|
||||
conn, err := dbus.SessionBus()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var value dbus.Variant
|
||||
err = conn.Object("org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop").
|
||||
Call("org.freedesktop.portal.Settings.ReadOne", 0, "org.freedesktop.appearance", "color-scheme").
|
||||
Store(&value)
|
||||
if err == nil {
|
||||
return []checkResult{{catServices, "xdg-desktop-portal", statusOK, "Settings portal reachable", "", doctorDocsURL + "#services"}}
|
||||
}
|
||||
hint := "Apps cannot follow light/dark mode. The portal is D-Bus activated and needs graphical-session.target; without systemd, or a compositor that never starts that target (Mango before 0.17.1), exec the portal backend and xdg-desktop-portal at session start."
|
||||
return []checkResult{{catServices, "xdg-desktop-portal", statusWarn, "Settings portal unreachable", hint, doctorDocsURL + "#services"}}
|
||||
}
|
||||
|
||||
type serviceState struct {
|
||||
exists bool
|
||||
enabled string
|
||||
@@ -1395,7 +1612,7 @@ func printResultLine(r checkResult, styles tui.Styles) {
|
||||
|
||||
fmt.Printf(" %s %s %s %s\n", style.Render(icon), name, styles.Subtle.Render(dots), r.message)
|
||||
|
||||
if doctorVerbose && r.details != "" {
|
||||
if r.details != "" && (doctorVerbose || r.status != statusOK) {
|
||||
fmt.Printf(" %s\n", styles.Subtle.Render("└─ "+r.details))
|
||||
}
|
||||
|
||||
@@ -1454,7 +1671,7 @@ func formatResultsPlain(results []checkResult) string {
|
||||
|
||||
fmt.Fprintf(&sb, "- [%s] %s: %s\n", r.status, r.name, r.message)
|
||||
|
||||
if doctorVerbose && r.details != "" {
|
||||
if r.details != "" && (doctorVerbose || r.status != statusOK) {
|
||||
fmt.Fprintf(&sb, " - %s\n", r.details)
|
||||
}
|
||||
}
|
||||
@@ -1472,25 +1689,29 @@ func formatResultsPlain(results []checkResult) string {
|
||||
}
|
||||
|
||||
const (
|
||||
defaultDoctorFontFamily = "Inter Variable"
|
||||
defaultDoctorFontFamily = "Google Sans Flex"
|
||||
defaultDoctorMonoFontFamily = "Fira Code"
|
||||
defaultDoctorDisplayFamily = "DM Serif Display"
|
||||
)
|
||||
|
||||
// bundledFontRelPaths maps settings/default family names to font files shipped with
|
||||
// the shell and loaded via Qt FontLoader (not registered with fontconfig).
|
||||
var bundledFontRelPaths = map[string][]string{
|
||||
"inter variable": {
|
||||
"DankCommon/assets/fonts/inter/InterVariable.ttf",
|
||||
"assets/fonts/inter/InterVariable.ttf",
|
||||
"google sans flex": {
|
||||
"DCommon/assets/fonts/google-sans-flex/GoogleSansFlex.ttf",
|
||||
},
|
||||
"fira code": {
|
||||
"DankCommon/assets/fonts/nerd-fonts/FiraCodeNerdFont-Regular.ttf",
|
||||
"DCommon/assets/fonts/nerd-fonts/FiraCodeNerdFont-Regular.ttf",
|
||||
"assets/fonts/nerd-fonts/FiraCodeNerdFont-Regular.ttf",
|
||||
},
|
||||
"firacode nerd font": {
|
||||
"DankCommon/assets/fonts/nerd-fonts/FiraCodeNerdFont-Regular.ttf",
|
||||
"DCommon/assets/fonts/nerd-fonts/FiraCodeNerdFont-Regular.ttf",
|
||||
"assets/fonts/nerd-fonts/FiraCodeNerdFont-Regular.ttf",
|
||||
},
|
||||
"dm serif display": {
|
||||
"DCommon/assets/fonts/dm-serif-display/DMSerifDisplay-Regular.ttf",
|
||||
},
|
||||
"notable": {
|
||||
"DCommon/assets/fonts/notable/Notable-Regular.ttf",
|
||||
},
|
||||
}
|
||||
|
||||
func resolveDoctorShellPath() string {
|
||||
@@ -1561,7 +1782,7 @@ func checkConfiguredFont(label, family, shellPath, fcCache string, fcListAvailab
|
||||
return checkResult{
|
||||
catFonts, label, statusWarn,
|
||||
fmt.Sprintf("'%s' bundled file missing", family),
|
||||
"Expected font file missing from shell install. Reinstall DMS or check DankCommon assets.",
|
||||
"Expected font file missing from shell install. Reinstall DMS or check DCommon assets.",
|
||||
url,
|
||||
}
|
||||
}
|
||||
@@ -1602,13 +1823,15 @@ func checkFonts() []checkResult {
|
||||
|
||||
fontFamily := defaultDoctorFontFamily
|
||||
monoFontFamily := defaultDoctorMonoFontFamily
|
||||
displayFontFamily := defaultDoctorDisplayFamily
|
||||
|
||||
if configDir, err := os.UserConfigDir(); err == nil {
|
||||
settingsPath := filepath.Join(configDir, "DankMaterialShell", "settings.json")
|
||||
settingsPath := filepath.Join(configDir, "dms", "settings.json")
|
||||
if data, err := os.ReadFile(settingsPath); err == nil {
|
||||
var settings struct {
|
||||
FontFamily string `json:"fontFamily"`
|
||||
MonoFontFamily string `json:"monoFontFamily"`
|
||||
FontFamily string `json:"fontFamily"`
|
||||
MonoFontFamily string `json:"monoFontFamily"`
|
||||
DisplayFontFamily string `json:"displayFontFamily"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &settings); err == nil {
|
||||
if settings.FontFamily != "" {
|
||||
@@ -1617,12 +1840,15 @@ func checkFonts() []checkResult {
|
||||
if settings.MonoFontFamily != "" {
|
||||
monoFontFamily = settings.MonoFontFamily
|
||||
}
|
||||
if settings.DisplayFontFamily != "" {
|
||||
displayFontFamily = settings.DisplayFontFamily
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
shellPath := resolveDoctorShellPath()
|
||||
needFontconfig := !isBundledDefaultFont(fontFamily) || !isBundledDefaultFont(monoFontFamily)
|
||||
needFontconfig := !isBundledDefaultFont(fontFamily) || !isBundledDefaultFont(monoFontFamily) || !isBundledDefaultFont(displayFontFamily)
|
||||
|
||||
fcListAvailable := utils.CommandExists("fc-list")
|
||||
fcCache := ""
|
||||
@@ -1646,6 +1872,7 @@ func checkFonts() []checkResult {
|
||||
results = append(results,
|
||||
checkConfiguredFont("Normal Font", fontFamily, shellPath, fcCache, fcListAvailable, url),
|
||||
checkConfiguredFont("Monospace Font", monoFontFamily, shellPath, fcCache, fcListAvailable, url),
|
||||
checkConfiguredFont("Display Font", displayFontFamily, shellPath, fcCache, fcListAvailable, url),
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCheckBundledGoogleSans(t *testing.T) {
|
||||
shellPath := t.TempDir()
|
||||
fontPath := filepath.Join(shellPath, "DCommon", "assets", "fonts", "google-sans-flex", "GoogleSansFlex.ttf")
|
||||
missing := checkConfiguredFont("UI font", "Google Sans Flex", shellPath, "", false, "")
|
||||
if missing.status != statusWarn {
|
||||
t.Fatalf("missing bundled font status = %v, want %v", missing.status, statusWarn)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(fontPath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(fontPath, []byte("font"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, family := range []string{"Google Sans Flex", " google sans flex "} {
|
||||
result := checkConfiguredFont("UI font", family, shellPath, "", false, "")
|
||||
if result.status != statusOK {
|
||||
t.Fatalf("bundled font %q status = %v, want %v", family, result.status, statusOK)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"debug/elf"
|
||||
"encoding/binary"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/mangoconf"
|
||||
)
|
||||
|
||||
func TestQuickshellVersionFailureDetails(t *testing.T) {
|
||||
@@ -59,24 +64,12 @@ QT_VERSION:5.15.19`
|
||||
key: "QT_INSTALL_PLUGINS",
|
||||
want: "/usr/lib/qt/plugins",
|
||||
},
|
||||
{
|
||||
name: "a different key from the same dump",
|
||||
output: dumpAll,
|
||||
key: "QT_VERSION",
|
||||
want: "5.15.19",
|
||||
},
|
||||
{
|
||||
name: "key absent from a multi line dump yields nothing",
|
||||
output: dumpAll,
|
||||
key: "QT_INSTALL_LIBEXECS",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "empty output yields nothing",
|
||||
output: "",
|
||||
key: "QT_INSTALL_PLUGINS",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "surrounding whitespace is trimmed",
|
||||
output: " QT_INSTALL_PLUGINS: /usr/lib/qt/plugins \n",
|
||||
@@ -119,24 +112,43 @@ func writePlugin(t *testing.T, pluginsDir, file string) {
|
||||
}
|
||||
}
|
||||
|
||||
// qtenginePluginFile("", major) is a path with a directory component, and
|
||||
// qtPluginPath joins platformthemes/ again — a regression here silently warns
|
||||
// at every correctly installed system, so pin the full path down.
|
||||
func TestQtenginePluginPath(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if got := qtenginePluginPath([]string{root}, "6"); got != "" {
|
||||
t.Fatalf("missing plugin should yield an empty path, got %q", got)
|
||||
// writeQtPluginELF writes a minimal ELF holding only the .note.qt.metadata
|
||||
// note Qt 6 embeds in plugins, recording the Qt version it was built against.
|
||||
func writeQtPluginELF(t *testing.T, pluginsDir, file string, major, minor byte) {
|
||||
t.Helper()
|
||||
var note bytes.Buffer
|
||||
binary.Write(¬e, binary.LittleEndian, []uint32{12, 4, 0x74510001})
|
||||
note.WriteString("qt-project!\x00")
|
||||
note.Write([]byte{1, major, minor, 1})
|
||||
shstrtab := []byte("\x00.note.qt.metadata\x00.shstrtab\x00")
|
||||
|
||||
noteOff := uint64(64)
|
||||
strOff := noteOff + uint64(note.Len())
|
||||
shOff := strOff + uint64(len(shstrtab))
|
||||
var out bytes.Buffer
|
||||
header := elf.Header64{
|
||||
Type: uint16(elf.ET_DYN), Machine: uint16(elf.EM_X86_64), Version: uint32(elf.EV_CURRENT),
|
||||
Shoff: shOff, Ehsize: 64, Shentsize: 64, Shnum: 3, Shstrndx: 2,
|
||||
}
|
||||
want := filepath.Join(root, "platformthemes", "libqt6engine-plugin.so")
|
||||
if err := os.MkdirAll(filepath.Join(root, "platformthemes"), 0o755); err != nil {
|
||||
copy(header.Ident[:], elf.ELFMAG)
|
||||
header.Ident[elf.EI_CLASS] = byte(elf.ELFCLASS64)
|
||||
header.Ident[elf.EI_DATA] = byte(elf.ELFDATA2LSB)
|
||||
header.Ident[elf.EI_VERSION] = byte(elf.EV_CURRENT)
|
||||
binary.Write(&out, binary.LittleEndian, header)
|
||||
out.Write(note.Bytes())
|
||||
out.Write(shstrtab)
|
||||
binary.Write(&out, binary.LittleEndian, []elf.Section64{
|
||||
{},
|
||||
{Name: 1, Type: uint32(elf.SHT_NOTE), Off: noteOff, Size: uint64(note.Len()), Addralign: 4},
|
||||
{Name: 20, Type: uint32(elf.SHT_STRTAB), Off: strOff, Size: uint64(len(shstrtab)), Addralign: 1},
|
||||
})
|
||||
|
||||
if err := os.MkdirAll(filepath.Join(pluginsDir, "platformthemes"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(want, nil, 0o644); err != nil {
|
||||
if err := os.WriteFile(filepath.Join(pluginsDir, "platformthemes", file), out.Bytes(), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := qtenginePluginPath([]string{root}, "6"); got != want {
|
||||
t.Fatalf("qtenginePluginPath = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQtPlatformThemePlugin(t *testing.T) {
|
||||
@@ -169,6 +181,34 @@ func TestCheckQtPlatformThemePlugin(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("plugin built for the installed Qt minor reports OK", func(t *testing.T) {
|
||||
t.Setenv("QT_QPA_PLATFORMTHEME", "qt6ct")
|
||||
plugins := t.TempDir()
|
||||
writeQtPluginELF(t, plugins, "libqt6ct.so", 6, 12)
|
||||
stubQtQueryTool(t, "qmake6", "6.12.3", plugins)
|
||||
results := checkQtPlatformThemePlugin("QT_QPA_PLATFORMTHEME")
|
||||
if len(results) != 1 || results[0].status != statusOK {
|
||||
t.Fatalf("got %+v, want one OK result", results)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("plugin built for another Qt minor warns", func(t *testing.T) {
|
||||
t.Setenv("QT_QPA_PLATFORMTHEME", "qt6ct")
|
||||
plugins := t.TempDir()
|
||||
writeQtPluginELF(t, plugins, "libqt6ct.so", 6, 11)
|
||||
stubQtQueryTool(t, "qmake6", "6.12.0", plugins)
|
||||
results := checkQtPlatformThemePlugin("QT_QPA_PLATFORMTHEME")
|
||||
if len(results) != 1 || results[0].status != statusWarn {
|
||||
t.Fatalf("got %+v, want one warn result", results)
|
||||
}
|
||||
if results[0].message != "Built for Qt 6.11, Qt is 6.12.0" {
|
||||
t.Fatalf("message = %q, want both Qt versions", results[0].message)
|
||||
}
|
||||
if !strings.Contains(results[0].details, filepath.Join(plugins, "platformthemes", "libqt6ct.so")) {
|
||||
t.Fatalf("details should name the stale plugin, got %q", results[0].details)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing plugin warns with the expected path", func(t *testing.T) {
|
||||
t.Setenv("QT_QPA_PLATFORMTHEME", "qt6ct")
|
||||
plugins := t.TempDir()
|
||||
@@ -192,15 +232,55 @@ func TestCheckQtPlatformThemePlugin(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unset and qtengine are left to their own checks", func(t *testing.T) {
|
||||
t.Run("unset is left to checkEnvVar", func(t *testing.T) {
|
||||
t.Setenv("PATH", t.TempDir())
|
||||
t.Setenv("QT_QPA_PLATFORMTHEME", "")
|
||||
if results := checkQtPlatformThemePlugin("QT_QPA_PLATFORMTHEME"); results != nil {
|
||||
t.Fatalf("unset should yield no results, got %+v", results)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("qtengine checks the Qt5 and Qt6 plugins", func(t *testing.T) {
|
||||
t.Setenv("QT_QPA_PLATFORMTHEME", "qtengine")
|
||||
if results := checkQtPlatformThemePlugin("QT_QPA_PLATFORMTHEME"); results != nil {
|
||||
t.Fatalf("qtengine should yield no results, got %+v", results)
|
||||
qt5, qt6 := t.TempDir(), t.TempDir()
|
||||
writePlugin(t, qt6, "libqt6engine-plugin.so")
|
||||
dir5 := stubQtQueryTool(t, "qmake-qt5", "5.15.19", qt5)
|
||||
dir6 := stubQtQueryTool(t, "qmake6", "6.11.2", qt6)
|
||||
t.Setenv("PATH", dir6+string(os.PathListSeparator)+dir5)
|
||||
results := checkQtPlatformThemePlugin("QT_QPA_PLATFORMTHEME")
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("got %d results, want 2: %+v", len(results), results)
|
||||
}
|
||||
if results[0].name != "qtengine plugin (Qt5)" || results[0].status != statusWarn {
|
||||
t.Fatalf("Qt5 result = %+v, want a warning for the missing plugin", results[0])
|
||||
}
|
||||
if !strings.Contains(results[0].details, filepath.Join(qt5, "platformthemes", "libqt5engine-plugin.so")) {
|
||||
t.Fatalf("Qt5 details should contain the expected path, got %q", results[0].details)
|
||||
}
|
||||
if results[1].name != "qtengine plugin (Qt6)" || results[1].status != statusOK {
|
||||
t.Fatalf("Qt6 result = %+v, want OK", results[1])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("qtengine in the qt6 variable skips Qt5", func(t *testing.T) {
|
||||
t.Setenv("QT_QPA_PLATFORMTHEME_QT6", "qtengine")
|
||||
qt5, qt6 := t.TempDir(), t.TempDir()
|
||||
writePlugin(t, qt6, "libqt6engine-plugin.so")
|
||||
dir5 := stubQtQueryTool(t, "qmake-qt5", "5.15.19", qt5)
|
||||
dir6 := stubQtQueryTool(t, "qmake6", "6.11.2", qt6)
|
||||
t.Setenv("PATH", dir6+string(os.PathListSeparator)+dir5)
|
||||
results := checkQtPlatformThemePlugin("QT_QPA_PLATFORMTHEME_QT6")
|
||||
if len(results) != 1 || results[0].name != "qtengine plugin (Qt6)" || results[0].status != statusOK {
|
||||
t.Fatalf("got %+v, want one OK Qt6 result", results)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("qtengine without Qt tools degrades to one info", func(t *testing.T) {
|
||||
t.Setenv("QT_QPA_PLATFORMTHEME", "qtengine")
|
||||
t.Setenv("PATH", t.TempDir())
|
||||
results := checkQtPlatformThemePlugin("QT_QPA_PLATFORMTHEME")
|
||||
if len(results) != 1 || results[0].status != statusInfo || results[0].name != "qtengine plugin (Qt6)" {
|
||||
t.Fatalf("got %+v, want one Qt6 info result", results)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -235,3 +315,123 @@ func TestCheckQtPlatformThemePlugin(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCheckQtengineConfig(t *testing.T) {
|
||||
configHome, dataHome := t.TempDir(), t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", configHome)
|
||||
t.Setenv("XDG_DATA_HOME", dataHome)
|
||||
path := filepath.Join(configHome, "qtengine", "config.json")
|
||||
scheme := filepath.Join(dataHome, "color-schemes", "DankMatugen.colors")
|
||||
|
||||
write := func(t *testing.T, content string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
content string // "" leaves the file absent
|
||||
status status
|
||||
message string
|
||||
}{
|
||||
{"missing", "", statusInfo, "Not found"},
|
||||
{"empty", "\n", statusInfo, "Not found"},
|
||||
{"unparseable", "{", statusWarn, "Unreadable"},
|
||||
{"theme is not an object", `{"theme": 1}`, statusWarn, "Unreadable"},
|
||||
{"dms scheme", `{"theme": {"colorScheme": "` + scheme + `"}}`, statusOK, "Uses DMS colors"},
|
||||
{"other scheme", `{"theme": {"colorScheme": "/usr/share/color-schemes/BreezeDark.colors"}}`, statusInfo, "Not using DMS colors"},
|
||||
{"no scheme", `{"theme": {}}`, statusInfo, "Not using DMS colors"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
os.Remove(path)
|
||||
if tc.content != "" {
|
||||
write(t, tc.content)
|
||||
}
|
||||
results := checkQtengineConfig()
|
||||
if len(results) != 1 || results[0].status != tc.status || results[0].message != tc.message {
|
||||
t.Fatalf("got %+v, want %s %q", results, tc.status, tc.message)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckXDGMenuFile(t *testing.T) {
|
||||
configHome, systemDir := t.TempDir(), t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", configHome)
|
||||
t.Setenv("XDG_CONFIG_DIRS", systemDir)
|
||||
|
||||
if checkXDGMenuFile("hyprland-") {
|
||||
t.Fatal("no menu file anywhere should not be found")
|
||||
}
|
||||
for _, dir := range []string{configHome, systemDir} {
|
||||
menus := filepath.Join(dir, "menus")
|
||||
if err := os.MkdirAll(menus, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file := filepath.Join(menus, "hyprland-applications.menu")
|
||||
if err := os.WriteFile(file, nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !checkXDGMenuFile("hyprland-") {
|
||||
t.Fatalf("menu file in %s should be found", menus)
|
||||
}
|
||||
os.Remove(file)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckMangoConfigFlagsWrongDialectAndOverviewBinds(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, "dms"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = os.WriteFile(filepath.Join(dir, "config.conf"), []byte("exec-once=dms run\nmousebind=NONE,btn_left,toggleoverview,1\n"), 0o644)
|
||||
_ = os.WriteFile(filepath.Join(dir, "dms", "layout.conf"), []byte("border_px=2\n"), 0o644)
|
||||
|
||||
if got := checkMangoConfig(dir, mangoconf.Legacy); len(got) != 2 || got[0].message != "Not legacy keys: layout.conf" {
|
||||
t.Fatalf("legacy: %+v", got)
|
||||
}
|
||||
if got := checkMangoConfig(dir, mangoconf.Snake); len(got) != 2 || got[0].message != "Not snake keys: config.conf" {
|
||||
t.Fatalf("snake: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHyprlandLacksLua(t *testing.T) {
|
||||
for ver, want := range map[string]bool{
|
||||
"0.54.9": true,
|
||||
"0.55.0": false,
|
||||
"": false,
|
||||
} {
|
||||
if got := hyprlandLacksLua(ver); got != want {
|
||||
t.Errorf("hyprlandLacksLua(%q) = %v, want %v", ver, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckHyprlandConfigFormat(t *testing.T) {
|
||||
for name, tc := range map[string]struct {
|
||||
files []string
|
||||
warn bool
|
||||
}{
|
||||
"lua only": {[]string{"hyprland.lua"}, false},
|
||||
"lua wins": {[]string{"hyprland.lua", "hyprland.conf"}, false},
|
||||
"conf only": {[]string{"hyprland.conf"}, true},
|
||||
"neither": {nil, false},
|
||||
} {
|
||||
dir := t.TempDir()
|
||||
for _, f := range tc.files {
|
||||
if err := os.WriteFile(filepath.Join(dir, f), nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
got := checkHyprlandConfigFormat(dir)
|
||||
if warned := len(got) == 1 && got[0].status == statusWarn; warned != tc.warn || (!tc.warn && len(got) != 0) {
|
||||
t.Errorf("%s: got %+v, want warn=%v", name, got, tc.warn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/netfetch"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/netfetch"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/log"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
|
||||
@@ -13,20 +13,21 @@ import (
|
||||
"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/netfetch"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/version"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/distros"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/errdefs"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/log"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/netfetch"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/privesc"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/server/sysupdate"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/utils"
|
||||
"github.com/duxlabs-dev/dux-shell/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",
|
||||
Short: "Update Dux Shell to the latest version",
|
||||
Long: "Update Dux Shell to the latest version using the appropriate package manager for your distribution",
|
||||
PreRunE: shellApp.ResolveConfig,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
runUpdate()
|
||||
@@ -35,7 +36,7 @@ var updateCmd = &cobra.Command{
|
||||
|
||||
var updateCheckCmd = &cobra.Command{
|
||||
Use: "check",
|
||||
Short: "Check if updates are available for DankMaterialShell",
|
||||
Short: "Check if updates are available for Dux Shell",
|
||||
Long: "Check for available updates without performing the actual update",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
runUpdateCheck()
|
||||
@@ -43,9 +44,19 @@ var updateCheckCmd = &cobra.Command{
|
||||
}
|
||||
|
||||
func runUpdateCheck() {
|
||||
fmt.Println("Checking for DankMaterialShell updates...")
|
||||
fmt.Println("Checking for Dux Shell updates...")
|
||||
fmt.Println()
|
||||
|
||||
shell := sysupdate.ProbeShell(context.Background(), Version)
|
||||
switch shell.InstallMethod {
|
||||
case sysupdate.InstallPacman, sysupdate.InstallRPM, sysupdate.InstallDpkg, sysupdate.InstallXbps:
|
||||
fmt.Printf("Installed as package %s (%s); update it with your package manager.\n", shell.PackageName, shell.InstallMethod)
|
||||
os.Exit(0)
|
||||
case sysupdate.InstallNix:
|
||||
fmt.Println("Managed by Nix; update through your flake or channel.")
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
versionInfo, err := version.GetDMSVersionInfo()
|
||||
if err != nil {
|
||||
log.Fatalf("Error checking for updates: %v", err)
|
||||
@@ -158,7 +169,7 @@ func updateArchLinux() error {
|
||||
return updateOtherDistros()
|
||||
}
|
||||
|
||||
fmt.Printf("This will update DankMaterialShell using %s.\n", helper)
|
||||
fmt.Printf("This will update Dux Shell using %s.\n", helper)
|
||||
if !confirmUpdate() {
|
||||
return errdefs.ErrUpdateCancelled
|
||||
}
|
||||
@@ -182,12 +193,8 @@ func updateOtherDistros() error {
|
||||
}
|
||||
|
||||
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)
|
||||
_, statErr := os.Stat(dmsPath)
|
||||
hasClone := statErr == nil
|
||||
|
||||
versionInfo, err := version.GetDMSVersionInfo()
|
||||
if err == nil && !versionInfo.HasUpdate {
|
||||
@@ -199,9 +206,25 @@ func updateOtherDistros() error {
|
||||
return errdefs.ErrNoUpdateNeeded
|
||||
}
|
||||
|
||||
// Release binaries embed the shell; without a clone the binary is the whole install.
|
||||
if !hasClone {
|
||||
fmt.Println("\nThis will update the dms binary from GitHub releases.")
|
||||
if !confirmUpdate() {
|
||||
return errdefs.ErrUpdateCancelled
|
||||
}
|
||||
fmt.Println("\n=== Updating dms binary ===")
|
||||
if err := updateDMSBinary(); err != nil {
|
||||
return fmt.Errorf("failed to update dms binary: %w", err)
|
||||
}
|
||||
fmt.Println("dms binary successfully updated")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("Found DMS configuration at %s\n", dmsPath)
|
||||
|
||||
fmt.Println("\nThis will update:")
|
||||
fmt.Println(" 1. The dms binary from GitHub releases")
|
||||
fmt.Println(" 2. DankMaterialShell configuration using git")
|
||||
fmt.Println(" 2. Dux Shell configuration using git")
|
||||
if !confirmUpdate() {
|
||||
return errdefs.ErrUpdateCancelled
|
||||
}
|
||||
@@ -362,7 +385,7 @@ func offerReclone(dmsPath string) bool {
|
||||
}
|
||||
|
||||
fmt.Println("Cloning fresh copy...")
|
||||
cloneCmd := exec.Command("git", "clone", "--recurse-submodules", "https://github.com/AvengeMedia/DankMaterialShell.git", dmsPath)
|
||||
cloneCmd := exec.Command("git", "clone", "--recurse-submodules", "https://github.com/duxlabs-dev/dux-shell.git", dmsPath)
|
||||
cloneCmd.Stdout = os.Stdout
|
||||
cloneCmd.Stderr = os.Stderr
|
||||
if err := cloneCmd.Run(); err != nil {
|
||||
@@ -426,8 +449,8 @@ func updateDMSBinary() error {
|
||||
}
|
||||
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)
|
||||
binaryURL := fmt.Sprintf("https://github.com/duxlabs-dev/dux-shell/releases/download/%s/dms-cli-%s.gz", version, arch)
|
||||
checksumURL := fmt.Sprintf("https://github.com/duxlabs-dev/dux-shell/releases/download/%s/dms-cli-%s.gz.sha256", version, arch)
|
||||
|
||||
binaryPath := filepath.Join(tempDir, "dms.gz")
|
||||
checksumPath := filepath.Join(tempDir, "dms.gz.sha256")
|
||||
|
||||
@@ -6,7 +6,8 @@ import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/distros"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/site"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -52,11 +53,15 @@ func greeterPackageInstallHint() string {
|
||||
return "Install package: dms-greeter"
|
||||
}
|
||||
|
||||
if config.ID == "pika" {
|
||||
return "Install with 'sudo apt install dms-greeter'"
|
||||
}
|
||||
|
||||
switch config.Family {
|
||||
case distros.FamilyDebian:
|
||||
return "Install with 'sudo apt install dms-greeter' (requires DankLinux OBS repo — see https://danklinux.com/docs/dankgreeter/installation#debian)"
|
||||
return "Install with 'sudo apt install dms-greeter' (requires Dux Labs OBS repo — see " + site.Docs + "/dms-greeter/installation#debian)"
|
||||
case distros.FamilySUSE:
|
||||
return "Install with 'sudo zypper install dms-greeter' (requires DankLinux OBS repo — see https://danklinux.com/docs/dankgreeter/installation#opensuse)"
|
||||
return "Install with 'sudo zypper install dms-greeter' (requires Dux Labs OBS repo — see " + site.Docs + "/dms-greeter/installation#opensuse--derivatives)"
|
||||
case distros.FamilyUbuntu:
|
||||
return "Install with 'sudo apt install dms-greeter' (requires ppa:avengemedia/danklinux: sudo add-apt-repository ppa:avengemedia/danklinux)"
|
||||
case distros.FamilyFedora:
|
||||
@@ -64,7 +69,7 @@ func greeterPackageInstallHint() string {
|
||||
case distros.FamilyArch:
|
||||
return "Install from AUR with 'paru -S greetd-dms-greeter-bin' or 'yay -S greetd-dms-greeter-bin'"
|
||||
case distros.FamilyVoid:
|
||||
return "Install with 'sudo xbps-install -S dms-greeter' (requires DMS XBPS repo: echo 'repository=https://void.danklinux.com/dms/current' | sudo tee /etc/xbps.d/dms.conf)"
|
||||
return "Install with 'sudo xbps-install -S dms-greeter' (requires DMS XBPS repo: echo 'repository=" + distros.VoidDMSRepo + "' | sudo tee /etc/xbps.d/dms.conf)"
|
||||
default:
|
||||
return "Install the dms-greeter package for your distribution"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,485 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/duxlabs-dev/dux-go/ipc"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/icc"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/log"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/server/wayland"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var iccCmd = &cobra.Command{
|
||||
Use: "icc",
|
||||
Short: "Manage ICC color profiles",
|
||||
Long: "Manage ICC color profile assignments for displays",
|
||||
}
|
||||
|
||||
var iccListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List available ICC profiles and current assignments",
|
||||
Args: cobra.NoArgs,
|
||||
Run: runICCList,
|
||||
}
|
||||
|
||||
var iccInfoCmd = &cobra.Command{
|
||||
Use: "info <file>",
|
||||
Short: "Show details of an ICC profile",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: runICCInfo,
|
||||
}
|
||||
|
||||
var iccApplyCmd = &cobra.Command{
|
||||
Use: "apply <output> <file>",
|
||||
Short: "Apply ICC profile to a specific output",
|
||||
Args: cobra.ExactArgs(2),
|
||||
Run: runICCApply,
|
||||
}
|
||||
|
||||
var iccRemoveCmd = &cobra.Command{
|
||||
Use: "remove <output>",
|
||||
Short: "Remove ICC profile from a specific output",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: runICCRemove,
|
||||
}
|
||||
|
||||
var iccStatusCmd = &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Show ICC profile status for all outputs",
|
||||
Args: cobra.NoArgs,
|
||||
Run: runICCStatus,
|
||||
}
|
||||
|
||||
var iccSetTempCmd = &cobra.Command{
|
||||
Use: "set-temp <output> <kelvin>",
|
||||
Aliases: []string{"setTemp"},
|
||||
Short: "Set the color temperature for a specific output",
|
||||
Long: "Set the color temperature for a specific output, independently of the\n" +
|
||||
"night light schedule: every display keeps its own value, which also makes\n" +
|
||||
"the range wider than the night light's.\n\n" +
|
||||
"For an output with an ICC profile the temperature is composed on top of\n" +
|
||||
"it, treating the profile as measured at 6500K.\n\n" +
|
||||
"Accepts 1000-10000K; 0 removes the override, so the output follows the\n" +
|
||||
"night light schedule again (6500K while it is disabled).",
|
||||
Args: cobra.ExactArgs(2),
|
||||
Run: runICCSetTemp,
|
||||
}
|
||||
|
||||
func init() {
|
||||
iccCmd.AddCommand(iccListCmd, iccInfoCmd, iccApplyCmd, iccRemoveCmd, iccStatusCmd, iccSetTempCmd)
|
||||
}
|
||||
|
||||
func getICCConfigDir() string {
|
||||
return wayland.ICCProfilesDir()
|
||||
}
|
||||
|
||||
func listAvailableProfiles() []string {
|
||||
dir := getICCConfigDir()
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var profiles []string
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
ext := strings.ToLower(filepath.Ext(name))
|
||||
if ext == ".icc" || ext == ".icm" {
|
||||
profiles = append(profiles, filepath.Join(dir, name))
|
||||
}
|
||||
}
|
||||
return profiles
|
||||
}
|
||||
|
||||
func runICCList(cmd *cobra.Command, args []string) {
|
||||
// Get current assignments from running server
|
||||
req := ipc.Request{
|
||||
Method: "wayland.icc.getStatus",
|
||||
}
|
||||
resp, err := sendServerRequest(req)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to get ICC status: %v", err)
|
||||
}
|
||||
|
||||
statusMap := make(map[string]map[string]any)
|
||||
outputs := []string{}
|
||||
if resp.Result != nil {
|
||||
if resultBytes, err := json.Marshal(*resp.Result); err == nil {
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal(resultBytes, &result); err == nil {
|
||||
if outputsRaw, ok := result["outputs"].([]any); ok {
|
||||
for _, o := range outputsRaw {
|
||||
if s, ok := o.(string); ok {
|
||||
outputs = append(outputs, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
if profilesRaw, ok := result["profiles"].(map[string]any); ok {
|
||||
for k, v := range profilesRaw {
|
||||
if m, ok := v.(map[string]any); ok {
|
||||
statusMap[k] = m
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show existing assignments
|
||||
fmt.Println("Current ICC Assignments:")
|
||||
fmt.Println(strings.Repeat("─", 80))
|
||||
if len(outputs) == 0 && len(statusMap) == 0 {
|
||||
fmt.Println(" (no outputs found)")
|
||||
} else {
|
||||
shown := make(map[string]bool)
|
||||
for _, output := range outputs {
|
||||
shown[output] = true
|
||||
if status, ok := statusMap[output]; ok {
|
||||
desc := ""
|
||||
if d, ok := status["description"].(string); ok {
|
||||
desc = d
|
||||
}
|
||||
path := ""
|
||||
if p, ok := status["path"].(string); ok {
|
||||
path = p
|
||||
}
|
||||
fmt.Printf(" %-20s %-30s %s\n", output, desc, path)
|
||||
} else {
|
||||
fmt.Printf(" %-20s %-30s %s\n", output, "(none)", "")
|
||||
}
|
||||
}
|
||||
// Show any outputs that are in statusMap but not in outputs list
|
||||
for output, status := range statusMap {
|
||||
if shown[output] {
|
||||
continue
|
||||
}
|
||||
desc := ""
|
||||
if d, ok := status["description"].(string); ok {
|
||||
desc = d
|
||||
}
|
||||
path := ""
|
||||
if p, ok := status["path"].(string); ok {
|
||||
path = p
|
||||
}
|
||||
fmt.Printf(" %-20s %-30s %s\n", output, desc, path)
|
||||
}
|
||||
}
|
||||
|
||||
// Show available profiles in config dir
|
||||
fmt.Println()
|
||||
fmt.Println("Available Profiles in", getICCConfigDir()+":")
|
||||
fmt.Println(strings.Repeat("─", 80))
|
||||
|
||||
profiles := listAvailableProfiles()
|
||||
if len(profiles) == 0 {
|
||||
fmt.Println(" (no .icc/.icm files found)")
|
||||
} else {
|
||||
for _, path := range profiles {
|
||||
profile, err := icc.ParseFile(path)
|
||||
if err != nil {
|
||||
fmt.Printf(" %-40s (parse error: %v)\n", filepath.Base(path), err)
|
||||
continue
|
||||
}
|
||||
// A profile without a vcgt table has no video card gamma ramp, so the
|
||||
// daemon refuses to apply it: say so here instead of letting the user
|
||||
// find out from a rejected `dms icc apply`.
|
||||
note := ""
|
||||
if !profile.HasVCGT {
|
||||
note = " (no vcgt table: cannot be applied)"
|
||||
}
|
||||
fmt.Printf(" %-40s %s%s\n", filepath.Base(path), profile.Description, note)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runICCInfo(cmd *cobra.Command, args []string) {
|
||||
path := args[0]
|
||||
|
||||
profile, err := icc.ParseFile(path)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to parse ICC profile: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("ICC Profile: %s\n", filepath.Base(path))
|
||||
fmt.Println(strings.Repeat("─", 60))
|
||||
fmt.Printf(" Description: %s\n", profile.Description)
|
||||
fmt.Printf(" Version: %s\n", profile.Version)
|
||||
fmt.Printf(" Class: %s\n", profile.Class)
|
||||
fmt.Printf(" Color Space: %s\n", profile.ColorSpace)
|
||||
fmt.Println()
|
||||
|
||||
if profile.HasMatrix {
|
||||
fmt.Println("Matrix:")
|
||||
for i := 0; i < 3; i++ {
|
||||
fmt.Printf(" %8.4f %8.4f %8.4f\n",
|
||||
profile.Matrix[i][0], profile.Matrix[i][1], profile.Matrix[i][2])
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
if profile.HasTRC {
|
||||
fmt.Println("TRC (Tone Reproduction Curves):")
|
||||
trcNames := []string{" Red", " Green", " Blue"}
|
||||
for i, name := range trcNames {
|
||||
curve := profile.TRC[i]
|
||||
switch curve.Type {
|
||||
case icc.CurveIdentity:
|
||||
fmt.Printf("%-8s identity\n", name)
|
||||
case icc.CurveParametric:
|
||||
fmt.Printf("%-8s gamma=%.2f\n", name, curve.Gamma)
|
||||
case icc.CurveTable:
|
||||
fmt.Printf("%-8s table (%d entries)\n", name, len(curve.Entries))
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
if profile.HasVCGT && profile.VCGT != nil {
|
||||
fmt.Printf("VCGT: %d channels, %d entries per channel\n", profile.VCGT.Channels, profile.VCGT.Entries)
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
if x, y, ok := profile.WhitePointXY(); ok {
|
||||
cct := profile.WhitePointCCT()
|
||||
name := profile.WhitePointName()
|
||||
switch {
|
||||
case name != "" && cct > 0:
|
||||
fmt.Printf("White Point: %s (%.4f, %.4f, %dK)\n", name, x, y, cct)
|
||||
case cct > 0:
|
||||
fmt.Printf("White Point: (%.4f, %.4f, %dK)\n", x, y, cct)
|
||||
default:
|
||||
fmt.Printf("White Point: (%.4f, %.4f)\n", x, y)
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("White Point: %.4f %.4f %.4f\n",
|
||||
profile.WhitePoint[0], profile.WhitePoint[1], profile.WhitePoint[2])
|
||||
}
|
||||
}
|
||||
|
||||
func runICCApply(cmd *cobra.Command, args []string) {
|
||||
outputName := args[0]
|
||||
filePath, err := filepath.Abs(args[1])
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to resolve path: %v", err)
|
||||
}
|
||||
|
||||
profile, err := icc.ParseFile(filePath)
|
||||
if err != nil {
|
||||
log.Fatalf("Invalid ICC profile: %v", err)
|
||||
}
|
||||
|
||||
req := ipc.Request{
|
||||
Method: "wayland.icc.apply",
|
||||
Params: map[string]any{
|
||||
"output": outputName,
|
||||
"path": filePath,
|
||||
},
|
||||
}
|
||||
resp, err := sendServerRequest(req)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to apply ICC profile: %v", err)
|
||||
}
|
||||
if resp.Error != "" {
|
||||
log.Fatalf("Server error: %s", resp.Error)
|
||||
}
|
||||
|
||||
fmt.Printf("Applied ICC profile '%s' to output '%s'\n", profile.Description, outputName)
|
||||
}
|
||||
|
||||
func runICCRemove(cmd *cobra.Command, args []string) {
|
||||
outputName := args[0]
|
||||
|
||||
req := ipc.Request{
|
||||
Method: "wayland.icc.remove",
|
||||
Params: map[string]any{
|
||||
"output": outputName,
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := sendServerRequest(req)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to remove ICC profile: %v", err)
|
||||
}
|
||||
|
||||
if resp.Error != "" {
|
||||
log.Fatalf("Server error: %s", resp.Error)
|
||||
}
|
||||
|
||||
fmt.Printf("Removed ICC profile from output '%s'\n", outputName)
|
||||
}
|
||||
|
||||
func runICCSetTemp(cmd *cobra.Command, args []string) {
|
||||
outputName := args[0]
|
||||
|
||||
temp, err := strconv.Atoi(args[1])
|
||||
if err != nil {
|
||||
log.Fatalf("Invalid temperature %q: expected a number in kelvin", args[1])
|
||||
}
|
||||
if temp != 0 && (temp < 1000 || temp > 10000) {
|
||||
log.Fatalf("Temperature %d out of range (1000-10000, or 0 to follow the schedule)", temp)
|
||||
}
|
||||
|
||||
req := ipc.Request{
|
||||
Method: "wayland.icc.setTemp",
|
||||
Params: map[string]any{
|
||||
"output": outputName,
|
||||
"temp": temp,
|
||||
},
|
||||
}
|
||||
resp, err := sendServerRequest(req)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to set output temperature: %v", err)
|
||||
}
|
||||
if resp.Error != "" {
|
||||
log.Fatalf("Server error: %s", resp.Error)
|
||||
}
|
||||
|
||||
if temp == 0 {
|
||||
fmt.Printf("Output '%s' follows the night light schedule again\n", outputName)
|
||||
return
|
||||
}
|
||||
fmt.Printf("Set output '%s' color temperature to %dK\n", outputName, temp)
|
||||
}
|
||||
|
||||
// fetchICCOutputTemps asks the running server for the per-output overrides.
|
||||
// Outputs without an override report 0.
|
||||
func fetchICCOutputTemps() map[string]int {
|
||||
temps := make(map[string]int)
|
||||
|
||||
resp, err := sendServerRequest(ipc.Request{Method: "wayland.icc.getTemps"})
|
||||
if err != nil || resp.Result == nil {
|
||||
return temps
|
||||
}
|
||||
resultBytes, err := json.Marshal(*resp.Result)
|
||||
if err != nil {
|
||||
return temps
|
||||
}
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(resultBytes, &raw); err != nil {
|
||||
return temps
|
||||
}
|
||||
for output, value := range raw {
|
||||
if f, ok := value.(float64); ok {
|
||||
temps[output] = int(f)
|
||||
}
|
||||
}
|
||||
return temps
|
||||
}
|
||||
|
||||
// formatOutputTemp renders a per-output override, or the schedule marker.
|
||||
func formatOutputTemp(temps map[string]int, output string) string {
|
||||
temp, ok := temps[output]
|
||||
if !ok || temp == 0 {
|
||||
return "schedule"
|
||||
}
|
||||
return strconv.Itoa(temp) + "K"
|
||||
}
|
||||
|
||||
func runICCStatus(cmd *cobra.Command, args []string) {
|
||||
req := ipc.Request{
|
||||
Method: "wayland.icc.getStatus",
|
||||
}
|
||||
|
||||
resp, err := sendServerRequest(req)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to get ICC status: %v", err)
|
||||
}
|
||||
|
||||
statusMap := make(map[string]map[string]any)
|
||||
outputs := []string{}
|
||||
if resp.Result != nil {
|
||||
if resultBytes, err := json.Marshal(*resp.Result); err == nil {
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal(resultBytes, &result); err == nil {
|
||||
if outputsRaw, ok := result["outputs"].([]any); ok {
|
||||
for _, o := range outputsRaw {
|
||||
if s, ok := o.(string); ok {
|
||||
outputs = append(outputs, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
if profilesRaw, ok := result["profiles"].(map[string]any); ok {
|
||||
for k, v := range profilesRaw {
|
||||
if m, ok := v.(map[string]any); ok {
|
||||
statusMap[k] = m
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
temps := fetchICCOutputTemps()
|
||||
|
||||
fmt.Println("ICC Profile Status:")
|
||||
fmt.Println(strings.Repeat("─", 100))
|
||||
fmt.Printf(" %-20s %-20s %-10s %-10s %-7s %-9s\n", "Output", "Profile", "Version", "ColorSpace", "Active", "Temp")
|
||||
fmt.Println(" " + strings.Repeat("─", 96))
|
||||
|
||||
shown := make(map[string]bool)
|
||||
for _, output := range outputs {
|
||||
shown[output] = true
|
||||
if status, ok := statusMap[output]; ok {
|
||||
active := false
|
||||
if a, ok := status["active"].(bool); ok {
|
||||
active = a
|
||||
}
|
||||
desc := "(none)"
|
||||
if d, ok := status["description"].(string); ok && d != "" {
|
||||
desc = d
|
||||
}
|
||||
version := ""
|
||||
if v, ok := status["version"].(string); ok {
|
||||
version = v
|
||||
}
|
||||
colorSpace := ""
|
||||
if c, ok := status["colorSpace"].(string); ok {
|
||||
colorSpace = c
|
||||
}
|
||||
activeStr := "no"
|
||||
if active {
|
||||
activeStr = "yes"
|
||||
}
|
||||
fmt.Printf(" %-20s %-20s %-10s %-10s %-7s %-9s\n", output, desc, version, colorSpace, activeStr, formatOutputTemp(temps, output))
|
||||
} else {
|
||||
fmt.Printf(" %-20s %-20s %-10s %-10s %-7s %-9s\n", output, "(none)", "", "", "no", formatOutputTemp(temps, output))
|
||||
}
|
||||
}
|
||||
|
||||
// Show any outputs that are in statusMap but not in outputs list
|
||||
for output, status := range statusMap {
|
||||
if shown[output] {
|
||||
continue
|
||||
}
|
||||
active := false
|
||||
if a, ok := status["active"].(bool); ok {
|
||||
active = a
|
||||
}
|
||||
desc := "(none)"
|
||||
if d, ok := status["description"].(string); ok && d != "" {
|
||||
desc = d
|
||||
}
|
||||
version := ""
|
||||
if v, ok := status["version"].(string); ok {
|
||||
version = v
|
||||
}
|
||||
colorSpace := ""
|
||||
if c, ok := status["colorSpace"].(string); ok {
|
||||
colorSpace = c
|
||||
}
|
||||
activeStr := "no"
|
||||
if active {
|
||||
activeStr = "yes"
|
||||
}
|
||||
fmt.Printf(" %-20s %-20s %-10s %-10s %-7s %-9s\n", output, desc, version, colorSpace, activeStr, formatOutputTemp(temps, output))
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,18 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds/providers"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/keybinds"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/keybinds/providers"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/log"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/wayland/keymap"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -46,7 +51,7 @@ var keybindsSetCmd = &cobra.Command{
|
||||
Short: "Set a keybind override",
|
||||
Long: "Create or update a keybind override for the specified provider",
|
||||
Args: cobra.ExactArgs(3),
|
||||
Run: runKeybindsSet,
|
||||
Run: runKeybindsEdit,
|
||||
}
|
||||
|
||||
var keybindsRemoveCmd = &cobra.Command{
|
||||
@@ -54,7 +59,7 @@ var keybindsRemoveCmd = &cobra.Command{
|
||||
Short: "Remove a keybind",
|
||||
Long: "Remove a keybind. For Hyprland this writes a negative override to dms/binds-user.lua so the key stays unbound across DMS updates. For other providers it deletes the entry from the managed file.",
|
||||
Args: cobra.ExactArgs(2),
|
||||
Run: runKeybindsRemove,
|
||||
Run: runKeybindsEdit,
|
||||
}
|
||||
|
||||
var keybindsResetCmd = &cobra.Command{
|
||||
@@ -62,10 +67,23 @@ var keybindsResetCmd = &cobra.Command{
|
||||
Short: "Reset a keybind override to its DMS default",
|
||||
Long: "Drop the user override for the given key so the DMS default re-applies. For providers without a separate default file (Niri, MangoWC) this is equivalent to remove.",
|
||||
Args: cobra.ExactArgs(2),
|
||||
Run: runKeybindsReset,
|
||||
Run: runKeybindsEdit,
|
||||
}
|
||||
|
||||
var keybindsKeymapCmd = &cobra.Command{
|
||||
Use: "keymap",
|
||||
Short: "Report which keysym each physical key carries on the first level",
|
||||
Long: "Print the keysyms the active keyboard layout puts on the first level of every physical key, keyed by xkb keycode.\n\n" +
|
||||
"Compositors resolve bind keysyms at that level, so a keysym missing from this list cannot be bound however many modifiers are pressed. " +
|
||||
"The keybind editor uses it to warn instead of storing a bind that never fires.",
|
||||
Run: runKeybindsKeymap,
|
||||
}
|
||||
|
||||
func init() {
|
||||
keybindsCmd.PersistentFlags().String("expected-generation", "", "Configuration generation observed when editing Aqueous bindings")
|
||||
for _, command := range []*cobra.Command{keybindsSetCmd, keybindsRemoveCmd, keybindsResetCmd} {
|
||||
command.Flags().Bool("json", false, "Return structured mutation results, including errors")
|
||||
}
|
||||
keybindsListCmd.Flags().BoolP("json", "j", false, "Output as JSON")
|
||||
keybindsShowCmd.Flags().String("path", "", "Override config path for the provider")
|
||||
keybindsSetCmd.Flags().String("desc", "", "Description for hotkey overlay")
|
||||
@@ -74,13 +92,14 @@ func init() {
|
||||
keybindsSetCmd.Flags().Bool("no-repeat", false, "Disable key repeat")
|
||||
keybindsSetCmd.Flags().Bool("no-inhibiting", false, "Keep bind active when shortcuts are inhibited (allow-inhibiting=false)")
|
||||
keybindsSetCmd.Flags().String("replace-key", "", "Original key to replace (removes old key)")
|
||||
keybindsSetCmd.Flags().String("flags", "", "Hyprland bind flags (e.g., 'e' for repeat, 'l' for locked, 'r' for release)")
|
||||
keybindsSetCmd.Flags().String("flags", "", "Hyprland bind flags as letters: e repeat, l locked, r release, o long-press, n non-consuming, m mouse, t transparent, i ignore-mods, u submap-universal, a auto-consuming, x allow-input-capture")
|
||||
|
||||
keybindsCmd.AddCommand(keybindsListCmd)
|
||||
keybindsCmd.AddCommand(keybindsShowCmd)
|
||||
keybindsCmd.AddCommand(keybindsSetCmd)
|
||||
keybindsCmd.AddCommand(keybindsRemoveCmd)
|
||||
keybindsCmd.AddCommand(keybindsResetCmd)
|
||||
keybindsCmd.AddCommand(keybindsKeymapCmd)
|
||||
|
||||
keybinds.SetJSONProviderFactory(func(filePath string) (keybinds.Provider, error) {
|
||||
return providers.NewJSONFileProvider(filePath)
|
||||
@@ -93,6 +112,9 @@ func initializeProviders() {
|
||||
registry := keybinds.GetDefaultRegistry()
|
||||
|
||||
hyprlandProvider := providers.NewHyprlandProvider("")
|
||||
if err := registry.Register(providers.NewAqueousProvider()); err != nil {
|
||||
log.Warnf("Failed to register Aqueous provider: %v", err)
|
||||
}
|
||||
if err := registry.Register(hyprlandProvider); err != nil {
|
||||
log.Warnf("Failed to register Hyprland provider: %v", err)
|
||||
}
|
||||
@@ -155,6 +177,33 @@ func runKeybindsList(cmd *cobra.Command, _ []string) {
|
||||
}
|
||||
}
|
||||
|
||||
// runKeybindsKeymap answers with empty fields rather than an error when there
|
||||
// is no Wayland seat to ask, so the caller can simply skip the check instead
|
||||
// of having to tell "unknown layout" apart from "nothing wrong".
|
||||
func runKeybindsKeymap(_ *cobra.Command, _ []string) {
|
||||
keysyms := map[string][]string{}
|
||||
named := []string{}
|
||||
|
||||
km, err := keymap.FromSeat()
|
||||
if err != nil {
|
||||
log.Warnf("Failed to read the seat keymap: %v", err)
|
||||
} else {
|
||||
for code, syms := range km.Level1ByXkbKeycode() {
|
||||
keysyms[strconv.FormatUint(uint64(code), 10)] = syms
|
||||
}
|
||||
// The vocabulary the keysyms above are drawn from, so a caller can
|
||||
// tell a keysym we could not name from one the layout is missing.
|
||||
named = slices.Compact(slices.Sorted(maps.Values(keymap.KeysymNames)))
|
||||
}
|
||||
|
||||
output, err := json.Marshal(map[string]any{"keysyms": keysyms, "named": named})
|
||||
if err != nil {
|
||||
log.Warnf("Failed to encode keymap: %v", err)
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(os.Stdout, string(output))
|
||||
}
|
||||
|
||||
func makeProviderWithPath(name, path string) keybinds.Provider {
|
||||
switch name {
|
||||
case "hyprland":
|
||||
@@ -206,85 +255,92 @@ func runKeybindsShow(cmd *cobra.Command, args []string) {
|
||||
printCheatSheet(provider)
|
||||
}
|
||||
|
||||
func getWritableProvider(name string) keybinds.WritableProvider {
|
||||
provider, err := keybinds.GetDefaultRegistry().Get(name)
|
||||
func keybindEditFailure(err error) map[string]any {
|
||||
code := "command_failed"
|
||||
var helperError *providers.AqueousError
|
||||
if errors.As(err, &helperError) {
|
||||
code = helperError.Code
|
||||
}
|
||||
return map[string]any{"success": false, "code": code, "message": err.Error()}
|
||||
}
|
||||
|
||||
func runKeybindsEdit(cmd *cobra.Command, args []string) {
|
||||
result, err := editKeybind(cmd, args)
|
||||
if err == nil {
|
||||
_ = json.NewEncoder(cmd.OutOrStdout()).Encode(result)
|
||||
return
|
||||
}
|
||||
structured, _ := cmd.Flags().GetBool("json")
|
||||
if !structured {
|
||||
log.Fatalf("Failed to save keybind: %v", err)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(cmd.OutOrStdout()).Encode(keybindEditFailure(err))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func editKeybind(cmd *cobra.Command, args []string) (map[string]any, error) {
|
||||
provider, err := keybinds.GetDefaultRegistry().Get(args[0])
|
||||
if err != nil {
|
||||
log.Fatalf("Error: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
key := args[1]
|
||||
if aqueous, ok := provider.(*providers.AqueousProvider); ok {
|
||||
generation, _ := cmd.Flags().GetString("expected-generation")
|
||||
edit := providers.AqueousBindEdit{Generation: generation, Key: key, Remove: cmd.Name() != "set"}
|
||||
if !edit.Remove {
|
||||
for _, flag := range []string{"desc", "allow-when-locked", "cooldown-ms", "no-repeat", "no-inhibiting", "flags"} {
|
||||
if cmd.Flags().Changed(flag) {
|
||||
return nil, fmt.Errorf("aqueous does not support --%s in this provider", flag)
|
||||
}
|
||||
}
|
||||
edit.Action = args[2]
|
||||
edit.OriginalKey, _ = cmd.Flags().GetString("replace-key")
|
||||
}
|
||||
result, err := aqueous.Edit(cmd.Context(), edit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"success": true, "code": "applied", "generation": result.String("generation"), "key": key}, nil
|
||||
}
|
||||
writable, ok := provider.(keybinds.WritableProvider)
|
||||
if !ok {
|
||||
log.Fatalf("Provider %s does not support writing keybinds", name)
|
||||
return nil, fmt.Errorf("provider %s does not support writing keybinds", args[0])
|
||||
}
|
||||
return writable
|
||||
}
|
||||
|
||||
func runKeybindsSet(cmd *cobra.Command, args []string) {
|
||||
providerName, key, action := args[0], args[1], args[2]
|
||||
writable := getWritableProvider(providerName)
|
||||
|
||||
if replaceKey, _ := cmd.Flags().GetString("replace-key"); replaceKey != "" && replaceKey != key {
|
||||
_ = writable.RemoveBind(replaceKey)
|
||||
}
|
||||
|
||||
options := make(map[string]any)
|
||||
if v, _ := cmd.Flags().GetBool("allow-when-locked"); v {
|
||||
options["allow-when-locked"] = true
|
||||
}
|
||||
if v, _ := cmd.Flags().GetInt("cooldown-ms"); v > 0 {
|
||||
options["cooldown-ms"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetBool("no-repeat"); v {
|
||||
options["repeat"] = false
|
||||
}
|
||||
if v, _ := cmd.Flags().GetBool("no-inhibiting"); v {
|
||||
options["allow-inhibiting"] = false
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("flags"); v != "" {
|
||||
options["flags"] = v
|
||||
}
|
||||
|
||||
desc, _ := cmd.Flags().GetString("desc")
|
||||
if err := writable.SetBind(key, action, desc, options); err != nil {
|
||||
log.Fatalf("Error setting keybind: %v", err)
|
||||
}
|
||||
|
||||
output, _ := json.MarshalIndent(map[string]any{
|
||||
"success": true,
|
||||
"key": key,
|
||||
"action": action,
|
||||
"path": writable.GetOverridePath(),
|
||||
}, "", " ")
|
||||
fmt.Fprintln(os.Stdout, string(output))
|
||||
}
|
||||
|
||||
func runKeybindsRemove(_ *cobra.Command, args []string) {
|
||||
providerName, key := args[0], args[1]
|
||||
writable := getWritableProvider(providerName)
|
||||
|
||||
if err := writable.RemoveBind(key); err != nil {
|
||||
log.Fatalf("Error removing keybind: %v", err)
|
||||
}
|
||||
|
||||
output, _ := json.MarshalIndent(map[string]any{
|
||||
"success": true,
|
||||
"key": key,
|
||||
"removed": true,
|
||||
}, "", " ")
|
||||
fmt.Fprintln(os.Stdout, string(output))
|
||||
}
|
||||
|
||||
func runKeybindsReset(_ *cobra.Command, args []string) {
|
||||
providerName, key := args[0], args[1]
|
||||
writable := getWritableProvider(providerName)
|
||||
|
||||
if err := writable.ResetBind(key); err != nil {
|
||||
log.Fatalf("Error resetting keybind: %v", err)
|
||||
}
|
||||
|
||||
output, _ := json.MarshalIndent(map[string]any{
|
||||
"success": true,
|
||||
"key": key,
|
||||
"reset": true,
|
||||
}, "", " ")
|
||||
fmt.Fprintln(os.Stdout, string(output))
|
||||
result := map[string]any{"success": true, "key": key}
|
||||
switch cmd.Name() {
|
||||
case "set":
|
||||
options := make(map[string]any)
|
||||
if v, _ := cmd.Flags().GetBool("allow-when-locked"); v {
|
||||
options["allow-when-locked"] = true
|
||||
}
|
||||
if v, _ := cmd.Flags().GetInt("cooldown-ms"); v > 0 {
|
||||
options["cooldown-ms"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetBool("no-repeat"); v {
|
||||
options["repeat"] = false
|
||||
}
|
||||
if v, _ := cmd.Flags().GetBool("no-inhibiting"); v {
|
||||
options["allow-inhibiting"] = false
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("flags"); v != "" {
|
||||
options["flags"] = v
|
||||
}
|
||||
if replaceKey, _ := cmd.Flags().GetString("replace-key"); replaceKey != "" && replaceKey != key {
|
||||
_ = writable.RemoveBind(replaceKey)
|
||||
}
|
||||
desc, _ := cmd.Flags().GetString("desc")
|
||||
err = writable.SetBind(key, args[2], desc, options)
|
||||
result["action"] = args[2]
|
||||
result["path"] = writable.GetOverridePath()
|
||||
case "remove":
|
||||
err = writable.RemoveBind(key)
|
||||
result["removed"] = true
|
||||
case "reset":
|
||||
err = writable.ResetBind(key)
|
||||
result["reset"] = true
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported keybind operation: %s", cmd.Name())
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/keybinds/providers"
|
||||
)
|
||||
|
||||
func TestKeybindEditFailure(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
err error
|
||||
code string
|
||||
}{
|
||||
{&providers.AqueousError{Code: "external_change", Message: "changed"}, "external_change"},
|
||||
{fmt.Errorf("helper: %w", &providers.AqueousError{Code: "read_only", Message: "denied"}), "read_only"},
|
||||
{errors.New("transport failed"), "command_failed"},
|
||||
} {
|
||||
result := keybindEditFailure(tc.err)
|
||||
if result["success"] != false || result["code"] != tc.code || result["message"] != tc.err.Error() {
|
||||
t.Fatalf("unexpected failure result: %#v", result)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,9 @@ import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/matugen"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||
"github.com/duxlabs-dev/dux-go/ipc"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/log"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/matugen"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -70,9 +70,13 @@ func init() {
|
||||
cmd.Flags().String("stock-colors", "", "Stock theme colors JSON")
|
||||
cmd.Flags().Bool("sync-mode-with-portal", false, "Sync color scheme with GNOME portal")
|
||||
cmd.Flags().Bool("terminals-always-dark", false, "Force terminal themes to dark variant")
|
||||
cmd.Flags().Int("terminal-opacity", 0, "Terminal background opacity percent, 1-99 (0 or 100 leaves the terminal's own setting)")
|
||||
cmd.Flags().String("terminal-palette", "", "Terminal palette: default, low, medium or high")
|
||||
cmd.Flags().String("skip-templates", "", "Comma-separated list of templates to skip")
|
||||
cmd.Flags().Float64("contrast", 0, "Contrast value from -1 to 1 (0 = standard)")
|
||||
cmd.Flags().String("source-mode", "", "Source color selection: dominant, colorful, darkness, lightness, saturation, less-saturation, value")
|
||||
cmd.Flags().String("seed-color", "", "Hex color to build the palette from instead of extracting one from the wallpaper")
|
||||
cmd.Flags().String("spec", "", "Material color spec: 2021 (matugen native, default), 2025 (Material 3 Expressive) or dms (2025 accents on neutral surfaces)")
|
||||
}
|
||||
|
||||
matugenQueueCmd.Flags().Bool("wait", true, "Wait for completion")
|
||||
@@ -80,6 +84,7 @@ func init() {
|
||||
matugenPreviewCmd.Flags().String("source-color", "", "Source color used to generate previews")
|
||||
matugenPreviewCmd.Flags().String("image", "", "Wallpaper image used to resolve the scheme-smart preview")
|
||||
matugenPreviewCmd.Flags().Float64("contrast", 0, "Contrast value from -1 to 1 (0 = standard)")
|
||||
matugenPreviewCmd.Flags().String("spec", "", "Material color spec: 2021 (matugen native, default), 2025 (Material 3 Expressive) or dms (2025 accents on neutral surfaces)")
|
||||
matugenQtengineCmd.Flags().String("config-dir", "", "User config directory")
|
||||
matugenQtengineCmd.Flags().String("icon-theme", "", "Icon theme name")
|
||||
}
|
||||
@@ -97,9 +102,13 @@ func buildMatugenOptions(cmd *cobra.Command) matugen.Options {
|
||||
stockColors, _ := cmd.Flags().GetString("stock-colors")
|
||||
syncModeWithPortal, _ := cmd.Flags().GetBool("sync-mode-with-portal")
|
||||
terminalsAlwaysDark, _ := cmd.Flags().GetBool("terminals-always-dark")
|
||||
terminalOpacity, _ := cmd.Flags().GetInt("terminal-opacity")
|
||||
terminalPalette, _ := cmd.Flags().GetString("terminal-palette")
|
||||
skipTemplates, _ := cmd.Flags().GetString("skip-templates")
|
||||
contrast, _ := cmd.Flags().GetFloat64("contrast")
|
||||
sourceMode, _ := cmd.Flags().GetString("source-mode")
|
||||
seedColor, _ := cmd.Flags().GetString("seed-color")
|
||||
spec, _ := cmd.Flags().GetString("spec")
|
||||
|
||||
return matugen.Options{
|
||||
StateDir: stateDir,
|
||||
@@ -115,8 +124,12 @@ func buildMatugenOptions(cmd *cobra.Command) matugen.Options {
|
||||
StockColors: stockColors,
|
||||
SyncModeWithPortal: syncModeWithPortal,
|
||||
TerminalsAlwaysDark: terminalsAlwaysDark,
|
||||
TerminalOpacity: terminalOpacity,
|
||||
TerminalPalette: terminalPalette,
|
||||
SkipTemplates: skipTemplates,
|
||||
SourceMode: sourceMode,
|
||||
SeedColor: seedColor,
|
||||
Spec: spec,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,7 +149,7 @@ func runMatugenQueue(cmd *cobra.Command, args []string) {
|
||||
wait, _ := cmd.Flags().GetBool("wait")
|
||||
timeout, _ := cmd.Flags().GetDuration("timeout")
|
||||
|
||||
request := models.Request{
|
||||
request := ipc.Request{
|
||||
ID: 1,
|
||||
Method: "matugen.queue",
|
||||
Params: map[string]any{
|
||||
@@ -152,9 +165,13 @@ func runMatugenQueue(cmd *cobra.Command, args []string) {
|
||||
"stockColors": opts.StockColors,
|
||||
"syncModeWithPortal": opts.SyncModeWithPortal,
|
||||
"terminalsAlwaysDark": opts.TerminalsAlwaysDark,
|
||||
"terminalOpacity": opts.TerminalOpacity,
|
||||
"terminalPalette": opts.TerminalPalette,
|
||||
"skipTemplates": opts.SkipTemplates,
|
||||
"contrast": opts.Contrast,
|
||||
"sourceMode": opts.SourceMode,
|
||||
"seedColor": opts.SeedColor,
|
||||
"spec": opts.Spec,
|
||||
"wait": wait,
|
||||
},
|
||||
}
|
||||
@@ -228,7 +245,8 @@ func runMatugenPreview(cmd *cobra.Command, args []string) {
|
||||
sourceColor, _ := cmd.Flags().GetString("source-color")
|
||||
imagePath, _ := cmd.Flags().GetString("image")
|
||||
contrast, _ := cmd.Flags().GetFloat64("contrast")
|
||||
previews, err := matugen.PreviewSchemes(sourceColor, contrast, imagePath)
|
||||
spec, _ := cmd.Flags().GetString("spec")
|
||||
previews, err := matugen.PreviewSchemes(sourceColor, contrast, imagePath, spec)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to generate Matugen previews: %v", err)
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMatugenQtengineCommandAcceptsConfigDir(t *testing.T) {
|
||||
flag := matugenQtengineCmd.Flags().Lookup("config-dir")
|
||||
require.NotNil(t, flag)
|
||||
assert.Equal(t, "", flag.DefValue)
|
||||
}
|
||||
+241
-30
@@ -3,30 +3,50 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/notify"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||
"github.com/duxlabs-dev/dux-go/ipc"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/notify"
|
||||
"github.com/godbus/dbus/v5"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
var (
|
||||
notifyAppName string
|
||||
notifyIcon string
|
||||
notifyFile string
|
||||
notifyTimeout int
|
||||
notifyAppName string
|
||||
notifyAppIcon string
|
||||
notifyIcon string
|
||||
notifyFile string
|
||||
notifyTimeout int
|
||||
notifyUrgency string
|
||||
notifyCategory string
|
||||
notifyTransient bool
|
||||
notifyHints []string
|
||||
notifyActions []string
|
||||
notifyPrintID bool
|
||||
notifyReplaceID uint32
|
||||
notifyWait bool
|
||||
notifyIDFd int
|
||||
notifyActionFd int
|
||||
)
|
||||
|
||||
var notifyCmd = &cobra.Command{
|
||||
Use: "notify <summary> [body]",
|
||||
Short: "Send a desktop notification",
|
||||
Long: `Send a desktop notification with optional actions.
|
||||
Long: `Send a desktop notification. Flags follow notify-send.
|
||||
|
||||
If --file is provided, the notification will have "Open" and "Open Folder" actions.
|
||||
If --file is provided, the notification gets "Open" and "Open Folder" actions
|
||||
that open the file or its directory when clicked.
|
||||
|
||||
Examples:
|
||||
dms notify "Hello" "World"
|
||||
dms notify "File received" "photo.jpg" --file ~/Downloads/photo.jpg --icon smartphone
|
||||
dms notify "Download complete" --file ~/Downloads/file.zip --app "My App"`,
|
||||
dms notify -u critical -t 0 "Disk almost full"
|
||||
dms notify -a "Backup" -i drive-harddisk -c transfer.complete "Backup finished"
|
||||
dms notify "File received" "photo.jpg" --file ~/Downloads/photo.jpg
|
||||
dms notify -A retry=Retry -A "Dismiss" "Build failed"
|
||||
dms notify -h string:x-dms-tag:build -h int:value:42 "Hinted"`,
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
Run: runNotify,
|
||||
}
|
||||
@@ -39,45 +59,236 @@ var genericNotifyActionCmd = &cobra.Command{
|
||||
},
|
||||
}
|
||||
|
||||
var notifyFlagAliases = map[string]string{
|
||||
"app": "app-name",
|
||||
"timeout": "expire-time",
|
||||
}
|
||||
|
||||
func init() {
|
||||
notifyCmd.Flags().StringVar(¬ifyAppName, "app", "DMS", "Application name")
|
||||
notifyCmd.Flags().StringVar(¬ifyIcon, "icon", "", "Icon name or path")
|
||||
notifyCmd.Flags().StringVar(¬ifyFile, "file", "", "File path (enables Open/Open Folder actions)")
|
||||
notifyCmd.Flags().IntVar(¬ifyTimeout, "timeout", 5000, "Timeout in milliseconds")
|
||||
f := notifyCmd.Flags()
|
||||
f.SetNormalizeFunc(func(fs *pflag.FlagSet, name string) pflag.NormalizedName {
|
||||
if alias, ok := notifyFlagAliases[name]; ok {
|
||||
return pflag.NormalizedName(alias)
|
||||
}
|
||||
return pflag.NormalizedName(name)
|
||||
})
|
||||
// Frees -h for --hint like notify-send; cobra skips its own help flag when one exists.
|
||||
f.Bool("help", false, "Show help")
|
||||
f.StringVarP(¬ifyUrgency, "urgency", "u", "", "Urgency level: low, normal, critical")
|
||||
f.IntVarP(¬ifyTimeout, "expire-time", "t", -1, "Timeout in milliseconds (0 never expires, -1 server default)")
|
||||
f.StringVarP(¬ifyAppName, "app-name", "a", "DMS", "Application name")
|
||||
f.StringVarP(¬ifyIcon, "icon", "i", "", "Icon name or path")
|
||||
f.StringVarP(¬ifyAppIcon, "app-icon", "n", "", "Application icon name or path; --icon then becomes the image")
|
||||
f.StringVar(¬ifyCategory, "category", "", "Notification category, comma separated (-c is taken by the global --config)")
|
||||
f.BoolVarP(¬ifyTransient, "transient", "e", false, "Do not keep the notification in history")
|
||||
f.StringArrayVarP(¬ifyHints, "hint", "h", nil, "Extra hint as TYPE:NAME:VALUE (boolean, int, double, string, byte)")
|
||||
f.BoolVarP(¬ifyPrintID, "print-id", "p", false, "Print the notification id")
|
||||
f.IntVar(¬ifyIDFd, "id-fd", -1, "File descriptor to write the notification id to")
|
||||
f.Uint32VarP(¬ifyReplaceID, "replace-id", "r", 0, "Id of the notification to replace")
|
||||
f.BoolVarP(¬ifyWait, "wait", "w", false, "Wait until the notification closes")
|
||||
f.StringArrayVarP(¬ifyActions, "action", "A", nil, "Action as [NAME=]Text; implies --wait, prints the chosen NAME (repeatable)")
|
||||
f.IntVar(¬ifyActionFd, "selected-action-fd", -1, "File descriptor to write the chosen action to")
|
||||
f.StringVar(¬ifyFile, "file", "", "File path (enables Open/Open Folder actions)")
|
||||
}
|
||||
|
||||
func runNotify(cmd *cobra.Command, args []string) {
|
||||
summary := args[0]
|
||||
body := ""
|
||||
if len(args) > 1 {
|
||||
body = args[1]
|
||||
n, err := buildNotification(args)
|
||||
if err != nil {
|
||||
fail(err)
|
||||
}
|
||||
|
||||
n := notify.Notification{
|
||||
AppName: notifyAppName,
|
||||
Icon: notifyIcon,
|
||||
Summary: summary,
|
||||
Body: body,
|
||||
FilePath: notifyFile,
|
||||
Timeout: int32(notifyTimeout),
|
||||
wait := notifyWait || len(n.Actions) > 0
|
||||
var waiter *notify.Waiter
|
||||
if wait {
|
||||
waiter, err = notify.NewWaiter()
|
||||
if err != nil {
|
||||
fail(err)
|
||||
}
|
||||
defer waiter.Close()
|
||||
}
|
||||
|
||||
id, err := notify.Send(n)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
fail(err)
|
||||
}
|
||||
if n.FilePath == "" {
|
||||
reportID(id)
|
||||
|
||||
if n.FilePath != "" {
|
||||
watchNotificationAction(id, n.FilePath)
|
||||
}
|
||||
if !wait {
|
||||
return
|
||||
}
|
||||
watchNotificationAction(id, n.FilePath)
|
||||
if id == 0 {
|
||||
return
|
||||
}
|
||||
res := waiter.Wait(id, waitTimeout())
|
||||
if res.TimedOut {
|
||||
fmt.Fprintln(os.Stderr, "Wait timeout expired")
|
||||
return
|
||||
}
|
||||
if !res.Invoked {
|
||||
return
|
||||
}
|
||||
fmt.Println(res.Action)
|
||||
writeFd(notifyActionFd, res.Action)
|
||||
}
|
||||
|
||||
func buildNotification(args []string) (notify.Notification, error) {
|
||||
n := notify.Notification{
|
||||
AppName: notifyAppName,
|
||||
AppIcon: notifyAppIcon,
|
||||
Icon: notifyIcon,
|
||||
Summary: args[0],
|
||||
FilePath: notifyFile,
|
||||
Timeout: int32(notifyTimeout),
|
||||
ReplaceID: notifyReplaceID,
|
||||
Hints: map[string]dbus.Variant{},
|
||||
}
|
||||
if len(args) > 1 {
|
||||
n.Body = args[1]
|
||||
}
|
||||
if notifyFile != "" && len(notifyActions) > 0 {
|
||||
return n, fmt.Errorf("--file and --action cannot be combined")
|
||||
}
|
||||
if notifyUrgency != "" {
|
||||
level, err := parseUrgency(notifyUrgency)
|
||||
if err != nil {
|
||||
return n, err
|
||||
}
|
||||
n.Hints["urgency"] = dbus.MakeVariant(level)
|
||||
}
|
||||
if notifyCategory != "" {
|
||||
n.Hints["category"] = dbus.MakeVariant(notifyCategory)
|
||||
}
|
||||
if notifyTransient {
|
||||
n.Hints["transient"] = dbus.MakeVariant(true)
|
||||
}
|
||||
for _, spec := range notifyHints {
|
||||
name, value, err := parseHint(spec)
|
||||
if err != nil {
|
||||
return n, err
|
||||
}
|
||||
n.Hints[name] = value
|
||||
}
|
||||
n.Actions = parseActions(notifyActions)
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func parseUrgency(s string) (byte, error) {
|
||||
switch strings.ToLower(s) {
|
||||
case "low", "0":
|
||||
return 0, nil
|
||||
case "normal", "1":
|
||||
return 1, nil
|
||||
case "critical", "2":
|
||||
return 2, nil
|
||||
}
|
||||
return 0, fmt.Errorf("invalid urgency %q: use low, normal or critical", s)
|
||||
}
|
||||
|
||||
func parseHint(spec string) (string, dbus.Variant, error) {
|
||||
parts := strings.SplitN(spec, ":", 3)
|
||||
if len(parts) != 3 || parts[1] == "" {
|
||||
return "", dbus.Variant{}, fmt.Errorf("invalid hint %q: expected TYPE:NAME:VALUE", spec)
|
||||
}
|
||||
typ, name, value := strings.ToLower(parts[0]), parts[1], parts[2]
|
||||
switch typ {
|
||||
case "string":
|
||||
return name, dbus.MakeVariant(value), nil
|
||||
case "int":
|
||||
v, err := strconv.ParseInt(value, 10, 32)
|
||||
if err != nil {
|
||||
return "", dbus.Variant{}, fmt.Errorf("hint %s: invalid int %q", name, value)
|
||||
}
|
||||
return name, dbus.MakeVariant(int32(v)), nil
|
||||
case "double":
|
||||
v, err := strconv.ParseFloat(value, 64)
|
||||
if err != nil {
|
||||
return "", dbus.Variant{}, fmt.Errorf("hint %s: invalid double %q", name, value)
|
||||
}
|
||||
return name, dbus.MakeVariant(v), nil
|
||||
case "byte":
|
||||
v, err := strconv.ParseUint(value, 0, 8)
|
||||
if err != nil {
|
||||
return "", dbus.Variant{}, fmt.Errorf("hint %s: invalid byte %q", name, value)
|
||||
}
|
||||
return name, dbus.MakeVariant(byte(v)), nil
|
||||
case "boolean":
|
||||
v, err := parseBoolean(value)
|
||||
if err != nil {
|
||||
return "", dbus.Variant{}, fmt.Errorf("hint %s: invalid boolean %q", name, value)
|
||||
}
|
||||
return name, dbus.MakeVariant(v), nil
|
||||
}
|
||||
return "", dbus.Variant{}, fmt.Errorf("invalid hint type %q: use boolean, int, double, string or byte", parts[0])
|
||||
}
|
||||
|
||||
func parseBoolean(s string) (bool, error) {
|
||||
switch strings.ToLower(s) {
|
||||
case "true":
|
||||
return true, nil
|
||||
case "false":
|
||||
return false, nil
|
||||
}
|
||||
v, err := strconv.ParseUint(s, 10, 64)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return v != 0, nil
|
||||
}
|
||||
|
||||
func parseActions(specs []string) []string {
|
||||
if len(specs) == 0 {
|
||||
return nil
|
||||
}
|
||||
actions := make([]string, 0, len(specs)*2)
|
||||
for i, spec := range specs {
|
||||
name, label, hasName := strings.Cut(spec, "=")
|
||||
if !hasName {
|
||||
name, label = strconv.Itoa(i), spec
|
||||
}
|
||||
actions = append(actions, name, label)
|
||||
}
|
||||
return actions
|
||||
}
|
||||
|
||||
func waitTimeout() time.Duration {
|
||||
if notifyTimeout <= 0 {
|
||||
return 0
|
||||
}
|
||||
return time.Duration(notifyTimeout) * time.Millisecond
|
||||
}
|
||||
|
||||
func reportID(id uint32) {
|
||||
if notifyPrintID {
|
||||
fmt.Println(id)
|
||||
}
|
||||
writeFd(notifyIDFd, strconv.FormatUint(uint64(id), 10))
|
||||
}
|
||||
|
||||
func writeFd(fd int, line string) {
|
||||
if fd < 0 {
|
||||
return
|
||||
}
|
||||
f := os.NewFile(uintptr(fd), "")
|
||||
if f == nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(f, line)
|
||||
_ = f.Sync()
|
||||
}
|
||||
|
||||
func fail(err error) {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func watchNotificationAction(id uint32, path string) {
|
||||
if id == 0 {
|
||||
return
|
||||
}
|
||||
resp, ok := tryServerRequest(models.Request{
|
||||
resp, ok := tryServerRequest(ipc.Request{
|
||||
Method: "notify.watchAction",
|
||||
Params: map[string]any{"id": id, "path": path},
|
||||
})
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||
"github.com/duxlabs-dev/dux-go/ipc"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/log"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -123,7 +123,7 @@ func runOpen(target string) {
|
||||
params["url"] = target
|
||||
}
|
||||
|
||||
req := models.Request{
|
||||
req := ipc.Request{
|
||||
ID: 1,
|
||||
Method: method,
|
||||
Params: params,
|
||||
|
||||
@@ -6,9 +6,9 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/qrcode"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||
"github.com/duxlabs-dev/dux-go/ipc"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/clipboard"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/qrcode"
|
||||
"github.com/mattn/go-isatty"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -243,7 +243,7 @@ func fatalf(format string, args ...any) {
|
||||
}
|
||||
|
||||
func fetchWifiQRContent(ssid string) (string, error) {
|
||||
resp, err := sendServerRequest(models.Request{
|
||||
resp, err := sendServerRequest(ipc.Request{
|
||||
ID: 1,
|
||||
Method: "network.qrcode-content",
|
||||
Params: map[string]any{"ssid": ssid},
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/log"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/registries"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/log"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/registries"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
var registryCmd = &cobra.Command{
|
||||
Use: "registry",
|
||||
Short: "Manage plugin and theme registries",
|
||||
Long: "Manage the registries DMS fetches plugins and themes from. The official registry is always active; additional registries can be added by name and git URL.",
|
||||
Long: "Manage the registries DMS fetches plugins and themes from. The official registry is always active and additional registries can be added by name and git URL, unless the CLI policy sets disable_registries.",
|
||||
}
|
||||
|
||||
var registryListCmd = &cobra.Command{
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "dms",
|
||||
Short: "dms CLI",
|
||||
Long: "dms is the DankMaterialShell management CLI and backend server.",
|
||||
Long: "dms is the Dux Shell management CLI and backend server.",
|
||||
}
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -9,17 +9,18 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/notify"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/screenshot"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/clipboard"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/notify"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/qsipc"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/screenshot"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
ssOutputName string
|
||||
ssSeat string
|
||||
ssCursor string
|
||||
ssFormat string
|
||||
ssQuality int
|
||||
@@ -33,6 +34,8 @@ var (
|
||||
ssStdout bool
|
||||
ssJSON bool
|
||||
ssGeometry bool
|
||||
ssAllowMulti bool
|
||||
ssHUD string
|
||||
)
|
||||
|
||||
type screenshotMetadata struct {
|
||||
@@ -57,7 +60,7 @@ Modes:
|
||||
full - Capture the focused output
|
||||
all - Capture all outputs combined
|
||||
output - Capture a specific output by name
|
||||
window - Capture the focused window (Hyprland/Mango/niri)
|
||||
window - Capture the focused window (Hyprland/Mango/niri/Aqueous)
|
||||
last - Capture the last selected region
|
||||
scroll - Select a region, then scroll to capture a stitched tall image
|
||||
|
||||
@@ -71,7 +74,7 @@ Examples:
|
||||
dms screenshot full # Full screen of focused output
|
||||
dms screenshot all # All screens combined
|
||||
dms screenshot output -o DP-1 # Specific output
|
||||
dms screenshot window # Focused window (Hyprland)
|
||||
dms screenshot window # Focused window
|
||||
dms screenshot last # Last region (pre-selected)
|
||||
dms screenshot --reset # Reset last region pre-selection
|
||||
dms screenshot --no-clipboard # Save file only
|
||||
@@ -80,6 +83,7 @@ Examples:
|
||||
dms screenshot --cursor=on # Include cursor
|
||||
dms screenshot -f jpg -q 85 # JPEG with quality 85
|
||||
dms screenshot --json # Print capture metadata as JSON
|
||||
dms screenshot --allow-multiple # Skip the one-selector-at-a-time guard
|
||||
dms screenshot -g # Print selected region geometry (X,Y WxH) to stdout
|
||||
dms screenshot scroll # Scroll capture, Enter finishes / Esc cancels
|
||||
dms screenshot scroll --interval 250`,
|
||||
@@ -120,7 +124,7 @@ If no previous region exists, falls back to interactive selection.`,
|
||||
var ssWindowCmd = &cobra.Command{
|
||||
Use: "window",
|
||||
Short: "Capture the focused window",
|
||||
Long: `Capture the currently focused window. Supported on Hyprland, Mango, and niri.`,
|
||||
Long: `Capture the currently focused window. Supported on Hyprland, Mango, niri, and Aqueous. Aqueous requires a running DMS shell and crops the output including borders and overlapping windows.`,
|
||||
Run: runScreenshotWindow,
|
||||
}
|
||||
|
||||
@@ -163,6 +167,7 @@ var notifyActionCmd = &cobra.Command{
|
||||
|
||||
func init() {
|
||||
screenshotCmd.PersistentFlags().StringVarP(&ssOutputName, "output", "o", "", "Output name for 'output' mode")
|
||||
screenshotCmd.PersistentFlags().StringVar(&ssSeat, "seat", "", "Seat for Aqueous capture (required with multiple seats)")
|
||||
screenshotCmd.PersistentFlags().StringVar(&ssCursor, "cursor", "off", "Include cursor in screenshot (on/off)")
|
||||
screenshotCmd.PersistentFlags().StringVarP(&ssFormat, "format", "f", "png", "Output format (png, jpg, ppm)")
|
||||
screenshotCmd.PersistentFlags().IntVarP(&ssQuality, "quality", "q", 90, "JPEG quality (1-100)")
|
||||
@@ -176,6 +181,8 @@ func init() {
|
||||
screenshotCmd.PersistentFlags().BoolVar(&ssStdout, "stdout", false, "Output image to stdout (for piping to swappy, etc.)")
|
||||
screenshotCmd.PersistentFlags().BoolVar(&ssJSON, "json", false, "Print capture metadata as JSON")
|
||||
screenshotCmd.PersistentFlags().BoolVarP(&ssGeometry, "geometry", "g", false, "Print selected region geometry (X,Y WxH) to stdout without capturing an image")
|
||||
screenshotCmd.PersistentFlags().BoolVar(&ssAllowMulti, "allow-multiple", false, "Open a selector even when another one is already open")
|
||||
screenshotCmd.PersistentFlags().StringVar(&ssHUD, "hud", "auto", "HUD overlay scale in region selector (auto, off/0, or integer scale 1-4)")
|
||||
|
||||
ssScrollCmd.Flags().IntVar(&ssScrollInterval, "interval", 45, "Capture interval in milliseconds (30-1000)")
|
||||
|
||||
@@ -195,6 +202,7 @@ func getScreenshotConfig(mode screenshot.Mode) screenshot.Config {
|
||||
config := screenshot.DefaultConfig()
|
||||
config.Mode = mode
|
||||
config.OutputName = ssOutputName
|
||||
config.Seat = ssSeat
|
||||
if strings.EqualFold(ssCursor, "on") {
|
||||
config.Cursor = screenshot.CursorOn
|
||||
}
|
||||
@@ -205,6 +213,8 @@ func getScreenshotConfig(mode screenshot.Mode) screenshot.Config {
|
||||
config.Reset = ssReset
|
||||
config.Stdout = ssStdout
|
||||
config.Geometry = ssGeometry
|
||||
config.AllowMultiple = ssAllowMulti
|
||||
config.HUD = ssHUD
|
||||
|
||||
if ssGeometry {
|
||||
config.Clipboard = false
|
||||
@@ -236,9 +246,29 @@ func getScreenshotConfig(mode screenshot.Mode) screenshot.Config {
|
||||
}
|
||||
config.Quality = ssQuality
|
||||
|
||||
if mode == screenshot.ModeRegion || mode == screenshot.ModeScroll {
|
||||
config.SnapTargets = fetchShellSnapTargets()
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
func fetchShellSnapTargets() []screenshot.SnapTarget {
|
||||
pid, ok := shellApp.SessionPID()
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
res, isVoid, err := qsipc.Call(qsipc.SocketPathForPID(pid), "screenshot", "getSurfaces", nil)
|
||||
if err != nil || isVoid || res == "" {
|
||||
return nil
|
||||
}
|
||||
var targets []screenshot.SnapTarget
|
||||
if err := json.Unmarshal([]byte(res), &targets); err != nil {
|
||||
return nil
|
||||
}
|
||||
return targets
|
||||
}
|
||||
|
||||
// setPopoutScreenshotMode toggles the shell handshake so popouts drop their keyboard grab during region select.
|
||||
// Best-effort and not awaited: qs takes ~20ms to come up, longer than the selector needs to show.
|
||||
func setPopoutScreenshotMode(begin bool) {
|
||||
@@ -246,18 +276,18 @@ func setPopoutScreenshotMode(begin bool) {
|
||||
if begin {
|
||||
fn = "begin"
|
||||
}
|
||||
cmdArgs := []string{"ipc"}
|
||||
if pid, ok := shellApp.SessionPID(); ok {
|
||||
cmdArgs = append(cmdArgs, "--pid", strconv.Itoa(pid))
|
||||
} else {
|
||||
if err := shellApp.ResolveConfig(nil, nil); err != nil {
|
||||
return
|
||||
}
|
||||
if qsHasAnyDisplay() {
|
||||
cmdArgs = append(cmdArgs, "--any-display")
|
||||
}
|
||||
cmdArgs = append(cmdArgs, "-p", shellApp.ConfigPath())
|
||||
_, _, _ = qsipc.Call(qsipc.SocketPathForPID(pid), "screenshot", fn, nil)
|
||||
return
|
||||
}
|
||||
cmdArgs := []string{"ipc"}
|
||||
if err := shellApp.ResolveConfig(nil, nil); err != nil {
|
||||
return
|
||||
}
|
||||
if qsHasAnyDisplay() {
|
||||
cmdArgs = append(cmdArgs, "--any-display")
|
||||
}
|
||||
cmdArgs = append(cmdArgs, "-p", shellApp.ConfigPath())
|
||||
cmdArgs = append(cmdArgs, "call", "screenshot", fn)
|
||||
_ = exec.Command("qs", cmdArgs...).Start()
|
||||
}
|
||||
@@ -348,6 +378,12 @@ func runScreenshot(config screenshot.Config) {
|
||||
}
|
||||
}
|
||||
|
||||
if result.CopyOnly {
|
||||
config.SaveFile = false
|
||||
config.Clipboard = true
|
||||
config.Stdout = false
|
||||
}
|
||||
|
||||
if config.Stdout {
|
||||
if err := writeImageToStdout(result.Buffer, config.Format, config.Quality, result.Format, result.CICP); err != nil {
|
||||
exitScreenshotError(" writing to stdout", err)
|
||||
@@ -378,31 +414,48 @@ func runScreenshot(config screenshot.Config) {
|
||||
}
|
||||
|
||||
if config.Clipboard {
|
||||
if err := copyImageToClipboard(result.Buffer, config.Format, config.Quality, result.Format, result.CICP); err != nil {
|
||||
var err error
|
||||
if filePath != "" && config.Format != screenshot.FormatPPM {
|
||||
err = copyFileToClipboard(filePath)
|
||||
} else {
|
||||
err = copyImageToClipboard(result.Buffer, config.Format, config.Quality, result.Format, result.CICP)
|
||||
}
|
||||
if err != nil {
|
||||
exitScreenshotError(" copying to clipboard", err)
|
||||
}
|
||||
if !ssJSON && !config.SaveFile {
|
||||
fmt.Println("Copied to clipboard")
|
||||
if ssStdout {
|
||||
fmt.Fprintln(os.Stderr, "Copied to clipboard")
|
||||
} else {
|
||||
fmt.Println("Copied to clipboard")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ssJSON {
|
||||
scale := result.Scale
|
||||
if scale <= 0 {
|
||||
scale = 1.0
|
||||
if result.CopyOnly {
|
||||
writeScreenshotJSON(screenshotMetadata{
|
||||
Status: "aborted",
|
||||
Error: "User copied to clipboard",
|
||||
})
|
||||
} else {
|
||||
scale := result.Scale
|
||||
if scale <= 0 {
|
||||
scale = 1.0
|
||||
}
|
||||
writeScreenshotJSON(screenshotMetadata{
|
||||
Status: "success",
|
||||
Path: filePath,
|
||||
Width: result.Buffer.Width,
|
||||
Height: result.Buffer.Height,
|
||||
Scale: scale,
|
||||
Mime: formatMime(config.Format),
|
||||
})
|
||||
}
|
||||
writeScreenshotJSON(screenshotMetadata{
|
||||
Status: "success",
|
||||
Path: filePath,
|
||||
Width: result.Buffer.Width,
|
||||
Height: result.Buffer.Height,
|
||||
Scale: scale,
|
||||
Mime: formatMime(config.Format),
|
||||
})
|
||||
}
|
||||
|
||||
if config.Notify {
|
||||
thumbData, thumbW, thumbH := bufferToRGBThumbnail(result.Buffer, 256, result.Format)
|
||||
thumbData, thumbW, thumbH := bufferToRGBAThumbnail(result.Buffer, 640, result.Format)
|
||||
id := screenshot.SendNotification(screenshot.NotifyResult{
|
||||
FilePath: filePath,
|
||||
Clipboard: config.Clipboard,
|
||||
@@ -443,28 +496,21 @@ func writeImageToStdout(buf *screenshot.ShmBuffer, format screenshot.Format, qua
|
||||
}
|
||||
}
|
||||
|
||||
func bufferToRGBThumbnail(buf *screenshot.ShmBuffer, maxSize int, pixelFormat uint32) ([]byte, int, int) {
|
||||
srcW, srcH := buf.Width, buf.Height
|
||||
scale := 1.0
|
||||
if srcW > maxSize || srcH > maxSize {
|
||||
if srcW > srcH {
|
||||
scale = float64(maxSize) / float64(srcW)
|
||||
} else {
|
||||
scale = float64(maxSize) / float64(srcH)
|
||||
}
|
||||
func bufferToRGBAThumbnail(buf *screenshot.ShmBuffer, maxSize int, pixelFormat uint32) ([]byte, int, int) {
|
||||
if buf == nil || buf.Width <= 0 || buf.Height <= 0 || maxSize <= 0 {
|
||||
return nil, 0, 0
|
||||
}
|
||||
|
||||
dstW := int(float64(srcW) * scale)
|
||||
dstH := int(float64(srcH) * scale)
|
||||
if dstW < 1 {
|
||||
dstW = 1
|
||||
}
|
||||
if dstH < 1 {
|
||||
dstH = 1
|
||||
srcW, srcH := buf.Width, buf.Height
|
||||
longest := max(srcW, srcH)
|
||||
dstW, dstH := srcW, srcH
|
||||
if longest > maxSize {
|
||||
dstW = max(1, srcW*maxSize/longest)
|
||||
dstH = max(1, srcH*maxSize/longest)
|
||||
}
|
||||
|
||||
data := buf.Data()
|
||||
rgb := make([]byte, dstW*dstH*3)
|
||||
rgba := make([]byte, dstW*dstH*4)
|
||||
|
||||
is10Bit := screenshot.PixelFormat(pixelFormat).Is10Bit()
|
||||
|
||||
@@ -477,41 +523,42 @@ func bufferToRGBThumbnail(buf *screenshot.ShmBuffer, maxSize int, pixelFormat ui
|
||||
swapRB = true
|
||||
}
|
||||
|
||||
for y := 0; y < dstH; y++ {
|
||||
srcY := int(float64(y) / scale)
|
||||
if srcY >= srcH {
|
||||
srcY = srcH - 1
|
||||
}
|
||||
for x := 0; x < dstW; x++ {
|
||||
srcX := int(float64(x) / scale)
|
||||
if srcX >= srcW {
|
||||
srcX = srcW - 1
|
||||
for y := range dstH {
|
||||
y0, y1 := y*srcH/dstH, (y+1)*srcH/dstH
|
||||
for x := range dstW {
|
||||
x0, x1 := x*srcW/dstW, (x+1)*srcW/dstW
|
||||
var r, g, b, samples uint64
|
||||
for srcY := y0; srcY < y1; srcY++ {
|
||||
for srcX := x0; srcX < x1; srcX++ {
|
||||
si := srcY*buf.Stride + srcX*4
|
||||
if si+3 >= len(data) {
|
||||
continue
|
||||
}
|
||||
c0, c1, c2 := data[si], data[si+1], data[si+2]
|
||||
if is10Bit {
|
||||
v := binary.LittleEndian.Uint32(data[si:])
|
||||
c0, c1, c2 = uint8(v>>2), uint8(v>>12), uint8(v>>22)
|
||||
}
|
||||
if swapRB {
|
||||
c0, c2 = c2, c0
|
||||
}
|
||||
r += uint64(c0)
|
||||
g += uint64(c1)
|
||||
b += uint64(c2)
|
||||
samples++
|
||||
}
|
||||
}
|
||||
si := srcY*buf.Stride + srcX*4
|
||||
di := (y*dstW + x) * 3
|
||||
if si+3 >= len(data) {
|
||||
if samples == 0 {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case is10Bit:
|
||||
v := binary.LittleEndian.Uint32(data[si:])
|
||||
c0, c1, c2 := uint8(v>>2), uint8(v>>12), uint8(v>>22)
|
||||
if swapRB {
|
||||
c0, c2 = c2, c0
|
||||
}
|
||||
rgb[di+0], rgb[di+1], rgb[di+2] = c0, c1, c2
|
||||
case swapRB:
|
||||
rgb[di+0] = data[si+2]
|
||||
rgb[di+1] = data[si+1]
|
||||
rgb[di+2] = data[si+0]
|
||||
default:
|
||||
rgb[di+0] = data[si+0]
|
||||
rgb[di+1] = data[si+1]
|
||||
rgb[di+2] = data[si+2]
|
||||
}
|
||||
di := (y*dstW + x) * 4
|
||||
rgba[di+0] = byte((r + samples/2) / samples)
|
||||
rgba[di+1] = byte((g + samples/2) / samples)
|
||||
rgba[di+2] = byte((b + samples/2) / samples)
|
||||
rgba[di+3] = 255
|
||||
}
|
||||
}
|
||||
return rgb, dstW, dstH
|
||||
return rgba, dstW, dstH
|
||||
}
|
||||
|
||||
func runScreenshotRegion(cmd *cobra.Command, args []string) {
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/screenshot"
|
||||
)
|
||||
|
||||
func thumbnailTestBuffer(t testing.TB, width, height int) *screenshot.ShmBuffer {
|
||||
t.Helper()
|
||||
buf, err := screenshot.CreateShmBuffer(width, height, width*4+16)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { buf.Close() })
|
||||
return buf
|
||||
}
|
||||
|
||||
func TestBufferToRGBAThumbnailPixels(t *testing.T) {
|
||||
formats := []struct {
|
||||
name string
|
||||
format screenshot.PixelFormat
|
||||
swapRB bool
|
||||
tenBit bool
|
||||
}{
|
||||
{"argb", screenshot.FormatARGB8888, true, false},
|
||||
{"xrgb", screenshot.FormatXRGB8888, true, false},
|
||||
{"abgr", screenshot.FormatABGR8888, false, false},
|
||||
{"xbgr", screenshot.FormatXBGR8888, false, false},
|
||||
{"argb10", screenshot.FormatARGB2101010, true, true},
|
||||
{"xrgb10", screenshot.FormatXRGB2101010, true, true},
|
||||
{"abgr10", screenshot.FormatABGR2101010, false, true},
|
||||
{"xbgr10", screenshot.FormatXBGR2101010, false, true},
|
||||
}
|
||||
for _, tc := range formats {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
buf := thumbnailTestBuffer(t, 4, 2)
|
||||
data := buf.Data()
|
||||
var originalRGBA []byte
|
||||
for y := range buf.Height {
|
||||
for x := range buf.Width {
|
||||
r, g, b := byte(20), byte(40), byte(60)
|
||||
if (x+y)%2 != 0 {
|
||||
r, g, b = 100, 160, 220
|
||||
}
|
||||
originalRGBA = append(originalRGBA, r, g, b, 255)
|
||||
if tc.swapRB {
|
||||
r, b = b, r
|
||||
}
|
||||
i := y*buf.Stride + x*4
|
||||
if tc.tenBit {
|
||||
binary.LittleEndian.PutUint32(data[i:], uint32(r)<<2|uint32(g)<<12|uint32(b)<<22)
|
||||
continue
|
||||
}
|
||||
data[i], data[i+1], data[i+2] = r, g, b
|
||||
}
|
||||
}
|
||||
original := bytes.Clone(data)
|
||||
got, width, height := bufferToRGBAThumbnail(buf, 2, uint32(tc.format))
|
||||
want := []byte{60, 100, 140, 255, 60, 100, 140, 255}
|
||||
if width != 2 || height != 1 || !bytes.Equal(got, want) {
|
||||
t.Fatalf("averaged thumbnail = %dx%d %v, want 2x1 %v", width, height, got, want)
|
||||
}
|
||||
got, width, height = bufferToRGBAThumbnail(buf, 640, uint32(tc.format))
|
||||
if width != 4 || height != 2 || !bytes.Equal(got, originalRGBA) {
|
||||
t.Fatalf("unscaled thumbnail = %dx%d %v, want 4x2 %v", width, height, got, originalRGBA)
|
||||
}
|
||||
if !bytes.Equal(data, original) {
|
||||
t.Fatal("source pixels changed")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBufferToRGBAThumbnailDimensions(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
width, height int
|
||||
wantWidth, wantHeight int
|
||||
}{
|
||||
{1920, 1080, 640, 360},
|
||||
{1080, 1920, 360, 640},
|
||||
{1365, 767, 640, 359},
|
||||
{1, 10000, 1, 640},
|
||||
{10000, 1, 640, 1},
|
||||
{1, 1, 1, 1},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("%dx%d", tc.width, tc.height), func(t *testing.T) {
|
||||
buf := thumbnailTestBuffer(t, tc.width, tc.height)
|
||||
got, width, height := bufferToRGBAThumbnail(buf, 640, uint32(screenshot.FormatXRGB8888))
|
||||
if width != tc.wantWidth || height != tc.wantHeight || len(got) != width*height*4 {
|
||||
t.Fatalf("thumbnail = %dx%d, %d bytes; want %dx%d RGBA", width, height, len(got), tc.wantWidth, tc.wantHeight)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBufferToRGBAThumbnailOddWidthRows(t *testing.T) {
|
||||
for _, width := range []int{319, 321, 361} {
|
||||
t.Run(fmt.Sprint(width), func(t *testing.T) {
|
||||
buf := thumbnailTestBuffer(t, width, 64)
|
||||
data := buf.Data()
|
||||
for y := range buf.Height {
|
||||
for x := range width {
|
||||
i := y*buf.Stride + x*4
|
||||
data[i], data[i+1], data[i+2] = byte(y), byte(x>>8), byte(x)
|
||||
}
|
||||
}
|
||||
got, gotWidth, height := bufferToRGBAThumbnail(buf, 640, uint32(screenshot.FormatXRGB8888))
|
||||
if gotWidth != width || height != buf.Height || len(got) != width*height*4 {
|
||||
t.Fatalf("thumbnail = %dx%d, %d bytes; want %dx%d RGBA", gotWidth, height, len(got), width, buf.Height)
|
||||
}
|
||||
for y := range height {
|
||||
for x := range width {
|
||||
i := y*width*4 + x*4
|
||||
want := []byte{byte(x), byte(x >> 8), byte(y), 255}
|
||||
if !bytes.Equal(got[i:i+4], want) {
|
||||
t.Fatalf("pixel (%d, %d) = %v, want %v", x, y, got[i:i+4], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBufferToRGBAThumbnailEmpty(t *testing.T) {
|
||||
for _, buf := range []*screenshot.ShmBuffer{nil, {}} {
|
||||
data, width, height := bufferToRGBAThumbnail(buf, 640, uint32(screenshot.FormatXRGB8888))
|
||||
if data != nil || width != 0 || height != 0 {
|
||||
t.Fatalf("empty thumbnail = %dx%d %v", width, height, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkBufferToRGBAThumbnail(b *testing.B) {
|
||||
for _, size := range [][2]int{{3840, 2160}, {3840, 3840}, {1920, 10000}} {
|
||||
b.Run(fmt.Sprintf("%dx%d", size[0], size[1]), func(b *testing.B) {
|
||||
buf := thumbnailTestBuffer(b, size[0], size[1])
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
bufferToRGBAThumbnail(buf, 640, uint32(screenshot.FormatXRGB8888))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/log"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
|
||||
@@ -8,12 +8,13 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/config"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/deps"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/distros"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/log"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/mangoconf"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/privesc"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/utils"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -129,7 +130,7 @@ var dmsConfigSpecs = map[string]dmsConfigSpec{
|
||||
mangoFile: "layout.conf",
|
||||
niriContent: func(_ string) string { return config.NiriLayoutConfig },
|
||||
hyprContent: func(_ string) string { return config.DMSLayoutLuaConfig },
|
||||
mangoContent: func(_ string) string { return config.MangoLayoutConfig },
|
||||
mangoContent: func(_ string) string { return mangoconf.Detect().Translate(config.MangoLayoutConfig) },
|
||||
},
|
||||
"colors": {
|
||||
niriFile: "colors.kdl",
|
||||
@@ -137,15 +138,19 @@ var dmsConfigSpecs = map[string]dmsConfigSpec{
|
||||
mangoFile: "colors.conf",
|
||||
niriContent: func(_ string) string { return config.NiriColorsConfig },
|
||||
hyprContent: func(_ string) string { return config.DMSColorsLuaConfig },
|
||||
mangoContent: func(_ string) string { return config.MangoColorsConfig },
|
||||
mangoContent: func(_ string) string { return mangoconf.Detect().Translate(config.MangoColorsConfig) },
|
||||
},
|
||||
"alttab": {
|
||||
niriFile: "alttab.kdl",
|
||||
niriContent: func(_ string) string { return config.NiriAlttabConfig },
|
||||
},
|
||||
"input": {
|
||||
niriFile: "input.kdl",
|
||||
niriContent: func(_ string) string { return config.NiriInputConfig },
|
||||
niriFile: "input.kdl",
|
||||
hyprFile: "input.lua",
|
||||
mangoFile: "input.conf",
|
||||
niriContent: func(_ string) string { return config.NiriInputConfig },
|
||||
hyprContent: func(_ string) string { return config.DMSInputLuaConfig },
|
||||
mangoContent: func(_ string) string { return "" },
|
||||
},
|
||||
"outputs": {
|
||||
niriFile: "outputs.kdl",
|
||||
@@ -173,15 +178,43 @@ var dmsConfigSpecs = map[string]dmsConfigSpec{
|
||||
},
|
||||
}
|
||||
|
||||
func detectTerminal() (string, error) {
|
||||
terminals := []string{"ghostty", "foot", "kitty", "alacritty"}
|
||||
var knownTerminals = []string{"ghostty", "foot", "kitty", "alacritty"}
|
||||
|
||||
func terminalFromEnv() string {
|
||||
return strings.TrimSpace(os.Getenv("TERMINAL"))
|
||||
}
|
||||
|
||||
func installedTerminals() []string {
|
||||
var found []string
|
||||
for _, t := range terminals {
|
||||
for _, t := range knownTerminals {
|
||||
if utils.CommandExists(t) {
|
||||
found = append(found, t)
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
func installedHyprlandVersion() string {
|
||||
_, ver := getVersionFromCommand("Hyprland", "--version", distros.ParseHyprlandVersion)
|
||||
return ver
|
||||
}
|
||||
|
||||
func defaultTerminalCommand() string {
|
||||
if env := terminalFromEnv(); env != "" {
|
||||
return env
|
||||
}
|
||||
if found := installedTerminals(); len(found) > 0 {
|
||||
return found[0]
|
||||
}
|
||||
return "ghostty"
|
||||
}
|
||||
|
||||
func detectTerminal() (string, error) {
|
||||
if env := terminalFromEnv(); env != "" {
|
||||
return env, nil
|
||||
}
|
||||
|
||||
found := installedTerminals()
|
||||
switch len(found) {
|
||||
case 0:
|
||||
return "ghostty", nil
|
||||
@@ -276,7 +309,7 @@ func runSetupDmsConfig(name string) error {
|
||||
case "hyprland":
|
||||
dmsDir = filepath.Join(utils.XDGConfigHome(), "hypr", "dms")
|
||||
case "mango", "mangowc":
|
||||
dmsDir = filepath.Join(utils.XDGConfigHome(), "mango", "dms")
|
||||
dmsDir = filepath.Join(mangoconf.Dir(), "dms")
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
|
||||
@@ -314,8 +347,9 @@ func runSetup() error {
|
||||
terminal, terminalSelected := promptTerminal()
|
||||
useSystemd := true
|
||||
if wmSelected {
|
||||
if wm == deps.WindowManagerMango {
|
||||
if wm == deps.WindowManagerMango && !mangoconf.SessionTargetInstalled() {
|
||||
useSystemd = false
|
||||
fmt.Println("\nThis Mango has no mango-session.target (added in 0.17.1); DMS will start from the Mango config.")
|
||||
} else if isVoidSetup() {
|
||||
useSystemd = false
|
||||
fmt.Println("\nVoid Linux detected; deploying non-systemd session config.")
|
||||
@@ -329,6 +363,13 @@ func runSetup() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
replaceBinds := false
|
||||
if wmSelected {
|
||||
if path, ok := config.ExistingBinds(wm); ok {
|
||||
replaceBinds = promptBinds(path)
|
||||
}
|
||||
}
|
||||
|
||||
if wmSelected || terminalSelected {
|
||||
willBackup := checkExistingConfigs(wm, wmSelected, terminal, terminalSelected)
|
||||
if willBackup {
|
||||
@@ -349,6 +390,7 @@ func runSetup() error {
|
||||
fmt.Println("\nDeploying configurations...")
|
||||
logChan := make(chan string, 100)
|
||||
deployer := config.NewConfigDeployer(logChan)
|
||||
deployer.SetReplaceBinds(replaceBinds)
|
||||
|
||||
go func() {
|
||||
for msg := range logChan {
|
||||
@@ -360,8 +402,15 @@ func runSetup() error {
|
||||
var err error
|
||||
|
||||
if wmSelected {
|
||||
terminalCommand := defaultTerminalCommand()
|
||||
if terminalSelected {
|
||||
terminalCommand = terminal.Command()
|
||||
}
|
||||
if wm == deps.WindowManagerHyprland {
|
||||
deployer.SetHyprlandVersion(installedHyprlandVersion())
|
||||
}
|
||||
var result config.DeploymentResult
|
||||
result, err = deployer.DeployCompositor(wm, terminal, useSystemd)
|
||||
result, err = deployer.DeployCompositor(wm, terminalCommand, useSystemd)
|
||||
results = append(results, result)
|
||||
}
|
||||
if err == nil && terminalSelected {
|
||||
@@ -477,6 +526,17 @@ func promptTerminal() (deps.Terminal, bool) {
|
||||
}
|
||||
}
|
||||
|
||||
func promptBinds(path string) bool {
|
||||
fmt.Printf("\nFound your keybinds in %s.\n", path)
|
||||
fmt.Println(" 1) Keep my binds as they are")
|
||||
fmt.Println(" 2) Start from the DMS stock binds")
|
||||
fmt.Println("Either way a timestamped backup is kept.")
|
||||
fmt.Print("Choice (1-2) [1]: ")
|
||||
var response string
|
||||
fmt.Scanln(&response)
|
||||
return strings.TrimSpace(response) == "2"
|
||||
}
|
||||
|
||||
func promptSystemd() bool {
|
||||
fmt.Println("\nUse systemd for session management?")
|
||||
fmt.Println("1) Yes (recommended for most distros)")
|
||||
|
||||
@@ -3,8 +3,8 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/config"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/deps"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -19,22 +19,24 @@ var setupHeadlessCmd = &cobra.Command{
|
||||
noSystemd, _ := cmd.Flags().GetBool("no-systemd")
|
||||
force, _ := cmd.Flags().GetBool("force")
|
||||
skipExisting, _ := cmd.Flags().GetBool("skip-existing")
|
||||
stockBinds, _ := cmd.Flags().GetBool("stock-binds")
|
||||
cmd.SilenceUsage = true
|
||||
return runSetupHeadless(compositor, terminal, noSystemd, force, skipExisting)
|
||||
return runSetupHeadless(compositor, terminal, noSystemd, force, skipExisting, stockBinds)
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
setupHeadlessCmd.Flags().String("compositor", "", "Compositor to configure: niri, hyprland, or mango")
|
||||
setupHeadlessCmd.Flags().String("terminal", "", "Also deploy a terminal config: ghostty, kitty, or alacritty")
|
||||
setupHeadlessCmd.Flags().String("terminal", "", "Also deploy a terminal config: ghostty, kitty, or alacritty (binds otherwise use $TERMINAL or the first installed terminal)")
|
||||
setupHeadlessCmd.Flags().Bool("no-systemd", false, "Deploy session config without systemd integration")
|
||||
setupHeadlessCmd.Flags().Bool("force", false, "Overwrite existing configs (timestamped backups are created)")
|
||||
setupHeadlessCmd.Flags().Bool("skip-existing", false, "Warn and skip instead of failing when a config already exists")
|
||||
setupHeadlessCmd.Flags().Bool("stock-binds", false, "Replace existing keybinds with the DMS stock set (a backup is kept)")
|
||||
_ = setupHeadlessCmd.MarkFlagRequired("compositor")
|
||||
setupHeadlessCmd.MarkFlagsMutuallyExclusive("force", "skip-existing")
|
||||
}
|
||||
|
||||
func runSetupHeadless(compositor, terminal string, noSystemd, force, skipExisting bool) error {
|
||||
func runSetupHeadless(compositor, terminal string, noSystemd, force, skipExisting, stockBinds bool) error {
|
||||
wm, err := parseHeadlessCompositor(compositor)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -79,11 +81,19 @@ func runSetupHeadless(compositor, terminal string, noSystemd, force, skipExistin
|
||||
close(logDone)
|
||||
}()
|
||||
deployer := config.NewConfigDeployer(logChan)
|
||||
deployer.SetReplaceBinds(stockBinds)
|
||||
|
||||
var results []config.DeploymentResult
|
||||
var deployErr error
|
||||
if deployCompositor {
|
||||
result, err := deployer.DeployCompositor(wm, term, headlessUseSystemd(wm, noSystemd))
|
||||
terminalCommand := defaultTerminalCommand()
|
||||
if termSelected {
|
||||
terminalCommand = term.Command()
|
||||
}
|
||||
if wm == deps.WindowManagerHyprland {
|
||||
deployer.SetHyprlandVersion(installedHyprlandVersion())
|
||||
}
|
||||
result, err := deployer.DeployCompositor(wm, terminalCommand, headlessUseSystemd(wm, noSystemd))
|
||||
results = append(results, result)
|
||||
deployErr = err
|
||||
}
|
||||
@@ -141,7 +151,7 @@ func parseHeadlessTerminal(name string) (deps.Terminal, bool, error) {
|
||||
}
|
||||
|
||||
func headlessUseSystemd(wm deps.WindowManager, noSystemd bool) bool {
|
||||
if noSystemd || wm == deps.WindowManagerMango {
|
||||
if noSystemd {
|
||||
return false
|
||||
}
|
||||
return !isVoidSetup()
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/backup"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/deps"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/distros"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/privesc"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const switchDMSDep = "dms (Dux Shell)"
|
||||
|
||||
// The greeter runs the shell's QML, so it moves with dms or the login screen can break.
|
||||
var switchFamily = []string{switchDMSDep, "quickshell", "dms-greeter"}
|
||||
|
||||
var switchAliases = map[string]string{
|
||||
"dms": switchDMSDep,
|
||||
"quickshell": "quickshell",
|
||||
"dms-greeter": "dms-greeter",
|
||||
"greeter": "dms-greeter",
|
||||
"dankcalendar": "dankcalendar",
|
||||
"danksearch": "danksearch",
|
||||
}
|
||||
|
||||
var switchAssumeYes bool
|
||||
|
||||
var switchCmd = &cobra.Command{
|
||||
Use: "switch <stable|beta> [package...]",
|
||||
Short: "Switch DMS packages between stable and beta builds",
|
||||
Long: `Switch installed DMS packages between stable releases and beta builds.
|
||||
|
||||
With no packages, dms, quickshell and dms-greeter switch together. Name packages
|
||||
(dms, quickshell, greeter, dankcalendar, danksearch) to switch only those.
|
||||
|
||||
Settings, plugins, themes and session state are backed up before anything changes.
|
||||
Settings written by beta can lose options on stable, so switching back to stable
|
||||
offers to restore the backup taken the last time you left stable.`,
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
ValidArgs: []string{"stable", "beta"},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if err := runSwitch(args[0], args[1:]); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
switchCmd.Flags().BoolVarP(&switchAssumeYes, "yes", "y", false, "Do not ask for confirmation; keeps current settings when switching to stable")
|
||||
}
|
||||
|
||||
func runSwitch(channel string, packages []string) error {
|
||||
var target deps.PackageVariant
|
||||
switch channel {
|
||||
case "stable":
|
||||
target = deps.VariantStable
|
||||
case "beta":
|
||||
target = deps.VariantGit
|
||||
default:
|
||||
return fmt.Errorf("unknown channel %q: use stable or beta", channel)
|
||||
}
|
||||
|
||||
explicit := len(packages) > 0
|
||||
names := switchFamily
|
||||
if explicit {
|
||||
names = nil
|
||||
for _, pkg := range packages {
|
||||
name, ok := switchAliases[strings.ToLower(pkg)]
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown package %q", pkg)
|
||||
}
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
|
||||
osInfo, err := distros.GetOSInfo()
|
||||
if err != nil {
|
||||
return fmt.Errorf("detecting distribution: %w", err)
|
||||
}
|
||||
distro, err := distros.NewDistribution(osInfo.Distribution.ID, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The package names are compositor independent, so any window manager works here.
|
||||
var wm deps.WindowManager
|
||||
var swaps []distros.VariantSwap
|
||||
for _, name := range names {
|
||||
swap, status := distros.PlanVariantSwap(distro, wm, name, target)
|
||||
switch status {
|
||||
case distros.SwapNeeded:
|
||||
swaps = append(swaps, swap)
|
||||
case distros.SwapUnsupported:
|
||||
return fmt.Errorf("switching packages is not supported on %s", osInfo.PrettyName)
|
||||
case distros.SwapAlreadyOnTarget:
|
||||
fmt.Printf("%s is already on %s\n", name, channel)
|
||||
case distros.SwapNoVariant:
|
||||
if explicit {
|
||||
fmt.Printf("%s has no separate beta build on %s\n", name, osInfo.PrettyName)
|
||||
}
|
||||
case distros.SwapNotInstalled:
|
||||
if explicit {
|
||||
fmt.Printf("%s is not installed from a package\n", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(swaps) == 0 {
|
||||
fmt.Println("Nothing to switch.")
|
||||
return nil
|
||||
}
|
||||
|
||||
shellChanges := false
|
||||
snapshotLabel := ""
|
||||
fmt.Printf("\nSwitching to %s:\n", channel)
|
||||
for _, swap := range swaps {
|
||||
fmt.Printf(" %s → %s\n", switchLabel(swap.From), switchLabel(swap.To))
|
||||
switch swap.Dep {
|
||||
case switchDMSDep:
|
||||
shellChanges = true
|
||||
snapshotLabel = "stable"
|
||||
if target == deps.VariantStable {
|
||||
snapshotLabel = "beta"
|
||||
}
|
||||
case "quickshell":
|
||||
shellChanges = true
|
||||
}
|
||||
}
|
||||
if snapshotLabel != "" {
|
||||
dir, _ := backup.SnapshotDir()
|
||||
fmt.Printf("\nYour DMS settings will be backed up to %s first.\n", dir)
|
||||
}
|
||||
if shellChanges {
|
||||
fmt.Println("The shell restarts when the switch is done.")
|
||||
}
|
||||
if !switchAssumeYes && !askSwitch("\nContinue? [y/N] ") {
|
||||
return errors.New("cancelled")
|
||||
}
|
||||
|
||||
if snapshotLabel != "" {
|
||||
path, err := backup.Snapshot(snapshotLabel)
|
||||
if err != nil {
|
||||
return fmt.Errorf("backing up settings, nothing was changed: %w", err)
|
||||
}
|
||||
if path != "" {
|
||||
fmt.Printf("Backed up settings to %s\n", path)
|
||||
}
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
if privesc.CheckCached(ctx) != nil {
|
||||
if err := privesc.Run(ctx, "", "true"); err != nil {
|
||||
return fmt.Errorf("authentication failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := runSwaps(ctx, distro.(distros.VariantSwapper), swaps); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
restore := ""
|
||||
if snapshotLabel == "beta" && !switchAssumeYes {
|
||||
if path, ok := backup.LatestSnapshot("stable"); ok {
|
||||
fmt.Printf("\nSettings saved by beta may lose options on stable. Restore the backup from your last\nstable run (%s)? Your beta settings stay in the beta backup. [y/N] ", path)
|
||||
if askSwitch("") {
|
||||
restore = path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if shellChanges || restore != "" {
|
||||
return restartShellAfterSwitch(restore)
|
||||
}
|
||||
fmt.Println("Done.")
|
||||
return nil
|
||||
}
|
||||
|
||||
func runSwaps(ctx context.Context, swapper distros.VariantSwapper, swaps []distros.VariantSwap) error {
|
||||
progressChan := make(chan distros.InstallProgressMsg, 100)
|
||||
swapErr := make(chan error, 1)
|
||||
go func() {
|
||||
defer close(progressChan)
|
||||
swapErr <- swapper.SwapVariants(ctx, swaps, "", progressChan)
|
||||
}()
|
||||
|
||||
// runWithProgress repeats the step on every output line, so only print it when it changes.
|
||||
lastStep := ""
|
||||
for msg := range progressChan {
|
||||
switch {
|
||||
case msg.Error != nil:
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", msg.Error)
|
||||
case msg.Step != "" && msg.Step != lastStep:
|
||||
fmt.Println(msg.Step)
|
||||
lastStep = msg.Step
|
||||
}
|
||||
if msg.LogOutput != "" {
|
||||
fmt.Printf(" %s\n", msg.LogOutput)
|
||||
}
|
||||
}
|
||||
return <-swapErr
|
||||
}
|
||||
|
||||
// restartShellAfterSwitch stops the shell before restoring, or the running one would migrate the
|
||||
// restored settings and save them back. A plain restart would also reload the old binary's QML.
|
||||
func restartShellAfterSwitch(restore string) error {
|
||||
managed := runSystemctl("--user", "is-active", "--quiet", dmsSystemdUnit) == nil
|
||||
if managed {
|
||||
if err := runSystemctl("--user", "stop", dmsSystemdUnit); err != nil {
|
||||
return fmt.Errorf("stopping the shell: %w", err)
|
||||
}
|
||||
} else {
|
||||
stopShellForSwitch()
|
||||
}
|
||||
|
||||
if restore != "" {
|
||||
if err := backup.RestoreSnapshot(restore); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Restoring settings failed: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf("Restored settings from %s\n", restore)
|
||||
}
|
||||
}
|
||||
|
||||
if managed {
|
||||
if err := runSystemctl("--user", "start", dmsSystemdUnit); err != nil {
|
||||
return fmt.Errorf("starting the shell: %w", err)
|
||||
}
|
||||
} else {
|
||||
dmsPath, err := exec.LookPath("dms")
|
||||
if err != nil {
|
||||
return fmt.Errorf("switch done, but dms was not found to start the shell: %w", err)
|
||||
}
|
||||
if err := exec.Command(dmsPath, "run", "-d").Run(); err != nil {
|
||||
return fmt.Errorf("switch done, but starting the shell failed (run 'dms run -d'): %w", err)
|
||||
}
|
||||
}
|
||||
fmt.Println("Done. The shell has restarted.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// stopShellForSwitch terminates the supervisors and waits for them: Kill() would SIGKILL them,
|
||||
// orphaning quickshell in its own session, which then keeps saving settings over the restore.
|
||||
func stopShellForSwitch() {
|
||||
for _, pid := range shellApp.PIDs() {
|
||||
if proc, err := os.FindProcess(pid); err == nil {
|
||||
proc.Signal(syscall.SIGTERM)
|
||||
}
|
||||
}
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for len(shellApp.PIDs()) > 0 && time.Now().Before(deadline) {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
shellApp.Kill()
|
||||
}
|
||||
|
||||
func switchLabel(m distros.PackageMapping) string {
|
||||
if m.RepoURL == "" {
|
||||
return m.Name
|
||||
}
|
||||
return m.RepoURL + "/" + m.Name
|
||||
}
|
||||
|
||||
func askSwitch(prompt string) bool {
|
||||
if !stdinIsTTY() {
|
||||
return false
|
||||
}
|
||||
fmt.Print(prompt)
|
||||
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
answer := strings.ToLower(strings.TrimSpace(line))
|
||||
return answer == "y" || answer == "yes"
|
||||
}
|
||||
@@ -6,13 +6,15 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/sysupdate"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/duxlabs-dev/dux-go/ipc"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/log"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/lowprio"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/server/sysupdate"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -34,6 +36,7 @@ Examples:
|
||||
dms system update --check # list available updates
|
||||
dms system update # apply updates (interactive prompt)
|
||||
dms system update --noconfirm # apply updates without prompting
|
||||
dms system update --interactive # let the package manager ask its own questions
|
||||
dms system update --dry # simulate without changing anything
|
||||
dms system update --no-flatpak --noconfirm # apply system updates only
|
||||
dms system update --interval 3600 # set the server poll interval to 1h`,
|
||||
@@ -41,24 +44,26 @@ Examples:
|
||||
}
|
||||
|
||||
var (
|
||||
sysUpdateCheck bool
|
||||
sysUpdateNoConfirm bool
|
||||
sysUpdateDry bool
|
||||
sysUpdateJSON bool
|
||||
sysUpdateNoFlatpak bool
|
||||
sysUpdateNoAUR bool
|
||||
sysUpdateIgnore []string
|
||||
sysUpdateIntervalS int
|
||||
sysUpdateListPmTime = 5 * time.Minute
|
||||
sysUpdateCheck bool
|
||||
sysUpdateNoConfirm bool
|
||||
sysUpdateInteractive bool
|
||||
sysUpdateDry bool
|
||||
sysUpdateJSON bool
|
||||
sysUpdateNoFlatpak bool
|
||||
sysUpdateNoAUR bool
|
||||
sysUpdateIgnore []string
|
||||
sysUpdateIntervalS int
|
||||
sysUpdateListPmTime = 5 * time.Minute
|
||||
)
|
||||
|
||||
func init() {
|
||||
systemUpdateCmd.Flags().BoolVar(&sysUpdateCheck, "check", false, "List available updates without applying")
|
||||
systemUpdateCmd.Flags().BoolVarP(&sysUpdateNoConfirm, "noconfirm", "y", false, "Apply updates without prompting")
|
||||
systemUpdateCmd.Flags().BoolVar(&sysUpdateInteractive, "interactive", false, "Let the package manager ask its own questions instead of answering yes")
|
||||
systemUpdateCmd.Flags().BoolVar(&sysUpdateDry, "dry", false, "Simulate the upgrade without applying changes")
|
||||
systemUpdateCmd.Flags().BoolVar(&sysUpdateJSON, "json", false, "Output as JSON (with --check)")
|
||||
systemUpdateCmd.Flags().BoolVar(&sysUpdateNoFlatpak, "no-flatpak", false, "Skip the Flatpak overlay")
|
||||
systemUpdateCmd.Flags().BoolVar(&sysUpdateNoAUR, "no-aur", false, "Skip the AUR (paru/yay only)")
|
||||
systemUpdateCmd.Flags().BoolVar(&sysUpdateNoAUR, "no-aur", false, "Skip AUR updates")
|
||||
systemUpdateCmd.Flags().StringSliceVar(&sysUpdateIgnore, "ignore", nil, "Skip specific packages (repeatable or comma-separated)")
|
||||
systemUpdateCmd.Flags().IntVar(&sysUpdateIntervalS, "interval", -1, "Set the DMS server poll interval in seconds and exit (requires running server)")
|
||||
|
||||
@@ -66,14 +71,19 @@ func init() {
|
||||
}
|
||||
|
||||
func runSystemUpdate(cmd *cobra.Command, args []string) {
|
||||
switch {
|
||||
case sysUpdateIntervalS >= 0:
|
||||
if sysUpdateIntervalS >= 0 {
|
||||
runSystemUpdateSetInterval(sysUpdateIntervalS)
|
||||
case sysUpdateCheck:
|
||||
runSystemUpdateCheck()
|
||||
default:
|
||||
runSystemUpdateApply()
|
||||
return
|
||||
}
|
||||
|
||||
// Package managers started from this thread inherit its priority.
|
||||
runtime.LockOSThread()
|
||||
lowprio.LowerThreadPriority()
|
||||
if sysUpdateCheck {
|
||||
runSystemUpdateCheck()
|
||||
return
|
||||
}
|
||||
runSystemUpdateApply()
|
||||
}
|
||||
|
||||
func selectBackends(ctx context.Context) []sysupdate.Backend {
|
||||
@@ -180,7 +190,7 @@ func runSystemUpdateApply() {
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
if !sysUpdateNoConfirm && !sysUpdateDry {
|
||||
if !sysUpdateNoConfirm && !sysUpdateInteractive && !sysUpdateDry {
|
||||
if !promptYesNo("Proceed with upgrade? [Y/n]: ") {
|
||||
fmt.Println("Aborted.")
|
||||
return
|
||||
@@ -199,6 +209,7 @@ func runSystemUpdateApply() {
|
||||
UseSudo: true,
|
||||
}
|
||||
opts.AttachStdio = sysupdate.UpgradeNeedsPrivilege(backends, pkgs, opts)
|
||||
opts.Interactive = sysUpdateInteractive && opts.AttachStdio
|
||||
|
||||
onLine := func(line string) { fmt.Println(line) }
|
||||
ran := false
|
||||
@@ -240,16 +251,12 @@ func filterUpdateTargets(pkgs []sysupdate.Package) []sysupdate.Package {
|
||||
if !sysUpdateNoAUR && len(sysUpdateIgnore) == 0 {
|
||||
return pkgs
|
||||
}
|
||||
ignored := make(map[string]bool, len(sysUpdateIgnore))
|
||||
for _, name := range sysUpdateIgnore {
|
||||
ignored[name] = true
|
||||
}
|
||||
out := pkgs[:0]
|
||||
for _, p := range pkgs {
|
||||
if sysUpdateNoAUR && p.Repo == sysupdate.RepoAUR {
|
||||
continue
|
||||
}
|
||||
if ignored[p.Name] {
|
||||
if sysupdate.IsPackageIgnored(p.Name, sysUpdateIgnore) {
|
||||
continue
|
||||
}
|
||||
out = append(out, p)
|
||||
@@ -258,7 +265,7 @@ func filterUpdateTargets(pkgs []sysupdate.Package) []sysupdate.Package {
|
||||
}
|
||||
|
||||
func runSystemUpdateSetInterval(seconds int) {
|
||||
resp, err := sendServerRequest(models.Request{
|
||||
resp, err := sendServerRequest(ipc.Request{
|
||||
ID: 1,
|
||||
Method: "sysupdate.setInterval",
|
||||
Params: map[string]any{"seconds": float64(seconds)},
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/config"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/deps"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var systemdCompositorFlag string
|
||||
|
||||
var systemdCmd = &cobra.Command{
|
||||
Use: "systemd",
|
||||
Short: "Start DMS as a systemd user service with the compositor session",
|
||||
Long: `Manage dms.service for the compositor session.
|
||||
|
||||
enable makes the compositor's own session unit want dms.service, so DMS starts
|
||||
with that compositor only: hyprland-session.target, niri.service or
|
||||
mango-session.target. The compositor is the running one, or --compositor.`,
|
||||
}
|
||||
|
||||
var systemdEnableCmd = &cobra.Command{
|
||||
Use: "enable",
|
||||
Short: "Start dms.service with the compositor session",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
wm, err := systemdCompositor()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
unit, err := config.EnableSession(wm)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("dms.service starts with %s\n", unit)
|
||||
now, _ := cmd.Flags().GetBool("now")
|
||||
if !now {
|
||||
return nil
|
||||
}
|
||||
if err := runSystemctl("--user", "start", dmsSystemdUnit); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("dms.service started")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var systemdDisableCmd = &cobra.Command{
|
||||
Use: "disable",
|
||||
Short: "Stop starting dms.service with any compositor session",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
removed, err := config.DisableSession()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(removed) == 0 {
|
||||
fmt.Println("dms.service was not wanted by any compositor session")
|
||||
} else {
|
||||
fmt.Printf("dms.service no longer starts with %s\n", strings.Join(removed, ", "))
|
||||
}
|
||||
now, _ := cmd.Flags().GetBool("now")
|
||||
if !now {
|
||||
return nil
|
||||
}
|
||||
return runSystemctl("--user", "stop", dmsSystemdUnit)
|
||||
},
|
||||
}
|
||||
|
||||
var systemdStatusCmd = &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Show which compositor sessions start dms.service",
|
||||
Args: cobra.NoArgs,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
state := getServiceState("dms", true)
|
||||
if !state.exists {
|
||||
fmt.Println("dms.service: not installed")
|
||||
return
|
||||
}
|
||||
fmt.Printf("dms.service: %s", state.enabled)
|
||||
if state.active != "" {
|
||||
fmt.Printf(", %s", state.active)
|
||||
}
|
||||
fmt.Println()
|
||||
wanted := config.SessionWanted()
|
||||
if len(wanted) == 0 {
|
||||
fmt.Println("Starts with: nothing (run 'dms systemd enable')")
|
||||
return
|
||||
}
|
||||
fmt.Printf("Starts with: %s\n", strings.Join(wanted, ", "))
|
||||
},
|
||||
}
|
||||
|
||||
func systemdLifecycleCmd(verb string) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: verb,
|
||||
Short: strings.ToUpper(verb[:1]) + verb[1:] + " dms.service",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runSystemctl("--user", verb, dmsSystemdUnit)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func systemdCompositor() (deps.WindowManager, error) {
|
||||
if systemdCompositorFlag == "" {
|
||||
if wm, ok := config.RunningCompositor(); ok {
|
||||
return wm, nil
|
||||
}
|
||||
name, err := detectCompositorForSetup()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
systemdCompositorFlag = name
|
||||
}
|
||||
switch strings.ToLower(systemdCompositorFlag) {
|
||||
case "hyprland":
|
||||
return deps.WindowManagerHyprland, nil
|
||||
case "niri":
|
||||
return deps.WindowManagerNiri, nil
|
||||
case "mango", "mangowc":
|
||||
return deps.WindowManagerMango, nil
|
||||
}
|
||||
return 0, fmt.Errorf("unknown compositor %q (hyprland, niri or mango)", systemdCompositorFlag)
|
||||
}
|
||||
|
||||
func init() {
|
||||
systemdEnableCmd.Flags().StringVar(&systemdCompositorFlag, "compositor", "", "hyprland, niri or mango (default: the running one)")
|
||||
systemdEnableCmd.Flags().Bool("now", false, "Also start dms.service")
|
||||
systemdDisableCmd.Flags().Bool("now", false, "Also stop dms.service")
|
||||
systemdCmd.AddCommand(systemdEnableCmd, systemdDisableCmd, systemdStatusCmd, systemdLifecycleCmd("start"), systemdLifecycleCmd("stop"), systemdLifecycleCmd("restart"))
|
||||
}
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/trash"
|
||||
"github.com/duxlabs-dev/dux-go/trash"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/log"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
|
||||
@@ -8,10 +8,11 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/windowrules"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/windowrules/providers"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/log"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/mangoconf"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/utils"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/windowrules"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/windowrules/providers"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -216,7 +217,7 @@ func runWindowrulesList(cmd *cobra.Command, args []string) {
|
||||
result.DMSStatus = parseResult.DMSStatus
|
||||
|
||||
case "mango", "mangowc":
|
||||
configDir := filepath.Join(utils.XDGConfigHome(), "mango")
|
||||
configDir := mangoconf.Dir()
|
||||
|
||||
parseResult, err := providers.ParseMangoWindowRules(configDir)
|
||||
if err != nil {
|
||||
@@ -351,7 +352,7 @@ func getWindowRulesProvider(compositor string) windowrules.WritableProvider {
|
||||
configDir := filepath.Join(utils.XDGConfigHome(), "hypr")
|
||||
return providers.NewHyprlandWritableProvider(configDir)
|
||||
case "mango", "mangowc":
|
||||
configDir := filepath.Join(utils.XDGConfigHome(), "mango")
|
||||
configDir := mangoconf.Dir()
|
||||
return providers.NewMangoWritableProvider(configDir)
|
||||
default:
|
||||
return nil
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_output_power"
|
||||
wlclient "github.com/AvengeMedia/dankgo/wayland/client"
|
||||
wlclient "github.com/duxlabs-dev/dux-go/wayland/client"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/proto/wlr_output_power"
|
||||
)
|
||||
|
||||
type cmd struct {
|
||||
|
||||
@@ -9,15 +9,12 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/clipolicy"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/privesc"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const (
|
||||
cliPolicyPackagedPath = "/usr/share/dms/cli-policy.json"
|
||||
cliPolicyAdminPath = "/etc/dms/cli-policy.json"
|
||||
)
|
||||
|
||||
var (
|
||||
immutablePolicyOnce sync.Once
|
||||
immutablePolicy immutableCommandPolicy
|
||||
@@ -34,13 +31,6 @@ type immutableCommandPolicy struct {
|
||||
Message string
|
||||
}
|
||||
|
||||
type cliPolicyFile struct {
|
||||
PolicyVersion int `json:"policy_version"`
|
||||
ImmutableSystem *bool `json:"immutable_system"`
|
||||
BlockedCommands *[]string `json:"blocked_commands"`
|
||||
Message *string `json:"message"`
|
||||
}
|
||||
|
||||
func normalizeCommandSpec(raw string) string {
|
||||
normalized := strings.ToLower(strings.TrimSpace(raw))
|
||||
normalized = strings.TrimPrefix(normalized, "dms ")
|
||||
@@ -82,25 +72,8 @@ func commandBlockedByPolicy(commandPath string, blocked []string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func loadPolicyFile(path string) (*cliPolicyFile, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to read %s: %w", path, err)
|
||||
}
|
||||
|
||||
var policy cliPolicyFile
|
||||
if err := json.Unmarshal(data, &policy); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse %s: %w", path, err)
|
||||
}
|
||||
|
||||
return &policy, nil
|
||||
}
|
||||
|
||||
func mergePolicyFile(base *immutableCommandPolicy, path string) error {
|
||||
policyFile, err := loadPolicyFile(path)
|
||||
policyFile, err := clipolicy.LoadFile(afero.NewOsFs(), path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -218,7 +191,7 @@ func getImmutablePolicy() (*immutableCommandPolicy, error) {
|
||||
Message: "This command is disabled on immutable/image-based systems. Use your distro-native workflow for system-level changes.",
|
||||
}
|
||||
|
||||
var defaultPolicy cliPolicyFile
|
||||
var defaultPolicy clipolicy.File
|
||||
if err := json.Unmarshal(defaultCLIPolicyJSON, &defaultPolicy); err != nil {
|
||||
immutablePolicyErr = fmt.Errorf("failed to parse embedded default CLI policy: %w", err)
|
||||
return
|
||||
@@ -233,11 +206,11 @@ func getImmutablePolicy() (*immutableCommandPolicy, error) {
|
||||
}
|
||||
}
|
||||
|
||||
if err := mergePolicyFile(&immutablePolicy, cliPolicyPackagedPath); err != nil {
|
||||
if err := mergePolicyFile(&immutablePolicy, clipolicy.PackagedPath); err != nil {
|
||||
immutablePolicyErr = err
|
||||
return
|
||||
}
|
||||
if err := mergePolicyFile(&immutablePolicy, cliPolicyAdminPath); err != nil {
|
||||
if err := mergePolicyFile(&immutablePolicy, clipolicy.AdminPath); err != nil {
|
||||
immutablePolicyErr = err
|
||||
return
|
||||
}
|
||||
@@ -268,7 +241,7 @@ func requireMutableSystemCommand(cmd *cobra.Command, _ []string) error {
|
||||
reason = "Detected immutable system: " + policy.ImmutableReason + "\n"
|
||||
}
|
||||
|
||||
return fmt.Errorf("%s%s\nCommand: dms %s\nPolicy files:\n %s\n %s", reason, policy.Message, commandPath, cliPolicyPackagedPath, cliPolicyAdminPath)
|
||||
return fmt.Errorf("%s%s\nCommand: dms %s\nPolicy files:\n %s\n %s", reason, policy.Message, commandPath, clipolicy.PackagedPath, clipolicy.AdminPath)
|
||||
}
|
||||
|
||||
// preRunPrivileged combines the immutable-system check with a privesc tool
|
||||
|
||||
@@ -3,10 +3,12 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/clipolicy"
|
||||
)
|
||||
|
||||
func TestDefaultImmutablePolicyAllowsSyncButBlocksEnable(t *testing.T) {
|
||||
var policyFile cliPolicyFile
|
||||
var policyFile clipolicy.File
|
||||
if err := json.Unmarshal(defaultCLIPolicyJSON, &policyFile); err != nil {
|
||||
t.Fatalf("failed to parse embedded CLI policy: %v", err)
|
||||
}
|
||||
|
||||
+10
-2
@@ -5,8 +5,9 @@ package main
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/clipboard"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/log"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/utils"
|
||||
)
|
||||
|
||||
var Version = "dev"
|
||||
@@ -33,6 +34,13 @@ func main() {
|
||||
log.Fatal("This program should not be run as root. Exiting.")
|
||||
}
|
||||
|
||||
if os.Geteuid() != 0 {
|
||||
for _, err := range utils.MigrateLegacyDirs() {
|
||||
log.Warnf("dms dir migration: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
os.Args = normalizeDmenuArgv(os.Args)
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -5,8 +5,9 @@ package main
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/clipboard"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/log"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/utils"
|
||||
)
|
||||
|
||||
var Version = "dev"
|
||||
@@ -29,6 +30,13 @@ func main() {
|
||||
log.Fatal("This program should not be run as root. Exiting.")
|
||||
}
|
||||
|
||||
if os.Geteuid() != 0 {
|
||||
for _, err := range utils.MigrateLegacyDirs() {
|
||||
log.Warnf("dms dir migration: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
os.Args = normalizeDmenuArgv(os.Args)
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_output_management"
|
||||
wlclient "github.com/AvengeMedia/dankgo/wayland/client"
|
||||
wlclient "github.com/duxlabs-dev/dux-go/wayland/client"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/proto/wlr_output_management"
|
||||
)
|
||||
|
||||
type randrOutput struct {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user