mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-08-02 03:28:28 -04:00
Compare commits
1703 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c128793239 | |||
| 069ddab041 | |||
| 45cf6ecefd | |||
| 7058b00091 | |||
| 6ba4b79039 | |||
| b8e2ce1da8 | |||
| c58d55db7d | |||
| e15db00714 | |||
| 906eba5c17 | |||
| d2b61b91ef | |||
| 6c27b92445 | |||
| a64bd80cc9 | |||
| 661dc27d26 | |||
| 3d5a6fb9d7 | |||
| c2a5397d8a | |||
| 9b5538323d | |||
| 8321d28d18 | |||
| 39278860c8 | |||
| f455456ff1 | |||
| 5a80a32951 | |||
| 3cedd56f23 | |||
| dc1ad5b329 | |||
| 25c12a14a9 | |||
| b72b276fb4 | |||
| f89fb67316 | |||
| f457e04cb1 | |||
| 23daca0b57 | |||
| accdd0972c | |||
| 00c28f69fd | |||
| 2e0337b68c | |||
| 4975960ea4 | |||
| 0381a112b3 | |||
| ed5e2a22c0 | |||
| 847718459c | |||
| 95b120b475 | |||
| 4dd2f1d5be | |||
| bf5faf725b | |||
| e5eff919b8 | |||
| 606115748a | |||
| ad936bfdc9 | |||
| 110396cb2a | |||
| 7b08e61863 | |||
| 876ead665b | |||
| 74896fb87c | |||
| f1f528b2cb | |||
| 9853e1431b | |||
| e7b8196a4a | |||
| 85ebb402cb | |||
| f37f4a1f35 | |||
| fc11dfbc57 | |||
| 7806cf4a58 | |||
| 89895da4fa | |||
| 4ff93bf46e | |||
| 63ffd7caec | |||
| dac08b34c6 | |||
| 840a448b4a | |||
| 26d429fa31 | |||
| fd20cce9b7 | |||
| ad5a96d95b | |||
| 8ba5e1a082 | |||
| 7d6a7bae06 | |||
| 986a6e0230 | |||
| 64ba1fbb1b | |||
| ab9df791b3 | |||
| 65f4a9a044 | |||
| 59164cbd30 | |||
| 7ab1f6a956 | |||
| 841167ac44 | |||
| c2a6a7aebd | |||
| b17ef80c91 | |||
| a55177f1d2 | |||
| 3d0ce17a8c | |||
| 6306bc7aa2 | |||
| eca92898f9 | |||
| db9fb0bd90 | |||
| 815820aa27 | |||
| 01c0ed2778 | |||
| 9967db0c5a | |||
| 883a787db7 | |||
| 3b1a3c1c22 | |||
| 970262f276 | |||
| b708e97ec5 | |||
| e94fc7fe99 | |||
| c4bca6f6cd | |||
| 9e7fc833e9 | |||
| 9e2d493749 | |||
| 93120fd73b | |||
| 536ebf7b0a | |||
| 1105047a80 | |||
| 8b46397200 | |||
| 6b70491ba9 | |||
| deb0138198 | |||
| 2e0587ca16 | |||
| 45cafc6bdb | |||
| 82d5081b7d | |||
| b5b083305f | |||
| 039d14c790 | |||
| ee7ac9d2bd | |||
| bd159be414 | |||
| 4dda0e3a5e | |||
| 724ee9422d | |||
| c91cb5d89a | |||
| 660953ebc5 | |||
| bd70a6286e | |||
| d87e2fcb01 | |||
| 928b272628 | |||
| 781ed16538 | |||
| 417da7408d | |||
| 0d550662de | |||
| 35e35a16fa | |||
| d96559f7df | |||
| 7f37799e53 | |||
| fcda17f517 | |||
| 4b05e7a0fa | |||
| 7c6ad65b5a | |||
| acda199e28 | |||
| b3a129af2f | |||
| e9d958b32b | |||
| dd9edd8a00 | |||
| 28c25d46d5 | |||
| 58530ae56c | |||
| c0134c249a | |||
| f7157f0e92 | |||
| e29b9749df | |||
| 6791c20f40 | |||
| 6c2572fbfe | |||
| 3ee07f7f20 | |||
| ef244cb645 | |||
| 860c347362 | |||
| c7b58e52d7 | |||
| e0fb817a75 | |||
| b5908f4d1b | |||
| ee6c91e094 | |||
| 59d367f4a4 | |||
| ee84489b95 | |||
| b1beecb083 | |||
| ffd3840058 | |||
| 447b4d89b6 | |||
| 36772735f3 | |||
| 375afaaf3a | |||
| fe0214d522 | |||
| fce1a09790 | |||
| 554e55c63a | |||
| f7d8e2b56c | |||
| 1d4572ac1c | |||
| c02a24fecf | |||
| bf644516f8 | |||
| 61f9ea4954 | |||
| 5a169bea72 | |||
| 3d60f35683 | |||
| b95f4cdbf3 | |||
| b834e82668 | |||
| 302cfda2f4 | |||
| c86676df9b | |||
| fa26dbb544 | |||
| 067d01f678 | |||
| 95c8cc80f6 | |||
| b8f216dc0f | |||
| e84e63fa3a | |||
| 6d08b91004 | |||
| bdfd565b72 | |||
| f3b698590c | |||
| 7826d42ce4 | |||
| 6cc30c289b | |||
| 43cc8e69d1 | |||
| a62ae336e0 | |||
| e94af2a7aa | |||
| bb9e69e0c1 | |||
| 1cc9218ff6 | |||
| 56712f46fa | |||
| 93168ee073 | |||
| 1724aedd49 | |||
| d799175c07 | |||
| ee6f7b4798 | |||
| 0511cd19df | |||
| 71b1901ab0 | |||
| 32e2d96e55 | |||
| 647766b9fa | |||
| 0cd8974110 | |||
| 0c4c5fc146 | |||
| b447e16374 | |||
| 0bb8353a33 | |||
| 19a7dcf17d | |||
| 8a1acb63c9 | |||
| 6f298d3f52 | |||
| 3e481a566b | |||
| 405ea708b3 | |||
| cc0bec2682 | |||
| 240adfa28f | |||
| c175f4823a | |||
| 35613ebba8 | |||
| 583e46f2f0 | |||
| 590555dfc6 | |||
| c11169eb4c | |||
| bddcdb3f99 | |||
| 6ec67cefd0 | |||
| 8d9d7ff0ef | |||
| acc39ceb16 | |||
| 43863a86fb | |||
| 36ad34a555 | |||
| 78e823e23a | |||
| b6a27dc713 | |||
| 79dfd34ca2 | |||
| eedba0e8c9 | |||
| 2b12292895 | |||
| dd7f4f3ac5 | |||
| 3df801e3b2 | |||
| 74d220767d | |||
| a3fba8fcb6 | |||
| 33384e4d5d | |||
| 78f2ea4642 | |||
| 7dffe85e94 | |||
| c5c3105469 | |||
| 8f100960cf | |||
| 8a5e7c78fb | |||
| 8a85ab06a1 | |||
| 30ceda97a0 | |||
| fefc0afa6f | |||
| b01042d530 | |||
| 7355cc54b2 | |||
| 187b7ba8ff | |||
| c0e7c1b2b4 | |||
| ea0c235d6e | |||
| 57a04cf409 | |||
| fd99558ce5 | |||
| 81a4d3b4e0 | |||
| bfda5f2d66 | |||
| 65c4167ab2 | |||
| e7bd8e4002 | |||
| 25a1b125b7 | |||
| d766c0773c | |||
| 5d12962fd6 | |||
| 9911698237 | |||
| 7e095415a7 | |||
| 68d5d7c5ed | |||
| 7b1c542585 | |||
| 52ed719489 | |||
| 6e508d416d | |||
| 518cc93276 | |||
| 8cb4e21716 | |||
| 19b44b1782 | |||
| 0509694d78 | |||
| da44e07cf7 | |||
| b34941e3b8 | |||
| c554d973ef | |||
| 2861cc89c6 | |||
| 1c5965f1e4 | |||
| 66d552b3d5 | |||
| ebad8e2dda | |||
| 8dadf73d52 | |||
| 9f77b3127c | |||
| 46f8457777 | |||
| 8465ed4311 | |||
| 6e3e178721 | |||
| 27230227ef | |||
| 7178563c2c | |||
| dfe309a543 | |||
| 04962ffed1 | |||
| 5008406be8 | |||
| 930a36bcfc | |||
| 3253adb6dd | |||
| b8fd52e00f | |||
| 8e7126472e | |||
| 61108b7668 | |||
| 09ca37c9fc | |||
| a2513e939a | |||
| a73380b310 | |||
| 69d7c1fb13 | |||
| d1eb655e46 | |||
| a3ecef7627 | |||
| 186621ab81 | |||
| 31efe9b385 | |||
| 6cdb891551 | |||
| 3bb3da228c | |||
| 0c99e4b8d1 | |||
| 8b79d1dad3 | |||
| 85fa3326c1 | |||
| 2fade1a617 | |||
| 597ba597e5 | |||
| 957020c9ea | |||
| ae520620ac | |||
| 4c43bed2f8 | |||
| 2663bcd96c | |||
| e423e17807 | |||
| 70fa0ec903 | |||
| db3615805c | |||
| 8162e60475 | |||
| 5594ec27ff | |||
| ca050ace9c | |||
| 093acdbff7 | |||
| a98417d66b | |||
| 83b9cdcb27 | |||
| 4ae8ef927c | |||
| 2128e6f14e | |||
| 9ff085c46a | |||
| 57d08f6b3b | |||
| 7964b66827 | |||
| 38a03a4485 | |||
| a1727d18fd | |||
| aabcef0c9c | |||
| 19406e99b9 | |||
| 5432d264f1 | |||
| d704a0ba3d | |||
| 161118122e | |||
| 9d550d7c11 | |||
| e40faaa442 | |||
| 894efa58a3 | |||
| 50570023ff | |||
| f293def3a8 | |||
| de3e8cffa0 | |||
| a874f76d72 | |||
| 424d4ed701 | |||
| 8f2a50c00e | |||
| 46cddb965f | |||
| 1ca64abad9 | |||
| 32d68a034f | |||
| 1a176920d4 | |||
| afa6fd9e2e | |||
| 6bee1b2c86 | |||
| 48f6a0c632 | |||
| 03d86f78f4 | |||
| 0bc89429bc | |||
| dd3eb71485 | |||
| 016527fda9 | |||
| d1f833fbbb | |||
| fd65a8cea9 | |||
| bcd6006e25 | |||
| cde0d83620 | |||
| 92994061da | |||
| 1e7ee25e13 | |||
| 838d21aae8 | |||
| 2fc3b8ee4a | |||
| 52834c6a35 | |||
| 1a39b7f66c | |||
| aea5189abb | |||
| 774796ada2 | |||
| 14f21b6c6d | |||
| 21ddc3cc3f | |||
| 8b94b149b2 | |||
| 99cc3b8449 | |||
| b2e728315b | |||
| bed11feaa4 | |||
| 28f40afccf | |||
| 0e7901ebbe | |||
| 73f3a72d00 | |||
| 63feabf517 | |||
| 84f87dd7f3 | |||
| 39c247d9cb | |||
| a98c4f6c54 | |||
| bed6622530 | |||
| c5e08a6f2c | |||
| ad32af6eeb | |||
| 02ced4b87b | |||
| 5b28a63f75 | |||
| b5e2e68a22 | |||
| 556c0819c4 | |||
| b0edccb80e | |||
| 4e1c921ffb | |||
| fdbbcafb6c | |||
| a043d477fb | |||
| 4cbe766cbd | |||
| 8610b915ec | |||
| 59fd6db83e | |||
| 465cf7355b | |||
| de91b78943 | |||
| 4203148cab | |||
| 5adc0c464a | |||
| 097290f7da | |||
| 475ef5d1ca | |||
| 2f37019782 | |||
| 9f4123cc3c | |||
| 482a87a80d | |||
| b925010cb3 | |||
| 085ce01da6 | |||
| 7af530de8f | |||
| 9a4cff4e49 | |||
| 480ffa4ac2 | |||
| d5ac0c9aa0 | |||
| 29f19b07a9 | |||
| 39301c534c | |||
| 58b9e4bda7 | |||
| 820a9ce983 | |||
| 68410e882d | |||
| f26c0af39a | |||
| 0ca451483f | |||
| 2849dd0ba2 | |||
| df41ae4acb | |||
| 2692777707 | |||
| ca1a45ccf8 | |||
| 2f39f248fc | |||
| 90f8ce5035 | |||
| cb29125580 | |||
| 988b54515e | |||
| 2fd9de5062 | |||
| fd5aabcb17 | |||
| 85b63219b9 | |||
| ddf943846f | |||
| e7221ec623 | |||
| 78daaf0cb4 | |||
| a6ab3bab4c | |||
| 53cea7023f | |||
| a098088f03 | |||
| 59998e9fd2 | |||
| 1df7e478df | |||
| 1fc4890857 | |||
| f5d52f1506 | |||
| 2026ba5bd2 | |||
| db56c8d74d | |||
| 9d1a81c93c | |||
| 3701b3d7a3 | |||
| bae98daa5c | |||
| b34a04f723 | |||
| 1c0245f2db | |||
| 7777e87dc8 | |||
| 820fa07846 | |||
| 66794582c9 | |||
| 73eb471ae3 | |||
| 0f2f4b96c4 | |||
| d53809cf2b | |||
| 08fd6e26d8 | |||
| 29e8470f2e | |||
| 573785d4ce | |||
| 5483303714 | |||
| 5a5cc4f4e9 | |||
| cd672c341f | |||
| 12438d63c2 | |||
| 35255e4053 | |||
| 8856d45887 | |||
| 38af56c6fd | |||
| 9111e4809d | |||
| d08c7c5e55 | |||
| 69f3dee25a | |||
| 8155970ba2 | |||
| d356957dad | |||
| e7ccb702a3 | |||
| bf3ce6deb2 | |||
| f5295fb35d | |||
| 6c5836722a | |||
| 5716249bd9 | |||
| 4d0aab773b | |||
| e50ac208e3 | |||
| bcb5617194 | |||
| d3c23ba737 | |||
| e0ab0a6b90 | |||
| 713ce5f430 | |||
| 8eb23bcc29 | |||
| 4181343ef3 | |||
| d16566aa8d | |||
| 45eb101f40 | |||
| 59431869dc | |||
| 6e7aca8b15 | |||
| 6f387b0481 | |||
| 82d4364032 | |||
| e3de54c941 | |||
| 6991b45fbe | |||
| e5fff91ae6 | |||
| 2f2d4c9d9b | |||
| bfca1b46a6 | |||
| b117c80e47 | |||
| d20aa3b80a | |||
| a34fda984d | |||
| 510269dda9 | |||
| d51b34797c | |||
| d2905072c0 | |||
| 1ee42506b6 | |||
| 84fe2d751f | |||
| 5d0fc48706 | |||
| 335c5b4ac5 | |||
| 8c20f448ed | |||
| 0a668df138 | |||
| 3e4d2b4d46 | |||
| 12e43d120e | |||
| a9845bf3cd | |||
| e51ceed175 | |||
| 304baf6f60 | |||
| 6b141a9b06 | |||
| 0c3659a612 | |||
| a44bef5796 | |||
| b1ac6b0ef9 | |||
| 98844a3b85 | |||
| a32b8911c7 | |||
| 3118e7b9c3 | |||
| 2ca2bc5fb8 | |||
| 4bfb08f6ef | |||
| 0689339780 | |||
| a265625851 | |||
| 389fffaf64 | |||
| b7daf3f64a | |||
| 461da22b08 | |||
| 2b661e241d | |||
| d7df3800c2 | |||
| f2961f9b6a | |||
| f2d5ee4692 | |||
| 7c2d5ce15e | |||
| 5ceb908b8b | |||
| d819865853 | |||
| 38176ab543 | |||
| 53936d7034 | |||
| aafc2ea4d7 | |||
| 8a4be4936a | |||
| af097d0f33 | |||
| 44867e7b43 | |||
| a366bf3ca0 | |||
| 89f86be00a | |||
| 12a744e985 | |||
| 54f272ba1e | |||
| 60b64f22c6 | |||
| 97666dc73d | |||
| 6c6756936b | |||
| 91f8ca4efe | |||
| 045ac59a44 | |||
| 078180fe42 | |||
| d9525908f1 | |||
| 6093c37b41 | |||
| bb05cbb6c5 | |||
| 4d4af8f549 | |||
| 0b55fbcb15 | |||
| 7476a220b5 | |||
| aaff1ab61e | |||
| 39622eb62a | |||
| eea039f575 | |||
| ef5de19f6b | |||
| f0c31bd7b3 | |||
| 7ddd0ca90d | |||
| b84e5abc4a | |||
| fb9ec8e721 | |||
| 078c9b4890 | |||
| 37c98220a9 | |||
| fc07611b3b | |||
| a923308c09 | |||
| 0990b43a43 | |||
| 548c2305fb | |||
| 4634763840 | |||
| cdc1102092 | |||
| 4845299cc2 | |||
| 81a1bb1cd7 | |||
| 4528552610 | |||
| 0b55bf5dac | |||
| 8dd891f93a | |||
| 9bd68d44a1 | |||
| 90ea136379 | |||
| 2f4a39f9eb | |||
| 5e558660c3 | |||
| c923c43322 | |||
| 9f2ae6241e | |||
| 05c7a77c8b | |||
| a8ab0b55f3 | |||
| fbcc28785a | |||
| 1c1ab1c7d5 | |||
| 0a892a4a9e | |||
| e5cd9caba1 | |||
| 9c4aa06664 | |||
| 66e38c5efe | |||
| 018795125e | |||
| be4ea71756 | |||
| 39b90bb140 | |||
| fb5198fd0b | |||
| 71438530a8 | |||
| 79fe956058 | |||
| a33c7e0250 | |||
| 459ec47310 | |||
| 4bb3dd8310 | |||
| 9a630fad92 | |||
| 5bde54fa89 | |||
| 022f47a580 | |||
| 0dfa95ffe4 | |||
| e6da762870 | |||
| 8f958658dc | |||
| 392eaea5fc | |||
| 0aea542e8f | |||
| 7a566e1088 | |||
| 4bc62cc6ec | |||
| 9b68fc8213 | |||
| c878ffb7f9 | |||
| 3fc805ba53 | |||
| 371a7d0cd1 | |||
| 189b7c84ce | |||
| b8f4c350a8 | |||
| 3989c7f801 | |||
| 2690305724 | |||
| 676219bc09 | |||
| b192b5f779 | |||
| a5352623fd | |||
| 2b6ae58bff | |||
| b12511481d | |||
| c7d44cfb12 | |||
| 4193cf51ff | |||
| 1ec0311086 | |||
| c6a1473d2f | |||
| ee16047e15 | |||
| 4968b80268 | |||
| e28b0c695e | |||
| 7f6486b3e7 | |||
| faa30c4d48 | |||
| cf641b4e08 | |||
| b75453c7d6 | |||
| 10872b5fc8 | |||
| 80c853f16c | |||
| 6167f22837 | |||
| d8835f2bc6 | |||
| 1c01774fde | |||
| 0d3eb774e2 | |||
| 7fb4b6e0d9 | |||
| 5df2b5fc33 | |||
| d49c49cd99 | |||
| b209827f38 | |||
| 1b9d1c667c | |||
| 04d961ad0b | |||
| e60caf8028 | |||
| 1e67927f8a | |||
| e6e343dacb | |||
| f87ad3d2ca | |||
| a6ba4b1c79 | |||
| 7cf718cd50 | |||
| d223a74740 | |||
| 408beb202c | |||
| cfe6e6867e | |||
| 7c991bc4e3 | |||
| 50f0cbb122 | |||
| 7ee0879103 | |||
| 56ef186ce8 | |||
| 5b507136e3 | |||
| 19c561da14 | |||
| cc47703d48 | |||
| 31e60a3df5 | |||
| 082de6f1f0 | |||
| fd24b4a36d | |||
| dd668469d7 | |||
| 434490e100 | |||
| d2f6cb3ae4 | |||
| c1cbd0994f | |||
| c81645bacb | |||
| cdc4ca7e1f | |||
| 7d92842ff2 | |||
| d8bf3bdfe8 | |||
| 23ed795e85 | |||
| 2877c63c97 | |||
| 86096db26b | |||
| f76724f7cd | |||
| 3b96c6ab22 | |||
| 1467f5dba9 | |||
| baaa30c94e | |||
| 24a3cd5a3d | |||
| 65151dbfd7 | |||
| 7bd9574868 | |||
| a4cfdf4a59 | |||
| fd651dc943 | |||
| 919b09fc96 | |||
| aeb3fdd637 | |||
| dc5636bed5 | |||
| 36a7692da7 | |||
| c9b38023d5 | |||
| 536e654b5e | |||
| e805f6b5ac | |||
| 94f4b6d4a9 | |||
| 28f68ac702 | |||
| 441ec42ee0 | |||
| 5415444e15 | |||
| bd5276b40d | |||
| dd3f17f51e | |||
| a459b7d1b4 | |||
| 0f71c29776 | |||
| 4a32739d3f | |||
| 1abb221024 | |||
| b2668a2ffc | |||
| f4c11bc2ff | |||
| 97fa86d8f0 | |||
| b87c36d29e | |||
| c6ed64b24e | |||
| cf382c0322 | |||
| 9139fd2fb1 | |||
| da3df9bb77 | |||
| e7834c981a | |||
| 316428b14a | |||
| 6a9de8b423 | |||
| f1e3452307 | |||
| 4c2c193766 | |||
| 112f2165f3 | |||
| 40e3a22b99 | |||
| 7ced91ede1 | |||
| c6e8067a22 | |||
| d7fb75f7f9 | |||
| cf0fa7da6b | |||
| 787d213722 | |||
| 2138fbf8b7 | |||
| 722b3fd1e8 | |||
| 2728296cbd | |||
| fe1fd92953 | |||
| 0ab9b1e4e9 | |||
| 6d0953de68 | |||
| bc6bbdbe9d | |||
| eff728fdf5 | |||
| 8d415e9568 | |||
| e6ed6a1cc2 | |||
| ca18174da5 | |||
| 976b231b93 | |||
| 3d75a51378 | |||
| dc4b1529e6 | |||
| f61438e11f | |||
| 8f78163941 | |||
| f894d338fc | |||
| f2df53afcd | |||
| 4179fcee83 | |||
| a0c9af1ee7 | |||
| 049266271a | |||
| 0eabda3164 | |||
| 32c063aab8 | |||
| 37f92677cf | |||
| 13e8130858 | |||
| f6e590a518 | |||
| 3194fc3fbe | |||
| 3318864ece | |||
| e224417593 | |||
| 3f7f6c5d2c | |||
| 0b88055742 | |||
| 2b0826e397 | |||
| 7db04c9660 | |||
| 14d1e1d985 | |||
| 903ab1e61d | |||
| 5982655539 | |||
| 1021a210cf | |||
| e34edb15bb | |||
| 61ee5f4336 | |||
| ce2a92ec27 | |||
| 66ce79b9bf | |||
| 30dd640314 | |||
| 28f9aabcd9 | |||
| 3d9bd73336 | |||
| 3497d5f523 | |||
| 8ef1d95e65 | |||
| e9aeb9ac60 | |||
| fb02f7294d | |||
| f15d49d80a | |||
| c471cff456 | |||
| f83bb10e0c | |||
| 74ad58b1e1 | |||
| 577863b969 | |||
| 03d2a3fd39 | |||
| 802b23ed60 | |||
| 2b9f3a9eef | |||
| 62c60900eb | |||
| b381e1e54c | |||
| e7ee26ce74 | |||
| 521a3fa6e8 | |||
| 5ee93a67fe | |||
| 5d0a03c822 | |||
| 293c2a0035 | |||
| 9a5fa50541 | |||
| d5ceea8a56 | |||
| faa5e7e02d | |||
| 516c478f3d | |||
| 906c6a2501 | |||
| 86d8fe4fa4 | |||
| 9b44bc3259 | |||
| 59b6d2237b | |||
| 7e559cc0bb | |||
| fd1facfce8 | |||
| 8f26193cc3 | |||
| 43b2e5315d | |||
| 5cad89e9cc | |||
| 3804d2f00b | |||
| 4d649468d5 | |||
| c5f145be36 | |||
| 76dff870a7 | |||
| 6c8d3fc007 | |||
| e7ffa23016 | |||
| 4266c064a9 | |||
| 5f631b36cd | |||
| be8326f497 | |||
| 07dbba6c53 | |||
| a53b9afb44 | |||
| a0c7ffd6b9 | |||
| 7ca1d2325a | |||
| 8d0f256f74 | |||
| 1a9449da1b | |||
| 1caf8942b7 | |||
| 9efbcbcd20 | |||
| 3d07b8c9c1 | |||
| dae74a40c0 | |||
| 959190dcbc | |||
| 1e48976ae5 | |||
| 0a8c111e12 | |||
| 19c786c0be | |||
| 7f8b260560 | |||
| 368536f698 | |||
| b227221df6 | |||
| 8e047f45f5 | |||
| fbe8cbb23f | |||
| 28315a165f | |||
| 1b32829dac | |||
| 1fce29324f | |||
| 1fab90178a | |||
| eb04ab7dca | |||
| e9fa2c78ee | |||
| 59dae954cd | |||
| 5c4ce86da4 | |||
| 0cf2c40377 | |||
| 679a59ad76 | |||
| db3209afbe | |||
| f0be36062e | |||
| 9578d6daf9 | |||
| cc6766135d | |||
| 28c9bb0925 | |||
| 7826d827dd | |||
| 7f392acc54 | |||
| 190fd662ad | |||
| e18587c471 | |||
| ddb079b62d | |||
| e7c8d208e2 | |||
| 0e2162cf29 | |||
| 4cf9b0adc7 | |||
| 1661d32641 | |||
| aa59187403 | |||
| bb08e1233a | |||
| 5343e97ab2 | |||
| edc544df7a | |||
| a880edd9fb | |||
| 7e1d808d70 | |||
| ce93f22669 | |||
| a58037b968 | |||
| ccf0b60935 | |||
| aad7011b1c | |||
| 3bde7ef4d3 | |||
| 04555dbfa7 | |||
| 3b494aa591 | |||
| 365387c3cd | |||
| bb74a0ca4d | |||
| 9cf2ef84b7 | |||
| 46aaf5ff77 | |||
| c544bda5df | |||
| e86227f05f | |||
| 53da60e4ca | |||
| 727d9c6c22 | |||
| 908e1f600e | |||
| 270d800df2 | |||
| d445d182ea | |||
| 476256c9e7 | |||
| 06ea7373f7 | |||
| e78ba77def | |||
| 7113afe9e2 | |||
| 1a2b6524e6 | |||
| 95c4aa9e4c | |||
| 9f2518c9e1 | |||
| 76c50a654a | |||
| ded2c38551 | |||
| 772094eacd | |||
| bddc2f6295 | |||
| 25dce2961b | |||
| 653cfbe6e0 | |||
| c539311083 | |||
| 60118c5d5b | |||
| c6b9b36566 | |||
| fd5b1b7c00 | |||
| ebc77b62c8 | |||
| 2ce888581f | |||
| 0e901b6404 | |||
| 688b9076e7 | |||
| c6ec7579b6 | |||
| 9417edac8d | |||
| 6185cc79d7 | |||
| 4ecdba94c2 | |||
| a11640d840 | |||
| 177a4c4095 | |||
| 63df19ab78 | |||
| 54e0eb5979 | |||
| 185284d422 | |||
| ce240405d9 | |||
| 58b700ed0d | |||
| d436fa4920 | |||
| d58486193e | |||
| e9404eb9b6 | |||
| 0fef4d515e | |||
| 86f9cf4376 | |||
| acf63c57e8 | |||
| baa956c3a1 | |||
| bb2081a936 | |||
| c984b0b9ae | |||
| 754bf8fa3c | |||
| 7840294517 | |||
| caaee88654 | |||
| e872ddc1e7 | |||
| 1eca9b4c2c | |||
| fe5bd42e25 | |||
| 32d16d0673 | |||
| 27c26d35ab | |||
| e04c919d78 | |||
| 246b6c44b0 | |||
| 847ddf7d38 | |||
| 16e8199f9e | |||
| 7d1519f546 | |||
| 1bf66ee482 | |||
| 39a43f4de5 | |||
| 971a511edb | |||
| 0f8e0bc2b4 | |||
| 537c44e354 | |||
| db53a9a719 | |||
| f4a10de790 | |||
| 8c9fe84d02 | |||
| f0fcc77bdb | |||
| cf4c4b7d69 | |||
| 7bb8499353 | |||
| ee1a2bc7de | |||
| 20d383d4ab | |||
| 9cb0d8baf2 | |||
| 362ded3bc9 | |||
| 654f2ec7ad | |||
| 3600e034b8 | |||
| d7c501e175 | |||
| b9e9da579f | |||
| 7bea6b4a62 | |||
| ab211266a6 | |||
| 4da22a4345 | |||
| fbc1ff62c7 | |||
| 1fe72e1a66 | |||
| f82d7610e3 | |||
| bd6ad53875 | |||
| 5d09acca4c | |||
| b4e7c4a4cd | |||
| a6269084c0 | |||
| 8271d8423d | |||
| c76e29c457 | |||
| 4750a7553b | |||
| 60786921a9 | |||
| 751bbcc127 | |||
| 58e8dd5456 | |||
| 1586c25847 | |||
| cded5a7948 | |||
| 6238e065f2 | |||
| 72fbbfdd0d | |||
| 2796c1cd4d | |||
| 54c9886627 | |||
| 05713cb389 | |||
| 8bb3ee5f18 | |||
| bc0b4825f1 | |||
| ef7f17abf4 | |||
| 876cd21f0b | |||
| 5c92d49873 | |||
| da47b573be | |||
| 2f04be8778 | |||
| 69178ddfd8 | |||
| a310f6fff0 | |||
| 7474abe286 | |||
| df2ba3a3c6 | |||
| e536456236 | |||
| 8d77122da3 | |||
| fb66effa51 | |||
| 5052e71c31 | |||
| bfc78d16ca | |||
| c425e3562b | |||
| 1f26092aa9 | |||
| 2849bb96f4 | |||
| 7b749f2a4c | |||
| 8803c94ce0 | |||
| f5235c943b | |||
| 59fec889b5 | |||
| f42f04a807 | |||
| 51f6f37925 | |||
| 9651a4ca98 | |||
| 2b7fd36322 | |||
| b8014fd4df | |||
| 07460f6e1f | |||
| f7bf3b2afb | |||
| 056f298cdf | |||
| e83da53162 | |||
| 9f38a47a02 | |||
| 236a4d4a6d | |||
| 0909471510 | |||
| 05eaf59c89 | |||
| 7749613801 | |||
| e3dbaedbb4 | |||
| 9f17ced6de | |||
| de54ef871d | |||
| b0da45d6d0 | |||
| 9b2a46fa92 | |||
| 12099d2db6 | |||
| 84fa75936a | |||
| d78d8121a1 | |||
| a9a3a52872 | |||
| 912e3bdfce | |||
| ee1b25d9e8 | |||
| 20ef5e2c18 | |||
| 6ee419bc52 | |||
| 85b00d3c76 | |||
| bc4ad31d48 | |||
| 71aad8ee32 | |||
| 8bb8231559 | |||
| 3cf9caae89 | |||
| f983c67135 | |||
| f2aef5b93f | |||
| 46d4288969 | |||
| 65516e872f | |||
| 171329246c | |||
| b2bee699e0 | |||
| 95c66b4d67 | |||
| babc8feb2b | |||
| 2f445c546a | |||
| a0283b3e3e | |||
| 61bd156fb0 | |||
| 8ad0cf8e5f | |||
| ecd6d70da6 | |||
| 359617d927 | |||
| 38c286329a | |||
| 401b4095cc | |||
| 06ab1a8ef0 | |||
| 726fb8b015 | |||
| b3b5c7a59f | |||
| d18f934978 | |||
| e67f1f79bc | |||
| e931829411 | |||
| db8ebd606c | |||
| 072a358a94 | |||
| 6ceb1b150c | |||
| a4e03e1877 | |||
| 02b3e4277b | |||
| 37daf801e6 | |||
| 68d9f7eeb2 | |||
| 526e2420ca | |||
| a9cc58fc28 | |||
| 77889ce1c6 | |||
| 549073119e | |||
| 5c5af5795f | |||
| 68e10934e4 | |||
| c67bb1444a | |||
| 07389a152e | |||
| e562e21555 | |||
| 86dfe7dd3f | |||
| ac0a8f3449 | |||
| 8e4a63db67 | |||
| c02c63806f | |||
| 42e5d7f6e9 | |||
| d8cf1af422 | |||
| 9723661c80 | |||
| 81cba7ad97 | |||
| c23f58de40 | |||
| 2cf67ca7da | |||
| 392bd850ea | |||
| 3b2ad9d1bd | |||
| 27b7474180 | |||
| 63948d728e | |||
| d219d3b873 | |||
| 93ab290bc1 | |||
| 7335c5d79a | |||
| 242ead722a | |||
| 8a6d9696a8 | |||
| 896b7ea242 | |||
| 0c7f4c7828 | |||
| 3d35af2a87 | |||
| fed3c36f84 | |||
| 414d81aa40 | |||
| d548803769 | |||
| 1180258394 | |||
| 48a566a24b | |||
| 3bc5d1df81 | |||
| c7222e2e86 | |||
| dd4c41a6b2 | |||
| 92a25fdb6a | |||
| d6650be008 | |||
| 2646e7b19a | |||
| 4133f11d82 | |||
| 22ed740394 | |||
| 063299a434 | |||
| 44d836c975 | |||
| da437e77fb | |||
| 34a6bbfb32 | |||
| 9ed53bac9e | |||
| 3a6752c3d2 | |||
| ef19568dd7 | |||
| f280cd9d3b | |||
| cf4ce3c476 | |||
| ae59e53c4c | |||
| 7e0d661f63 | |||
| 0b33d3f905 | |||
| d62bdda56b | |||
| 5841b38cd9 | |||
| 83e2b5a7a6 | |||
| 2f863f64ee | |||
| 1a8b397cfd | |||
| 196c421b75 | |||
| 8399d64c2d | |||
| c530eab303 | |||
| 45b6362dd3 | |||
| 50b77dcfc3 | |||
| be8f3adf01 | |||
| 75a8c171ea | |||
| 466ff59573 | |||
| 053bb91927 | |||
| 2c9b22c016 | |||
| a9ee91586e | |||
| 81bce74612 | |||
| f2a6d2c7da | |||
| 0a9a34912e | |||
| abff670814 | |||
| 0d49acaaa8 | |||
| ebe1785411 | |||
| f9f0192b22 | |||
| e5cdbf4cf5 | |||
| 13ef1efa7b | |||
| fbd9301a2d | |||
| 24e3024b57 | |||
| 52d5af11ba | |||
| 44a45b00cf | |||
| 2b78fe5b9f | |||
| 14f92669c6 | |||
| 124106de87 | |||
| bb8e0d384f | |||
| 59d37847ec | |||
| acdc531dca | |||
| ce75dac81b | |||
| b8d40761ff | |||
| 3a7430f6da | |||
| 242660c51d | |||
| 8a6c1e45ce | |||
| b8e5f9f3b1 | |||
| d60e70f9cc | |||
| cdb70fadb3 | |||
| 7867deef60 | |||
| a77c1adb32 | |||
| da14d75a3b | |||
| 7c66a34931 | |||
| 425715e0f0 | |||
| a3baf8ce31 | |||
| 605e03b065 | |||
| 0e9b21d359 | |||
| ba5bf0cabc | |||
| 96b9d7aab3 | |||
| 750e4c4527 | |||
| 7417e26444 | |||
| 00e1099912 | |||
| bd46d29ff0 | |||
| 1a9d7684b9 | |||
| 0133c19276 | |||
| 46bb3b613b | |||
| 5839a5de30 | |||
| 535d0bb0f0 | |||
| 4d316007af | |||
| 3c2d60d8e1 | |||
| 9c4f4cbd0d | |||
| a337585b00 | |||
| 1cdec5d687 | |||
| 081b15e24c | |||
| b04cb7b3cc | |||
| e2c3ff00fb | |||
| c783ff3dcf | |||
| 2c360dc3e8 | |||
| 5342647bfb | |||
| 46a2f6f0d8 | |||
| f8af8fc171 | |||
| 3d0ee9d72b | |||
| 5a0bb260b4 | |||
| 9a7f1f5f2f | |||
| d88b04fc30 | |||
| 6fe4cc98b9 | |||
| b9bcfd8d2c | |||
| e3bd31bb52 | |||
| 0922e3e459 | |||
| a168b12bb2 | |||
| 8c01deba86 | |||
| b645487e79 | |||
| 91569affd7 | |||
| 1ed44ee6f3 | |||
| fce120fa31 | |||
| a02b0c0c3c | |||
| c86999f389 | |||
| 2b546967d2 | |||
| 591d2ba4d4 | |||
| 37cc4ab197 | |||
| d775974a90 | |||
| cc62aa4a9e | |||
| 5b8b7b04be | |||
| b4a8853591 | |||
| 4220dfe2a5 | |||
| 4557426c28 | |||
| 8ee7fe8e66 | |||
| c4a41f994a | |||
| fa639424f5 | |||
| e618a8390c | |||
| 393e9ed2e4 | |||
| e1ea441215 | |||
| 654661fd66 | |||
| c5a21f8da0 | |||
| ca5b168117 | |||
| aa88eb42ee | |||
| ac84cadd77 | |||
| 81d5235b9f | |||
| 8944762c76 | |||
| 3d05c34673 | |||
| c2ee41c844 | |||
| 6b537f30a5 | |||
| a3ae95df09 | |||
| 4349d68f87 | |||
| 7d5c20125a | |||
| 2583dbd3f2 | |||
| a103b93583 | |||
| fff018eafb | |||
| 60b824e7a4 | |||
| e27e904157 | |||
| fe15667986 | |||
| bd9029e533 | |||
| fa71d563ea | |||
| 143918bc5e | |||
| 961680af8c | |||
| 6e3b3ce888 | |||
| 44292c3b55 | |||
| c024c1b8e4 | |||
| 13adfdec11 | |||
| e9ec28aab7 | |||
| 0af4d1d6e3 | |||
| 3ef0e63533 | |||
| f4dad69ccd | |||
| b811316d0c | |||
| f59aeb2782 | |||
| 24ce41935e | |||
| 3c4749ead0 | |||
| 22ab5b9660 | |||
| 22f16f1da3 | |||
| a97409dfd7 | |||
| eaa6a664c8 | |||
| d934b3b3b4 | |||
| 546cbfb3ca | |||
| 39b70a53a0 | |||
| 795f84adce | |||
| 3d80a9dd0f | |||
| 9669e9bc87 | |||
| 5f2a5a5d7d | |||
| ecfd721fc0 | |||
| 07242a00b3 | |||
| 4602442feb | |||
| a90717b20c | |||
| 02edce2999 | |||
| f2d9066f90 | |||
| f6f7b1ed72 | |||
| 803bc1cb7f | |||
| 67d3aa9da3 | |||
| 9fbff5e833 | |||
| c371140a97 | |||
| c755a3719d | |||
| 4f153f3026 | |||
| f2b1dbd256 | |||
| be0ca993ff | |||
| ed87e1b00b | |||
| ac509933d7 | |||
| f49f98ff85 | |||
| 10923346d7 | |||
| f27bffc387 | |||
| 36b43f93a3 | |||
| 2deeab9d08 | |||
| f00854879c | |||
| 75fd62865b | |||
| 757054e140 | |||
| eda59b348c | |||
| d19e81ffac | |||
| 60c6872aec | |||
| a9cb2fe912 | |||
| a168a8160c | |||
| 78662f9613 | |||
| d9d7bb8dcc | |||
| 3136f48b30 | |||
| 0c46711b01 | |||
| 68159b5c41 | |||
| 6557d66f94 | |||
| 9553cb06d3 | |||
| 122fb16dfb | |||
| 511502220f | |||
| 8bfe7439c0 | |||
| 8499033221 | |||
| 705d5b04dd | |||
| 17eaa761f8 | |||
| 1cdbd01748 | |||
| 08cc076a4c | |||
| 2a02d5594c | |||
| 2263338878 | |||
| 26bc5425d3 | |||
| 38b4d1dc95 | |||
| 3aaca7ff39 | |||
| 83d9808536 | |||
| ad458dfece | |||
| 8f6fe7ed2b | |||
| 419a692593 | |||
| 03fdf795e0 | |||
| 832807a217 | |||
| f7df3b2a68 | |||
| 0d03e73595 | |||
| c5ae1a77d3 | |||
| 5f16624000 | |||
| 80025804ab | |||
| 028d3b4e61 | |||
| 9cce5ccfe6 | |||
| a260b8060e | |||
| f945307232 | |||
| 8f44d52cb2 | |||
| 3413cb7b89 | |||
| 4e3b24ffbb | |||
| 03cfa55e0b | |||
| a887e60f40 | |||
| 816819bf9f | |||
| 78f3bb3812 | |||
| 01d7ed5dd8 | |||
| 50311db280 | |||
| 01b1a276c5 | |||
| 6d4c31492c | |||
| f8c5f07e9f | |||
| 11e23feb0e | |||
| b4ba2dac37 | |||
| d013c3b718 | |||
| b3ea28c5c4 | |||
| 775b381987 | |||
| 3a41f2f1ed | |||
| 972fc534a4 | |||
| 808ee66e11 | |||
| 3936a516f8 | |||
| 15dc91f779 | |||
| dd3d2908a2 | |||
| 0857023dba | |||
| 1edc8f468e | |||
| 2681fe87bb | |||
| 3f0d0f4d95 | |||
| f24ecf1b99 | |||
| acdd1d2ec4 | |||
| d08496f237 | |||
| 27b4e0221b | |||
| 496ace0cd4 | |||
| f61ed8b8a6 | |||
| 41ee88a3cf | |||
| 6bf1438ef1 | |||
| b819306ab6 | |||
| b140afca8e | |||
| 6735989455 | |||
| db37ac24c7 | |||
| 0231270f9e | |||
| b5194aa9e1 | |||
| ea0ffaacb0 | |||
| 3b1f084a13 | |||
| 39a9e3a89f | |||
| 7a7af775c2 | |||
| 6ac2a305f7 | |||
| 3507c6cec3 | |||
| 3ff00768ac | |||
| 556d253ea8 | |||
| 3922070488 | |||
| eebb4827c4 | |||
| fd2c6a0784 | |||
| 417bf37515 | |||
| 132e799265 | |||
| bdc864781b | |||
| a343bc7562 | |||
| 1f2e231386 | |||
| 0e7f628c4a | |||
| 553f5257b3 | |||
| 80ce6aa19c | |||
| 2b2977de4a | |||
| 1d5d876e16 | |||
| 3c39162016 | |||
| d38767fb5a | |||
| f2be6cfeb1 | |||
| 65486ed3cf | |||
| cc30e2a9e4 | |||
| ac68451cdf | |||
| 0f6ae11c3d | |||
| 7cb39f00ad | |||
| f313d03348 | |||
| 1adbf3937b | |||
| a685d9da52 | |||
| 13dededcc9 | |||
| 3bed2d9feb | |||
| 7241877995 | |||
| 340d79000c | |||
| 162ec909da | |||
| 53f5240d41 | |||
| 27f0df07af | |||
| ad940b5884 | |||
| ec8ab47462 | |||
| 35cbfeb008 | |||
| 7036362b9b | |||
| 2bcb33e85c | |||
| 76ac036f85 | |||
| 581073394a | |||
| d7b7086b21 | |||
| 59be179821 | |||
| 1cf2f6b946 | |||
| a57a9c2121 | |||
| 67568c3746 | |||
| afce792b80 | |||
| f5c7493dbb | |||
| f9b9d98638 | |||
| 2a97e03fa6 | |||
| d6dacc2975 | |||
| aab4b6765d | |||
| 3539aca1f7 | |||
| 81fbe9eaba | |||
| f9dc6de485 | |||
| 012022d370 | |||
| 993216e157 | |||
| c992f2b582 | |||
| 3243adebca | |||
| baccef57d4 | |||
| a823095372 | |||
| 172a743de4 | |||
| 623eec3689 | |||
| 53a033fe35 | |||
| c490ee24f4 | |||
| cc1e49294e | |||
| e6fa46ae26 | |||
| 35fe774a1b | |||
| 1e6a0f9423 | |||
| cc1877aadb | |||
| f1eb1fa9ba | |||
| bdd01e335d | |||
| 4b7baf82cd | |||
| 15c88ce1d2 | |||
| 8891c388d0 | |||
| cd9d92d884 | |||
| 1b69a5e62b | |||
| 61d311b157 | |||
| 6b76b86930 | |||
| dcfb947c36 | |||
| 59893b7f44 | |||
| d2c62f5533 | |||
| 2bbe9a0c45 | |||
| 4e2ce82c0a | |||
| 104762186f | |||
| f1233ab1e3 | |||
| d6b407ec37 | |||
| 022b4b4bb3 | |||
| 49b322582d | |||
| 1280bd047d | |||
| 6f206d7523 | |||
| 2e58283859 | |||
| 99a5721fe8 | |||
| 5302ebd840 | |||
| fa427ea1ac | |||
| 7027bd1646 | |||
| 3c38e17472 | |||
| 510ea5d2e4 | |||
| bb2234d328 | |||
| edbdeb0fb8 | |||
| 19541fc573 | |||
| 7c936cacfb | |||
| c60cd3a341 | |||
| e37135f80d | |||
| aac937cbcc | |||
| 4b46d022af | |||
| 7f0181b310 | |||
| 6a109274f8 | |||
| 0f09cc693a | |||
| af0166a553 | |||
| a283017f26 | |||
| 5ae2cd1dfb | |||
| eece811fb0 | |||
| 1ff1f3a7f2 | |||
| a21a846bf5 | |||
| f5f21e738a | |||
| 033e62418a | |||
| 3c69e8b1cc | |||
| 118be27796 | |||
| 721d35d417 | |||
| 7bc3d5910d | |||
| ccc7047be0 | |||
| a5e107c89d | |||
| 646d60dcbf | |||
| 5dc7c0d797 | |||
| db1de9df38 | |||
| 3dd21382ba | |||
| ec2b3d0d4b | |||
| a205df1bd6 | |||
| e822fa73da | |||
| 634e75b80c | |||
| ec5b507efc | |||
| e6d289d48c | |||
| 745d7f26ce | |||
| ad43053b94 | |||
| 721700190b | |||
| 8c9c936d0e | |||
| 842bf6e3ff | |||
| c1fbeb3f5e | |||
| c45eb2cccf | |||
| 1b5abca83a | |||
| 45818b202f | |||
| 1c8ce46f25 | |||
| f762f9ae49 | |||
| 4484f6bd61 | |||
| 0076c45496 | |||
| ab071e12aa | |||
| 8386b40c50 | |||
| 03a985228d | |||
| ef7d7ec13d | |||
| 824792cca7 | |||
| 850e5b6572 | |||
| 64310854a6 | |||
| 4005a55bf2 | |||
| 0236fe3276 | |||
| c1d95a3086 | |||
| 9b027df1d5 | |||
| 5e03afe7f0 | |||
| 145a974b6d | |||
| d23fc9f2df | |||
| 7ac5191e8d | |||
| 29d27ebd6d | |||
| e45075dd84 | |||
| 80bc87e76b | |||
| 76d88517ec | |||
| 151d695212 | |||
| 2e1bed5fb5 | |||
| f163b97c17 | |||
| 436c99927e | |||
| aa72eacae7 | |||
| 913bb2ff67 | |||
| 3bb2696263 | |||
| 166843ded4 | |||
| 02166a4ca5 | |||
| f0f2e6ef72 | |||
| 8d8d5de5fd | |||
| 6d76f0b476 | |||
| f3f720bb37 | |||
| 2bf85bc4dd | |||
| faddc46185 | |||
| 2991aac82e | |||
| e1817027b1 | |||
| ba2d51bcbb | |||
| 7f10d6a9b8 | |||
| 405749aa98 | |||
| 77681fd387 | |||
| 8253ec4496 | |||
| a1e001e640 | |||
| 3a65ea21ba | |||
| 7d761c4c9a | |||
| 4cb90c5367 | |||
| 1c7d15db0b | |||
| 7268a3fe7f | |||
| d2c4391514 | |||
| 69b1d0c2da | |||
| ba28767492 | |||
| 6cff5f1146 | |||
| 3e1c6534bd | |||
| c1d57946d9 | |||
| 5e111d89a5 | |||
| 1a98da22b2 | |||
| 618ccbcb2f | |||
| d3a79a055e | |||
| bae32e51ff | |||
| edfda965e9 | |||
| a547966b23 | |||
| f6279b1b2e | |||
| 957c89a85d | |||
| 571a9dabcd | |||
| 51ca9a7686 | |||
| c141ad1e34 | |||
| 37f972d075 | |||
| 7d8de6e6f0 | |||
| 7ff751f8a2 | |||
| 651672afe2 | |||
| 2dbadfe1b5 | |||
| 621710bd86 | |||
| 1edecb05bb | |||
| f1a876301b | |||
| 97a07c399a | |||
| 18f095cb23 | |||
| d95d516d64 | |||
| 45ba64ab02 | |||
| 9501d66af6 | |||
| 2127fc339a | |||
| 7962fee0bd | |||
| d5c7b5c0cc | |||
| 5f77d69dd8 | |||
| 60034be06a | |||
| 518a5d38aa | |||
| 2eeaf8ff62 | |||
| cffee0fae6 | |||
| f08e2ef5b8 | |||
| 2b0070c31a | |||
| ae82716afa | |||
| c281bf3b53 | |||
| 45b8b2a895 | |||
| 7b9ba840fb | |||
| 84fb567ff5 | |||
| bbd57e0673 | |||
| b1632a0a03 | |||
| 7aff1182c8 | |||
| fbe362cd20 | |||
| e7f94c94cc | |||
| 7523190b16 | |||
| da37e16b6e | |||
| 5c420ab50b | |||
| 4493b7c231 | |||
| 40a96c6eaf | |||
| 23a93082c6 | |||
| f7650b5e1f | |||
| 3ebdd5631c | |||
| 6c4caf121a | |||
| 89788e9ca7 | |||
| 0787c63fed | |||
| 9fc0d5efff | |||
| 6611dfbe05 | |||
| 8a71ead51d | |||
| d9d6ab5776 | |||
| d6fe7bea27 | |||
| 1194f3ffb8 | |||
| 5ac81e6dd6 | |||
| 987856a1de | |||
| ef52ce0990 | |||
| 06b14a5869 | |||
| fd839059c0 | |||
| ec6db7962a | |||
| adf92cbc46 | |||
| 6b6f51cd1f | |||
| df6c60213f | |||
| 6303304a10 | |||
| 8e76789119 | |||
| 10e81cfdd3 | |||
| 03fd3a4f16 | |||
| 8fdc748ed2 | |||
| 6c56d23b93 | |||
| 45d34dcb5b | |||
| d7ac0d50fa | |||
| 1d4d145187 | |||
| a5b9ff98c0 | |||
| 6feaecd92e | |||
| b066a25308 | |||
| 777a552b57 | |||
| 7dbe608c28 | |||
| 61630e447b | |||
| 91385e7c83 | |||
| 04648fcca7 | |||
| 080fc7e44e | |||
| 0b60da3d6d | |||
| a4492b90e7 | |||
| c9331b7338 | |||
| 4982ea53dd | |||
| c703cb6504 | |||
| a7494971fd | |||
| c548255bfc | |||
| 9656c7afd7 | |||
| 414b8c8272 | |||
| b4f83d09d4 | |||
| 67ee74ac20 | |||
| 93539d2b6b | |||
| 524d967745 | |||
| 0effbebbb6 | |||
| dca07a70f8 | |||
| 02936c97fd | |||
| 8f7e732827 | |||
| 5ffe563b7d | |||
| 6ef08c3d54 | |||
| 908b4b58cd | |||
| f2611e0de0 | |||
| ea75a9d351 | |||
| 3a744d7d68 | |||
| 195d312ae2 | |||
| 76006a7377 | |||
| 11536da512 | |||
| 2a91bc41f7 | |||
| baf23157fc | |||
| 83b81be825 | |||
| 4aefa0f1f7 | |||
| e53a7cee97 | |||
| 8437e1aa7b | |||
| 632f40cc0a | |||
| 7d81445341 | |||
| 78a5f401d7 | |||
| 8745f98c95 | |||
| f0f5bcc630 | |||
| 8a3c513605 | |||
| 145d2636dd | |||
| f2b9dc8988 | |||
| 2e4d56728b | |||
| 18231ed324 | |||
| d0b61d8ed1 | |||
| d385a44949 | |||
| d97392d46e | |||
| 6abb2c73fd | |||
| 7e141c6b36 | |||
| 53553c1f62 | |||
| 523ccc6bf8 | |||
| 811e89fcfa | |||
| 5d5be4d9d7 | |||
| 88457ab139 | |||
| 0034926df7 | |||
| d082d41ab9 | |||
| b7911475b6 | |||
| 672754b0b5 | |||
| 0d1553123b | |||
| ba6c51c102 | |||
| d64206a9ff | |||
| d9a1089039 | |||
| 55fe463405 | |||
| e84210e962 | |||
| ff506548d3 | |||
| f6b09751e9 | |||
| 3d863979c4 | |||
| 2947ff4131 | |||
| b8fca10896 | |||
| 33e45794d2 | |||
| 42cc88ca65 | |||
| 0b7f2416ca | |||
| 5d5c745ee5 | |||
| e0429e4c60 | |||
| 0bece5287e | |||
| 60b5e47836 | |||
| aa75b44790 | |||
| 769f58caa9 | |||
| e7facf740d | |||
| 04921eef62 | |||
| 8863c42879 | |||
| 2745116ac5 |
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(cat:*)",
|
||||
"Bash(git -C /home/purian23/dms diff --stat .github/workflows/)",
|
||||
"Bash(git -C /home/purian23/projects/danklinux diff --stat .github/workflows/)",
|
||||
"Bash(git -C /home/purian23/dms diff .github/workflows/)",
|
||||
"Bash(git -C /home/purian23/dms diff .github/workflows/run-ppa.yml)",
|
||||
"Bash(osc cat:*)",
|
||||
"Bash(ls:*)",
|
||||
"Bash(find:*)",
|
||||
"Bash(git show-ref:*)",
|
||||
"Bash(git tag:*)",
|
||||
"Bash(bash -c 'ALL_PATHS=$(grep -A 5 \"\"<service name=\\\"\"download_url\\\"\">\"\" distro/debian/dms/_service | grep \"\"<param name=\\\"\"path\\\"\">\"\" | sed \"\"s/.*<param name=\\\"\"path\\\"\">\\(.*\\)<\\/param>.*/\\1/\"\"); SOURCE_PATH=\"\"\"\"; for path in $ALL_PATHS; do if echo \"\"$path\"\" | grep -qE \"\"(source|archive|\\.tar\\.(gz|xz|bz2))\"\" && ! echo \"\"$path\"\" | grep -qE \"\"(distropkg|binary)\"\"; then SOURCE_PATH=\"\"$path\"\"; break; fi; done; echo \"\"Selected path: $SOURCE_PATH\"\"')",
|
||||
"Bash(curl:*)",
|
||||
"Bash(tar:*)",
|
||||
"Bash(git -C /home/purian23/dms log:*)",
|
||||
"Bash(osc status:*)",
|
||||
"Bash(osc commit:*)",
|
||||
"Bash(osc up:*)",
|
||||
"Bash(osc results:*)",
|
||||
"Bash(osc api:*)",
|
||||
"Bash(systemctl:*)",
|
||||
"Bash(dms version:*)",
|
||||
"Bash(git describe:*)",
|
||||
"Bash(qmlsc:*)",
|
||||
"Bash(qmllint-qt6:*)",
|
||||
"Bash(make fmt:*)",
|
||||
"Bash(make test:*)",
|
||||
"Bash(dms chroma list-styles:*)",
|
||||
"Bash(python3:*)",
|
||||
"Bash(time dms chroma:*)",
|
||||
"Bash(dms chroma:*)",
|
||||
"Bash(make build:*)",
|
||||
"Bash(pgrep:*)",
|
||||
"Bash(go build:*)",
|
||||
"Bash(/tmp/dms-test chroma:*)",
|
||||
"Bash(1)",
|
||||
"Bash(go install:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(journalctl:*)",
|
||||
"Bash(qdbus:*)",
|
||||
"Bash(TZ='Asia/Tokyo' date:*)",
|
||||
"Bash(dms --help:*)",
|
||||
"Bash(dms run:*)",
|
||||
"Bash(dms status:*)",
|
||||
"Bash(dms kill:*)",
|
||||
"Bash(tee:*)",
|
||||
"Bash(qmlscene:*)",
|
||||
"Bash(quickshell --version:*)",
|
||||
"WebFetch(domain:forum.qt.io)",
|
||||
"Bash(gh api:*)",
|
||||
"WebFetch(domain:github.com)",
|
||||
"WebFetch(domain:raw.githubusercontent.com)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
# Agent Skills
|
||||
|
||||
This directory contains agent skills following the [Agent Skills](https://agentskills.io) open standard - a portable, version-controlled format for giving AI agents specialized capabilities.
|
||||
|
||||
Each skill is a directory with a `SKILL.md` entrypoint, optional reference docs, scripts, and templates. Agents load skills progressively: metadata at startup, full instructions on activation, and supporting files on demand.
|
||||
|
||||
## Available Skills
|
||||
|
||||
| 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. |
|
||||
|
||||
## Installation
|
||||
|
||||
The `.agents/skills/` directory at the project root is the standard location defined by the agentskills.io spec. Many agents discover skills from this path automatically. Some agents use their own directory conventions and need a symlink or copy.
|
||||
|
||||
### Claude Code
|
||||
|
||||
Claude Code discovers skills from `.claude/skills/` (project-level) or `~/.claude/skills/` (personal). To make skills from `.agents/skills/` available, symlink them into the Claude Code skills directory:
|
||||
|
||||
**Project-level** (this repo only):
|
||||
|
||||
```bash
|
||||
mkdir -p .claude/skills
|
||||
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
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
See the [Claude Code skills docs](https://code.claude.com/docs/en/skills) for more on skill configuration, invocation control, and frontmatter options.
|
||||
|
||||
### Cursor
|
||||
|
||||
Cursor discovers skills from `.cursor/skills/` in the project root:
|
||||
|
||||
```bash
|
||||
mkdir -p .cursor/skills
|
||||
ln -s ../../.agents/skills/dms-plugin-dev .cursor/skills/dms-plugin-dev
|
||||
```
|
||||
|
||||
See [Cursor skills docs](https://cursor.com/docs/context/skills) for details.
|
||||
|
||||
### VS Code (Copilot)
|
||||
|
||||
VS Code Copilot discovers skills from `.github/skills/` or `.vscode/skills/`:
|
||||
|
||||
```bash
|
||||
mkdir -p .github/skills
|
||||
ln -s ../../.agents/skills/dms-plugin-dev .github/skills/dms-plugin-dev
|
||||
```
|
||||
|
||||
See [VS Code skills docs](https://code.visualstudio.com/docs/copilot/customization/agent-skills) for details.
|
||||
|
||||
### Gemini CLI
|
||||
|
||||
Gemini CLI discovers skills from `.gemini/skills/` in the project root:
|
||||
|
||||
```bash
|
||||
mkdir -p .gemini/skills
|
||||
ln -s ../../.agents/skills/dms-plugin-dev .gemini/skills/dms-plugin-dev
|
||||
```
|
||||
|
||||
See [Gemini CLI skills docs](https://geminicli.com/docs/cli/skills/) for details.
|
||||
|
||||
### OpenAI Codex
|
||||
|
||||
Codex discovers skills from `.codex/skills/` in the project root:
|
||||
|
||||
```bash
|
||||
mkdir -p .codex/skills
|
||||
ln -s ../../.agents/skills/dms-plugin-dev .codex/skills/dms-plugin-dev
|
||||
```
|
||||
|
||||
See [Codex skills docs](https://developers.openai.com/codex/skills/) for details.
|
||||
|
||||
### Other Agents
|
||||
|
||||
The Agent Skills standard is supported by 30+ tools including Goose, Roo Code, JetBrains Junie, Amp, OpenCode, OpenHands, Kiro, and more. Most discover skills from a dot-directory at the project root (e.g., `.goose/skills/`, `.roo/skills/`). Some read `.agents/skills/` directly.
|
||||
|
||||
Check the [Agent Skills client showcase](https://agentskills.io/clients) for setup instructions specific to your agent.
|
||||
|
||||
The general pattern is:
|
||||
|
||||
```bash
|
||||
mkdir -p .<agent>/skills
|
||||
ln -s ../../.agents/skills/dms-plugin-dev .<agent>/skills/dms-plugin-dev
|
||||
```
|
||||
|
||||
## Adding New Skills
|
||||
|
||||
To add a new skill to this directory:
|
||||
|
||||
1. Create a subdirectory named with lowercase letters, numbers, and hyphens (e.g., `my-new-skill/`)
|
||||
2. Add a `SKILL.md` file with YAML frontmatter (`name`, `description`) and markdown instructions
|
||||
3. Optionally add `references/`, `scripts/`, and `assets/` subdirectories
|
||||
4. Keep `SKILL.md` under 500 lines - move detailed content to reference files
|
||||
|
||||
See the [Agent Skills specification](https://agentskills.io/specification) for the full format.
|
||||
@@ -0,0 +1,609 @@
|
||||
---
|
||||
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,
|
||||
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"
|
||||
domain: qml-desktop-development
|
||||
framework: DankMaterialShell
|
||||
languages: qml, javascript
|
||||
allowed-tools: Bash Read Write Edit
|
||||
---
|
||||
|
||||
# DankMaterialShell 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/`.
|
||||
|
||||
**Minimum plugin structure:**
|
||||
|
||||
```
|
||||
~/.config/DankMaterialShell/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/
|
||||
|
||||
**Five 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 |
|
||||
|
||||
## 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.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
## Step 2: Create the Manifest
|
||||
|
||||
Create `plugin.json` in your plugin directory. See [plugin-manifest-reference.md](references/plugin-manifest-reference.md) for the full schema.
|
||||
|
||||
**Minimal manifest:**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "yourPlugin",
|
||||
"name": "Your Plugin Name",
|
||||
"description": "Brief description of what your plugin does",
|
||||
"version": "1.0.0",
|
||||
"author": "Your Name",
|
||||
"type": "widget",
|
||||
"capabilities": ["your-capability"],
|
||||
"component": "./YourWidget.qml"
|
||||
}
|
||||
```
|
||||
|
||||
**With settings, startup check, and permissions:**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "yourPlugin",
|
||||
"name": "Your Plugin Name",
|
||||
"description": "Brief description",
|
||||
"version": "1.0.0",
|
||||
"author": "Your Name",
|
||||
"type": "widget",
|
||||
"capabilities": ["your-capability"],
|
||||
"component": "./YourWidget.qml",
|
||||
"icon": "extension",
|
||||
"settings": "./Settings.qml",
|
||||
"startupCheck": "./StartupCheck.qml",
|
||||
"requires_dms": ">=0.1.0",
|
||||
"dependencies": ["mytool"],
|
||||
"permissions": ["settings_read", "settings_write"]
|
||||
}
|
||||
```
|
||||
|
||||
**Composite plugin (multi-surface):**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "myComposite",
|
||||
"name": "My Composite Plugin",
|
||||
"description": "Daemon + widget + desktop from one plugin",
|
||||
"version": "1.0.0",
|
||||
"author": "Your Name",
|
||||
"type": "composite",
|
||||
"capabilities": ["daemon", "dankbar-widget", "desktop-widget"],
|
||||
"icon": "extension",
|
||||
"components": {
|
||||
"daemon": "./MyDaemon.qml",
|
||||
"widget": "./MyBarWidget.qml",
|
||||
"desktop": "./MyDesktopWidget.qml"
|
||||
},
|
||||
"settings": "./Settings.qml",
|
||||
"permissions": ["settings_read", "settings_write"]
|
||||
}
|
||||
```
|
||||
|
||||
**Key rules:**
|
||||
- `id` must be camelCase, matching pattern `^[a-zA-Z][a-zA-Z0-9]*$`
|
||||
- `version` must be semver (e.g., `1.0.0`)
|
||||
- Provide either `component` (single-surface) or `components` (multi-surface), not both
|
||||
- `component` / component paths must start with `./` and end with `.qml`
|
||||
- `type: "launcher"` (or a `components` object with a `launcher` key) requires a `trigger` field
|
||||
- `settings_write` permission is **required** if the plugin has a settings component
|
||||
- `dependencies` replaces the deprecated `requires` field
|
||||
|
||||
## Step 3: Create the Main Component
|
||||
|
||||
### Widget
|
||||
|
||||
```qml
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
import qs.Widgets
|
||||
import qs.Modules.Plugins
|
||||
|
||||
PluginComponent {
|
||||
property var popoutService: null
|
||||
|
||||
horizontalBarPill: Component {
|
||||
StyledRect {
|
||||
width: label.implicitWidth + Theme.spacingM * 2
|
||||
height: parent.widgetThickness
|
||||
radius: Theme.cornerRadius
|
||||
color: Theme.surfaceContainerHigh
|
||||
|
||||
StyledText {
|
||||
id: label
|
||||
anchors.centerIn: parent
|
||||
text: "Hello"
|
||||
color: Theme.surfaceText
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
verticalBarPill: Component {
|
||||
StyledRect {
|
||||
width: parent.widgetThickness
|
||||
height: label.implicitHeight + Theme.spacingM * 2
|
||||
radius: Theme.cornerRadius
|
||||
color: Theme.surfaceContainerHigh
|
||||
|
||||
StyledText {
|
||||
id: label
|
||||
anchors.centerIn: parent
|
||||
text: "Hi"
|
||||
color: Theme.surfaceText
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
rotation: 90
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See [widget-plugin-guide.md](references/widget-plugin-guide.md) for popouts, CC integration, and advanced features.
|
||||
|
||||
### Launcher
|
||||
|
||||
```qml
|
||||
import QtQuick
|
||||
import qs.Services
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property var pluginService: null
|
||||
property string trigger: "#"
|
||||
|
||||
signal itemsChanged()
|
||||
|
||||
function getItems(query) {
|
||||
const items = [
|
||||
{ name: "Item One", icon: "material:star", comment: "Description",
|
||||
action: "toast:Hello!", categories: ["MyPlugin"] }
|
||||
]
|
||||
if (!query) return items
|
||||
const q = query.toLowerCase()
|
||||
return items.filter(i => i.name.toLowerCase().includes(q))
|
||||
}
|
||||
|
||||
function executeItem(item) {
|
||||
const [type, ...rest] = item.action.split(":")
|
||||
const data = rest.join(":")
|
||||
if (type === "toast") ToastService?.showInfo(data)
|
||||
else if (type === "copy") Quickshell.execDetached(["dms", "cl", "copy", data])
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See [launcher-plugin-guide.md](references/launcher-plugin-guide.md) for triggers, icon types, context menus, and image tiles.
|
||||
|
||||
### Desktop
|
||||
|
||||
```qml
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property var pluginService: null
|
||||
property string pluginId: ""
|
||||
property bool editMode: false
|
||||
property real widgetWidth: 200
|
||||
property real widgetHeight: 200
|
||||
property real minWidth: 150
|
||||
property real minHeight: 150
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: Theme.cornerRadius
|
||||
color: Theme.surfaceContainer
|
||||
opacity: 0.85
|
||||
border.color: root.editMode ? Theme.primary : "transparent"
|
||||
border.width: root.editMode ? 2 : 0
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: "Desktop Widget"
|
||||
color: Theme.surfaceText
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See [desktop-plugin-guide.md](references/desktop-plugin-guide.md) for sizing, persistence, and edit mode.
|
||||
|
||||
### Daemon
|
||||
|
||||
```qml
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
import qs.Modules.Plugins
|
||||
|
||||
PluginComponent {
|
||||
property var popoutService: null
|
||||
|
||||
Connections {
|
||||
target: SessionData
|
||||
function onSomeSignal() {
|
||||
console.log("Event received")
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See [daemon-plugin-guide.md](references/daemon-plugin-guide.md) for event-driven patterns and process execution.
|
||||
|
||||
### Composite
|
||||
|
||||
For composite plugins, create a separate QML file per surface. Each surface uses the same
|
||||
base component as the corresponding single-surface type (PluginComponent for widget/daemon,
|
||||
Item for launcher, etc.). All surfaces share the same `pluginId` and `pluginService`.
|
||||
|
||||
```
|
||||
MyCompositePlugin/
|
||||
plugin.json
|
||||
MyBarWidget.qml # PluginComponent (widget surface)
|
||||
MyDaemon.qml # PluginComponent (daemon surface)
|
||||
MyDesktopWidget.qml # Item with desktop widget properties
|
||||
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.
|
||||
|
||||
## Step 4: Add Startup Check (Optional)
|
||||
|
||||
Gate plugin activation on dependency checks by providing a `startupCheck` component. This
|
||||
runs before the plugin loads and blocks activation if a required tool or condition is missing.
|
||||
|
||||
Create a `StartupCheck.qml` (non-visual QtObject):
|
||||
|
||||
```qml
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
|
||||
QtObject {
|
||||
function check(done) {
|
||||
Proc.runCommand("myPlugin.depCheck", ["sh", "-c", "command -v mytool"], (stdout, exitCode) => {
|
||||
if (exitCode === 0) {
|
||||
done(null);
|
||||
return;
|
||||
}
|
||||
done({
|
||||
"title": I18n.tr("mytool is required"),
|
||||
"details": I18n.tr("Install 'mytool' and re-enable this plugin.")
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `done` callback accepts:
|
||||
- `null` - allow activation
|
||||
- A string - block with a short error message
|
||||
- `{ title, details }` - block with a title and expandable details body
|
||||
|
||||
A synchronous variant (no `done` parameter, return the result directly) is also supported.
|
||||
|
||||
Failed checks show a toast error and store the error in `pluginService.pluginLoadErrors`.
|
||||
|
||||
Add to your manifest:
|
||||
```json
|
||||
{
|
||||
"startupCheck": "./StartupCheck.qml",
|
||||
"dependencies": ["mytool"]
|
||||
}
|
||||
```
|
||||
|
||||
## Step 5: Add Settings (Optional)
|
||||
|
||||
Wrap settings in `PluginSettings` with your `pluginId`. All settings auto-save and auto-load.
|
||||
|
||||
```qml
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
import qs.Widgets
|
||||
import qs.Modules.Plugins
|
||||
|
||||
PluginSettings {
|
||||
pluginId: "yourPlugin"
|
||||
|
||||
StringSetting {
|
||||
settingKey: "apiKey"
|
||||
label: "API Key"
|
||||
description: "Your API key"
|
||||
placeholder: "sk-..."
|
||||
}
|
||||
|
||||
ToggleSetting {
|
||||
settingKey: "enabled"
|
||||
label: "Enable Feature"
|
||||
defaultValue: true
|
||||
}
|
||||
|
||||
SelectionSetting {
|
||||
settingKey: "interval"
|
||||
label: "Refresh Interval"
|
||||
options: [
|
||||
{ label: "1 min", value: "60" },
|
||||
{ label: "5 min", value: "300" }
|
||||
]
|
||||
defaultValue: "300"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Available setting components:** StringSetting, ToggleSetting, SelectionSetting, SliderSetting, ColorSetting, ListSetting, ListSettingWithInput.
|
||||
|
||||
See [settings-components-reference.md](references/settings-components-reference.md) for full property lists.
|
||||
|
||||
**Important:** Your plugin must declare `"permissions": ["settings_write"]` in plugin.json, or the settings UI will show an error.
|
||||
|
||||
## Step 6: Use Data Persistence
|
||||
|
||||
Three tiers of persistence:
|
||||
|
||||
| API | Persisted | Use case |
|
||||
|-----|-----------|----------|
|
||||
| `pluginService.savePluginData(id, key, val)` / `loadPluginData(id, key, default)` | Yes (settings.json) | User preferences, config |
|
||||
| `pluginService.savePluginState(id, key, val)` / `loadPluginState(id, key, default)` | Yes (separate state file) | Runtime state, history, cache |
|
||||
| `PluginGlobalVar { varName; defaultValue; value; set() }` | No (runtime only) | Cross-instance shared state |
|
||||
| `pluginService.getPluginPath(id)` | N/A | Get the plugin's installation directory path |
|
||||
|
||||
- `pluginData` is a reactive property on PluginComponent, auto-loaded from settings
|
||||
- React to settings changes with `Connections { target: pluginService; function onPluginDataChanged(id) { ... } }`
|
||||
- Global vars sync across all instances (multi-monitor, multiple bar sections)
|
||||
|
||||
See [data-persistence-guide.md](references/data-persistence-guide.md) for details and examples.
|
||||
|
||||
## Step 7: Theme Integration
|
||||
|
||||
Always use `Theme.*` properties from `qs.Common` - never hardcode colors or sizes.
|
||||
|
||||
**Essential properties:**
|
||||
- Colors: `Theme.surfaceContainerHigh`, `Theme.surfaceText`, `Theme.primary`, `Theme.onPrimary`
|
||||
- Fonts: `Theme.fontSizeSmall` (12), `Theme.fontSizeMedium` (14), `Theme.fontSizeLarge` (16), `Theme.fontSizeXLarge` (20)
|
||||
- Spacing: `Theme.spacingXS`, `Theme.spacingS`, `Theme.spacingM`, `Theme.spacingL`, `Theme.spacingXL`
|
||||
- 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`.
|
||||
|
||||
See [theme-reference.md](references/theme-reference.md) for the complete property list.
|
||||
|
||||
## Step 8: Add Popout Content (Widgets Only)
|
||||
|
||||
Add a popout that opens when the bar pill is clicked:
|
||||
|
||||
```qml
|
||||
PluginComponent {
|
||||
popoutWidth: 400
|
||||
popoutHeight: 300
|
||||
|
||||
popoutContent: Component {
|
||||
PopoutComponent {
|
||||
headerText: "My Plugin"
|
||||
detailsText: "Optional subtitle"
|
||||
showCloseButton: true
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: Theme.spacingM
|
||||
|
||||
StyledText {
|
||||
text: "Content here"
|
||||
color: Theme.surfaceText
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
horizontalBarPill: Component { /* ... */ }
|
||||
verticalBarPill: Component { /* ... */ }
|
||||
}
|
||||
```
|
||||
|
||||
**PopoutComponent properties:** `headerText`, `detailsText`, `showCloseButton`, `closePopout()` (auto-injected), `headerHeight` (readonly), `detailsHeight` (readonly).
|
||||
|
||||
Calculate available content height: `popoutHeight - headerHeight - detailsHeight - spacing`
|
||||
|
||||
## Step 9: Control Center Integration (Widgets Only)
|
||||
|
||||
Add your widget to the Control Center grid:
|
||||
|
||||
```qml
|
||||
PluginComponent {
|
||||
ccWidgetIcon: "toggle_on"
|
||||
ccWidgetPrimaryText: "My Feature"
|
||||
ccWidgetSecondaryText: isActive ? "On" : "Off"
|
||||
ccWidgetIsActive: isActive
|
||||
|
||||
onCcWidgetToggled: {
|
||||
isActive = !isActive
|
||||
pluginService?.savePluginData(pluginId, "active", isActive)
|
||||
}
|
||||
|
||||
// Optional: expandable detail panel (for CompoundPill)
|
||||
ccDetailContent: Component {
|
||||
Rectangle {
|
||||
implicitHeight: 200
|
||||
color: Theme.surfaceContainerHigh
|
||||
radius: Theme.cornerRadius
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**CC sizing:** 25% width = SmallToggleButton (icon only), 50% width = ToggleButton or CompoundPill (if ccDetailContent is defined).
|
||||
|
||||
## Step 10: External Commands and Clipboard
|
||||
|
||||
**Run commands and capture output:**
|
||||
|
||||
```qml
|
||||
import qs.Common
|
||||
|
||||
Proc.runCommand(
|
||||
"myPlugin.fetch",
|
||||
["curl", "-s", "https://api.example.com/data"],
|
||||
(stdout, exitCode) => {
|
||||
if (exitCode === 0) processData(stdout)
|
||||
},
|
||||
500 // debounce ms
|
||||
)
|
||||
```
|
||||
|
||||
**Fire-and-forget (clipboard, notifications):**
|
||||
|
||||
```qml
|
||||
import Quickshell
|
||||
|
||||
Quickshell.execDetached(["dms", "cl", "copy", textToCopy])
|
||||
```
|
||||
|
||||
**Long-running processes:** Use the `Process` QML component from `Quickshell.Io` with `StdioCollector`.
|
||||
|
||||
**Shell commands with pipes:** `["sh", "-c", "ps aux | grep foo"]`
|
||||
|
||||
**Do NOT use** `globalThis.clipboard` or browser JavaScript APIs - they don't exist in the QML runtime.
|
||||
|
||||
## Step 11: Validate and Test
|
||||
|
||||
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
|
||||
|
||||
**Runtime plugin discovery via IPC:**
|
||||
|
||||
Plugins can be scanned, rescanned, and reloaded at runtime without restarting the shell:
|
||||
|
||||
```bash
|
||||
dms ipc plugin-scan scan # Trigger a full rescan of all plugin directories
|
||||
dms ipc plugin-scan rescan <id> # Force rescan of a specific plugin
|
||||
dms ipc plugin-scan reload <id> # Force reload of a loaded plugin
|
||||
dms ipc plugin-scan list # List all known plugins (TSV: id, loaded, type, name)
|
||||
dms ipc plugin-scan status <id> # Get status of a specific plugin (TSV: loaded, type, error)
|
||||
```
|
||||
|
||||
Plugin IDs are validated against `^[a-zA-Z0-9_\-:]{1,64}$`.
|
||||
|
||||
**Common issues:**
|
||||
- Plugin not detected: check plugin.json syntax with `jq . plugin.json`
|
||||
- Widget not showing: ensure it's enabled AND added to a DankBar section
|
||||
- Settings error: verify `settings_write` permission is declared
|
||||
- Data not persisting: check pluginService injection and permissions
|
||||
- Startup check failing: check `pluginService.pluginLoadErrors` or run `dms ipc plugin-scan status <id>`
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
1. **Missing `settings_write` permission** - Settings UI shows error without it
|
||||
2. **Missing `property var popoutService: null`** - Must declare for injection to work
|
||||
3. **Missing vertical bar pill** - Widget disappears when bar is on left/right edge
|
||||
4. **Hardcoded colors** - Use `Theme.*` properties, not hex values
|
||||
5. **Using `globalThis.clipboard`** - Does not exist; use `Quickshell.execDetached(["dms", "cl", "copy", text])`
|
||||
6. **Wrong Theme property names** - `Theme.fontSizeS` does not exist, use `Theme.fontSizeSmall`
|
||||
7. **Wrong import for Quickshell** - Use `import Quickshell` (not `import QtQuick` for execDetached)
|
||||
8. **Forgetting `categories` in launcher items** - Items won't display without it
|
||||
9. **Not handling null pluginService** - Always use optional chaining or null checks
|
||||
10. **Using `PluginComponent` for launchers** - Launchers use plain `Item`, not `PluginComponent`
|
||||
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
|
||||
|
||||
## Quick Reference: Imports
|
||||
|
||||
**Widget / Daemon:**
|
||||
```qml
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
import qs.Widgets
|
||||
import qs.Modules.Plugins
|
||||
```
|
||||
|
||||
**Launcher:**
|
||||
```qml
|
||||
import QtQuick
|
||||
import qs.Services
|
||||
```
|
||||
|
||||
**Desktop:**
|
||||
```qml
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
```
|
||||
|
||||
**For clipboard/exec:** `import Quickshell`
|
||||
**For processes:** `import Quickshell.Io`
|
||||
**For networking:** `import Quickshell.Networking`
|
||||
**For toast notifications:** access `ToastService` from `qs.Services`
|
||||
|
||||
## Quick Reference: File Naming
|
||||
|
||||
- **Directory name:** PascalCase (e.g., `MyAwesomePlugin/`)
|
||||
- **Plugin ID:** camelCase (e.g., `myAwesomePlugin`)
|
||||
- **QML files:** PascalCase (e.g., `MyWidget.qml`, `Settings.qml`)
|
||||
- **Component paths in manifest:** relative with `./` prefix (e.g., `"./MyWidget.qml"`)
|
||||
- **JS utility files:** camelCase (e.g., `utils.js`, `apiAdapter.js`)
|
||||
|
||||
## Reference Files
|
||||
|
||||
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
|
||||
- [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
|
||||
- [settings-components-reference.md](references/settings-components-reference.md) - All 7 setting components with complete property lists
|
||||
- [theme-reference.md](references/theme-reference.md) - Theme colors, spacing, fonts, radii, common patterns
|
||||
- [data-persistence-guide.md](references/data-persistence-guide.md) - pluginData, state API, global variables
|
||||
- [popout-service-reference.md](references/popout-service-reference.md) - PopoutService API for controlling shell popouts and modals
|
||||
- [advanced-patterns.md](references/advanced-patterns.md) - Variants, JS utilities, qmldir, IPC, multi-file plugins
|
||||
@@ -0,0 +1,177 @@
|
||||
{
|
||||
"$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",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"description",
|
||||
"version",
|
||||
"author",
|
||||
"type",
|
||||
"capabilities"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Unique plugin identifier (camelCase, no spaces)",
|
||||
"pattern": "^[a-zA-Z][a-zA-Z0-9]*$"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Human-readable plugin name",
|
||||
"minLength": 1
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Short description of plugin functionality",
|
||||
"minLength": 1
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"description": "Semantic version string (e.g., '1.0.0')",
|
||||
"pattern": "^\\d+\\.\\d+\\.\\d+(-[a-zA-Z0-9.-]+)?(\\+[a-zA-Z0-9.-]+)?$"
|
||||
},
|
||||
"author": {
|
||||
"type": "string",
|
||||
"description": "Plugin creator name or email",
|
||||
"minLength": 1
|
||||
},
|
||||
"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"]
|
||||
},
|
||||
"capabilities": {
|
||||
"type": "array",
|
||||
"description": "Array of plugin capabilities",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"minItems": 1
|
||||
},
|
||||
"component": {
|
||||
"type": "string",
|
||||
"description": "Relative path to main QML component file. Required unless 'components' is provided.",
|
||||
"pattern": "^\\./.*\\.qml$"
|
||||
},
|
||||
"components": {
|
||||
"type": "object",
|
||||
"description": "Map of surface name to relative QML component path, for multi-surface (composite) plugins. Provide any subset of surfaces; each is loaded independently.",
|
||||
"properties": {
|
||||
"widget": {
|
||||
"type": "string",
|
||||
"description": "Bar/Control Center widget component (PluginComponent)",
|
||||
"pattern": "^\\./.*\\.qml$"
|
||||
},
|
||||
"desktop": {
|
||||
"type": "string",
|
||||
"description": "Desktop widget component",
|
||||
"pattern": "^\\./.*\\.qml$"
|
||||
},
|
||||
"daemon": {
|
||||
"type": "string",
|
||||
"description": "Background daemon component (instantiated once)",
|
||||
"pattern": "^\\./.*\\.qml$"
|
||||
},
|
||||
"launcher": {
|
||||
"type": "string",
|
||||
"description": "Launcher provider component (requires 'trigger')",
|
||||
"pattern": "^\\./.*\\.qml$"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"minProperties": 1
|
||||
},
|
||||
"trigger": {
|
||||
"type": "string",
|
||||
"description": "Trigger string for launcher activation (required for launcher type)"
|
||||
},
|
||||
"icon": {
|
||||
"type": "string",
|
||||
"description": "Material Design icon name"
|
||||
},
|
||||
"settings": {
|
||||
"type": "string",
|
||||
"description": "Path to settings component QML file",
|
||||
"pattern": "^\\./.*\\.qml$"
|
||||
},
|
||||
"startupCheck": {
|
||||
"type": "string",
|
||||
"description": "Path to a non-visual (QtObject) component exposing a check(done) function that gates activation. done(null) allows; done(error) blocks, where error is a string or { title, details }.",
|
||||
"pattern": "^\\./.*\\.qml$"
|
||||
},
|
||||
"requires_dms": {
|
||||
"type": "string",
|
||||
"description": "Minimum DMS version requirement (e.g., '>=0.1.18', '>0.1.0')",
|
||||
"pattern": "^(>=?|<=?|=|>|<)\\d+\\.\\d+\\.\\d+$"
|
||||
},
|
||||
"dependencies": {
|
||||
"type": "array",
|
||||
"description": "Array of required system tools/dependencies (registry metadata)",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"requires": {
|
||||
"type": "array",
|
||||
"description": "Deprecated alias for 'dependencies'.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"permissions": {
|
||||
"type": "array",
|
||||
"description": "Required capabilities",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"settings_read",
|
||||
"settings_write",
|
||||
"process",
|
||||
"network"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"type": {
|
||||
"const": "launcher"
|
||||
}
|
||||
}
|
||||
},
|
||||
"then": {
|
||||
"required": ["trigger"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"required": ["components"],
|
||||
"properties": {
|
||||
"components": {
|
||||
"required": ["launcher"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"then": {
|
||||
"required": ["trigger"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"anyOf": [
|
||||
{
|
||||
"required": ["component"]
|
||||
},
|
||||
{
|
||||
"required": ["components"]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"additionalProperties": true
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
import qs.Modules.Plugins
|
||||
|
||||
PluginComponent {
|
||||
id: root
|
||||
property var popoutService: null
|
||||
|
||||
// TODO: Read configuration from settings
|
||||
property string configValue: pluginData?.configValue || ""
|
||||
|
||||
Connections {
|
||||
target: pluginService
|
||||
function onPluginDataChanged(changedId) {
|
||||
if (changedId !== pluginId) return
|
||||
configValue = pluginService.loadPluginData(pluginId, "configValue", "")
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Connect to the service events you need
|
||||
// Connections {
|
||||
// target: SessionData
|
||||
// function onWallpaperPathChanged() {
|
||||
// console.log("[MyDaemon] Wallpaper changed:", SessionData.wallpaperPath)
|
||||
// handleEvent(SessionData.wallpaperPath)
|
||||
// }
|
||||
// }
|
||||
|
||||
function handleEvent(data) {
|
||||
Proc.runCommand(
|
||||
"myDaemon.handle",
|
||||
["echo", "Event received:", data],
|
||||
(stdout, exitCode) => {
|
||||
if (exitCode === 0) {
|
||||
console.log("[MyDaemon] Output:", stdout)
|
||||
} else {
|
||||
console.error("[MyDaemon] Failed:", exitCode)
|
||||
ToastService?.showInfo("Daemon action failed")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
console.log("[MyDaemon] Started")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
import qs.Widgets
|
||||
import qs.Modules.Plugins
|
||||
|
||||
PluginSettings {
|
||||
pluginId: "myDaemon"
|
||||
|
||||
StringSetting {
|
||||
settingKey: "configValue"
|
||||
label: "Configuration"
|
||||
description: "Value used by the daemon"
|
||||
placeholder: "Enter value..."
|
||||
defaultValue: ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"id": "myDaemon",
|
||||
"name": "My Daemon",
|
||||
"description": "A background service that reacts to events",
|
||||
"version": "1.0.0",
|
||||
"author": "Your Name",
|
||||
"type": "daemon",
|
||||
"capabilities": ["background-service"],
|
||||
"component": "./Daemon.qml",
|
||||
"icon": "settings",
|
||||
"settings": "./Settings.qml",
|
||||
"permissions": ["settings_read", "settings_write", "process"]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
import qs.Widgets
|
||||
import qs.Modules.Plugins
|
||||
|
||||
PluginSettings {
|
||||
pluginId: "myDesktopWidget"
|
||||
|
||||
SliderSetting {
|
||||
settingKey: "opacity"
|
||||
label: "Opacity"
|
||||
description: "Widget background opacity"
|
||||
defaultValue: 85
|
||||
minimum: 10
|
||||
maximum: 100
|
||||
unit: "%"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property var pluginService: null
|
||||
property string pluginId: ""
|
||||
property bool editMode: false
|
||||
property real widgetWidth: 200
|
||||
property real widgetHeight: 200
|
||||
property real minWidth: 150
|
||||
property real minHeight: 150
|
||||
|
||||
// TODO: Load settings reactively
|
||||
property real bgOpacity: {
|
||||
if (!pluginService) return 0.85
|
||||
var val = pluginService.loadPluginData(pluginId, "opacity", 85)
|
||||
return val / 100
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: pluginService
|
||||
function onPluginDataChanged(changedId) {
|
||||
if (changedId !== pluginId) return
|
||||
var val = pluginService.loadPluginData(pluginId, "opacity", 85)
|
||||
bgOpacity = val / 100
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: Theme.cornerRadius
|
||||
color: Theme.surfaceContainer
|
||||
opacity: root.bgOpacity
|
||||
border.color: root.editMode ? Theme.primary : "transparent"
|
||||
border.width: root.editMode ? 2 : 0
|
||||
|
||||
// TODO: Add your widget content here
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: "Desktop Widget"
|
||||
color: Theme.surfaceText
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"id": "myDesktopWidget",
|
||||
"name": "My Desktop Widget",
|
||||
"description": "A custom desktop widget",
|
||||
"version": "1.0.0",
|
||||
"author": "Your Name",
|
||||
"type": "desktop",
|
||||
"capabilities": ["desktop-widget"],
|
||||
"component": "./Widget.qml",
|
||||
"icon": "widgets",
|
||||
"settings": "./Settings.qml",
|
||||
"permissions": ["settings_read", "settings_write"]
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.Services
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property var pluginService: null
|
||||
property string trigger: "#"
|
||||
|
||||
signal itemsChanged()
|
||||
|
||||
// TODO: Define your items
|
||||
property var allItems: [
|
||||
{
|
||||
name: "Example Item",
|
||||
icon: "material:star",
|
||||
comment: "An example launcher item",
|
||||
action: "toast:Hello from my launcher!",
|
||||
categories: ["MyLauncher"]
|
||||
}
|
||||
]
|
||||
|
||||
function getItems(query) {
|
||||
if (!query || query.length === 0) return allItems
|
||||
|
||||
var q = query.toLowerCase()
|
||||
return allItems.filter(function(item) {
|
||||
return item.name.toLowerCase().includes(q) ||
|
||||
item.comment.toLowerCase().includes(q)
|
||||
})
|
||||
}
|
||||
|
||||
function executeItem(item) {
|
||||
var actionParts = item.action.split(":")
|
||||
var actionType = actionParts[0]
|
||||
var actionData = actionParts.slice(1).join(":")
|
||||
|
||||
switch (actionType) {
|
||||
case "toast":
|
||||
if (typeof ToastService !== "undefined")
|
||||
ToastService.showInfo(actionData)
|
||||
break
|
||||
case "copy":
|
||||
Quickshell.execDetached(["dms", "cl", "copy", actionData])
|
||||
if (typeof ToastService !== "undefined")
|
||||
ToastService.showInfo("Copied to clipboard")
|
||||
break
|
||||
default:
|
||||
console.warn("Unknown action type:", actionType)
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
if (pluginService) {
|
||||
trigger = pluginService.loadPluginData("myLauncher", "trigger", "#")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
import qs.Widgets
|
||||
import qs.Modules.Plugins
|
||||
|
||||
PluginSettings {
|
||||
pluginId: "myLauncher"
|
||||
|
||||
StringSetting {
|
||||
settingKey: "trigger"
|
||||
label: "Trigger"
|
||||
description: "Type this prefix in the launcher to activate the plugin"
|
||||
placeholder: "#"
|
||||
defaultValue: "#"
|
||||
}
|
||||
|
||||
ToggleSetting {
|
||||
settingKey: "noTrigger"
|
||||
label: "Always Visible"
|
||||
description: "Show items alongside regular apps without needing a trigger"
|
||||
defaultValue: false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"id": "myLauncher",
|
||||
"name": "My Launcher",
|
||||
"description": "Custom launcher plugin with searchable items",
|
||||
"version": "1.0.0",
|
||||
"author": "Your Name",
|
||||
"type": "launcher",
|
||||
"capabilities": ["launcher"],
|
||||
"component": "./Launcher.qml",
|
||||
"trigger": "#",
|
||||
"icon": "search",
|
||||
"settings": "./Settings.qml",
|
||||
"permissions": ["settings_read", "settings_write"]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
import qs.Widgets
|
||||
import qs.Modules.Plugins
|
||||
|
||||
PluginSettings {
|
||||
pluginId: "myWidget"
|
||||
|
||||
StringSetting {
|
||||
settingKey: "text"
|
||||
label: "Display Text"
|
||||
description: "Text shown in the bar widget"
|
||||
placeholder: "Hello"
|
||||
defaultValue: "Hello"
|
||||
}
|
||||
|
||||
ToggleSetting {
|
||||
settingKey: "showIcon"
|
||||
label: "Show Icon"
|
||||
description: "Display an icon next to the text"
|
||||
defaultValue: true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
import qs.Widgets
|
||||
import qs.Modules.Plugins
|
||||
|
||||
PluginComponent {
|
||||
id: root
|
||||
property var popoutService: null
|
||||
|
||||
// TODO: Read settings reactively
|
||||
property string displayText: pluginData?.text || "Hello"
|
||||
|
||||
Connections {
|
||||
target: pluginService
|
||||
function onPluginDataChanged(changedId) {
|
||||
if (changedId !== pluginId) return
|
||||
displayText = pluginService.loadPluginData(pluginId, "text", "Hello")
|
||||
}
|
||||
}
|
||||
|
||||
horizontalBarPill: Component {
|
||||
StyledRect {
|
||||
width: label.implicitWidth + Theme.spacingM * 2
|
||||
height: parent.widgetThickness
|
||||
radius: Theme.cornerRadius
|
||||
color: Theme.surfaceContainerHigh
|
||||
|
||||
StyledText {
|
||||
id: label
|
||||
anchors.centerIn: parent
|
||||
text: root.displayText
|
||||
color: Theme.surfaceText
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
verticalBarPill: Component {
|
||||
StyledRect {
|
||||
width: parent.widgetThickness
|
||||
height: label.implicitHeight + Theme.spacingM * 2
|
||||
radius: Theme.cornerRadius
|
||||
color: Theme.surfaceContainerHigh
|
||||
|
||||
StyledText {
|
||||
id: label
|
||||
anchors.centerIn: parent
|
||||
text: root.displayText
|
||||
color: Theme.surfaceText
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
rotation: 90
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Uncomment and customize popout content
|
||||
// popoutWidth: 350
|
||||
// popoutHeight: 300
|
||||
// popoutContent: Component {
|
||||
// PopoutComponent {
|
||||
// headerText: "My Widget"
|
||||
// showCloseButton: true
|
||||
//
|
||||
// Column {
|
||||
// width: parent.width
|
||||
// spacing: Theme.spacingM
|
||||
//
|
||||
// StyledText {
|
||||
// text: "Popout content here"
|
||||
// color: Theme.surfaceText
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"id": "myWidget",
|
||||
"name": "My Widget",
|
||||
"description": "A custom bar widget",
|
||||
"version": "1.0.0",
|
||||
"author": "Your Name",
|
||||
"type": "widget",
|
||||
"capabilities": ["dankbar-widget"],
|
||||
"component": "./Widget.qml",
|
||||
"icon": "extension",
|
||||
"settings": "./Settings.qml",
|
||||
"permissions": ["settings_read", "settings_write"]
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
# Advanced Patterns
|
||||
|
||||
Patterns observed in production DMS plugins that go beyond the basics.
|
||||
|
||||
## Plugin Variants
|
||||
|
||||
Create multiple widget instances from a single plugin definition. Each variant has its own configuration.
|
||||
|
||||
### Manifest
|
||||
|
||||
No special manifest changes needed - the variant system is built into PluginComponent.
|
||||
|
||||
### Widget with Variant Support
|
||||
|
||||
```qml
|
||||
PluginComponent {
|
||||
property string variantId: ""
|
||||
property var variantData: ({})
|
||||
|
||||
property string displayText: variantData?.text || "Default"
|
||||
|
||||
horizontalBarPill: Component {
|
||||
StyledRect {
|
||||
width: label.implicitWidth + Theme.spacingM * 2
|
||||
height: parent.widgetThickness
|
||||
|
||||
StyledText {
|
||||
id: label
|
||||
anchors.centerIn: parent
|
||||
text: root.displayText
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Widget format in bar config: `pluginId:variantId` (e.g., `exampleVariants:variant_1234567890`)
|
||||
|
||||
### Settings with Variant Management
|
||||
|
||||
```qml
|
||||
PluginSettings {
|
||||
pluginId: "exampleVariants"
|
||||
|
||||
// Variant creation UI
|
||||
DankButton {
|
||||
text: "Add New Instance"
|
||||
onClicked: {
|
||||
var id = "variant_" + Date.now()
|
||||
root.createVariant(id, { name: "New Instance", text: "Hello" })
|
||||
}
|
||||
}
|
||||
|
||||
// Per-variant configuration
|
||||
Repeater {
|
||||
model: root.variants
|
||||
delegate: Column {
|
||||
StringSetting {
|
||||
settingKey: modelData.id + "_text"
|
||||
label: modelData.name || modelData.id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## JavaScript Utility Files
|
||||
|
||||
For complex logic, split into `.js` files:
|
||||
|
||||
### utils.js
|
||||
|
||||
```javascript
|
||||
.pragma library
|
||||
|
||||
function formatDuration(ms) {
|
||||
if (ms < 60000) return "just now"
|
||||
if (ms < 3600000) return Math.floor(ms / 60000) + "m ago"
|
||||
return Math.floor(ms / 3600000) + "h ago"
|
||||
}
|
||||
|
||||
function parseResponse(json) {
|
||||
try {
|
||||
return JSON.parse(json)
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Using in QML
|
||||
|
||||
```qml
|
||||
import "utils.js" as Utils
|
||||
|
||||
Item {
|
||||
StyledText {
|
||||
text: Utils.formatDuration(Date.now() - timestamp)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `.pragma library` directive makes the JS file a shared singleton - it is loaded once and shared across all QML instances that import it.
|
||||
|
||||
## qmldir for Singleton Services
|
||||
|
||||
For plugins with internal singleton services:
|
||||
|
||||
### qmldir
|
||||
|
||||
```
|
||||
singleton MyService 1.0 MyService.qml
|
||||
```
|
||||
|
||||
### MyService.qml
|
||||
|
||||
```qml
|
||||
pragma Singleton
|
||||
import QtQuick
|
||||
|
||||
QtObject {
|
||||
property var cache: ({})
|
||||
|
||||
function getData(key) {
|
||||
return cache[key] || null
|
||||
}
|
||||
|
||||
function setData(key, value) {
|
||||
cache[key] = value
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Using the singleton
|
||||
|
||||
```qml
|
||||
import "." as Local
|
||||
|
||||
Item {
|
||||
Component.onCompleted: {
|
||||
Local.MyService.setData("key", "value")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Inline Component Declarations
|
||||
|
||||
Reusable sub-components defined inline:
|
||||
|
||||
```qml
|
||||
Item {
|
||||
component StatusBadge: Rectangle {
|
||||
property string label: ""
|
||||
property color badgeColor: Theme.primary
|
||||
|
||||
width: badgeText.implicitWidth + Theme.spacingM * 2
|
||||
height: 24
|
||||
radius: 12
|
||||
color: badgeColor
|
||||
|
||||
StyledText {
|
||||
id: badgeText
|
||||
anchors.centerIn: parent
|
||||
text: label
|
||||
color: Theme.onPrimary
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
spacing: Theme.spacingS
|
||||
StatusBadge { label: "Running"; badgeColor: Theme.success }
|
||||
StatusBadge { label: "Stopped"; badgeColor: Theme.error }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Multi-Provider Adapter Pattern
|
||||
|
||||
For plugins supporting multiple backends (AI providers, API services):
|
||||
|
||||
### apiAdapters.js
|
||||
|
||||
```javascript
|
||||
.pragma library
|
||||
|
||||
function createAdapter(provider) {
|
||||
switch (provider) {
|
||||
case "openai": return {
|
||||
url: "https://api.openai.com/v1/chat/completions",
|
||||
headers: (key) => ({ "Authorization": "Bearer " + key }),
|
||||
formatRequest: (messages) => JSON.stringify({ model: "gpt-4", messages: messages }),
|
||||
parseResponse: (text) => JSON.parse(text).choices[0].message.content
|
||||
}
|
||||
case "anthropic": return {
|
||||
url: "https://api.anthropic.com/v1/messages",
|
||||
headers: (key) => ({ "x-api-key": key, "anthropic-version": "2023-06-01" }),
|
||||
formatRequest: (messages) => JSON.stringify({ model: "claude-sonnet-4-20250514", messages: messages }),
|
||||
parseResponse: (text) => JSON.parse(text).content[0].text
|
||||
}
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## IPC Integration
|
||||
|
||||
For plugins that respond to keyboard shortcuts or external commands:
|
||||
|
||||
```qml
|
||||
PluginComponent {
|
||||
Connections {
|
||||
target: DMSIpc
|
||||
function onCommandReceived(command, args) {
|
||||
if (command === "myPlugin.toggle") {
|
||||
doToggle()
|
||||
} else if (command === "myPlugin.next") {
|
||||
goNext()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
External trigger: `dms ipc call myPlugin.toggle`
|
||||
|
||||
## Networking with Quickshell.Networking
|
||||
|
||||
For API calls using the built-in networking module:
|
||||
|
||||
```qml
|
||||
import Quickshell.Networking
|
||||
|
||||
Item {
|
||||
NetworkRequest {
|
||||
id: request
|
||||
url: "https://api.example.com/data"
|
||||
method: "GET"
|
||||
|
||||
onResponseReceived: (response) => {
|
||||
const data = JSON.parse(response.body)
|
||||
processData(data)
|
||||
}
|
||||
|
||||
onErrorOccurred: (error) => {
|
||||
console.error("Network error:", error)
|
||||
}
|
||||
}
|
||||
|
||||
function fetchData() {
|
||||
request.send()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Toast Notifications
|
||||
|
||||
Show user feedback:
|
||||
|
||||
```qml
|
||||
import qs.Services
|
||||
|
||||
// Info toast
|
||||
ToastService?.showInfo("Operation completed")
|
||||
|
||||
// With title
|
||||
ToastService?.showInfo("Plugin Name", "Data refreshed successfully")
|
||||
```
|
||||
|
||||
Always use optional chaining since ToastService may not be available in all contexts.
|
||||
|
||||
## Clipboard Operations
|
||||
|
||||
```qml
|
||||
import Quickshell
|
||||
|
||||
function copyToClipboard(text) {
|
||||
Quickshell.execDetached(["dms", "cl", "copy", text])
|
||||
ToastService?.showInfo("Copied to clipboard")
|
||||
}
|
||||
```
|
||||
|
||||
Do NOT use `globalThis.clipboard`, `navigator.clipboard`, or any browser API - they do not exist in the QML runtime.
|
||||
|
||||
## Multi-File Plugin Architecture
|
||||
|
||||
Large plugins can be split across multiple files:
|
||||
|
||||
```
|
||||
MyPlugin/
|
||||
plugin.json
|
||||
Main.qml # Main widget component
|
||||
Settings.qml # Settings UI
|
||||
DetailView.qml # Popout detail view
|
||||
utils.js # Utility functions
|
||||
apiAdapter.js # API adapter layer
|
||||
qmldir # Optional: singleton registrations
|
||||
```
|
||||
|
||||
Import sibling files:
|
||||
|
||||
```qml
|
||||
// In Main.qml
|
||||
import "." as Local
|
||||
|
||||
Item {
|
||||
Loader {
|
||||
source: "DetailView.qml"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Tips
|
||||
|
||||
1. Use `Proc.runCommand` with appropriate debounce for external commands
|
||||
2. Pre-cache images and thumbnails for image-heavy plugins
|
||||
3. Limit concurrent network requests
|
||||
4. Use `Timer` with reasonable intervals (don't poll faster than needed)
|
||||
5. Lazy-load heavy content (use `Loader` for complex popout content)
|
||||
6. Avoid blocking the UI thread with synchronous operations
|
||||
@@ -0,0 +1,272 @@
|
||||
# Daemon Plugin Guide
|
||||
|
||||
Daemon plugins are invisible background services that react to events and execute actions. They have no bar pills or desktop presence.
|
||||
|
||||
## Base Component
|
||||
|
||||
Daemons use `PluginComponent` with no bar pills:
|
||||
|
||||
```qml
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
import qs.Modules.Plugins
|
||||
|
||||
PluginComponent {
|
||||
id: root
|
||||
property var popoutService: null
|
||||
|
||||
// Event-driven logic goes here
|
||||
}
|
||||
```
|
||||
|
||||
## When to Use Daemons
|
||||
|
||||
- Monitor system events (wallpaper changes, battery level, notifications)
|
||||
- Run periodic background tasks (polling APIs, checking system state)
|
||||
- Execute scripts in response to events
|
||||
- Control shell UI via PopoutService based on conditions
|
||||
|
||||
## Event-Driven Pattern
|
||||
|
||||
Use `Connections` to react to service signals:
|
||||
|
||||
```qml
|
||||
PluginComponent {
|
||||
property var popoutService: null
|
||||
|
||||
Connections {
|
||||
target: SessionData
|
||||
function onWallpaperPathChanged() {
|
||||
console.log("Wallpaper changed to:", SessionData.wallpaperPath)
|
||||
runScript(SessionData.wallpaperPath)
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: BatteryService
|
||||
function onPercentageChanged() {
|
||||
if (BatteryService.percentage < 10 && !BatteryService.isCharging) {
|
||||
popoutService?.openBattery()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Available Services
|
||||
|
||||
Common services daemons can connect to:
|
||||
|
||||
| Service | Signals/Properties | Description |
|
||||
|---------|-------------------|-------------|
|
||||
| `SessionData` | `wallpaperPath`, `onWallpaperPathChanged` | Desktop session state |
|
||||
| `BatteryService` | `percentage`, `isCharging`, `batteryAvailable` | Battery status |
|
||||
| `NotificationService` | `onNotificationReceived(notification)` | Desktop notifications |
|
||||
| `PluginService` | `onPluginLoaded`, `onGlobalVarChanged` | Plugin lifecycle |
|
||||
|
||||
Import services from `qs.Services`.
|
||||
|
||||
## Process Execution
|
||||
|
||||
### Simple command with Proc
|
||||
|
||||
```qml
|
||||
import qs.Common
|
||||
|
||||
PluginComponent {
|
||||
function runScript(arg) {
|
||||
Proc.runCommand(
|
||||
"myDaemon.script",
|
||||
["bash", "-c", "echo 'Processing: " + arg + "'"],
|
||||
(stdout, exitCode) => {
|
||||
if (exitCode === 0) {
|
||||
console.log("Script output:", stdout)
|
||||
} else {
|
||||
ToastService?.showInfo("Script failed: exit " + exitCode)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Long-running process with Process component
|
||||
|
||||
```qml
|
||||
import Quickshell.Io
|
||||
|
||||
PluginComponent {
|
||||
property string scriptPath: ""
|
||||
|
||||
Process {
|
||||
id: proc
|
||||
command: ["bash", scriptPath]
|
||||
running: false
|
||||
|
||||
stdout: StdioCollector {
|
||||
onTextReceived: (text) => {
|
||||
console.log("stdout:", text)
|
||||
}
|
||||
}
|
||||
|
||||
stderr: StdioCollector {
|
||||
onTextReceived: (text) => {
|
||||
console.error("stderr:", text)
|
||||
}
|
||||
}
|
||||
|
||||
onExited: (exitCode) => {
|
||||
if (exitCode !== 0) {
|
||||
ToastService?.showInfo("Process failed: exit " + exitCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function startProcess() {
|
||||
if (scriptPath && !proc.running) {
|
||||
proc.running = true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Timer-Based Polling
|
||||
|
||||
```qml
|
||||
PluginComponent {
|
||||
Timer {
|
||||
interval: 60000 // every minute
|
||||
running: true
|
||||
repeat: true
|
||||
onTriggered: checkStatus()
|
||||
}
|
||||
|
||||
function checkStatus() {
|
||||
Proc.runCommand(
|
||||
"myDaemon.check",
|
||||
["sh", "-c", "systemctl is-active myservice"],
|
||||
(stdout, exitCode) => {
|
||||
const active = stdout.trim() === "active"
|
||||
PluginService.setGlobalVar("myDaemon", "serviceActive", active)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Data Persistence
|
||||
|
||||
Daemons access PluginService directly (it's injected via PluginComponent):
|
||||
|
||||
```qml
|
||||
PluginComponent {
|
||||
property string configuredScript: pluginData?.scriptPath || ""
|
||||
|
||||
Connections {
|
||||
target: pluginService
|
||||
function onPluginDataChanged(changedId) {
|
||||
if (changedId === pluginId) {
|
||||
configuredScript = pluginService.loadPluginData(pluginId, "scriptPath", "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## PopoutService Usage
|
||||
|
||||
Daemons can control shell UI via the injected popoutService:
|
||||
|
||||
```qml
|
||||
PluginComponent {
|
||||
property var popoutService: null
|
||||
|
||||
function showAlert() {
|
||||
popoutService?.openNotificationCenter()
|
||||
}
|
||||
|
||||
function openSettings() {
|
||||
popoutService?.openSettings()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See [popout-service-reference.md](popout-service-reference.md) for the full API.
|
||||
|
||||
## Complete Example
|
||||
|
||||
Based on the WallpaperWatcherDaemon:
|
||||
|
||||
```qml
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
import qs.Modules.Plugins
|
||||
|
||||
PluginComponent {
|
||||
id: root
|
||||
property var popoutService: null
|
||||
|
||||
property string scriptPath: pluginData?.scriptPath || ""
|
||||
|
||||
Connections {
|
||||
target: pluginService
|
||||
function onPluginDataChanged(changedId) {
|
||||
if (changedId === pluginId) {
|
||||
scriptPath = pluginService.loadPluginData(pluginId, "scriptPath", "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: SessionData
|
||||
function onWallpaperPathChanged() {
|
||||
if (scriptPath) {
|
||||
runWallpaperScript(SessionData.wallpaperPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function runWallpaperScript(wallpaperPath) {
|
||||
console.log("[WallpaperWatcher] Running script:", scriptPath, wallpaperPath)
|
||||
|
||||
Proc.runCommand(
|
||||
"wallpaperWatcher.run",
|
||||
["bash", scriptPath, wallpaperPath],
|
||||
(stdout, exitCode) => {
|
||||
if (exitCode === 0) {
|
||||
console.log("[WallpaperWatcher] Script output:", stdout)
|
||||
} else {
|
||||
console.error("[WallpaperWatcher] Script failed:", exitCode)
|
||||
ToastService?.showInfo("Wallpaper script failed")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
console.log("[WallpaperWatcher] Daemon started")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Manifest Example
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "wallpaperWatcher",
|
||||
"name": "Wallpaper Watcher",
|
||||
"description": "Runs a script when the wallpaper changes",
|
||||
"version": "1.0.0",
|
||||
"author": "Developer",
|
||||
"type": "daemon",
|
||||
"capabilities": ["wallpaper-automation"],
|
||||
"component": "./WallpaperWatcher.qml",
|
||||
"icon": "wallpaper",
|
||||
"settings": "./Settings.qml",
|
||||
"permissions": ["settings_read", "settings_write", "process"]
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,186 @@
|
||||
# Data Persistence Guide
|
||||
|
||||
DMS plugins have three tiers of data persistence, each suited for different use cases.
|
||||
|
||||
## Tier 1: Plugin Data (Settings)
|
||||
|
||||
Persisted to `settings.json`. Use for user preferences and configuration.
|
||||
|
||||
### Saving
|
||||
|
||||
```qml
|
||||
pluginService.savePluginData(pluginId, "key", value)
|
||||
```
|
||||
|
||||
### Loading
|
||||
|
||||
```qml
|
||||
var value = pluginService.loadPluginData(pluginId, "key", defaultValue)
|
||||
```
|
||||
|
||||
### Reactive Access via pluginData
|
||||
|
||||
`PluginComponent` has a reactive `pluginData` property that auto-loads from settings:
|
||||
|
||||
```qml
|
||||
PluginComponent {
|
||||
property string displayText: pluginData?.text || "Default"
|
||||
property bool showIcon: pluginData?.showIcon !== undefined ? pluginData.showIcon : true
|
||||
}
|
||||
```
|
||||
|
||||
### Reacting to Settings Changes
|
||||
|
||||
When settings are changed (e.g., from the settings UI), react with `Connections`:
|
||||
|
||||
```qml
|
||||
Connections {
|
||||
target: pluginService
|
||||
function onPluginDataChanged(changedId) {
|
||||
if (changedId !== pluginId) return
|
||||
displayText = pluginService.loadPluginData(pluginId, "text", "Default")
|
||||
showIcon = pluginService.loadPluginData(pluginId, "showIcon", true)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Tier 2: Plugin State
|
||||
|
||||
Persisted to a separate state file. Use for runtime state that should survive restarts but is not user-configurable (history, cache, counters).
|
||||
|
||||
### Saving
|
||||
|
||||
```qml
|
||||
pluginService.savePluginState(pluginId, "key", value)
|
||||
```
|
||||
|
||||
### Loading
|
||||
|
||||
```qml
|
||||
var state = pluginService.loadPluginState(pluginId, "key", defaultValue)
|
||||
```
|
||||
|
||||
### Additional Methods
|
||||
|
||||
```qml
|
||||
pluginService.clearPluginState(pluginId)
|
||||
pluginService.removePluginStateKey(pluginId, "key")
|
||||
```
|
||||
|
||||
### Example: Persistent History
|
||||
|
||||
```qml
|
||||
Item {
|
||||
property var history: []
|
||||
|
||||
Component.onCompleted: {
|
||||
history = pluginService?.loadPluginState(pluginId, "history", []) || []
|
||||
}
|
||||
|
||||
function addToHistory(entry) {
|
||||
history.unshift({
|
||||
text: entry,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
if (history.length > 100) history = history.slice(0, 100)
|
||||
pluginService?.savePluginState(pluginId, "history", history)
|
||||
}
|
||||
|
||||
function clearHistory() {
|
||||
history = []
|
||||
pluginService?.removePluginStateKey(pluginId, "history")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Tier 3: Global Variables (Runtime Only)
|
||||
|
||||
NOT persisted. Shared across all instances of a plugin. Use for cross-instance state synchronization (multi-monitor consistency, multi-instance widgets).
|
||||
|
||||
### Using PluginGlobalVar Component
|
||||
|
||||
```qml
|
||||
import qs.Modules.Plugins
|
||||
|
||||
PluginComponent {
|
||||
PluginGlobalVar {
|
||||
id: globalCounter
|
||||
varName: "counter"
|
||||
defaultValue: 0
|
||||
}
|
||||
|
||||
horizontalBarPill: Component {
|
||||
StyledRect {
|
||||
// ...
|
||||
StyledText {
|
||||
text: "Count: " + globalCounter.value
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
onClicked: globalCounter.set(globalCounter.value + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**PluginGlobalVar properties:**
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `varName` | string | Required: name of the global variable |
|
||||
| `defaultValue` | any | Optional: default if not set |
|
||||
| `value` | any | Readonly: current value |
|
||||
|
||||
**Methods:**
|
||||
- `set(newValue)` - update the value (triggers reactivity across all instances)
|
||||
|
||||
### Using PluginService API Directly
|
||||
|
||||
```qml
|
||||
import qs.Services
|
||||
|
||||
property int counter: PluginService.getGlobalVar("myPlugin", "counter", 0)
|
||||
|
||||
Connections {
|
||||
target: PluginService
|
||||
function onGlobalVarChanged(pluginId, varName) {
|
||||
if (pluginId === "myPlugin" && varName === "counter") {
|
||||
counter = PluginService.getGlobalVar("myPlugin", "counter", 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function increment() {
|
||||
var current = PluginService.getGlobalVar("myPlugin", "counter", 0)
|
||||
PluginService.setGlobalVar("myPlugin", "counter", current + 1)
|
||||
}
|
||||
```
|
||||
|
||||
## Decision Matrix
|
||||
|
||||
| Need | API | Persisted | Scope |
|
||||
|------|-----|-----------|-------|
|
||||
| User preferences (API keys, themes, intervals) | `savePluginData` / `loadPluginData` | Yes (settings.json) | Per plugin |
|
||||
| Runtime state (history, cache, counters) | `savePluginState` / `loadPluginState` | Yes (state file) | Per plugin |
|
||||
| Cross-instance sync (multi-monitor data) | `PluginGlobalVar` or `getGlobalVar`/`setGlobalVar` | No (runtime only) | All instances |
|
||||
| Quick reactive reads from settings | `pluginData` property | N/A (read-only) | Per instance |
|
||||
|
||||
## Plugin Path
|
||||
|
||||
Retrieve a plugin's installation directory at runtime:
|
||||
|
||||
```qml
|
||||
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.
|
||||
|
||||
## Important Notes
|
||||
|
||||
1. **pluginData is reactive** - bindings update automatically when data changes
|
||||
2. **Global vars are NOT persistent** - they reset when the shell restarts
|
||||
3. **State vs Data** - data is for user-facing settings, state is for internal runtime data
|
||||
4. **Null safety** - always check `pluginService` is not null before calling methods
|
||||
5. **Signal namespacing** - global var signals include `pluginId` to filter for your plugin
|
||||
6. **Performance** - global vars are efficient for frequent updates; settings writes are batched
|
||||
@@ -0,0 +1,240 @@
|
||||
# Desktop Plugin Guide
|
||||
|
||||
Desktop plugins are widgets that appear on the desktop background layer. They support drag-and-drop positioning and resize via corner handles.
|
||||
|
||||
## Base Component
|
||||
|
||||
Desktop widgets use a plain `Item` with injected properties:
|
||||
|
||||
```qml
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property var pluginService: null
|
||||
property string pluginId: ""
|
||||
property bool editMode: false
|
||||
property real widgetWidth: 200
|
||||
property real widgetHeight: 200
|
||||
property real minWidth: 150
|
||||
property real minHeight: 150
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: Theme.cornerRadius
|
||||
color: Theme.surfaceContainer
|
||||
opacity: 0.85
|
||||
|
||||
// Your content here
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Injected Properties
|
||||
|
||||
These are set automatically by the DesktopPluginWrapper:
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `pluginService` | object | PluginService reference for data persistence |
|
||||
| `pluginId` | string | Plugin's unique identifier |
|
||||
| `editMode` | bool | `true` when user is dragging/resizing |
|
||||
| `widgetWidth` | real | Current widget container width |
|
||||
| `widgetHeight` | real | Current widget container height |
|
||||
|
||||
## Optional Properties
|
||||
|
||||
Define these on your root item to customize behavior:
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|----------|------|---------|-------------|
|
||||
| `minWidth` | real | 100 | Minimum allowed width during resize |
|
||||
| `minHeight` | real | 100 | Minimum allowed height during resize |
|
||||
|
||||
## Position and Size Persistence
|
||||
|
||||
Position (`desktopX`, `desktopY`) and size (`desktopWidth`, `desktopHeight`) are automatically managed by the DesktopPluginWrapper. You do not need to handle persistence for positioning.
|
||||
|
||||
## Edit Mode
|
||||
|
||||
When `editMode` is true, the user is repositioning or resizing. Use this to:
|
||||
- Show visual indicators (borders, handles)
|
||||
- Disable interactive elements to prevent accidental actions
|
||||
- Display additional controls
|
||||
|
||||
```qml
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
border.color: root.editMode ? Theme.primary : "transparent"
|
||||
border.width: root.editMode ? 2 : 0
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
enabled: !root.editMode
|
||||
onClicked: doSomething()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Loading and Saving Data
|
||||
|
||||
Use the injected `pluginService` for data persistence:
|
||||
|
||||
```qml
|
||||
property string displayMode: {
|
||||
if (!pluginService) return "default"
|
||||
return pluginService.loadPluginData(pluginId, "displayMode", "default")
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: pluginService
|
||||
function onPluginDataChanged(changedId) {
|
||||
if (changedId !== pluginId) return
|
||||
root.displayMode = pluginService.loadPluginData(pluginId, "displayMode", "default")
|
||||
}
|
||||
}
|
||||
|
||||
function saveMode(mode) {
|
||||
pluginService?.savePluginData(pluginId, "displayMode", mode)
|
||||
}
|
||||
```
|
||||
|
||||
## Settings Component
|
||||
|
||||
Desktop plugin settings use the same `PluginSettings` wrapper as other types:
|
||||
|
||||
```qml
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
import qs.Widgets
|
||||
import qs.Modules.Plugins
|
||||
|
||||
PluginSettings {
|
||||
pluginId: "myDesktopWidget"
|
||||
|
||||
SliderSetting {
|
||||
settingKey: "opacity"
|
||||
label: "Opacity"
|
||||
description: "Widget background opacity"
|
||||
defaultValue: 85
|
||||
minimum: 10
|
||||
maximum: 100
|
||||
unit: "%"
|
||||
}
|
||||
|
||||
SelectionSetting {
|
||||
settingKey: "style"
|
||||
label: "Display Style"
|
||||
options: [
|
||||
{ label: "Compact", value: "compact" },
|
||||
{ label: "Expanded", value: "expanded" }
|
||||
]
|
||||
defaultValue: "compact"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## User Interaction
|
||||
|
||||
Desktop widgets support:
|
||||
1. **Drag** - click and drag anywhere (in edit mode)
|
||||
2. **Resize** - drag bottom-right corner handle (in edit mode)
|
||||
3. **Edit mode toggle** - via the desktop edit button
|
||||
|
||||
## Complete Example
|
||||
|
||||
Based on the ExampleDesktopClock pattern:
|
||||
|
||||
```qml
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property var pluginService: null
|
||||
property string pluginId: ""
|
||||
property bool editMode: false
|
||||
property real widgetWidth: 250
|
||||
property real widgetHeight: 250
|
||||
property real minWidth: 150
|
||||
property real minHeight: 150
|
||||
|
||||
property string clockStyle: {
|
||||
if (!pluginService) return "digital"
|
||||
return pluginService.loadPluginData(pluginId, "clockStyle", "digital")
|
||||
}
|
||||
|
||||
property real bgOpacity: {
|
||||
if (!pluginService) return 0.85
|
||||
var val = pluginService.loadPluginData(pluginId, "opacity", 85)
|
||||
return val / 100
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: pluginService
|
||||
function onPluginDataChanged(changedId) {
|
||||
if (changedId !== pluginId) return
|
||||
clockStyle = pluginService.loadPluginData(pluginId, "clockStyle", "digital")
|
||||
var val = pluginService.loadPluginData(pluginId, "opacity", 85)
|
||||
bgOpacity = val / 100
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: Theme.cornerRadius
|
||||
color: Theme.surfaceContainer
|
||||
opacity: root.bgOpacity
|
||||
border.color: root.editMode ? Theme.primary : "transparent"
|
||||
border.width: root.editMode ? 2 : 0
|
||||
|
||||
Column {
|
||||
anchors.centerIn: parent
|
||||
spacing: Theme.spacingS
|
||||
|
||||
Text {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: Qt.formatTime(new Date(), "hh:mm:ss")
|
||||
color: Theme.surfaceText
|
||||
font.pixelSize: root.widgetWidth * 0.15
|
||||
font.weight: Font.Bold
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: Qt.formatDate(new Date(), "ddd, MMM d")
|
||||
color: Theme.onSurfaceVariant
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
interval: 1000
|
||||
running: true
|
||||
repeat: true
|
||||
onTriggered: root.widgetWidth = root.widgetWidth // force update
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Manifest Example
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "myDesktopClock",
|
||||
"name": "Desktop Clock",
|
||||
"description": "Analog and digital clock for the desktop",
|
||||
"version": "1.0.0",
|
||||
"author": "Developer",
|
||||
"type": "desktop",
|
||||
"capabilities": ["desktop-widget"],
|
||||
"component": "./ClockWidget.qml",
|
||||
"icon": "schedule",
|
||||
"settings": "./Settings.qml",
|
||||
"permissions": ["settings_read", "settings_write"]
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,308 @@
|
||||
# Launcher Plugin Guide
|
||||
|
||||
Launcher plugins extend the DMS launcher with custom searchable items and actions. They use trigger-based filtering and integrate directly into the app drawer.
|
||||
|
||||
## Base Component
|
||||
|
||||
Launchers use a plain `Item` (not PluginComponent):
|
||||
|
||||
```qml
|
||||
import QtQuick
|
||||
import qs.Services
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property var pluginService: null
|
||||
property string trigger: "#"
|
||||
|
||||
signal itemsChanged()
|
||||
|
||||
function getItems(query) {
|
||||
// Return array of items
|
||||
return []
|
||||
}
|
||||
|
||||
function executeItem(item) {
|
||||
// Handle item selection
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Required Interface
|
||||
|
||||
| Member | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `pluginService` | property | Injected PluginService reference (declare as `null`) |
|
||||
| `trigger` | property | Trigger string for activation |
|
||||
| `itemsChanged` | signal | Emit when item list changes (triggers UI refresh) |
|
||||
| `getItems(query)` | function | Return array of items matching query |
|
||||
| `executeItem(item)` | function | Handle item selection |
|
||||
|
||||
## Item Structure
|
||||
|
||||
Each item returned by `getItems()`:
|
||||
|
||||
```javascript
|
||||
{
|
||||
name: "Item Display Name", // Required: shown in launcher
|
||||
icon: "material:star", // Optional: icon specification
|
||||
comment: "Description text", // Required: subtitle text
|
||||
action: "type:data", // Required: action identifier
|
||||
categories: ["MyPlugin"], // Required: array with plugin category
|
||||
imageUrl: "https://..." // Optional: image for tile view
|
||||
}
|
||||
```
|
||||
|
||||
## Icon Types
|
||||
|
||||
### 1. Material Design Icons
|
||||
|
||||
```javascript
|
||||
{ icon: "material:lightbulb" }
|
||||
{ icon: "material:terminal" }
|
||||
{ icon: "material:translate" }
|
||||
```
|
||||
|
||||
Uses the Material Symbols Rounded font.
|
||||
|
||||
### 2. Unicode / Emoji Icons
|
||||
|
||||
```javascript
|
||||
{ icon: "unicode:smile_face" }
|
||||
```
|
||||
|
||||
Rendered at 70-80% of icon size with theming.
|
||||
|
||||
### 3. Desktop Theme Icons
|
||||
|
||||
```javascript
|
||||
{ icon: "firefox" }
|
||||
{ icon: "folder" }
|
||||
```
|
||||
|
||||
Uses the user's installed icon theme.
|
||||
|
||||
### 4. No Icon
|
||||
|
||||
Omit the `icon` field entirely. The launcher hides the icon area and gives full width to the item name.
|
||||
|
||||
## Trigger System
|
||||
|
||||
**Custom trigger** (items only appear when trigger is typed):
|
||||
|
||||
```json
|
||||
{ "trigger": "#" }
|
||||
```
|
||||
|
||||
- Type `#` alone: shows all plugin items
|
||||
- Type `# query`: filters plugin items by query
|
||||
- The query string (without trigger) is passed to `getItems(query)`
|
||||
|
||||
**No trigger** (items always visible alongside regular apps):
|
||||
|
||||
```json
|
||||
{ "trigger": "" }
|
||||
```
|
||||
|
||||
Save empty trigger at runtime:
|
||||
```qml
|
||||
Component.onCompleted: {
|
||||
trigger = pluginService?.loadPluginData(pluginId, "trigger", "#") ?? "#"
|
||||
}
|
||||
```
|
||||
|
||||
## Action Execution
|
||||
|
||||
Parse action strings in `executeItem()`:
|
||||
|
||||
```qml
|
||||
function executeItem(item) {
|
||||
const actionParts = item.action.split(":")
|
||||
const actionType = actionParts[0]
|
||||
const actionData = actionParts.slice(1).join(":")
|
||||
|
||||
switch (actionType) {
|
||||
case "toast":
|
||||
ToastService?.showInfo(actionData)
|
||||
break
|
||||
case "copy":
|
||||
Quickshell.execDetached(["dms", "cl", "copy", actionData])
|
||||
ToastService?.showInfo("Copied to clipboard")
|
||||
break
|
||||
case "exec":
|
||||
Quickshell.execDetached(actionData.split(" "))
|
||||
break
|
||||
case "url":
|
||||
Quickshell.execDetached(["xdg-open", actionData])
|
||||
break
|
||||
default:
|
||||
console.warn("Unknown action type:", actionType)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Search / Filtering
|
||||
|
||||
The `query` parameter in `getItems()` contains the user's search text (without the trigger prefix).
|
||||
|
||||
```qml
|
||||
function getItems(query) {
|
||||
const allItems = [
|
||||
{ name: "Calculator", icon: "material:calculate",
|
||||
comment: "Open calculator", action: "exec:gnome-calculator",
|
||||
categories: ["Tools"] },
|
||||
{ name: "Terminal", icon: "material:terminal",
|
||||
comment: "Open terminal", action: "exec:alacritty",
|
||||
categories: ["Tools"] }
|
||||
]
|
||||
|
||||
if (!query || query.length === 0) return allItems
|
||||
|
||||
const q = query.toLowerCase()
|
||||
return allItems.filter(item =>
|
||||
item.name.toLowerCase().includes(q) ||
|
||||
item.comment.toLowerCase().includes(q)
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Context Menu Actions
|
||||
|
||||
Add right-click actions to launcher items:
|
||||
|
||||
```qml
|
||||
function getContextMenuActions(item) {
|
||||
return [
|
||||
{ name: "Copy", icon: "material:content_copy",
|
||||
action: "copy:" + item.name },
|
||||
{ name: "Open in Browser", icon: "material:open_in_new",
|
||||
action: "url:" + item.url }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Context menu actions use the same `executeItem()` handler.
|
||||
|
||||
## Image Tile View
|
||||
|
||||
For image-heavy launchers (GIF search, sticker pickers), use tile view:
|
||||
|
||||
In `plugin.json`:
|
||||
```json
|
||||
{
|
||||
"viewMode": "tile",
|
||||
"viewModeEnforced": true
|
||||
}
|
||||
```
|
||||
|
||||
In items:
|
||||
```javascript
|
||||
{
|
||||
name: "Image Title",
|
||||
imageUrl: "https://example.com/image.png",
|
||||
comment: "Description",
|
||||
action: "copy:https://example.com/image.png",
|
||||
categories: ["MyPlugin"]
|
||||
}
|
||||
```
|
||||
|
||||
## State Persistence
|
||||
|
||||
For plugins with persistent state (notes, history, favorites):
|
||||
|
||||
```qml
|
||||
property var notes: []
|
||||
|
||||
Component.onCompleted: {
|
||||
const saved = pluginService?.loadPluginState(pluginId, "notes", [])
|
||||
if (saved) notes = saved
|
||||
}
|
||||
|
||||
function addNote(text) {
|
||||
notes.push({ text: text, timestamp: Date.now() })
|
||||
pluginService?.savePluginState(pluginId, "notes", notes)
|
||||
itemsChanged()
|
||||
}
|
||||
```
|
||||
|
||||
Use `savePluginState/loadPluginState` for runtime data and `savePluginData/loadPluginData` for user preferences.
|
||||
|
||||
## Settings for Trigger Configuration
|
||||
|
||||
Provide a PluginSettings component for trigger customization:
|
||||
|
||||
```qml
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
import qs.Widgets
|
||||
import qs.Modules.Plugins
|
||||
|
||||
PluginSettings {
|
||||
pluginId: "myLauncher"
|
||||
|
||||
StringSetting {
|
||||
settingKey: "trigger"
|
||||
label: "Trigger"
|
||||
description: "Type this prefix to activate the launcher plugin"
|
||||
placeholder: "#"
|
||||
defaultValue: "#"
|
||||
}
|
||||
|
||||
ToggleSetting {
|
||||
settingKey: "noTrigger"
|
||||
label: "Always Visible"
|
||||
description: "Show items alongside regular apps (no trigger needed)"
|
||||
defaultValue: false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Complete Example
|
||||
|
||||
```qml
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.Services
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property var pluginService: null
|
||||
property string trigger: "!"
|
||||
|
||||
signal itemsChanged()
|
||||
|
||||
property var commands: [
|
||||
{ name: "Lock Screen", icon: "material:lock",
|
||||
comment: "Lock the session", action: "exec:loginctl lock-session" },
|
||||
{ name: "Screenshot", icon: "material:screenshot_monitor",
|
||||
comment: "Take a screenshot", action: "exec:grim" },
|
||||
{ name: "File Manager", icon: "material:folder",
|
||||
comment: "Open file manager", action: "exec:nautilus" }
|
||||
]
|
||||
|
||||
function getItems(query) {
|
||||
if (!query) return commands
|
||||
const q = query.toLowerCase()
|
||||
return commands.filter(c =>
|
||||
c.name.toLowerCase().includes(q) ||
|
||||
c.comment.toLowerCase().includes(q)
|
||||
)
|
||||
}
|
||||
|
||||
function executeItem(item) {
|
||||
const [type, ...rest] = item.action.split(":")
|
||||
const data = rest.join(":")
|
||||
if (type === "exec") {
|
||||
Quickshell.execDetached(data.split(" "))
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
if (pluginService) {
|
||||
trigger = pluginService.loadPluginData("quickCommands", "trigger", "!")
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,188 @@
|
||||
# Plugin Manifest Reference (plugin.json)
|
||||
|
||||
## Required Fields
|
||||
|
||||
| Field | Type | Description | Validation |
|
||||
|-------|------|-------------|------------|
|
||||
| `id` | string | Unique plugin identifier | camelCase, pattern `^[a-zA-Z][a-zA-Z0-9]*$` |
|
||||
| `name` | string | Human-readable name | Non-empty |
|
||||
| `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` |
|
||||
| `capabilities` | array | Plugin capabilities | At least 1 string item |
|
||||
|
||||
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` |
|
||||
|
||||
## Conditional Requirements
|
||||
|
||||
| Condition | Required Field | Description |
|
||||
|-----------|---------------|-------------|
|
||||
| `type: "launcher"` | `trigger` | Trigger string for launcher activation (e.g., `=`, `#`, `!`) |
|
||||
| `components` has `launcher` key | `trigger` | Same requirement applies to composite plugins with a launcher surface |
|
||||
|
||||
## Optional Fields
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `icon` | string | Material Design icon name (displayed in plugin list UI) |
|
||||
| `settings` | string | Path to settings QML file (must start with `./`, end with `.qml`) |
|
||||
| `startupCheck` | string | Path to a QtObject component that gates plugin activation via a `check(done)` function (must start with `./`, end with `.qml`). See Startup Check section below. |
|
||||
| `requires_dms` | string | Minimum DMS version (e.g., `>=0.1.18`), pattern `^(>=?\|<=?\|=\|>\|<)\d+\.\d+\.\d+$` |
|
||||
| `dependencies` | array | System tool dependencies (e.g., `["curl", "jq"]`). Registry metadata. |
|
||||
| `requires` | array | Deprecated alias for `dependencies` |
|
||||
| `permissions` | array | Required permissions |
|
||||
| `trigger` | string | Launcher trigger string (required for launcher type) |
|
||||
|
||||
## Permissions
|
||||
|
||||
| Permission | Description | Enforced |
|
||||
|------------|-------------|----------|
|
||||
| `settings_read` | Read plugin configuration | No (not currently enforced) |
|
||||
| `settings_write` | Write plugin configuration / use PluginSettings | **Yes** |
|
||||
| `process` | Execute system commands | No (not currently enforced) |
|
||||
| `network` | Network access | No (not currently enforced) |
|
||||
|
||||
If your plugin has a `settings` component but does not declare `settings_write`, users will see an error instead of the settings UI.
|
||||
|
||||
## Capabilities
|
||||
|
||||
Capabilities are free-form strings that describe what the plugin does. Common values:
|
||||
|
||||
- `dankbar-widget` - general bar widget
|
||||
- `control-center` - integrates with Control Center
|
||||
- `monitoring` - system/service monitoring
|
||||
- `launcher` - launcher search provider
|
||||
- `desktop-widget` - desktop background widget
|
||||
- `ai` - AI/LLM integration
|
||||
- `slideout` - uses slideout panel
|
||||
|
||||
## Startup Check
|
||||
|
||||
The `startupCheck` field points to a non-visual `QtObject` component that gates plugin activation on dependency checks. The component must expose a `check(done)` function:
|
||||
|
||||
```qml
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
|
||||
QtObject {
|
||||
function check(done) {
|
||||
Proc.runCommand("myPlugin.depCheck", ["sh", "-c", "command -v mytool"], (stdout, exitCode) => {
|
||||
if (exitCode === 0) {
|
||||
done(null);
|
||||
return;
|
||||
}
|
||||
done({
|
||||
"title": I18n.tr("mytool is required"),
|
||||
"details": I18n.tr("Install 'mytool' and re-enable this plugin.")
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `done` callback accepts:
|
||||
- `null` - allow activation
|
||||
- A string - block with a short error message
|
||||
- `{ title, details }` - block with a title and expandable details
|
||||
|
||||
A synchronous variant (no `done` parameter, return the result directly) is also supported.
|
||||
|
||||
Failed startup checks show a toast error and store the error in `pluginService.pluginLoadErrors`.
|
||||
|
||||
## Components (Composite Plugins)
|
||||
|
||||
The `components` field maps surface names to QML paths, allowing a single plugin to register multiple surfaces:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "myComposite",
|
||||
"name": "My Composite Plugin",
|
||||
"description": "Daemon + widget + desktop from one plugin",
|
||||
"version": "1.0.0",
|
||||
"author": "Developer Name",
|
||||
"type": "composite",
|
||||
"capabilities": ["daemon", "dankbar-widget", "desktop-widget"],
|
||||
"icon": "extension",
|
||||
"components": {
|
||||
"daemon": "./MyDaemon.qml",
|
||||
"widget": "./MyBarWidget.qml",
|
||||
"desktop": "./MyDesktopWidget.qml"
|
||||
},
|
||||
"settings": "./Settings.qml",
|
||||
"permissions": ["settings_read", "settings_write"]
|
||||
}
|
||||
```
|
||||
|
||||
Valid surface keys: `widget`, `desktop`, `daemon`, `launcher`. Provide any subset. Each surface is loaded independently in the appropriate registry.
|
||||
|
||||
## Complete Example
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "myPlugin",
|
||||
"name": "My Plugin",
|
||||
"description": "A sample plugin demonstrating all fields",
|
||||
"version": "1.0.0",
|
||||
"author": "Developer Name",
|
||||
"type": "widget",
|
||||
"capabilities": ["dankbar-widget", "control-center"],
|
||||
"component": "./MyWidget.qml",
|
||||
"icon": "extension",
|
||||
"settings": "./Settings.qml",
|
||||
"startupCheck": "./StartupCheck.qml",
|
||||
"requires_dms": ">=0.1.18",
|
||||
"dependencies": ["curl", "jq"],
|
||||
"permissions": ["settings_read", "settings_write", "process", "network"]
|
||||
}
|
||||
```
|
||||
|
||||
## Launcher Example
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "myLauncher",
|
||||
"name": "My Launcher",
|
||||
"description": "Search and execute custom actions",
|
||||
"version": "1.0.0",
|
||||
"author": "Developer Name",
|
||||
"type": "launcher",
|
||||
"capabilities": ["launcher"],
|
||||
"component": "./MyLauncher.qml",
|
||||
"trigger": "#",
|
||||
"icon": "search",
|
||||
"settings": "./Settings.qml",
|
||||
"requires_dms": ">=0.1.18",
|
||||
"permissions": ["settings_read", "settings_write"]
|
||||
}
|
||||
```
|
||||
|
||||
## JSON Schema
|
||||
|
||||
The complete JSON schema is available at `assets/plugin-schema.json` in this skill. Validate with:
|
||||
|
||||
```bash
|
||||
# Using python
|
||||
python3 -c "
|
||||
import json, jsonschema
|
||||
schema = json.load(open('path/to/plugin-schema.json'))
|
||||
manifest = json.load(open('plugin.json'))
|
||||
jsonschema.validate(manifest, schema)
|
||||
print('Valid!')
|
||||
"
|
||||
|
||||
# Using jq (syntax check only)
|
||||
jq . plugin.json
|
||||
```
|
||||
|
||||
## Additional Properties
|
||||
|
||||
The schema allows additional properties (`"additionalProperties": true`), so plugins can include custom fields. Common custom fields seen in production plugins:
|
||||
|
||||
- `viewMode` - launcher display mode (`"tile"` for image grids)
|
||||
- `viewModeEnforced` - lock launcher to specific view mode (`true`/`false`)
|
||||
@@ -0,0 +1,120 @@
|
||||
# PopoutService Reference
|
||||
|
||||
The `PopoutService` singleton lets plugins control all DMS popouts and modals. It is automatically injected into widget, daemon, and settings components.
|
||||
|
||||
## Setup
|
||||
|
||||
Declare the property in your component for injection to work:
|
||||
|
||||
```qml
|
||||
property var popoutService: null
|
||||
```
|
||||
|
||||
Without this declaration, injection fails with: `Cannot assign to non-existent property "popoutService"`
|
||||
|
||||
## Popouts (DankPopout-based)
|
||||
|
||||
| Component | Open | Close | Toggle |
|
||||
|-----------|------|-------|--------|
|
||||
| Control Center | `openControlCenter()` | `closeControlCenter()` | `toggleControlCenter()` |
|
||||
| Notification Center | `openNotificationCenter()` | `closeNotificationCenter()` | `toggleNotificationCenter()` |
|
||||
| App Drawer | `openAppDrawer()` | `closeAppDrawer()` | `toggleAppDrawer()` |
|
||||
| Process List | `openProcessList()` | `closeProcessList()` | `toggleProcessList()` |
|
||||
| DankDash | `openDankDash(tab)` | `closeDankDash()` | `toggleDankDash(tab)` |
|
||||
| Battery | `openBattery()` | `closeBattery()` | `toggleBattery()` |
|
||||
| VPN | `openVpn()` | `closeVpn()` | `toggleVpn()` |
|
||||
| System Update | `openSystemUpdate()` | `closeSystemUpdate()` | `toggleSystemUpdate()` |
|
||||
|
||||
## Modals (DankModal-based)
|
||||
|
||||
| Modal | Show | Hide | Notes |
|
||||
|-------|------|------|-------|
|
||||
| Settings | `openSettings()` | `closeSettings()` | Full settings interface |
|
||||
| Clipboard History | `openClipboardHistory()` | `closeClipboardHistory()` | Clipboard integration |
|
||||
| Launcher | `openDankLauncherV2()` | `closeDankLauncherV2()` | Also has `toggleDankLauncherV2()` |
|
||||
| Power Menu | `openPowerMenu()` | `closePowerMenu()` | Also has `togglePowerMenu()` |
|
||||
| Process List Modal | `showProcessListModal()` | `hideProcessListModal()` | Has `toggleProcessListModal()` |
|
||||
| Color Picker | `showColorPicker()` | `hideColorPicker()` | Theme color selection |
|
||||
| Notification | `showNotificationModal()` | `hideNotificationModal()` | Notification details |
|
||||
| WiFi Password | `showWifiPasswordModal()` | `hideWifiPasswordModal()` | Network auth |
|
||||
| Network Info | `showNetworkInfoModal()` | `hideNetworkInfoModal()` | Network details |
|
||||
|
||||
## Slideouts
|
||||
|
||||
| Component | Open | Close | Toggle |
|
||||
|-----------|------|-------|--------|
|
||||
| Notepad | `openNotepad()` | `closeNotepad()` | `toggleNotepad()` |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Simple toggle
|
||||
|
||||
```qml
|
||||
MouseArea {
|
||||
onClicked: popoutService?.toggleControlCenter()
|
||||
}
|
||||
```
|
||||
|
||||
### Conditional popout
|
||||
|
||||
```qml
|
||||
Connections {
|
||||
target: BatteryService
|
||||
function onPercentageChanged() {
|
||||
if (BatteryService.percentage < 10 && !BatteryService.isCharging) {
|
||||
popoutService?.openBattery()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Context menu with multiple actions
|
||||
|
||||
```qml
|
||||
MouseArea {
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||
onClicked: (mouse) => {
|
||||
if (mouse.button === Qt.RightButton) contextMenu.popup()
|
||||
else popoutService?.toggleControlCenter()
|
||||
}
|
||||
}
|
||||
|
||||
Menu {
|
||||
id: contextMenu
|
||||
MenuItem { text: "Settings"; onClicked: popoutService?.openSettings() }
|
||||
MenuItem { text: "Notifications"; onClicked: popoutService?.toggleNotificationCenter() }
|
||||
MenuItem { text: "Power"; onClicked: popoutService?.openPowerMenu() }
|
||||
}
|
||||
```
|
||||
|
||||
### Position-aware toggle (from bar pill)
|
||||
|
||||
Some toggle functions accept position parameters for proper popout placement:
|
||||
|
||||
```qml
|
||||
pillClickAction: (x, y, width, section, screen) => {
|
||||
popoutService?.toggleControlCenter(x, y, width, section, screen)
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always use optional chaining** (`?.`) - the service may not be injected yet
|
||||
2. **Check feature availability** before opening feature-specific popouts:
|
||||
```qml
|
||||
if (BatteryService.batteryAvailable) {
|
||||
popoutService?.openBattery()
|
||||
}
|
||||
```
|
||||
3. **Lazy loading** - first access may activate lazy loaders; this is normal
|
||||
4. **Popouts are shared** - avoid opening conflicting popouts simultaneously
|
||||
5. **User intent** - only trigger popouts from user actions or critical system events
|
||||
6. **Multi-monitor** - positioned popouts are screen-aware when using position parameters
|
||||
|
||||
## Injection Locations
|
||||
|
||||
The service is injected at these points:
|
||||
- `DMSShell.qml` - daemon plugins
|
||||
- `WidgetHost.qml` - widget plugins in left/right bar sections
|
||||
- `CenterSection.qml` - center bar widgets
|
||||
- `PluginsTab.qml` - settings components
|
||||
@@ -0,0 +1,273 @@
|
||||
# Settings Components Reference
|
||||
|
||||
All plugin settings use the `PluginSettings` wrapper. Setting components auto-save on change and auto-load on creation.
|
||||
|
||||
## PluginSettings Wrapper
|
||||
|
||||
```qml
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
import qs.Widgets
|
||||
import qs.Modules.Plugins
|
||||
|
||||
PluginSettings {
|
||||
pluginId: "yourPlugin" // Required: must match plugin.json id
|
||||
|
||||
// Setting components go here
|
||||
}
|
||||
```
|
||||
|
||||
**Important:** The plugin must declare `"permissions": ["settings_write"]` in plugin.json for the settings UI to render. Without it, users see an error.
|
||||
|
||||
**PluginSettings provides to children:**
|
||||
- `saveValue(key, value)` - save a setting value
|
||||
- `loadValue(key, defaultValue)` - load a setting value
|
||||
- `saveState(key, value)` - save plugin state (separate file)
|
||||
- `loadState(key, defaultValue)` - load plugin state
|
||||
- `clearState()` - clear all plugin state
|
||||
- Variant management functions (for variant plugins)
|
||||
|
||||
## StringSetting
|
||||
|
||||
Text input field.
|
||||
|
||||
```qml
|
||||
StringSetting {
|
||||
settingKey: "apiKey" // Required: storage key
|
||||
label: "API Key" // Required: display label
|
||||
description: "Your API key" // Optional: help text
|
||||
placeholder: "sk-..." // Optional: input placeholder
|
||||
defaultValue: "" // Optional: default (default: "")
|
||||
}
|
||||
```
|
||||
|
||||
**Layout:** Vertical stack - label, description, input field.
|
||||
|
||||
## ToggleSetting
|
||||
|
||||
Boolean toggle switch.
|
||||
|
||||
```qml
|
||||
ToggleSetting {
|
||||
settingKey: "notifications" // Required: storage key
|
||||
label: "Enable Notifications" // Required: display label
|
||||
description: "Show alerts" // Optional: help text
|
||||
defaultValue: true // Optional: default (default: false)
|
||||
}
|
||||
```
|
||||
|
||||
**Layout:** Horizontal - label/description on left, toggle on right.
|
||||
|
||||
## SelectionSetting
|
||||
|
||||
Dropdown menu.
|
||||
|
||||
```qml
|
||||
SelectionSetting {
|
||||
settingKey: "theme" // Required: storage key
|
||||
label: "Theme" // Required: display label
|
||||
description: "Color scheme" // Optional: help text
|
||||
options: [ // Required: array of options
|
||||
{ label: "Dark", value: "dark" },
|
||||
{ label: "Light", value: "light" },
|
||||
{ label: "Auto", value: "auto" }
|
||||
]
|
||||
defaultValue: "dark" // Optional: default value
|
||||
}
|
||||
```
|
||||
|
||||
Options can be `{ label, value }` objects or simple strings. Stores the `value` field, displays the `label` field.
|
||||
|
||||
**Layout:** Horizontal - label/description on left, dropdown on right.
|
||||
|
||||
**Reacting to changes:**
|
||||
```qml
|
||||
SelectionSetting {
|
||||
settingKey: "updateInterval"
|
||||
label: "Update Interval"
|
||||
options: [
|
||||
{ label: "1 minute", value: "60" },
|
||||
{ label: "5 minutes", value: "300" }
|
||||
]
|
||||
defaultValue: "300"
|
||||
onValueChanged: (newValue) => {
|
||||
console.log("Interval changed to:", newValue)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## SliderSetting
|
||||
|
||||
Numeric slider with min/max.
|
||||
|
||||
```qml
|
||||
SliderSetting {
|
||||
settingKey: "opacity" // Required: storage key
|
||||
label: "Opacity" // Required: display label
|
||||
description: "Background" // Optional: help text
|
||||
defaultValue: 85 // Optional: default value
|
||||
minimum: 0 // Required: min value
|
||||
maximum: 100 // Required: max value
|
||||
unit: "%" // Optional: unit label shown after value
|
||||
leftIcon: "dark_mode" // Optional: Material icon on left
|
||||
rightIcon: "light_mode" // Optional: Material icon on right
|
||||
}
|
||||
```
|
||||
|
||||
## ColorSetting
|
||||
|
||||
Color picker.
|
||||
|
||||
```qml
|
||||
ColorSetting {
|
||||
settingKey: "accentColor" // Required: storage key
|
||||
label: "Accent Color" // Required: display label
|
||||
description: "Custom accent" // Optional: help text
|
||||
defaultValue: "#ff5722" // Optional: default hex color
|
||||
}
|
||||
```
|
||||
|
||||
Displays a color swatch that opens a color picker dialog.
|
||||
|
||||
## ListSetting
|
||||
|
||||
Manage a list of items with manual add/remove. Use when you need custom UI for adding items.
|
||||
|
||||
```qml
|
||||
ListSetting {
|
||||
id: itemList
|
||||
settingKey: "items" // Required: storage key
|
||||
label: "Saved Items" // Required: display label
|
||||
description: "Your items" // Optional: help text
|
||||
defaultValue: [] // Optional: default array
|
||||
delegate: Component { // Optional: custom item display
|
||||
StyledRect {
|
||||
width: parent.width
|
||||
height: 40
|
||||
radius: Theme.cornerRadius
|
||||
color: Theme.surfaceContainerHigh
|
||||
|
||||
StyledText {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: Theme.spacingM
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: modelData.name
|
||||
color: Theme.surfaceText
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: Theme.spacingM
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 60
|
||||
height: 28
|
||||
color: removeArea.containsMouse ? Theme.errorHover : Theme.error
|
||||
radius: Theme.cornerRadius
|
||||
|
||||
StyledText {
|
||||
anchors.centerIn: parent
|
||||
text: "Remove"
|
||||
color: Theme.errorText
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.Medium
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: removeArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: itemList.removeItem(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Methods:**
|
||||
- `addItem(item)` - add an item to the list
|
||||
- `removeItem(index)` - remove item at index
|
||||
|
||||
## ListSettingWithInput
|
||||
|
||||
Complete list management with built-in form. Best for collecting structured data.
|
||||
|
||||
```qml
|
||||
ListSettingWithInput {
|
||||
settingKey: "locations" // Required: storage key
|
||||
label: "Locations" // Required: display label
|
||||
description: "Track zones" // Optional: help text
|
||||
defaultValue: [] // Optional: default array
|
||||
fields: [ // Required: field definitions
|
||||
{
|
||||
id: "name", // Required: key in saved object
|
||||
label: "Name", // Required: column header
|
||||
placeholder: "Home", // Optional: input placeholder
|
||||
width: 150, // Optional: column width (default: 200)
|
||||
required: true // Optional: must have value to add
|
||||
},
|
||||
{
|
||||
id: "timezone",
|
||||
label: "Timezone",
|
||||
placeholder: "America/New_York",
|
||||
width: 200,
|
||||
required: true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Automatically generates: column headers, input fields, add button with validation, list display, remove buttons.
|
||||
|
||||
## Mixing Custom UI with Settings
|
||||
|
||||
You can interleave regular QML elements with setting components:
|
||||
|
||||
```qml
|
||||
PluginSettings {
|
||||
pluginId: "myPlugin"
|
||||
|
||||
StyledText {
|
||||
width: parent.width
|
||||
text: "General Settings"
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
font.weight: Font.Bold
|
||||
color: Theme.surfaceText
|
||||
}
|
||||
|
||||
StringSetting {
|
||||
settingKey: "name"
|
||||
label: "Display Name"
|
||||
}
|
||||
|
||||
StyledText {
|
||||
width: parent.width
|
||||
text: "Advanced Settings"
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
font.weight: Font.Bold
|
||||
color: Theme.surfaceText
|
||||
topPadding: Theme.spacingL
|
||||
}
|
||||
|
||||
ToggleSetting {
|
||||
settingKey: "debug"
|
||||
label: "Debug Mode"
|
||||
defaultValue: false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Default Values
|
||||
|
||||
Define sensible defaults in every setting component. The default is used when no saved value exists:
|
||||
|
||||
```qml
|
||||
StringSetting { settingKey: "text"; defaultValue: "Hello" }
|
||||
ToggleSetting { settingKey: "enabled"; defaultValue: true }
|
||||
SelectionSetting { settingKey: "mode"; defaultValue: "auto" }
|
||||
SliderSetting { settingKey: "opacity"; defaultValue: 85 }
|
||||
ColorSetting { settingKey: "color"; defaultValue: "#ff5722" }
|
||||
ListSetting { settingKey: "items"; defaultValue: [] }
|
||||
ListSettingWithInput { settingKey: "data"; defaultValue: [] }
|
||||
```
|
||||
@@ -0,0 +1,216 @@
|
||||
# Theme Property Reference
|
||||
|
||||
All theme properties are accessed via the `Theme` singleton from `qs.Common`. Always use these instead of hardcoded values.
|
||||
|
||||
## Font Sizes
|
||||
|
||||
```qml
|
||||
Theme.fontSizeSmall // 12px (scaled by SettingsData.fontScale)
|
||||
Theme.fontSizeMedium // 14px (scaled)
|
||||
Theme.fontSizeLarge // 16px (scaled)
|
||||
Theme.fontSizeXLarge // 20px (scaled)
|
||||
```
|
||||
|
||||
## Icon Sizes
|
||||
|
||||
```qml
|
||||
Theme.iconSizeSmall // 16px
|
||||
Theme.iconSize // 24px (default)
|
||||
Theme.iconSizeLarge // 32px
|
||||
```
|
||||
|
||||
## Spacing
|
||||
|
||||
```qml
|
||||
Theme.spacingXS // Extra small
|
||||
Theme.spacingS // Small
|
||||
Theme.spacingM // Medium
|
||||
Theme.spacingL // Large
|
||||
Theme.spacingXL // Extra large
|
||||
```
|
||||
|
||||
## Border Radius
|
||||
|
||||
```qml
|
||||
Theme.cornerRadius // Standard
|
||||
Theme.cornerRadiusSmall // Smaller
|
||||
Theme.cornerRadiusLarge // Larger
|
||||
```
|
||||
|
||||
## Surface Colors
|
||||
|
||||
```qml
|
||||
Theme.surface
|
||||
Theme.surfaceContainerLow
|
||||
Theme.surfaceContainer
|
||||
Theme.surfaceContainerHigh
|
||||
Theme.surfaceContainerHighest
|
||||
```
|
||||
|
||||
## Text Colors
|
||||
|
||||
```qml
|
||||
Theme.onSurface // Primary text on surface
|
||||
Theme.onSurfaceVariant // Secondary text on surface
|
||||
Theme.surfaceText // Alias for primary surface text
|
||||
Theme.surfaceVariantText // Alias for secondary surface text
|
||||
Theme.outline // Border/divider color
|
||||
```
|
||||
|
||||
## Semantic Colors
|
||||
|
||||
```qml
|
||||
Theme.primary
|
||||
Theme.onPrimary
|
||||
Theme.secondary
|
||||
Theme.onSecondary
|
||||
Theme.error
|
||||
Theme.errorHover
|
||||
Theme.errorText
|
||||
Theme.warning
|
||||
Theme.success
|
||||
```
|
||||
|
||||
## Special Functions
|
||||
|
||||
```qml
|
||||
Theme.popupBackground() // Popup background with proper opacity
|
||||
```
|
||||
|
||||
## Common Widget Patterns
|
||||
|
||||
### Icon with Text
|
||||
|
||||
```qml
|
||||
import qs.Widgets
|
||||
|
||||
Row {
|
||||
spacing: Theme.spacingS
|
||||
|
||||
DankIcon {
|
||||
name: "icon_name"
|
||||
color: Theme.onSurface
|
||||
font.pixelSize: Theme.iconSize
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: "Label"
|
||||
color: Theme.onSurface
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Container with Border
|
||||
|
||||
```qml
|
||||
Rectangle {
|
||||
color: Theme.surfaceContainerHigh
|
||||
radius: Theme.cornerRadius
|
||||
border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08)
|
||||
border.width: 1
|
||||
}
|
||||
```
|
||||
|
||||
### Hover Effect
|
||||
|
||||
```qml
|
||||
Rectangle {
|
||||
id: container
|
||||
color: Theme.surfaceContainerHigh
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
onEntered: container.color = Qt.lighter(Theme.surfaceContainerHigh, 1.1)
|
||||
onExited: container.color = Theme.surfaceContainerHigh
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Clickable Pill
|
||||
|
||||
```qml
|
||||
StyledRect {
|
||||
width: content.implicitWidth + Theme.spacingM * 2
|
||||
height: parent.widgetThickness
|
||||
radius: Theme.cornerRadius
|
||||
color: mouseArea.containsMouse
|
||||
? Qt.lighter(Theme.surfaceContainerHigh, 1.1)
|
||||
: Theme.surfaceContainerHigh
|
||||
|
||||
MouseArea {
|
||||
id: mouseArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
}
|
||||
|
||||
Row {
|
||||
id: content
|
||||
anchors.centerIn: parent
|
||||
spacing: Theme.spacingS
|
||||
|
||||
DankIcon {
|
||||
name: "star"
|
||||
color: Theme.surfaceText
|
||||
font.pixelSize: Theme.iconSize
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: "Label"
|
||||
color: Theme.surfaceText
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
**Wrong property names** (these do NOT exist):
|
||||
```qml
|
||||
Theme.fontSizeS // Use Theme.fontSizeSmall
|
||||
Theme.iconSizeS // Use Theme.iconSizeSmall
|
||||
Theme.spacingSmall // Use Theme.spacingS
|
||||
Theme.borderRadius // Use Theme.cornerRadius
|
||||
```
|
||||
|
||||
**Hardcoded values** (do NOT do this):
|
||||
```qml
|
||||
color: "#1e1e1e" // Use Theme.surfaceContainerHigh
|
||||
color: "white" // Use Theme.surfaceText
|
||||
font.pixelSize: 14 // Use Theme.fontSizeMedium
|
||||
```
|
||||
|
||||
## Available Widgets from qs.Widgets
|
||||
|
||||
| Widget | Description |
|
||||
|--------|-------------|
|
||||
| `StyledText` | Themed text with proper color defaults |
|
||||
| `StyledRect` | Themed rectangle |
|
||||
| `DankIcon` | 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 |
|
||||
| `DankCollapsibleSection` | Collapsible content section |
|
||||
| `DankTooltip` | Hover tooltip |
|
||||
| `DankNumberStepper` | Number +/- control |
|
||||
| `DankFilterChips` | Filter chip row |
|
||||
| `CachingImage` | Image with disk cache |
|
||||
| `NumericText` | Fixed-width numeric display |
|
||||
|
||||
## Checking All Properties
|
||||
|
||||
```bash
|
||||
grep "property" Common/Theme.qml
|
||||
```
|
||||
@@ -0,0 +1,371 @@
|
||||
# Widget Plugin Guide
|
||||
|
||||
Widgets are bar plugins that display pills in DankBar, optionally open popouts, and can integrate with the Control Center.
|
||||
|
||||
## Base Component
|
||||
|
||||
Widgets use `PluginComponent` from `qs.Modules.Plugins`.
|
||||
|
||||
```qml
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
import qs.Widgets
|
||||
import qs.Modules.Plugins
|
||||
|
||||
PluginComponent {
|
||||
property var popoutService: null
|
||||
|
||||
horizontalBarPill: Component { /* ... */ }
|
||||
verticalBarPill: Component { /* ... */ }
|
||||
popoutContent: Component { /* ... */ }
|
||||
popoutWidth: 400
|
||||
popoutHeight: 300
|
||||
}
|
||||
```
|
||||
|
||||
## Injected Properties
|
||||
|
||||
These are automatically set by the plugin host:
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `axis` | object | Bar axis info (horizontal/vertical) |
|
||||
| `section` | string | Bar section: `"left"`, `"center"`, or `"right"` |
|
||||
| `parentScreen` | object | Screen reference for multi-monitor |
|
||||
| `widgetThickness` | real | Widget size perpendicular to bar edge |
|
||||
| `barThickness` | real | Bar thickness parallel to edge |
|
||||
| `pluginId` | string | This plugin's ID |
|
||||
| `pluginService` | object | PluginService reference |
|
||||
| `pluginData` | object | Reactive plugin settings data |
|
||||
|
||||
## Bar Pills
|
||||
|
||||
Define `horizontalBarPill` (for top/bottom bars) and `verticalBarPill` (for left/right bars).
|
||||
|
||||
### Horizontal Bar Pill
|
||||
|
||||
```qml
|
||||
horizontalBarPill: Component {
|
||||
StyledRect {
|
||||
width: content.implicitWidth + Theme.spacingM * 2
|
||||
height: parent.widgetThickness
|
||||
radius: Theme.cornerRadius
|
||||
color: Theme.surfaceContainerHigh
|
||||
|
||||
Row {
|
||||
id: content
|
||||
anchors.centerIn: parent
|
||||
spacing: Theme.spacingS
|
||||
|
||||
DankIcon {
|
||||
name: "star"
|
||||
color: Theme.surfaceText
|
||||
font.pixelSize: Theme.iconSize
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: "Label"
|
||||
color: Theme.surfaceText
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Vertical Bar Pill
|
||||
|
||||
```qml
|
||||
verticalBarPill: Component {
|
||||
StyledRect {
|
||||
width: parent.widgetThickness
|
||||
height: content.implicitHeight + Theme.spacingM * 2
|
||||
radius: Theme.cornerRadius
|
||||
color: Theme.surfaceContainerHigh
|
||||
|
||||
Column {
|
||||
id: content
|
||||
anchors.centerIn: parent
|
||||
spacing: Theme.spacingS
|
||||
|
||||
DankIcon {
|
||||
name: "star"
|
||||
color: Theme.surfaceText
|
||||
font.pixelSize: Theme.iconSizeSmall
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Important:** Always define both pills. If a pill is missing, the widget disappears when the bar is on that orientation's edge.
|
||||
|
||||
## Popout Content
|
||||
|
||||
Open a popout window when the bar pill is clicked:
|
||||
|
||||
```qml
|
||||
PluginComponent {
|
||||
popoutWidth: 400
|
||||
popoutHeight: 300
|
||||
|
||||
popoutContent: Component {
|
||||
PopoutComponent {
|
||||
headerText: "My Plugin"
|
||||
detailsText: "Optional subtitle"
|
||||
showCloseButton: true
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: Theme.spacingM
|
||||
|
||||
StyledText {
|
||||
text: "Content here"
|
||||
color: Theme.surfaceText
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**PopoutComponent properties:**
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|----------|------|---------|-------------|
|
||||
| `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). |
|
||||
|
||||
**Content sizing:** Content children render below the header/details. Calculate available height: `popoutHeight - headerHeight - detailsHeight - spacing`
|
||||
|
||||
## Custom Click Actions
|
||||
|
||||
Override the default popout behavior:
|
||||
|
||||
```qml
|
||||
PluginComponent {
|
||||
// Simple no-args handler
|
||||
pillClickAction: () => {
|
||||
popoutService?.toggleControlCenter()
|
||||
}
|
||||
|
||||
// With position params (x, y, width, section, screen)
|
||||
pillClickAction: (x, y, width, section, screen) => {
|
||||
popoutService?.toggleControlCenter(x, y, width, section, screen)
|
||||
}
|
||||
|
||||
pillRightClickAction: () => {
|
||||
popoutService?.openSettings()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Control Center Integration
|
||||
|
||||
Add CC properties to show your widget in the Control Center grid:
|
||||
|
||||
```qml
|
||||
PluginComponent {
|
||||
ccWidgetIcon: "toggle_on"
|
||||
ccWidgetPrimaryText: "Feature Name"
|
||||
ccWidgetSecondaryText: isActive ? "Active" : "Off"
|
||||
ccWidgetIsActive: isActive
|
||||
|
||||
onCcWidgetToggled: {
|
||||
isActive = !isActive
|
||||
pluginService?.savePluginData(pluginId, "active", isActive)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**CC properties:**
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `ccWidgetIcon` | string | Material icon name |
|
||||
| `ccWidgetPrimaryText` | string | Main label |
|
||||
| `ccWidgetSecondaryText` | string | Subtitle / status text |
|
||||
| `ccWidgetIsActive` | bool | Active state (changes styling) |
|
||||
|
||||
**CC signals:**
|
||||
|
||||
| Signal | When fired |
|
||||
|--------|-----------|
|
||||
| `ccWidgetToggled()` | Icon area clicked |
|
||||
| `ccWidgetExpanded()` | Expand area clicked (CompoundPill only) |
|
||||
|
||||
**CC sizing rules:**
|
||||
- 25% width - SmallToggleButton (icon only)
|
||||
- 50% width - ToggleButton (no detail) or CompoundPill (with ccDetailContent)
|
||||
- Users can resize in CC edit mode
|
||||
|
||||
### Detail Content (CompoundPill)
|
||||
|
||||
Add an expandable panel below the CC widget:
|
||||
|
||||
```qml
|
||||
ccDetailContent: Component {
|
||||
Rectangle {
|
||||
implicitHeight: 200
|
||||
color: Theme.surfaceContainerHigh
|
||||
radius: Theme.cornerRadius
|
||||
|
||||
Column {
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.spacingM
|
||||
spacing: Theme.spacingS
|
||||
|
||||
// Detail UI here
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Visibility Control
|
||||
|
||||
Conditionally show/hide the bar pill:
|
||||
|
||||
```qml
|
||||
PluginComponent {
|
||||
visibilityCommand: "pgrep -x myapp"
|
||||
visibilityInterval: 5 // seconds between checks
|
||||
}
|
||||
```
|
||||
|
||||
**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.
|
||||
|
||||
## Popout Namespace
|
||||
|
||||
For plugins with multiple popout instances, use `layerNamespacePlugin` to isolate popout state:
|
||||
|
||||
```qml
|
||||
PluginComponent {
|
||||
layerNamespacePlugin: true
|
||||
}
|
||||
```
|
||||
|
||||
## Reading Plugin Data
|
||||
|
||||
Access saved settings reactively via the injected `pluginData`:
|
||||
|
||||
```qml
|
||||
PluginComponent {
|
||||
property string displayText: pluginData?.text || "Default"
|
||||
|
||||
Connections {
|
||||
target: pluginService
|
||||
function onPluginDataChanged(changedId) {
|
||||
if (changedId === pluginId)
|
||||
displayText = pluginService.loadPluginData(pluginId, "text", "Default")
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Complete Example
|
||||
|
||||
Based on the ExampleEmojiPlugin pattern:
|
||||
|
||||
```qml
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.Common
|
||||
import qs.Widgets
|
||||
import qs.Services
|
||||
import qs.Modules.Plugins
|
||||
|
||||
PluginComponent {
|
||||
id: root
|
||||
property var popoutService: null
|
||||
|
||||
property var emojis: ["star", "heart", "smile"]
|
||||
property int currentIndex: 0
|
||||
|
||||
Timer {
|
||||
interval: 2000
|
||||
running: true
|
||||
repeat: true
|
||||
onTriggered: currentIndex = (currentIndex + 1) % emojis.length
|
||||
}
|
||||
|
||||
popoutWidth: 350
|
||||
popoutHeight: 400
|
||||
|
||||
horizontalBarPill: Component {
|
||||
StyledRect {
|
||||
width: label.implicitWidth + Theme.spacingM * 2
|
||||
height: parent.widgetThickness
|
||||
radius: Theme.cornerRadius
|
||||
color: Theme.surfaceContainerHigh
|
||||
|
||||
StyledText {
|
||||
id: label
|
||||
anchors.centerIn: parent
|
||||
text: root.emojis[root.currentIndex]
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
verticalBarPill: Component {
|
||||
StyledRect {
|
||||
width: parent.widgetThickness
|
||||
height: label.implicitHeight + Theme.spacingM * 2
|
||||
radius: Theme.cornerRadius
|
||||
color: Theme.surfaceContainerHigh
|
||||
|
||||
StyledText {
|
||||
id: label
|
||||
anchors.centerIn: parent
|
||||
text: root.emojis[root.currentIndex]
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
popoutContent: Component {
|
||||
PopoutComponent {
|
||||
headerText: "Emoji Picker"
|
||||
showCloseButton: true
|
||||
|
||||
DankGridView {
|
||||
width: parent.width
|
||||
height: 300
|
||||
cellWidth: 50
|
||||
cellHeight: 50
|
||||
model: root.emojis
|
||||
|
||||
delegate: Rectangle {
|
||||
width: 48
|
||||
height: 48
|
||||
radius: Theme.cornerRadius
|
||||
color: mouseArea.containsMouse ? Theme.surfaceContainerHighest : "transparent"
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: modelData
|
||||
font.pixelSize: 24
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: mouseArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
onClicked: {
|
||||
Quickshell.execDetached(["dms", "cl", "copy", modelData])
|
||||
ToastService?.showInfo("Copied " + modelData)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -1,65 +0,0 @@
|
||||
---
|
||||
name: Bug Report
|
||||
about: Crashes or unexpected behaviors
|
||||
title: ""
|
||||
labels: "bug"
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
<!-- If your issue is related to ICONS
|
||||
- Purple and black checkerboards are QT's way of signalling an icon doesn't exist
|
||||
- FIX: Configure a QT6 or Icon Pack in DMS Settings that has the icon you want
|
||||
- Follow the [THEMING](https://danklinux.com/docs/dankmaterialshell/icon-theming) section to ensure your QT environment variable is configured correctly for themes.
|
||||
- Once done, configure an icon theme - either however you normally do with gtk3 or qt6ct, or through the built-in settings modal. -->
|
||||
|
||||
## Compositor
|
||||
|
||||
- [ ] niri
|
||||
- [ ] Hyprland
|
||||
- [ ] dwl (MangoWC)
|
||||
- [ ] sway
|
||||
- [ ] Other (specify)
|
||||
|
||||
## Distribution
|
||||
|
||||
<!-- Arch, Fedora, Debian, etc. -->
|
||||
|
||||
## dms version
|
||||
|
||||
<!-- Output of dms version command -->
|
||||
|
||||
## Description
|
||||
|
||||
<!-- Brief description of the issue -->
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
<!-- Describe what you expected to happen -->
|
||||
|
||||
## Steps to Reproduce
|
||||
|
||||
<!-- Please provide detailed steps to reproduce the issue -->
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
## Error Messages/Logs
|
||||
|
||||
<!-- Please include any error messages, stack traces, or relevant logs -->
|
||||
<!-- you can get a log file with the following steps:
|
||||
dms kill
|
||||
mkdir ~/dms_logs
|
||||
nohup dms run > ~/dms_logs/dms-$(date +%s).txt 2>&1 &
|
||||
|
||||
Then trigger your issue, and share the contents of ~/dms_logs/dms-<timestamp>.txt
|
||||
|
||||
-->
|
||||
|
||||
```
|
||||
Paste error messages or logs here
|
||||
```
|
||||
|
||||
## Screenshots/Recordings
|
||||
|
||||
<!-- If applicable, add screenshots or screen recordings -->
|
||||
@@ -0,0 +1,129 @@
|
||||
name: Bug Report
|
||||
description: Crashes or unexpected behaviors
|
||||
labels:
|
||||
- bug
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## DankMaterialShell Bug Report
|
||||
Limit your report to one issue per submission unless similarly related
|
||||
- type: dropdown
|
||||
id: compositor
|
||||
attributes:
|
||||
label: Compositor
|
||||
options:
|
||||
- Niri
|
||||
- Hyprland
|
||||
- MangoWC (dwl)
|
||||
- Sway
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: distribution
|
||||
attributes:
|
||||
label: Distribution
|
||||
options:
|
||||
- Arch Linux
|
||||
- CachyOS
|
||||
- Fedora
|
||||
- NixOS
|
||||
- Debian
|
||||
- Ubuntu
|
||||
- Gentoo
|
||||
- OpenSUSE
|
||||
- Other (specify below)
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: distribution_other
|
||||
attributes:
|
||||
label: If Other, please specify
|
||||
placeholder: e.g., PikaOS, Void Linux, etc.
|
||||
validations:
|
||||
required: false
|
||||
- type: dropdown
|
||||
id: installation_method
|
||||
attributes:
|
||||
label: Select your Installation Method
|
||||
options:
|
||||
- DankInstaller
|
||||
- Distro Packaging
|
||||
- Source
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: original_installation_method
|
||||
attributes:
|
||||
label: Was this your original Installation method?
|
||||
options:
|
||||
- "Yes"
|
||||
- No (specify below)
|
||||
default: 0
|
||||
validations:
|
||||
required: false
|
||||
- type: input
|
||||
id: original_installation_method_specify
|
||||
attributes:
|
||||
label: If no, specify
|
||||
placeholder: e.g., Distro Packaging, then Source
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: dms_doctor
|
||||
attributes:
|
||||
label: dms doctor -vC
|
||||
description: Output of `dms doctor -vC` command — paste between the details tags below to keep it collapsed in the issue
|
||||
placeholder: Paste the output of `dms doctor -vC` here
|
||||
value: |
|
||||
<details>
|
||||
<summary>Click to expand</summary>
|
||||
|
||||
|
||||
</details>
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: Brief description of the issue
|
||||
placeholder: What happened?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected_behavior
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: What did you expect to happen?
|
||||
placeholder: Describe the expected behavior
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: steps_to_reproduce
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
description: Please provide detailed steps to reproduce the issue
|
||||
placeholder: |
|
||||
1. ...
|
||||
2. ...
|
||||
3. ...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Error Messages/Logs
|
||||
description: Please include any error messages, stack traces, or relevant logs
|
||||
placeholder: |
|
||||
Paste error messages or logs here
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: Screenshots/Recordings
|
||||
description: If applicable, add screenshots or screen recordings
|
||||
placeholder: Attach images or videos here
|
||||
validations:
|
||||
required: false
|
||||
@@ -1,33 +0,0 @@
|
||||
---
|
||||
name: Request a Feature
|
||||
about: New widgets, new widget behavior, etc.
|
||||
title: ""
|
||||
labels: "enhancement"
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
## Feature Description
|
||||
|
||||
<!-- Brief description of the feature requested -->
|
||||
|
||||
## Use Case
|
||||
|
||||
<!-- Explain the purpose of this feature/why it'd be useful to you -->
|
||||
|
||||
## Compositor
|
||||
|
||||
Is this feature specific to one compositor?
|
||||
|
||||
- [ ] All compositors
|
||||
- [ ] niri
|
||||
- [ ] Hyprland
|
||||
- [ ] dwl (MangoWC)
|
||||
- [ ] sway
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
<!-- If you have any ideas for how to implement this, please share! -->
|
||||
|
||||
## Alternatives/Existing Solutions
|
||||
|
||||
<!-- Include any similar/pre-existing products that solve this problem -->
|
||||
@@ -0,0 +1,62 @@
|
||||
name: Feature Request
|
||||
description: Suggest a new feature or improvement for DMS. Keep features focused on a single topic with clear benefits, examples, etc. Avoid vague or broad requests, they will be closed.
|
||||
labels:
|
||||
- enhancement
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## DankMaterialShell Feature Request
|
||||
- type: textarea
|
||||
id: feature_description
|
||||
attributes:
|
||||
label: Feature Description
|
||||
description: Brief description of the feature requested
|
||||
placeholder: What feature would you like to see?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: use_case
|
||||
attributes:
|
||||
label: Use Case
|
||||
description: Explain the purpose of this feature/why it'd be useful to you
|
||||
placeholder: Why is this feature important?
|
||||
validations:
|
||||
required: false
|
||||
- type: dropdown
|
||||
id: compositor
|
||||
attributes:
|
||||
label: Compositor(s)
|
||||
description: Is this feature specific to one or more compositors?
|
||||
options:
|
||||
- All compositors
|
||||
- Niri
|
||||
- Hyprland
|
||||
- MangoWC (dwl)
|
||||
- Sway
|
||||
- Other (specify below)
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: compositor_other
|
||||
attributes:
|
||||
label: If Other, please specify
|
||||
placeholder: e.g., Wayfire, Mutter, etc.
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: proposed_solution
|
||||
attributes:
|
||||
label: Proposed Solution
|
||||
description: If you have any ideas for how to implement this, please share!
|
||||
placeholder: Suggest a solution or approach
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives/Existing Solutions
|
||||
description: Include any similar/pre-existing products that solve this problem
|
||||
placeholder: List alternatives or existing solutions
|
||||
validations:
|
||||
required: false
|
||||
@@ -1,40 +0,0 @@
|
||||
---
|
||||
name: Request Assistance or Support
|
||||
about: Help with installation, usage, or general questions.
|
||||
title: ""
|
||||
labels: "support"
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
## Compositor
|
||||
|
||||
- [ ] niri
|
||||
- [ ] Hyprland
|
||||
- [ ] dwl (MangoWC)
|
||||
- [ ] sway
|
||||
- [ ] other
|
||||
|
||||
## Distribution
|
||||
|
||||
<!-- Arch, Fedora, Debian, etc. -->
|
||||
|
||||
## dms version
|
||||
|
||||
<!-- Output of dms version command -->
|
||||
|
||||
## Description
|
||||
|
||||
<!-- Brief description of the support needed -->
|
||||
|
||||
## Solutions Tried
|
||||
|
||||
<!-- Describe what you've tried so far -->
|
||||
<!-- Outlining what you've tried so far helps us make improvements to the user experience and documentation to avoid recurrent issues -->
|
||||
|
||||
## Configuration Details
|
||||
|
||||
<!-- Include any configuration if relevant -->
|
||||
|
||||
## Screenshots/Recordings
|
||||
|
||||
<!-- If applicable, add screenshots or screen recordings -->
|
||||
@@ -0,0 +1,124 @@
|
||||
name: Support Request
|
||||
description: Help with installation, usage, or general questions about DankMaterialShell
|
||||
labels:
|
||||
- support
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## DankMaterialShell Support Request
|
||||
- type: dropdown
|
||||
id: compositor
|
||||
attributes:
|
||||
label: Compositor
|
||||
options:
|
||||
- Niri
|
||||
- Hyprland
|
||||
- MangoWC (dwl)
|
||||
- Sway
|
||||
- Other (specify below)
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: compositor_other
|
||||
attributes:
|
||||
label: If Other, please specify
|
||||
placeholder: e.g., Wayfire, Mutter, etc.
|
||||
validations:
|
||||
required: false
|
||||
- type: dropdown
|
||||
id: distribution
|
||||
attributes:
|
||||
label: Distribution
|
||||
options:
|
||||
- Arch Linux
|
||||
- CachyOS
|
||||
- Fedora
|
||||
- NixOS
|
||||
- Debian
|
||||
- Ubuntu
|
||||
- Gentoo
|
||||
- OpenSUSE
|
||||
- Other (specify below)
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: distribution_other
|
||||
attributes:
|
||||
label: If Other, please specify
|
||||
placeholder: e.g., PikaOS, Void Linux, etc.
|
||||
validations:
|
||||
required: false
|
||||
- type: dropdown
|
||||
id: installation_method
|
||||
attributes:
|
||||
label: Select your Installation Method
|
||||
options:
|
||||
- DankInstaller
|
||||
- Distro Packaging
|
||||
- Source
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: original_installation_method_different
|
||||
attributes:
|
||||
label: Was your original Installation method different?
|
||||
options:
|
||||
- "Yes"
|
||||
- No (specify below)
|
||||
default: 0
|
||||
validations:
|
||||
required: false
|
||||
- type: input
|
||||
id: original_installation_method_specify
|
||||
attributes:
|
||||
label: If no, specify
|
||||
placeholder: e.g., Distro Packaging, then Source
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: dms_doctor
|
||||
attributes:
|
||||
label: dms doctor -vC
|
||||
description: Output of `dms doctor -vC` command — paste between the lines below to keep it collapsed in the issue
|
||||
placeholder: Paste the output of `dms doctor -vC` here
|
||||
value: |
|
||||
<details>
|
||||
<summary>Click to expand</summary>
|
||||
|
||||
|
||||
</details>
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: Brief description of the support needed
|
||||
placeholder: What do you need help with?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: solutions_tried
|
||||
attributes:
|
||||
label: Solutions Tried
|
||||
description: Describe what you've tried so far (commands, documentation, etc.)
|
||||
placeholder: List steps or resources you've already tried
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: configuration
|
||||
attributes:
|
||||
label: Configuration Details
|
||||
description: Include any relevant configuration if relevant
|
||||
placeholder: Add configuration or environment info
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: Screenshots/Recordings
|
||||
description: If applicable, add screenshots or screen recordings
|
||||
placeholder: Attach images or videos here
|
||||
validations:
|
||||
required: false
|
||||
@@ -0,0 +1,31 @@
|
||||
## Description
|
||||
|
||||
<!-- What does this PR do and why? -->
|
||||
|
||||
## Type of change
|
||||
|
||||
<!-- Check all that apply. -->
|
||||
|
||||
- [ ] Bug fix (non-breaking change that fixes an issue)
|
||||
- [ ] New feature (non-breaking change that adds functionality)
|
||||
- [ ] Breaking change (fix or feature that changes existing behavior)
|
||||
- [ ] Refactor / internal cleanup
|
||||
- [ ] Documentation
|
||||
- [ ] Other
|
||||
|
||||
## Related issues
|
||||
|
||||
<!-- e.g. "Fixes #123", "Closes #123". Leave blank if none. -->
|
||||
|
||||
## Screenshots / video
|
||||
|
||||
<!-- Include screenshots or a video for any user-facing or visual change. -->
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] My code follows the conventions in CONTRIBUTING.md
|
||||
- [ ] I have tested my changes locally
|
||||
- [ ] 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
|
||||
@@ -0,0 +1,23 @@
|
||||
# Categories for GitHub's auto-generated release notes
|
||||
changelog:
|
||||
exclude:
|
||||
authors:
|
||||
- github-actions[bot]
|
||||
- dependabot[bot]
|
||||
labels:
|
||||
- internal
|
||||
categories:
|
||||
- title: Breaking Changes
|
||||
labels: [breaking]
|
||||
- title: Features
|
||||
labels: [feature]
|
||||
- title: Fixes
|
||||
labels: [fix]
|
||||
- title: Packaging
|
||||
labels: [packaging]
|
||||
- title: Internationalization
|
||||
labels: [i18n]
|
||||
- title: Documentation
|
||||
labels: [docs]
|
||||
- title: Other Changes
|
||||
labels: ["*"]
|
||||
Executable
+170
@@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
echo "usage: $0 download|publish" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
require_env() {
|
||||
local name
|
||||
for name in "$@"; do
|
||||
if [[ -z "${!name:-}" ]]; then
|
||||
echo "error: $name is required" >&2
|
||||
exit 2
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
require_env R2_BUCKET R2_ENDPOINT R2_PREFIX REPOSITORY_DIR
|
||||
|
||||
aws_r2() {
|
||||
aws --endpoint-url "$R2_ENDPOINT" "$@"
|
||||
}
|
||||
|
||||
download() {
|
||||
mkdir -p "$REPOSITORY_DIR/current" "$REPOSITORY_DIR/previous-current"
|
||||
|
||||
aws_r2 s3 sync \
|
||||
"s3://${R2_BUCKET}/${R2_PREFIX}/current/" \
|
||||
"$REPOSITORY_DIR/current/" \
|
||||
--only-show-errors
|
||||
|
||||
cp -a "$REPOSITORY_DIR/current/." "$REPOSITORY_DIR/previous-current/"
|
||||
}
|
||||
|
||||
build_manifest() {
|
||||
local packages='[]'
|
||||
local file filename pkgver name version revision sha size
|
||||
|
||||
for file in "$REPOSITORY_DIR"/current/*.xbps; do
|
||||
filename="$(basename "$file")"
|
||||
pkgver="$(xbps-uhelper binpkgver "$filename")"
|
||||
name="$(xbps-uhelper getpkgname "$pkgver")"
|
||||
version="$(xbps-uhelper getpkgversion "$pkgver")"
|
||||
revision="$(xbps-uhelper getpkgrevision "$pkgver")"
|
||||
version="${version%_"${revision}"}"
|
||||
sha="$(sha256sum "$file" | cut -d' ' -f1)"
|
||||
size="$(stat -c '%s' "$file")"
|
||||
packages="$(jq \
|
||||
--arg name "$name" \
|
||||
--arg version "$version" \
|
||||
--arg revision "$revision" \
|
||||
--arg filename "$filename" \
|
||||
--arg sha256 "$sha" \
|
||||
--argjson size "$size" \
|
||||
'. + [{name: $name, version: $version, revision: $revision, filename: $filename, sha256: $sha256, size: $size}]' \
|
||||
<<<"$packages")"
|
||||
done
|
||||
|
||||
jq -n \
|
||||
--arg repository "$R2_PREFIX" \
|
||||
--arg source_commit "$SOURCE_COMMIT" \
|
||||
--arg published_at "$PUBLISHED_AT" \
|
||||
--argjson packages "$packages" \
|
||||
'{schema: 1, repository: $repository, source_commit: $source_commit, published_at: $published_at, packages: $packages}' \
|
||||
> "$REPOSITORY_DIR/current/manifest.json"
|
||||
}
|
||||
|
||||
verify_immutable_packages() {
|
||||
local file previous
|
||||
|
||||
for file in "$REPOSITORY_DIR"/current/*.xbps "$REPOSITORY_DIR"/current/*.sig2; do
|
||||
previous="$REPOSITORY_DIR/previous-current/$(basename "$file")"
|
||||
if [[ -f "$previous" ]] && ! cmp -s "$previous" "$file"; then
|
||||
echo "error: refusing to replace immutable object $(basename "$file")" >&2
|
||||
echo "bump the XBPS revision or version before publishing a changed build" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
archive_retired() {
|
||||
local old filename
|
||||
local archive_prefix="archive/${R2_PREFIX}/${PUBLISHED_AT//:/-}"
|
||||
|
||||
shopt -s nullglob
|
||||
for old in "$REPOSITORY_DIR"/previous-current/*.xbps "$REPOSITORY_DIR"/previous-current/*.sig2; do
|
||||
filename="$(basename "$old")"
|
||||
if [[ ! -e "$REPOSITORY_DIR/current/$filename" ]]; then
|
||||
aws_r2 s3 cp \
|
||||
"$old" \
|
||||
"s3://${R2_BUCKET}/${archive_prefix}/${filename}" \
|
||||
--cache-control 'private,no-store' \
|
||||
--only-show-errors
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
upload_current() {
|
||||
local file filename old
|
||||
|
||||
# Versioned package objects must exist before repodata can reference them.
|
||||
for file in "$REPOSITORY_DIR"/current/*.xbps "$REPOSITORY_DIR"/current/*.sig2; do
|
||||
filename="$(basename "$file")"
|
||||
aws_r2 s3 cp \
|
||||
"$file" \
|
||||
"s3://${R2_BUCKET}/${R2_PREFIX}/current/${filename}" \
|
||||
--cache-control 'public,max-age=31536000,immutable' \
|
||||
--only-show-errors
|
||||
done
|
||||
|
||||
aws_r2 s3 cp \
|
||||
"$REPOSITORY_DIR/current/x86_64-repodata" \
|
||||
"s3://${R2_BUCKET}/${R2_PREFIX}/current/x86_64-repodata" \
|
||||
--cache-control 'no-cache' \
|
||||
--only-show-errors
|
||||
|
||||
# The manifest is the publication marker and is always uploaded last.
|
||||
aws_r2 s3 cp \
|
||||
"$REPOSITORY_DIR/current/manifest.json" \
|
||||
"s3://${R2_BUCKET}/${R2_PREFIX}/current/manifest.json" \
|
||||
--cache-control 'no-cache' \
|
||||
--only-show-errors
|
||||
|
||||
# Once the new index and marker are live, remove objects no longer referenced.
|
||||
shopt -s nullglob
|
||||
for old in "$REPOSITORY_DIR"/previous-current/*; do
|
||||
filename="$(basename "$old")"
|
||||
if [[ ! -e "$REPOSITORY_DIR/current/$filename" ]]; then
|
||||
aws_r2 s3 rm \
|
||||
"s3://${R2_BUCKET}/${R2_PREFIX}/current/${filename}" \
|
||||
--only-show-errors
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
publish() {
|
||||
require_env SOURCE_COMMIT
|
||||
PUBLISHED_AT="${PUBLISHED_AT:-$(date -u +'%Y-%m-%dT%H:%M:%SZ')}"
|
||||
export PUBLISHED_AT
|
||||
|
||||
shopt -s nullglob
|
||||
local packages=("$REPOSITORY_DIR"/current/*.xbps)
|
||||
if (( ${#packages[@]} == 0 )); then
|
||||
echo "error: refusing to publish an empty XBPS repository" >&2
|
||||
exit 1
|
||||
fi
|
||||
[[ -s "$REPOSITORY_DIR/current/x86_64-repodata" ]] || {
|
||||
echo "error: x86_64-repodata is missing or empty" >&2
|
||||
exit 1
|
||||
}
|
||||
for file in "${packages[@]}"; do
|
||||
[[ -s "${file}.sig2" ]] || {
|
||||
echo "error: signature is missing for $(basename "$file")" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
|
||||
verify_immutable_packages
|
||||
build_manifest
|
||||
archive_retired
|
||||
upload_current
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
download) download ;;
|
||||
publish) publish ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
@@ -1,383 +0,0 @@
|
||||
name: Update OBS Packages
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
package:
|
||||
description: "Package to update (dms, dms-git, or all)"
|
||||
required: false
|
||||
default: "all"
|
||||
force_upload:
|
||||
description: "Force upload without version check"
|
||||
required: false
|
||||
default: "false"
|
||||
type: choice
|
||||
options:
|
||||
- "false"
|
||||
- "true"
|
||||
rebuild_release:
|
||||
description: "Release number for rebuilds (e.g., 2, 3, 4 to increment spec Release)"
|
||||
required: false
|
||||
default: ""
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
schedule:
|
||||
- cron: "0 */3 * * *" # Every 3 hours for dms-git builds
|
||||
|
||||
jobs:
|
||||
check-updates:
|
||||
name: Check for updates
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
outputs:
|
||||
has_updates: ${{ steps.check.outputs.has_updates }}
|
||||
packages: ${{ steps.check.outputs.packages }}
|
||||
version: ${{ steps.check.outputs.version }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install OSC
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y osc
|
||||
|
||||
mkdir -p ~/.config/osc
|
||||
cat > ~/.config/osc/oscrc << EOF
|
||||
[general]
|
||||
apiurl = https://api.opensuse.org
|
||||
|
||||
[https://api.opensuse.org]
|
||||
user = ${{ secrets.OBS_USERNAME }}
|
||||
pass = ${{ secrets.OBS_PASSWORD }}
|
||||
EOF
|
||||
chmod 600 ~/.config/osc/oscrc
|
||||
|
||||
- name: Check for updates
|
||||
id: check
|
||||
run: |
|
||||
if [[ "${{ github.event_name }}" == "push" && "${{ github.ref }}" =~ ^refs/tags/ ]]; then
|
||||
echo "packages=dms" >> $GITHUB_OUTPUT
|
||||
VERSION="${GITHUB_REF#refs/tags/}"
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
||||
echo "Triggered by tag: $VERSION (always update)"
|
||||
elif [[ "${{ github.event_name }}" == "schedule" ]]; then
|
||||
echo "packages=dms-git" >> $GITHUB_OUTPUT
|
||||
echo "Checking if dms-git source has changed..."
|
||||
|
||||
# Get current commit hash (8 chars to match spec format)
|
||||
CURRENT_COMMIT=$(git rev-parse --short=8 HEAD)
|
||||
|
||||
# Check OBS for last uploaded commit
|
||||
OBS_BASE="$HOME/.cache/osc-checkouts"
|
||||
mkdir -p "$OBS_BASE"
|
||||
OBS_PROJECT="home:AvengeMedia:dms-git"
|
||||
|
||||
if [[ -d "$OBS_BASE/$OBS_PROJECT/dms-git" ]]; then
|
||||
cd "$OBS_BASE/$OBS_PROJECT/dms-git"
|
||||
osc up -q 2>/dev/null || true
|
||||
|
||||
# Extract commit hash from spec Version line & format like; 0.6.2+git2264.a679be68
|
||||
if [[ -f "dms-git.spec" ]]; then
|
||||
OBS_COMMIT=$(grep "^Version:" "dms-git.spec" | grep -oP '\.[a-f0-9]{8}' | tr -d '.' || echo "")
|
||||
|
||||
if [[ -n "$OBS_COMMIT" ]]; then
|
||||
if [[ "$CURRENT_COMMIT" == "$OBS_COMMIT" ]]; then
|
||||
echo "has_updates=false" >> $GITHUB_OUTPUT
|
||||
echo "📋 Commit $CURRENT_COMMIT already uploaded to OBS, skipping"
|
||||
else
|
||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
||||
echo "📋 New commit detected: $CURRENT_COMMIT (OBS has $OBS_COMMIT)"
|
||||
fi
|
||||
else
|
||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
||||
echo "📋 Could not extract OBS commit, proceeding with update"
|
||||
fi
|
||||
else
|
||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
||||
echo "📋 No spec file in OBS, proceeding with update"
|
||||
fi
|
||||
|
||||
cd "${{ github.workspace }}"
|
||||
else
|
||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
||||
echo "📋 First upload to OBS, update needed"
|
||||
fi
|
||||
elif [[ "${{ github.event.inputs.force_upload }}" == "true" ]]; then
|
||||
PKG="${{ github.event.inputs.package }}"
|
||||
if [[ -z "$PKG" || "$PKG" == "all" ]]; then
|
||||
echo "packages=all" >> $GITHUB_OUTPUT
|
||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
||||
echo "🚀 Force upload: all packages"
|
||||
else
|
||||
echo "packages=$PKG" >> $GITHUB_OUTPUT
|
||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
||||
echo "🚀 Force upload: $PKG"
|
||||
fi
|
||||
elif [[ -n "${{ github.event.inputs.package }}" ]]; then
|
||||
echo "packages=${{ github.event.inputs.package }}" >> $GITHUB_OUTPUT
|
||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
||||
echo "Manual trigger: ${{ github.event.inputs.package }}"
|
||||
else
|
||||
echo "packages=all" >> $GITHUB_OUTPUT
|
||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
update-obs:
|
||||
name: Upload to OBS
|
||||
needs: check-updates
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
if: |
|
||||
github.event.inputs.force_upload == 'true' ||
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.check-updates.outputs.has_updates == 'true'
|
||||
|
||||
steps:
|
||||
- name: Generate GitHub App Token
|
||||
id: generate_token
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
|
||||
- name: Check if last commit was automated
|
||||
id: check-loop
|
||||
run: |
|
||||
LAST_COMMIT_MSG=$(git log -1 --pretty=%B | head -1)
|
||||
if [[ "$LAST_COMMIT_MSG" == "ci: Auto-update PPA packages"* ]] || [[ "$LAST_COMMIT_MSG" == "ci: Auto-update OBS packages"* ]]; then
|
||||
echo "⏭️ Last commit was automated ($LAST_COMMIT_MSG), skipping to prevent infinite loop"
|
||||
echo "skip=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "✅ Last commit was not automated, proceeding"
|
||||
echo "skip=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Determine packages to update
|
||||
if: steps.check-loop.outputs.skip != 'true'
|
||||
id: packages
|
||||
run: |
|
||||
if [[ "${{ github.event_name }}" == "push" && "${{ github.ref }}" =~ ^refs/tags/ ]]; then
|
||||
echo "packages=dms" >> $GITHUB_OUTPUT
|
||||
VERSION="${GITHUB_REF#refs/tags/}"
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Triggered by tag: $VERSION"
|
||||
elif [[ "${{ github.event_name }}" == "schedule" ]]; then
|
||||
echo "packages=${{ needs.check-updates.outputs.packages }}" >> $GITHUB_OUTPUT
|
||||
echo "Triggered by schedule: updating git package"
|
||||
elif [[ -n "${{ github.event.inputs.package }}" ]]; then
|
||||
echo "packages=${{ github.event.inputs.package }}" >> $GITHUB_OUTPUT
|
||||
echo "Manual trigger: ${{ github.event.inputs.package }}"
|
||||
else
|
||||
echo "packages=${{ needs.check-updates.outputs.packages }}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Update dms-git spec version
|
||||
if: steps.check-loop.outputs.skip != 'true' && (contains(steps.packages.outputs.packages, 'dms-git') || steps.packages.outputs.packages == 'all')
|
||||
run: |
|
||||
# Get commit info for dms-git versioning
|
||||
COMMIT_HASH=$(git rev-parse --short=8 HEAD)
|
||||
COMMIT_COUNT=$(git rev-list --count HEAD)
|
||||
BASE_VERSION=$(grep -oP '^Version:\s+\K[0-9.]+' distro/opensuse/dms.spec | head -1 || echo "0.6.2")
|
||||
|
||||
NEW_VERSION="${BASE_VERSION}+git${COMMIT_COUNT}.${COMMIT_HASH}"
|
||||
echo "📦 Updating dms-git.spec to version: $NEW_VERSION"
|
||||
|
||||
# Update version in spec
|
||||
sed -i "s/^Version:.*/Version: $NEW_VERSION/" distro/opensuse/dms-git.spec
|
||||
|
||||
# Add changelog entry
|
||||
DATE_STR=$(date "+%a %b %d %Y")
|
||||
CHANGELOG_ENTRY="* $DATE_STR Avenge Media <AvengeMedia.US@gmail.com> - ${NEW_VERSION}-1\n- Git snapshot (commit $COMMIT_COUNT: $COMMIT_HASH)"
|
||||
sed -i "/%changelog/a\\$CHANGELOG_ENTRY" distro/opensuse/dms-git.spec
|
||||
|
||||
- name: Update Debian dms-git changelog version
|
||||
if: steps.check-loop.outputs.skip != 'true' && (contains(steps.packages.outputs.packages, 'dms-git') || steps.packages.outputs.packages == 'all')
|
||||
run: |
|
||||
# Get commit info for dms-git versioning
|
||||
COMMIT_HASH=$(git rev-parse --short=8 HEAD)
|
||||
COMMIT_COUNT=$(git rev-list --count HEAD)
|
||||
BASE_VERSION=$(grep -oP '^Version:\s+\K[0-9.]+' distro/opensuse/dms.spec | head -1 || echo "0.6.2")
|
||||
|
||||
# Debian version format: 0.6.2+git2256.9162e314
|
||||
NEW_VERSION="${BASE_VERSION}+git${COMMIT_COUNT}.${COMMIT_HASH}"
|
||||
echo "📦 Updating Debian dms-git changelog to version: $NEW_VERSION"
|
||||
|
||||
CHANGELOG_DATE=$(date -R)
|
||||
|
||||
CHANGELOG_FILE="distro/debian/dms-git/debian/changelog"
|
||||
|
||||
# Get current version from changelog
|
||||
CURRENT_VERSION=$(head -1 "$CHANGELOG_FILE" | sed 's/.*(\([^)]*\)).*/\1/')
|
||||
|
||||
echo "Current Debian version: $CURRENT_VERSION"
|
||||
echo "New version: $NEW_VERSION"
|
||||
|
||||
# Only update if version changed
|
||||
if [ "$CURRENT_VERSION" != "$NEW_VERSION" ]; then
|
||||
# Create new changelog entry at top
|
||||
TEMP_CHANGELOG=$(mktemp)
|
||||
|
||||
cat > "$TEMP_CHANGELOG" << EOF
|
||||
dms-git ($NEW_VERSION) nightly; urgency=medium
|
||||
|
||||
* Git snapshot (commit $COMMIT_COUNT: $COMMIT_HASH)
|
||||
|
||||
-- Avenge Media <AvengeMedia.US@gmail.com> $CHANGELOG_DATE
|
||||
|
||||
EOF
|
||||
|
||||
# Prepend to existing changelog
|
||||
cat "$CHANGELOG_FILE" >> "$TEMP_CHANGELOG"
|
||||
mv "$TEMP_CHANGELOG" "$CHANGELOG_FILE"
|
||||
|
||||
echo "✓ Updated Debian changelog: $CURRENT_VERSION → $NEW_VERSION"
|
||||
else
|
||||
echo "✓ Debian changelog already at version $NEW_VERSION"
|
||||
fi
|
||||
|
||||
- name: Update dms stable version
|
||||
if: steps.check-loop.outputs.skip != 'true' && steps.packages.outputs.version != ''
|
||||
run: |
|
||||
VERSION="${{ steps.packages.outputs.version }}"
|
||||
VERSION_NO_V="${VERSION#v}"
|
||||
echo "Updating packaging to version $VERSION_NO_V"
|
||||
|
||||
# Update openSUSE dms spec (stable only)
|
||||
sed -i "s/^Version:.*/Version: $VERSION_NO_V/" distro/opensuse/dms.spec
|
||||
|
||||
# Update openSUSE spec changelog
|
||||
DATE_STR=$(date "+%a %b %d %Y")
|
||||
CHANGELOG_ENTRY="* $DATE_STR AvengeMedia <maintainer@avengemedia.com> - ${VERSION_NO_V}-1\\n- Update to stable $VERSION release\\n- Bug fixes and improvements"
|
||||
sed -i "/%changelog/a\\$CHANGELOG_ENTRY\\n" distro/opensuse/dms.spec
|
||||
|
||||
# Update Debian _service files (both tar_scm and download_url formats)
|
||||
for service in distro/debian/*/_service; do
|
||||
if [[ -f "$service" ]]; then
|
||||
# Update tar_scm revision parameter (for dms-git)
|
||||
sed -i "s|<param name=\"revision\">v[0-9.]*</param>|<param name=\"revision\">$VERSION</param>|" "$service"
|
||||
|
||||
# Update download_url paths (for dms stable)
|
||||
sed -i "s|/v[0-9.]\+/|/$VERSION/|g" "$service"
|
||||
sed -i "s|/tags/v[0-9.]\+\.tar\.gz|/tags/$VERSION.tar.gz|g" "$service"
|
||||
fi
|
||||
done
|
||||
|
||||
# Update Debian changelog for dms stable
|
||||
if [[ -f "distro/debian/dms/debian/changelog" ]]; then
|
||||
CHANGELOG_DATE=$(date -R)
|
||||
TEMP_CHANGELOG=$(mktemp)
|
||||
|
||||
cat > "$TEMP_CHANGELOG" << EOF
|
||||
dms ($VERSION_NO_V) stable; urgency=medium
|
||||
|
||||
* Update to $VERSION stable release
|
||||
* Bug fixes and improvements
|
||||
|
||||
-- Avenge Media <AvengeMedia.US@gmail.com> $CHANGELOG_DATE
|
||||
|
||||
EOF
|
||||
|
||||
cat "distro/debian/dms/debian/changelog" >> "$TEMP_CHANGELOG"
|
||||
mv "$TEMP_CHANGELOG" "distro/debian/dms/debian/changelog"
|
||||
|
||||
echo "✓ Updated Debian changelog to $VERSION_NO_V"
|
||||
fi
|
||||
|
||||
- name: Install Go
|
||||
if: steps.check-loop.outputs.skip != 'true'
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
|
||||
- name: Install OSC
|
||||
if: steps.check-loop.outputs.skip != 'true'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y osc
|
||||
|
||||
mkdir -p ~/.config/osc
|
||||
cat > ~/.config/osc/oscrc << EOF
|
||||
[general]
|
||||
apiurl = https://api.opensuse.org
|
||||
|
||||
[https://api.opensuse.org]
|
||||
user = ${{ secrets.OBS_USERNAME }}
|
||||
pass = ${{ secrets.OBS_PASSWORD }}
|
||||
EOF
|
||||
chmod 600 ~/.config/osc/oscrc
|
||||
|
||||
- name: Upload to OBS
|
||||
if: steps.check-loop.outputs.skip != 'true'
|
||||
env:
|
||||
FORCE_UPLOAD: ${{ github.event.inputs.force_upload }}
|
||||
REBUILD_RELEASE: ${{ github.event.inputs.rebuild_release }}
|
||||
run: |
|
||||
PACKAGES="${{ steps.packages.outputs.packages }}"
|
||||
MESSAGE="Automated update from GitHub Actions"
|
||||
|
||||
if [[ -n "${{ steps.packages.outputs.version }}" ]]; then
|
||||
MESSAGE="Update to ${{ steps.packages.outputs.version }}"
|
||||
fi
|
||||
|
||||
if [[ "$PACKAGES" == "all" ]]; then
|
||||
bash distro/scripts/obs-upload.sh dms "$MESSAGE"
|
||||
bash distro/scripts/obs-upload.sh dms-git "Automated git update"
|
||||
else
|
||||
bash distro/scripts/obs-upload.sh "$PACKAGES" "$MESSAGE"
|
||||
fi
|
||||
|
||||
- name: Get changed packages
|
||||
if: steps.check-loop.outputs.skip != 'true'
|
||||
id: changed-packages
|
||||
run: |
|
||||
# Check if there are any changes to commit
|
||||
if git diff --exit-code distro/debian/ distro/opensuse/ >/dev/null 2>&1; then
|
||||
echo "has_changes=false" >> $GITHUB_OUTPUT
|
||||
echo "📋 No changelog or spec changes to commit"
|
||||
else
|
||||
echo "has_changes=true" >> $GITHUB_OUTPUT
|
||||
# Get list of changed packages for commit message
|
||||
CHANGED_DEB=$(git diff --name-only distro/debian/ 2>/dev/null | grep 'debian/changelog' | xargs dirname 2>/dev/null | xargs dirname 2>/dev/null | xargs basename 2>/dev/null | tr '\n' ', ' | sed 's/, $//' || echo "")
|
||||
CHANGED_SUSE=$(git diff --name-only distro/opensuse/ 2>/dev/null | grep '\.spec$' | sed 's|distro/opensuse/||' | sed 's/\.spec$//' | tr '\n' ', ' | sed 's/, $//' || echo "")
|
||||
|
||||
PKGS=$(echo "$CHANGED_DEB,$CHANGED_SUSE" | tr ',' '\n' | grep -v '^$' | sort -u | tr '\n' ',' | sed 's/,$//')
|
||||
echo "packages=$PKGS" >> $GITHUB_OUTPUT
|
||||
echo "📋 Changed packages: $PKGS"
|
||||
fi
|
||||
|
||||
- name: Commit packaging changes
|
||||
if: steps.check-loop.outputs.skip != 'true' && steps.changed-packages.outputs.has_changes == 'true'
|
||||
run: |
|
||||
git config user.name "dms-ci[bot]"
|
||||
git config user.email "dms-ci[bot]@users.noreply.github.com"
|
||||
git add distro/debian/*/debian/changelog distro/opensuse/*.spec
|
||||
git commit -m "ci: Auto-update OBS packages [${{ steps.changed-packages.outputs.packages }}]" -m "🤖 Automated by GitHub Actions"
|
||||
git pull --rebase origin master
|
||||
git push
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
echo "### OBS Package Update Complete" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Packages**: ${{ steps.packages.outputs.packages }}" >> $GITHUB_STEP_SUMMARY
|
||||
if [[ -n "${{ steps.packages.outputs.version }}" ]]; then
|
||||
echo "- **Version**: ${{ steps.packages.outputs.version }}" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
if [[ "${{ needs.check-updates.outputs.has_updates }}" == "false" ]]; then
|
||||
echo "- **Status**: Skipped (no changes detected)" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
echo "- **Project**: https://build.opensuse.org/project/show/home:AvengeMedia" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -1,298 +0,0 @@
|
||||
name: Update PPA Packages
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
package:
|
||||
description: "Package to upload (dms, dms-git, dms-greeter, or all)"
|
||||
required: false
|
||||
default: "dms-git"
|
||||
force_upload:
|
||||
description: "Force upload without version check"
|
||||
required: false
|
||||
default: "false"
|
||||
type: choice
|
||||
options:
|
||||
- "false"
|
||||
- "true"
|
||||
rebuild_release:
|
||||
description: "Release number for rebuilds (e.g., 2, 3, 4 for ppa2, ppa3, ppa4)"
|
||||
required: false
|
||||
default: ""
|
||||
schedule:
|
||||
- cron: "0 */3 * * *" # Every 3 hours for dms-git builds
|
||||
|
||||
jobs:
|
||||
check-updates:
|
||||
name: Check for updates
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
outputs:
|
||||
has_updates: ${{ steps.check.outputs.has_updates }}
|
||||
packages: ${{ steps.check.outputs.packages }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check for updates
|
||||
id: check
|
||||
run: |
|
||||
if [[ "${{ github.event_name }}" == "schedule" ]]; then
|
||||
echo "packages=dms-git" >> $GITHUB_OUTPUT
|
||||
echo "Checking if dms-git source has changed..."
|
||||
|
||||
# Get current commit hash (8 chars to match changelog format)
|
||||
CURRENT_COMMIT=$(git rev-parse --short=8 HEAD)
|
||||
|
||||
# Extract commit hash from changelog
|
||||
# Format: dms-git (0.6.2+git2264.c5c5ce84) questing; urgency=medium
|
||||
CHANGELOG_FILE="distro/ubuntu/dms-git/debian/changelog"
|
||||
|
||||
if [[ -f "$CHANGELOG_FILE" ]]; then
|
||||
CHANGELOG_COMMIT=$(head -1 "$CHANGELOG_FILE" | grep -oP '\.[a-f0-9]{8}' | tr -d '.' || echo "")
|
||||
|
||||
if [[ -n "$CHANGELOG_COMMIT" ]]; then
|
||||
if [[ "$CURRENT_COMMIT" == "$CHANGELOG_COMMIT" ]]; then
|
||||
echo "has_updates=false" >> $GITHUB_OUTPUT
|
||||
echo "📋 Commit $CURRENT_COMMIT already in changelog, skipping upload"
|
||||
else
|
||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
||||
echo "📋 New commit detected: $CURRENT_COMMIT (changelog has $CHANGELOG_COMMIT)"
|
||||
fi
|
||||
else
|
||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
||||
echo "📋 Could not extract commit from changelog, proceeding with upload"
|
||||
fi
|
||||
else
|
||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
||||
echo "📋 No changelog file found, proceeding with upload"
|
||||
fi
|
||||
elif [[ -n "${{ github.event.inputs.package }}" ]]; then
|
||||
echo "packages=${{ github.event.inputs.package }}" >> $GITHUB_OUTPUT
|
||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
||||
echo "Manual trigger: ${{ github.event.inputs.package }}"
|
||||
else
|
||||
echo "packages=dms-git" >> $GITHUB_OUTPUT
|
||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
upload-ppa:
|
||||
name: Upload to PPA
|
||||
needs: check-updates
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
if: |
|
||||
github.event.inputs.force_upload == 'true' ||
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.check-updates.outputs.has_updates == 'true'
|
||||
|
||||
steps:
|
||||
- name: Generate GitHub App Token
|
||||
id: generate_token
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
|
||||
- name: Check if last commit was automated
|
||||
id: check-loop
|
||||
run: |
|
||||
LAST_COMMIT_MSG=$(git log -1 --pretty=%B | head -1)
|
||||
if [[ "$LAST_COMMIT_MSG" == "ci: Auto-update PPA packages"* ]] || [[ "$LAST_COMMIT_MSG" == "ci: Auto-update OBS packages"* ]]; then
|
||||
echo "⏭️ Last commit was automated ($LAST_COMMIT_MSG), skipping to prevent infinite loop"
|
||||
echo "skip=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "✅ Last commit was not automated, proceeding"
|
||||
echo "skip=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Set up Go
|
||||
if: steps.check-loop.outputs.skip != 'true'
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
cache: false
|
||||
|
||||
- name: Install build dependencies
|
||||
if: steps.check-loop.outputs.skip != 'true'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
debhelper \
|
||||
devscripts \
|
||||
dput \
|
||||
lftp \
|
||||
build-essential \
|
||||
fakeroot \
|
||||
dpkg-dev
|
||||
|
||||
- name: Configure GPG
|
||||
if: steps.check-loop.outputs.skip != 'true'
|
||||
env:
|
||||
GPG_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
|
||||
run: |
|
||||
echo "$GPG_KEY" | gpg --import
|
||||
GPG_KEY_ID=$(gpg --list-secret-keys --keyid-format LONG | grep sec | awk '{print $2}' | cut -d'/' -f2)
|
||||
echo "DEBSIGN_KEYID=$GPG_KEY_ID" >> $GITHUB_ENV
|
||||
|
||||
- name: Determine packages to upload
|
||||
if: steps.check-loop.outputs.skip != 'true'
|
||||
id: packages
|
||||
run: |
|
||||
if [[ "${{ github.event.inputs.force_upload }}" == "true" ]]; then
|
||||
PKG="${{ github.event.inputs.package }}"
|
||||
if [[ -z "$PKG" || "$PKG" == "all" ]]; then
|
||||
echo "packages=all" >> $GITHUB_OUTPUT
|
||||
echo "🚀 Force upload: all packages"
|
||||
else
|
||||
echo "packages=$PKG" >> $GITHUB_OUTPUT
|
||||
echo "🚀 Force upload: $PKG"
|
||||
fi
|
||||
elif [[ "${{ github.event_name }}" == "schedule" ]]; then
|
||||
echo "packages=${{ needs.check-updates.outputs.packages }}" >> $GITHUB_OUTPUT
|
||||
echo "Triggered by schedule: uploading git package"
|
||||
elif [[ -n "${{ github.event.inputs.package }}" ]]; then
|
||||
# Manual package selection should respect change detection
|
||||
SELECTED_PKG="${{ github.event.inputs.package }}"
|
||||
UPDATED_PKG="${{ needs.check-updates.outputs.packages }}"
|
||||
|
||||
# Check if manually selected package is in the updated list
|
||||
if [[ "$UPDATED_PKG" == *"$SELECTED_PKG"* ]] || [[ "$SELECTED_PKG" == "all" ]]; then
|
||||
echo "packages=$SELECTED_PKG" >> $GITHUB_OUTPUT
|
||||
echo "📦 Manual selection (has updates): $SELECTED_PKG"
|
||||
else
|
||||
echo "packages=" >> $GITHUB_OUTPUT
|
||||
echo "⚠️ Manual selection '$SELECTED_PKG' has no updates - skipping (use force_upload to override)"
|
||||
fi
|
||||
else
|
||||
echo "packages=${{ needs.check-updates.outputs.packages }}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Upload to PPA
|
||||
if: steps.check-loop.outputs.skip != 'true'
|
||||
run: |
|
||||
PACKAGES="${{ steps.packages.outputs.packages }}"
|
||||
REBUILD_RELEASE="${{ github.event.inputs.rebuild_release }}"
|
||||
|
||||
if [[ -z "$PACKAGES" ]]; then
|
||||
echo "No packages selected for upload. Skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Build command arguments
|
||||
BUILD_ARGS=()
|
||||
if [[ -n "$REBUILD_RELEASE" ]]; then
|
||||
BUILD_ARGS+=("$REBUILD_RELEASE")
|
||||
echo "✓ Using rebuild release number: ppa$REBUILD_RELEASE"
|
||||
fi
|
||||
|
||||
if [[ "$PACKAGES" == "all" ]]; then
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "Uploading dms to PPA..."
|
||||
if [ -n "$REBUILD_RELEASE" ]; then
|
||||
echo "🔄 Using rebuild release number: ppa$REBUILD_RELEASE"
|
||||
fi
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
bash distro/scripts/ppa-upload.sh dms dms questing "${BUILD_ARGS[@]}"
|
||||
|
||||
echo ""
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "Uploading dms-git to PPA..."
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
bash distro/scripts/ppa-upload.sh dms-git dms-git questing "${BUILD_ARGS[@]}"
|
||||
|
||||
echo ""
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "Uploading dms-greeter to PPA..."
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
bash distro/scripts/ppa-upload.sh dms-greeter danklinux questing "${BUILD_ARGS[@]}"
|
||||
else
|
||||
# Map package to PPA name
|
||||
case "$PACKAGES" in
|
||||
dms)
|
||||
PPA_NAME="dms"
|
||||
;;
|
||||
dms-git)
|
||||
PPA_NAME="dms-git"
|
||||
;;
|
||||
dms-greeter)
|
||||
PPA_NAME="danklinux"
|
||||
;;
|
||||
*)
|
||||
PPA_NAME="$PACKAGES"
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "Uploading $PACKAGES to PPA..."
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
bash distro/scripts/ppa-upload.sh "$PACKAGES" "$PPA_NAME" questing "${BUILD_ARGS[@]}"
|
||||
fi
|
||||
|
||||
- name: Get changed packages
|
||||
if: steps.check-loop.outputs.skip != 'true'
|
||||
id: changed-packages
|
||||
run: |
|
||||
# Check if there are any changelog changes to commit
|
||||
if git diff --exit-code distro/ubuntu/ >/dev/null 2>&1; then
|
||||
echo "has_changes=false" >> $GITHUB_OUTPUT
|
||||
echo "📋 No changelog changes to commit"
|
||||
else
|
||||
echo "has_changes=true" >> $GITHUB_OUTPUT
|
||||
# Get list of changed packages for commit message (deduplicate)
|
||||
CHANGED=$(git diff --name-only distro/ubuntu/ | grep 'debian/changelog' | sed 's|/debian/changelog||' | xargs -I{} basename {} | sort -u | tr '\n' ',' | sed 's/,$//')
|
||||
echo "packages=$CHANGED" >> $GITHUB_OUTPUT
|
||||
echo "📋 Changed packages: $CHANGED"
|
||||
echo "📋 Debug - Changed files:"
|
||||
git diff --name-only distro/ubuntu/ | grep 'debian/changelog' || echo "No changelog files found"
|
||||
fi
|
||||
|
||||
- name: Commit changelog changes
|
||||
if: steps.check-loop.outputs.skip != 'true' && steps.changed-packages.outputs.has_changes == 'true'
|
||||
run: |
|
||||
git config user.name "dms-ci[bot]"
|
||||
git config user.email "dms-ci[bot]@users.noreply.github.com"
|
||||
git add distro/ubuntu/*/debian/changelog
|
||||
git commit -m "ci: Auto-update PPA packages [${{ steps.changed-packages.outputs.packages }}]" -m "🤖 Automated by GitHub Actions"
|
||||
git pull --rebase origin master
|
||||
git push
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
echo "### PPA Package Upload Complete" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Packages**: ${{ steps.packages.outputs.packages }}" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
if [[ "${{ needs.check-updates.outputs.has_updates }}" == "false" ]]; then
|
||||
echo "- **Status**: Skipped (no changes detected)" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
PACKAGES="${{ steps.packages.outputs.packages }}"
|
||||
if [[ "$PACKAGES" == "all" ]]; then
|
||||
echo "- **PPA dms**: https://launchpad.net/~avengemedia/+archive/ubuntu/dms/+packages" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **PPA dms-git**: https://launchpad.net/~avengemedia/+archive/ubuntu/dms-git/+packages" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **PPA danklinux**: https://launchpad.net/~avengemedia/+archive/ubuntu/danklinux/+packages" >> $GITHUB_STEP_SUMMARY
|
||||
elif [[ "$PACKAGES" == "dms" ]]; then
|
||||
echo "- **PPA**: https://launchpad.net/~avengemedia/+archive/ubuntu/dms/+packages" >> $GITHUB_STEP_SUMMARY
|
||||
elif [[ "$PACKAGES" == "dms-git" ]]; then
|
||||
echo "- **PPA**: https://launchpad.net/~avengemedia/+archive/ubuntu/dms-git/+packages" >> $GITHUB_STEP_SUMMARY
|
||||
elif [[ "$PACKAGES" == "dms-greeter" ]]; then
|
||||
echo "- **PPA**: https://launchpad.net/~avengemedia/+archive/ubuntu/danklinux/+packages" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
if [[ -n "${{ steps.packages.outputs.version }}" ]]; then
|
||||
echo "- **Version**: ${{ steps.packages.outputs.version }}" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Builds will appear once Launchpad processes the uploads." >> $GITHUB_STEP_SUMMARY
|
||||
@@ -0,0 +1,84 @@
|
||||
name: Update stable branch
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
update-stable:
|
||||
# skip prerelease tags
|
||||
if: ${{ !contains(github.ref_name, '-') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Create GitHub App token
|
||||
id: app_token
|
||||
uses: actions/create-github-app-token@v3
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ steps.app_token.outputs.token }}
|
||||
|
||||
- name: Push to stable branch
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# don't roll stable backwards
|
||||
if git fetch origin stable --quiet 2>/dev/null; then
|
||||
stable_tag=$(git describe --tags --abbrev=0 FETCH_HEAD 2>/dev/null || echo "v0.0.0")
|
||||
newest=$(printf '%s\n%s\n' "$stable_tag" "${GITHUB_REF_NAME}" | sort -V | tail -1)
|
||||
if [ "$newest" != "${GITHUB_REF_NAME}" ]; then
|
||||
echo "skipping: ${GITHUB_REF_NAME} is older than stable (${stable_tag})"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
git push "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" HEAD:refs/heads/stable --force
|
||||
|
||||
cut-release-branch:
|
||||
# create stable-X.Y at each vX.Y.0 tag
|
||||
if: ${{ !contains(github.ref_name, '-') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Create GitHub App token
|
||||
id: app_token
|
||||
uses: actions/create-github-app-token@v3
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ steps.app_token.outputs.token }}
|
||||
|
||||
- name: Create release branch
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ ! "${GITHUB_REF_NAME}" =~ ^v([0-9]+)\.([0-9]+)\.0$ ]]; then
|
||||
echo "not a vX.Y.0 tag, no release branch to cut"
|
||||
exit 0
|
||||
fi
|
||||
branch="stable-${BASH_REMATCH[1]}.${BASH_REMATCH[2]}"
|
||||
if git ls-remote --exit-code origin "refs/heads/${branch}" >/dev/null 2>&1; then
|
||||
echo "${branch} already exists"
|
||||
exit 0
|
||||
fi
|
||||
git push "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" "HEAD:refs/heads/${branch}"
|
||||
echo "created ${branch} at ${GITHUB_REF_NAME}"
|
||||
GH_TOKEN="${{ secrets.GITHUB_TOKEN }}" gh label create "port ${branch}" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--description "Auto-port merged PR to ${branch}" --color 0e8a16 ||
|
||||
echo "label 'port ${branch}' may already exist"
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
- "core/**"
|
||||
- ".github/workflows/go-ci.yml"
|
||||
pull_request:
|
||||
branches: [master, main]
|
||||
branches: [master, main, "stable-*"]
|
||||
paths:
|
||||
- "core/**"
|
||||
- ".github/workflows/go-ci.yml"
|
||||
@@ -26,10 +26,19 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install flatpak
|
||||
run: sudo apt update && sudo apt install -y flatpak
|
||||
|
||||
- name: Add flathub
|
||||
run: sudo flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
|
||||
|
||||
- name: Add a flatpak that mutagen could support
|
||||
run: sudo flatpak install -y org.freedesktop.Platform/x86_64/24.08 app.zen_browser.zen
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: ./core/go.mod
|
||||
|
||||
|
||||
@@ -1,23 +1,43 @@
|
||||
name: Check nix flake
|
||||
name: Nix flake and NixOS tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [master, main]
|
||||
branches: [master, main, "stable-*"]
|
||||
paths:
|
||||
- "flake.*"
|
||||
- "distro/nix/**"
|
||||
jobs:
|
||||
check-flake:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Nix
|
||||
uses: cachix/install-nix-action@v31
|
||||
with:
|
||||
enable_kvm: true
|
||||
extra_nix_config: |
|
||||
system-features = nixos-test benchmark big-parallel kvm
|
||||
|
||||
- name: Check the flake
|
||||
run: nix flake check
|
||||
run: nix flake check -L
|
||||
|
||||
- name: Run NixOS module test
|
||||
run: nix build .#nixosTests.x86_64-linux.nixos-module -L
|
||||
|
||||
- name: Run NixOS service start test
|
||||
run: nix build .#nixosTests.x86_64-linux.nixos-service-start-module -L
|
||||
|
||||
- name: Run greeter niri test
|
||||
run: nix build .#nixosTests.x86_64-linux.greeter-niri-module -L
|
||||
|
||||
- name: Run home-manager module test
|
||||
run: nix build .#nixosTests.x86_64-linux.home-manager-module -L
|
||||
|
||||
- name: Run niri home-manager module test
|
||||
run: nix build .#nixosTests.x86_64-linux.niri-home-module -L
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
name: Point release
|
||||
|
||||
# Cuts vX.Y.Z from stable-X.Y: runs the port audit (warn-only), bumps
|
||||
# quickshell/VERSION, tags, and dispatches the Release workflow. Distro
|
||||
# builds are dispatched separately.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Point release version (e.g. 1.5.1)"
|
||||
required: true
|
||||
type: string
|
||||
notify_issues:
|
||||
description: "Comment on open related/fixes issues asking reporters to retest"
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
actions: write
|
||||
|
||||
concurrency:
|
||||
group: point-release
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
steps:
|
||||
- name: Validate version and derive branch
|
||||
id: derive
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "::error::version must be X.Y.Z (got '$VERSION')"; exit 1
|
||||
fi
|
||||
echo "branch=stable-${VERSION%.*}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create GitHub App token
|
||||
id: app_token
|
||||
uses: actions/create-github-app-token@v3
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Checkout release branch
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ steps.derive.outputs.branch }}
|
||||
fetch-depth: 0
|
||||
token: ${{ steps.app_token.outputs.token }}
|
||||
|
||||
- name: Preflight — verify Release workflow is dispatchable
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh api "repos/${{ github.repository }}/actions/workflows/release.yml" \
|
||||
--jq '.state' | grep -qx active ||
|
||||
{ echo "::error::release.yml is not dispatchable; aborting before any push"; exit 1; }
|
||||
|
||||
- name: Port audit (informational)
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||
run: |
|
||||
bash scripts/port-audit.sh "${{ steps.derive.outputs.branch }}" ||
|
||||
echo "::warning::port audit failed; continuing"
|
||||
|
||||
- name: Bump VERSION, tag, and push
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||
TAG: ${{ steps.derive.outputs.tag }}
|
||||
BRANCH: ${{ steps.derive.outputs.branch }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
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"
|
||||
|
||||
echo "${TAG}" > quickshell/VERSION
|
||||
git add quickshell/VERSION
|
||||
git commit -m "bump VERSION to ${TAG}"
|
||||
git tag "${TAG}"
|
||||
git push "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" "HEAD:${BRANCH}" "refs/tags/${TAG}"
|
||||
|
||||
- name: Dispatch Release workflow
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
gh workflow run release.yml --ref "${{ steps.derive.outputs.tag }}" \
|
||||
-f tag="${{ steps.derive.outputs.tag }}" \
|
||||
-f notify_issues="${{ inputs.notify_issues }}"
|
||||
|
||||
- name: Next steps
|
||||
run: |
|
||||
{
|
||||
echo "## ${{ steps.derive.outputs.tag }} tagged on ${{ steps.derive.outputs.branch }} — Release workflow dispatched"
|
||||
echo ""
|
||||
echo "Distro builds are manual: run the per-distro workflows (COPR/OBS/PPA/XBPS) once the release is published."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -0,0 +1,30 @@
|
||||
name: Port audit
|
||||
|
||||
# On-demand report of master commits not yet ported to a release branch.
|
||||
# Updates the "Port status: <branch>" tracking issue and the step summary.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
target:
|
||||
description: "Release branch to audit (default: newest release/*)"
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
audit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Run audit
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: bash scripts/port-audit.sh ${{ inputs.target }} --issue
|
||||
@@ -0,0 +1,101 @@
|
||||
name: Port to release branch
|
||||
|
||||
# Ports flagged commits from master onto stable-X.Y branches:
|
||||
# - "port 1.5" flag in a commit message pushed to master — own line or
|
||||
# mid-line; separators : / - or space; "stable-1.5" also accepted;
|
||||
# comma lists ("Port: 1.5, 1.4") work in the own-line form
|
||||
# - "port stable-1.5" label on a merged PR
|
||||
# Conflicts are reported to the "Port status: <branch>" tracking issue.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request_target:
|
||||
types: [closed, labeled]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: port-engine
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
port:
|
||||
name: Port flagged commits
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Create GitHub App token
|
||||
id: app_token
|
||||
uses: actions/create-github-app-token@v3
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
# Base-repo code only; PR head code is never checked out or executed.
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: master
|
||||
fetch-depth: 0
|
||||
token: ${{ steps.app_token.outputs.token }}
|
||||
|
||||
- name: Port trailer-flagged commits
|
||||
if: github.event_name == 'push'
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||
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"
|
||||
|
||||
for sha in $(jq -r '.[].id' <<<"$COMMITS"); do
|
||||
git cat-file -e "$sha" 2>/dev/null || continue
|
||||
# skip merge commits (handled by the label path)
|
||||
[ "$(git rev-list --no-walk --count --min-parents=2 "$sha")" -eq 0 ] || continue
|
||||
|
||||
# own-line form (supports comma lists), validated version-shaped
|
||||
t1=$(git log -1 --format=%B "$sha" |
|
||||
{ grep -iE '^Port[:/ -]' || true; } | sed -E 's|^port[-:/ ]+||I' | tr ',' '\n' |
|
||||
sed 's/[[:space:]]//g; /^$/d' | sed -E 's,^stable-,,I' |
|
||||
{ grep -E '^[0-9]+\.[0-9]+(\.[0-9]+)?$' || true; })
|
||||
# mid-line form, e.g. "fix thing fixes #2802 port 1.5"
|
||||
t2=$(git log -1 --format=%B "$sha" |
|
||||
{ grep -oiE '\bport[-: /]+(stable-)?[0-9]+\.[0-9]+(\.[0-9]+)?\b' || true; } |
|
||||
sed -E 's|^port[-:/ ]+||I' | sed -E 's,^stable-,,I')
|
||||
targets=$(printf '%s\n%s\n' "$t1" "$t2" | sed '/^$/d' | sort -u)
|
||||
for ver in $targets; do
|
||||
echo "::group::port $sha -> stable-$ver"
|
||||
bash scripts/port.sh "stable-$ver" "$sha"
|
||||
echo "::endgroup::"
|
||||
done
|
||||
done
|
||||
|
||||
- name: Port label-flagged PR
|
||||
if: >
|
||||
github.event_name == 'pull_request_target' &&
|
||||
github.event.pull_request.merged == true &&
|
||||
(github.event.action == 'closed' ||
|
||||
(github.event.action == 'labeled' && startsWith(github.event.label.name, 'port ')))
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||
LABELS: ${{ toJSON(github.event.pull_request.labels.*.name) }}
|
||||
MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }}
|
||||
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"
|
||||
|
||||
targets=$(jq -r '.[] | select(startswith("port ")) | sub("^port +"; "")' <<<"$LABELS" |
|
||||
sed -E 's,^stable-,,' | sort -u)
|
||||
[ -n "$targets" ] || { echo "no port labels, nothing to do"; exit 0; }
|
||||
[ -n "$MERGE_SHA" ] || { echo "::error::PR has no merge commit sha"; exit 1; }
|
||||
|
||||
for ver in $targets; do
|
||||
echo "::group::port PR #${PORT_SOURCE_PR} ($MERGE_SHA) -> stable-$ver"
|
||||
bash scripts/port.sh "stable-$ver" "$MERGE_SHA"
|
||||
echo "::endgroup::"
|
||||
done
|
||||
@@ -3,13 +3,27 @@ name: Pre-commit Checks
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
branches: [master, main]
|
||||
branches: [master, main, "stable-*"]
|
||||
jobs:
|
||||
pre-commit-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install flatpak
|
||||
run: sudo apt update && sudo apt install -y flatpak
|
||||
|
||||
- name: Add flathub
|
||||
run: sudo flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
|
||||
|
||||
- name: Add a flatpak that mutagen could support
|
||||
run: sudo flatpak install -y org.freedesktop.Platform/x86_64/24.08 app.zen_browser.zen
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: core/go.mod
|
||||
|
||||
- name: run pre-commit hooks
|
||||
uses: j178/prek-action@v1
|
||||
uses: j178/prek-action@v2
|
||||
|
||||
+61
-304
@@ -7,10 +7,16 @@ on:
|
||||
description: "Tag to release (e.g., v1.0.1)"
|
||||
required: true
|
||||
type: string
|
||||
notify_issues:
|
||||
description: "Comment on open related/fixes issues asking reporters to retest"
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
actions: write
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: release-${{ inputs.tag }}
|
||||
@@ -32,13 +38,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.tag }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: ./core/go.mod
|
||||
|
||||
@@ -106,7 +112,7 @@ jobs:
|
||||
|
||||
- name: Upload artifacts (${{ matrix.arch }})
|
||||
if: matrix.arch == 'arm64'
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: core-assets-${{ matrix.arch }}
|
||||
path: |
|
||||
@@ -120,7 +126,7 @@ jobs:
|
||||
|
||||
- name: Upload artifacts with completions
|
||||
if: matrix.arch == 'amd64'
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: core-assets-${{ matrix.arch }}
|
||||
path: |
|
||||
@@ -147,7 +153,7 @@ jobs:
|
||||
# private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
# - name: Checkout
|
||||
# uses: actions/checkout@v4
|
||||
# uses: actions/checkout@v6
|
||||
# with:
|
||||
# token: ${{ steps.app_token.outputs.token }}
|
||||
# fetch-depth: 0
|
||||
@@ -181,7 +187,7 @@ jobs:
|
||||
TAG: ${{ inputs.tag }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.tag }}
|
||||
fetch-depth: 0
|
||||
@@ -191,8 +197,13 @@ jobs:
|
||||
git fetch origin --force tag ${TAG}
|
||||
git checkout ${TAG}
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: ./core/go.mod
|
||||
|
||||
- name: Download core artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
pattern: core-assets-*
|
||||
merge-multiple: true
|
||||
@@ -200,13 +211,23 @@ jobs:
|
||||
|
||||
- name: Generate Changelog
|
||||
id: changelog
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -e
|
||||
PREVIOUS_TAG=$(git describe --tags --abbrev=0 "${TAG}^" 2>/dev/null || echo "")
|
||||
if [ -z "$PREVIOUS_TAG" ]; then
|
||||
CHANGELOG=$(git log --oneline --pretty=format:"%an|%s (%h)" | grep -v "^github-actions\[bot\]|" | sed 's/^[^|]*|/- /' | head -50)
|
||||
else
|
||||
CHANGELOG=$(git log --oneline --pretty=format:"%an|%s (%h)" "${PREVIOUS_TAG}..${TAG}" | grep -v "^github-actions\[bot\]|" | sed 's/^[^|]*|/- /')
|
||||
CHANGELOG=""
|
||||
if [ -n "$PREVIOUS_TAG" ]; then
|
||||
# PR-based notes with author credits; falls back to raw git log below
|
||||
CHANGELOG=$(python3 scripts/release-notes.py "${PREVIOUS_TAG}..${TAG}" --format github --bare 2>/dev/null || true)
|
||||
fi
|
||||
if [ -z "$CHANGELOG" ]; then
|
||||
echo "release-notes.py unavailable or empty, using git log fallback"
|
||||
if [ -z "$PREVIOUS_TAG" ]; then
|
||||
CHANGELOG=$(git log --oneline --pretty=format:"%an|%s (%h)" | grep -v "^github-actions\[bot\]|" | sed 's/^[^|]*|/- /' | head -50)
|
||||
else
|
||||
CHANGELOG=$(git log --oneline --pretty=format:"%an|%s (%h)" "${PREVIOUS_TAG}..${TAG}" | grep -v "^github-actions\[bot\]|" | sed 's/^[^|]*|/- /')
|
||||
fi
|
||||
fi
|
||||
|
||||
cat > RELEASE_BODY.md << 'EOF'
|
||||
@@ -229,6 +250,7 @@ jobs:
|
||||
- **`dms-distropkg-arm64.gz`** - DMS CLI binary built with distro_package tag for ARM64 systems
|
||||
- **`dankinstall-amd64.gz`** - Installer binary for x86_64 systems
|
||||
- **`dankinstall-arm64.gz`** - Installer binary for ARM64 systems
|
||||
- **`dms-cli-<version>.tar.gz`** - Go source code with vendored modules (for distro packaging)
|
||||
- **`dms-qml.tar.gz`** - QML source code only
|
||||
|
||||
### Checksums
|
||||
@@ -387,6 +409,19 @@ jobs:
|
||||
rm -rf _temp_full
|
||||
done
|
||||
|
||||
- name: Generate vendored source tarball
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
VERSION_NUM=${TAG#v}
|
||||
cd core
|
||||
go mod vendor
|
||||
cd ..
|
||||
tar czf "_release_assets/dms-cli-${VERSION_NUM}.tar.gz" \
|
||||
--transform "s,^core/,dms-cli-${VERSION_NUM}/," \
|
||||
--exclude='core/.git' \
|
||||
core/
|
||||
(cd _release_assets && sha256sum "dms-cli-${VERSION_NUM}.tar.gz" > "dms-cli-${VERSION_NUM}.tar.gz.sha256")
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
@@ -399,296 +434,18 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# trigger-obs-update:
|
||||
# runs-on: ubuntu-latest
|
||||
# needs: release
|
||||
# env:
|
||||
# TAG: ${{ inputs.tag }}
|
||||
# steps:
|
||||
# - name: Checkout
|
||||
# uses: actions/checkout@v4
|
||||
# with:
|
||||
# ref: ${{ inputs.tag }}
|
||||
|
||||
# - name: Install OSC
|
||||
# run: |
|
||||
# sudo apt-get update
|
||||
# sudo apt-get install -y osc
|
||||
|
||||
# mkdir -p ~/.config/osc
|
||||
# cat > ~/.config/osc/oscrc << EOF
|
||||
# [general]
|
||||
# apiurl = https://api.opensuse.org
|
||||
|
||||
# [https://api.opensuse.org]
|
||||
# user = ${{ secrets.OBS_USERNAME }}
|
||||
# pass = ${{ secrets.OBS_PASSWORD }}
|
||||
# EOF
|
||||
# chmod 600 ~/.config/osc/oscrc
|
||||
|
||||
# - name: Update OBS packages
|
||||
# run: |
|
||||
# cd distro
|
||||
# bash scripts/obs-upload.sh dms "Update to ${TAG}"
|
||||
|
||||
# trigger-ppa-update:
|
||||
# runs-on: ubuntu-latest
|
||||
# needs: release
|
||||
# env:
|
||||
# TAG: ${{ inputs.tag }}
|
||||
# steps:
|
||||
# - name: Checkout
|
||||
# uses: actions/checkout@v4
|
||||
# with:
|
||||
# ref: ${{ inputs.tag }}
|
||||
|
||||
# - name: Install build dependencies
|
||||
# run: |
|
||||
# sudo apt-get update
|
||||
# sudo apt-get install -y \
|
||||
# debhelper \
|
||||
# devscripts \
|
||||
# dput \
|
||||
# lftp \
|
||||
# build-essential \
|
||||
# fakeroot \
|
||||
# dpkg-dev
|
||||
|
||||
# - name: Configure GPG
|
||||
# env:
|
||||
# GPG_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
|
||||
# run: |
|
||||
# echo "$GPG_KEY" | gpg --import
|
||||
# GPG_KEY_ID=$(gpg --list-secret-keys --keyid-format LONG | grep sec | awk '{print $2}' | cut -d'/' -f2)
|
||||
# echo "DEBSIGN_KEYID=$GPG_KEY_ID" >> $GITHUB_ENV
|
||||
|
||||
# - name: Upload to PPA
|
||||
# run: |
|
||||
# cd distro/ubuntu/ppa
|
||||
# bash create-and-upload.sh ../dms dms questing
|
||||
|
||||
# copr-build:
|
||||
# runs-on: ubuntu-latest
|
||||
# needs: release
|
||||
# env:
|
||||
# TAG: ${{ inputs.tag }}
|
||||
|
||||
# steps:
|
||||
# - name: Checkout repository
|
||||
# uses: actions/checkout@v4
|
||||
# with:
|
||||
# ref: ${{ inputs.tag }}
|
||||
|
||||
# - name: Determine version
|
||||
# id: version
|
||||
# run: |
|
||||
# VERSION="${TAG#v}"
|
||||
# echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
# echo "Building DMS stable version: $VERSION"
|
||||
|
||||
# - name: Setup build environment
|
||||
# run: |
|
||||
# sudo apt-get update
|
||||
# sudo apt-get install -y rpm wget curl jq gzip
|
||||
# mkdir -p ~/rpmbuild/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS}
|
||||
|
||||
# - name: Download release assets
|
||||
# run: |
|
||||
# VERSION="${{ steps.version.outputs.version }}"
|
||||
# cd ~/rpmbuild/SOURCES
|
||||
|
||||
# wget "https://github.com/AvengeMedia/DankMaterialShell/releases/download/v${VERSION}/dms-qml.tar.gz" || {
|
||||
# echo "Failed to download dms-qml.tar.gz for v${VERSION}"
|
||||
# exit 1
|
||||
# }
|
||||
|
||||
# - name: Generate stable spec file
|
||||
# run: |
|
||||
# VERSION="${{ steps.version.outputs.version }}"
|
||||
# CHANGELOG_DATE="$(date '+%a %b %d %Y')"
|
||||
|
||||
# cat > ~/rpmbuild/SPECS/dms.spec <<'SPECEOF'
|
||||
# # Spec for DMS stable releases - Generated by GitHub Actions
|
||||
|
||||
# %global debug_package %{nil}
|
||||
# %global version VERSION_PLACEHOLDER
|
||||
# %global pkg_summary DankMaterialShell - Material 3 inspired shell for Wayland compositors
|
||||
|
||||
# Name: dms
|
||||
# Version: %{version}
|
||||
# Release: 1%{?dist}
|
||||
# Summary: %{pkg_summary}
|
||||
|
||||
# License: MIT
|
||||
# URL: https://github.com/AvengeMedia/DankMaterialShell
|
||||
|
||||
# Source0: dms-qml.tar.gz
|
||||
|
||||
# BuildRequires: gzip
|
||||
# BuildRequires: wget
|
||||
# BuildRequires: systemd-rpm-macros
|
||||
|
||||
# Requires: (quickshell or quickshell-git)
|
||||
# Requires: accountsservice
|
||||
# Requires: dms-cli = %{version}-%{release}
|
||||
# Requires: dgop
|
||||
|
||||
# Recommends: cava
|
||||
# Recommends: cliphist
|
||||
# Recommends: danksearch
|
||||
# Recommends: matugen
|
||||
# Recommends: wl-clipboard
|
||||
# Recommends: NetworkManager
|
||||
# Recommends: qt6-qtmultimedia
|
||||
# Suggests: qt6ct
|
||||
|
||||
# %description
|
||||
# DankMaterialShell (DMS) is a modern Wayland desktop shell built with Quickshell
|
||||
# and optimized for the niri and hyprland compositors. Features notifications,
|
||||
# app launcher, wallpaper customization, and fully customizable with plugins.
|
||||
|
||||
# Includes auto-theming for GTK/Qt apps with matugen, 20+ customizable widgets,
|
||||
# process monitoring, notification center, clipboard history, dock, control center,
|
||||
# lock screen, and comprehensive plugin system.
|
||||
|
||||
# %package -n dms-cli
|
||||
# Summary: DankMaterialShell CLI tool
|
||||
# License: MIT
|
||||
# URL: https://github.com/AvengeMedia/DankMaterialShell
|
||||
|
||||
# %description -n dms-cli
|
||||
# Command-line interface for DankMaterialShell configuration and management.
|
||||
# Provides native DBus bindings, NetworkManager integration, and system utilities.
|
||||
|
||||
# %prep
|
||||
# %setup -q -c -n dms-qml
|
||||
|
||||
# # Download architecture-specific binaries during build
|
||||
# case "%{_arch}" in
|
||||
# x86_64)
|
||||
# ARCH_SUFFIX="amd64"
|
||||
# ;;
|
||||
# aarch64)
|
||||
# ARCH_SUFFIX="arm64"
|
||||
# ;;
|
||||
# *)
|
||||
# echo "Unsupported architecture: %{_arch}"
|
||||
# exit 1
|
||||
# ;;
|
||||
# esac
|
||||
|
||||
# wget -O %{_builddir}/dms-cli.gz "https://github.com/AvengeMedia/DankMaterialShell/releases/latest/download/dms-distropkg-${ARCH_SUFFIX}.gz" || {
|
||||
# echo "Failed to download dms-cli for architecture %{_arch}"
|
||||
# exit 1
|
||||
# }
|
||||
# gunzip -c %{_builddir}/dms-cli.gz > %{_builddir}/dms-cli
|
||||
# chmod +x %{_builddir}/dms-cli
|
||||
|
||||
# %build
|
||||
|
||||
# %install
|
||||
# install -Dm755 %{_builddir}/dms-cli %{buildroot}%{_bindir}/dms
|
||||
|
||||
# install -d %{buildroot}%{_datadir}/bash-completion/completions
|
||||
# install -d %{buildroot}%{_datadir}/zsh/site-functions
|
||||
# install -d %{buildroot}%{_datadir}/fish/vendor_completions.d
|
||||
# %{_builddir}/dms-cli completion bash > %{buildroot}%{_datadir}/bash-completion/completions/dms || :
|
||||
# %{_builddir}/dms-cli completion zsh > %{buildroot}%{_datadir}/zsh/site-functions/_dms || :
|
||||
# %{_builddir}/dms-cli completion fish > %{buildroot}%{_datadir}/fish/vendor_completions.d/dms.fish || :
|
||||
|
||||
# install -Dm644 assets/systemd/dms.service %{buildroot}%{_userunitdir}/dms.service
|
||||
|
||||
# install -Dm644 assets/dms-open.desktop %{buildroot}%{_datadir}/applications/dms-open.desktop
|
||||
# install -Dm644 assets/danklogo.svg %{buildroot}%{_datadir}/icons/hicolor/scalable/apps/danklogo.svg
|
||||
|
||||
# install -dm755 %{buildroot}%{_datadir}/quickshell/dms
|
||||
# cp -r %{_builddir}/dms-qml/* %{buildroot}%{_datadir}/quickshell/dms/
|
||||
|
||||
# rm -rf %{buildroot}%{_datadir}/quickshell/dms/.git*
|
||||
# rm -f %{buildroot}%{_datadir}/quickshell/dms/.gitignore
|
||||
# rm -rf %{buildroot}%{_datadir}/quickshell/dms/.github
|
||||
# rm -rf %{buildroot}%{_datadir}/quickshell/dms/distro
|
||||
|
||||
# echo "%{version}" > %{buildroot}%{_datadir}/quickshell/dms/VERSION
|
||||
|
||||
# %posttrans
|
||||
# if [ -d "%{_sysconfdir}/xdg/quickshell/dms" ]; then
|
||||
# rmdir "%{_sysconfdir}/xdg/quickshell/dms" 2>/dev/null || true
|
||||
# rmdir "%{_sysconfdir}/xdg/quickshell" 2>/dev/null || true
|
||||
# rmdir "%{_sysconfdir}/xdg" 2>/dev/null || true
|
||||
# fi
|
||||
# # Signal running DMS instances to reload
|
||||
# pkill -USR1 -x dms >/dev/null 2>&1 || :
|
||||
|
||||
# %files
|
||||
# %license LICENSE
|
||||
# %doc README.md CONTRIBUTING.md
|
||||
# %{_datadir}/quickshell/dms/
|
||||
# %{_userunitdir}/dms.service
|
||||
# %{_datadir}/applications/dms-open.desktop
|
||||
# %{_datadir}/icons/hicolor/scalable/apps/danklogo.svg
|
||||
|
||||
# %files -n dms-cli
|
||||
# %{_bindir}/dms
|
||||
# %{_datadir}/bash-completion/completions/dms
|
||||
# %{_datadir}/zsh/site-functions/_dms
|
||||
# %{_datadir}/fish/vendor_completions.d/dms.fish
|
||||
|
||||
# %changelog
|
||||
# * CHANGELOG_DATE_PLACEHOLDER AvengeMedia <contact@avengemedia.com> - VERSION_PLACEHOLDER-1
|
||||
# - Stable release VERSION_PLACEHOLDER
|
||||
# - Built from GitHub release
|
||||
# SPECEOF
|
||||
|
||||
# sed -i "s/VERSION_PLACEHOLDER/${VERSION}/g" ~/rpmbuild/SPECS/dms.spec
|
||||
# sed -i "s/CHANGELOG_DATE_PLACEHOLDER/${CHANGELOG_DATE}/g" ~/rpmbuild/SPECS/dms.spec
|
||||
|
||||
# - name: Build SRPM
|
||||
# id: build
|
||||
# run: |
|
||||
# cd ~/rpmbuild/SPECS
|
||||
# rpmbuild -bs dms.spec
|
||||
|
||||
# SRPM=$(ls ~/rpmbuild/SRPMS/*.src.rpm | tail -n 1)
|
||||
# SRPM_NAME=$(basename "$SRPM")
|
||||
|
||||
# echo "srpm_path=$SRPM" >> $GITHUB_OUTPUT
|
||||
# echo "srpm_name=$SRPM_NAME" >> $GITHUB_OUTPUT
|
||||
# echo "SRPM built: $SRPM_NAME"
|
||||
|
||||
# - name: Upload SRPM artifact
|
||||
# uses: actions/upload-artifact@v4
|
||||
# with:
|
||||
# name: dms-stable-srpm-${{ steps.version.outputs.version }}
|
||||
# path: ${{ steps.build.outputs.srpm_path }}
|
||||
# retention-days: 90
|
||||
|
||||
# - name: Install Copr CLI
|
||||
# run: |
|
||||
# sudo apt-get install -y python3-pip
|
||||
# pip3 install copr-cli
|
||||
|
||||
# mkdir -p ~/.config
|
||||
# cat > ~/.config/copr << EOF
|
||||
# [copr-cli]
|
||||
# login = ${{ secrets.COPR_LOGIN }}
|
||||
# username = avengemedia
|
||||
# token = ${{ secrets.COPR_TOKEN }}
|
||||
# copr_url = https://copr.fedorainfracloud.org
|
||||
# EOF
|
||||
# chmod 600 ~/.config/copr
|
||||
|
||||
# - name: Upload to Copr
|
||||
# run: |
|
||||
# SRPM="${{ steps.build.outputs.srpm_path }}"
|
||||
# VERSION="${{ steps.version.outputs.version }}"
|
||||
|
||||
# echo "Uploading SRPM to avengemedia/dms..."
|
||||
# BUILD_OUTPUT=$(copr-cli build avengemedia/dms "$SRPM" --nowait 2>&1)
|
||||
# echo "$BUILD_OUTPUT"
|
||||
|
||||
# BUILD_ID=$(echo "$BUILD_OUTPUT" | grep -oP 'Build was added to.*\K[0-9]+' || echo "unknown")
|
||||
|
||||
# if [ "$BUILD_ID" != "unknown" ]; then
|
||||
# echo "Build submitted: https://copr.fedorainfracloud.org/coprs/avengemedia/dms/build/$BUILD_ID/"
|
||||
# fi
|
||||
# Ask reporters on still-open "related/fixes #N" issues to retest.
|
||||
# Opt-in via notify_issues (Point release defaults it on). Skipped for
|
||||
# prereleases. Preview: scripts/notify-issues.sh <prev> <tag> --dry-run
|
||||
- name: Notify open issues to retest
|
||||
if: ${{ inputs.notify_issues && !contains(env.TAG, '-') }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PREVIOUS_TAG=$(git describe --tags --abbrev=0 "${TAG}^" 2>/dev/null || true)
|
||||
if [ -z "$PREVIOUS_TAG" ]; then
|
||||
echo "no previous tag; skipping issue notify"
|
||||
exit 0
|
||||
fi
|
||||
bash scripts/notify-issues.sh "$PREVIOUS_TAG" "$TAG"
|
||||
|
||||
+72
-150
@@ -3,8 +3,17 @@ name: DMS Copr Stable Release
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
package:
|
||||
description: 'Package to build (dms, dms-greeter, or both)'
|
||||
required: false
|
||||
default: 'dms'
|
||||
type: choice
|
||||
options:
|
||||
- dms
|
||||
- dms-greeter
|
||||
- both
|
||||
version:
|
||||
description: 'Versioning (e.g., 0.1.14, leave empty for latest release)'
|
||||
description: 'Versioning (e.g., 1.0.3, leave empty for latest release)'
|
||||
required: false
|
||||
default: ''
|
||||
release:
|
||||
@@ -13,12 +22,31 @@ on:
|
||||
default: '1'
|
||||
|
||||
jobs:
|
||||
build-and-upload:
|
||||
determine-packages:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
packages: ${{ steps.set-packages.outputs.packages }}
|
||||
steps:
|
||||
- name: Set package list
|
||||
id: set-packages
|
||||
run: |
|
||||
PACKAGE_INPUT="${{ github.event.inputs.package || 'dms' }}"
|
||||
if [ "$PACKAGE_INPUT" = "both" ]; then
|
||||
echo 'packages=["dms","dms-greeter"]' >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "packages=[\"$PACKAGE_INPUT\"]" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
build-and-upload:
|
||||
needs: determine-packages
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
package: ${{ fromJSON(needs.determine-packages.outputs.packages) }}
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Determine version
|
||||
id: version
|
||||
@@ -39,7 +67,7 @@ jobs:
|
||||
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "release=$RELEASE" >> $GITHUB_OUTPUT
|
||||
echo "✅ Building DMS hotfix version: $VERSION-$RELEASE"
|
||||
echo "✅ Building ${{ matrix.package }} version: $VERSION-$RELEASE"
|
||||
|
||||
- name: Setup build environment
|
||||
run: |
|
||||
@@ -70,157 +98,31 @@ jobs:
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
RELEASE="${{ steps.version.outputs.release }}"
|
||||
CHANGELOG_DATE="$(date '+%a %b %d %Y')"
|
||||
PACKAGE="${{ matrix.package }}"
|
||||
|
||||
cat > ~/rpmbuild/SPECS/dms.spec <<'SPECEOF'
|
||||
# Spec for DMS stable releases - Generated by GitHub Actions
|
||||
# Copy spec file from repository
|
||||
cp distro/fedora/${PACKAGE}.spec ~/rpmbuild/SPECS/${PACKAGE}.spec
|
||||
|
||||
%global debug_package %{nil}
|
||||
%global version VERSION_PLACEHOLDER
|
||||
%global pkg_summary DankMaterialShell - Material 3 inspired shell for Wayland compositors
|
||||
# Replace placeholders with actual values
|
||||
sed -i "s/VERSION_PLACEHOLDER/${VERSION}/g" ~/rpmbuild/SPECS/${PACKAGE}.spec
|
||||
sed -i "s/RELEASE_PLACEHOLDER/${RELEASE}/g" ~/rpmbuild/SPECS/${PACKAGE}.spec
|
||||
sed -i "s/CHANGELOG_DATE_PLACEHOLDER/${CHANGELOG_DATE}/g" ~/rpmbuild/SPECS/${PACKAGE}.spec
|
||||
|
||||
Name: dms
|
||||
Version: %{version}
|
||||
Release: RELEASE_PLACEHOLDER%{?dist}
|
||||
Summary: %{pkg_summary}
|
||||
|
||||
License: MIT
|
||||
URL: https://github.com/AvengeMedia/DankMaterialShell
|
||||
|
||||
Source0: dms-qml.tar.gz
|
||||
|
||||
BuildRequires: gzip
|
||||
BuildRequires: wget
|
||||
BuildRequires: systemd-rpm-macros
|
||||
|
||||
Requires: (quickshell or quickshell-git)
|
||||
Requires: accountsservice
|
||||
Requires: dms-cli = %{version}-%{release}
|
||||
Requires: dgop
|
||||
|
||||
Recommends: cava
|
||||
Recommends: cliphist
|
||||
Recommends: danksearch
|
||||
Recommends: hyprpicker
|
||||
Recommends: matugen
|
||||
Recommends: wl-clipboard
|
||||
Recommends: NetworkManager
|
||||
Recommends: qt6-qtmultimedia
|
||||
Suggests: qt6ct
|
||||
|
||||
%description
|
||||
DankMaterialShell (DMS) is a modern Wayland desktop shell built with Quickshell
|
||||
and optimized for the niri and hyprland compositors. Features notifications,
|
||||
app launcher, wallpaper customization, and fully customizable with plugins.
|
||||
|
||||
Includes auto-theming for GTK/Qt apps with matugen, 20+ customizable widgets,
|
||||
process monitoring, notification center, clipboard history, dock, control center,
|
||||
lock screen, and comprehensive plugin system.
|
||||
|
||||
%package -n dms-cli
|
||||
Summary: DankMaterialShell CLI tool
|
||||
License: MIT
|
||||
URL: https://github.com/AvengeMedia/DankMaterialShell
|
||||
|
||||
%description -n dms-cli
|
||||
Command-line interface for DankMaterialShell configuration and management.
|
||||
Provides native DBus bindings, NetworkManager integration, and system utilities.
|
||||
|
||||
%prep
|
||||
%setup -q -c -n dms-qml
|
||||
|
||||
# Download architecture-specific binaries during build
|
||||
# This ensures the correct architecture is used for each build target
|
||||
case "%{_arch}" in
|
||||
x86_64)
|
||||
ARCH_SUFFIX="amd64"
|
||||
;;
|
||||
aarch64)
|
||||
ARCH_SUFFIX="arm64"
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported architecture: %{_arch}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Download dms-cli for target architecture
|
||||
wget -O %{_builddir}/dms-cli.gz "https://github.com/AvengeMedia/DankMaterialShell/releases/latest/download/dms-distropkg-${ARCH_SUFFIX}.gz" || {
|
||||
echo "Failed to download dms-cli for architecture %{_arch}"
|
||||
exit 1
|
||||
}
|
||||
gunzip -c %{_builddir}/dms-cli.gz > %{_builddir}/dms-cli
|
||||
chmod +x %{_builddir}/dms-cli
|
||||
|
||||
%build
|
||||
|
||||
%install
|
||||
install -Dm755 %{_builddir}/dms-cli %{buildroot}%{_bindir}/dms
|
||||
|
||||
# Shell completions
|
||||
install -d %{buildroot}%{_datadir}/bash-completion/completions
|
||||
install -d %{buildroot}%{_datadir}/zsh/site-functions
|
||||
install -d %{buildroot}%{_datadir}/fish/vendor_completions.d
|
||||
%{_builddir}/dms-cli completion bash > %{buildroot}%{_datadir}/bash-completion/completions/dms || :
|
||||
%{_builddir}/dms-cli completion zsh > %{buildroot}%{_datadir}/zsh/site-functions/_dms || :
|
||||
%{_builddir}/dms-cli completion fish > %{buildroot}%{_datadir}/fish/vendor_completions.d/dms.fish || :
|
||||
|
||||
install -Dm644 %{_builddir}/dms-qml/assets/systemd/dms.service %{buildroot}%{_userunitdir}/dms.service
|
||||
|
||||
install -dm755 %{buildroot}%{_datadir}/quickshell/dms
|
||||
cp -r %{_builddir}/dms-qml/* %{buildroot}%{_datadir}/quickshell/dms/
|
||||
|
||||
rm -rf %{buildroot}%{_datadir}/quickshell/dms/.git*
|
||||
rm -f %{buildroot}%{_datadir}/quickshell/dms/.gitignore
|
||||
rm -rf %{buildroot}%{_datadir}/quickshell/dms/.github
|
||||
rm -rf %{buildroot}%{_datadir}/quickshell/dms/distro
|
||||
|
||||
%posttrans
|
||||
# Clean up old installation path from previous versions (only if empty)
|
||||
if [ -d "%{_sysconfdir}/xdg/quickshell/dms" ]; then
|
||||
# Remove directories only if empty (preserves any user-added files)
|
||||
rmdir "%{_sysconfdir}/xdg/quickshell/dms" 2>/dev/null || true
|
||||
rmdir "%{_sysconfdir}/xdg/quickshell" 2>/dev/null || true
|
||||
rmdir "%{_sysconfdir}/xdg" 2>/dev/null || true
|
||||
fi
|
||||
# Signal running DMS instances to reload (harmless if none running)
|
||||
pkill -USR1 -x dms >/dev/null 2>&1 || :
|
||||
|
||||
%files
|
||||
%license LICENSE
|
||||
%doc README.md CONTRIBUTING.md
|
||||
%{_datadir}/quickshell/dms/
|
||||
%{_userunitdir}/dms.service
|
||||
|
||||
%files -n dms-cli
|
||||
%{_bindir}/dms
|
||||
%{_datadir}/bash-completion/completions/dms
|
||||
%{_datadir}/zsh/site-functions/_dms
|
||||
%{_datadir}/fish/vendor_completions.d/dms.fish
|
||||
|
||||
%changelog
|
||||
* CHANGELOG_DATE_PLACEHOLDER AvengeMedia <contact@avengemedia.com> - VERSION_PLACEHOLDER-RELEASE_PLACEHOLDER
|
||||
- Stable release VERSION_PLACEHOLDER
|
||||
- Built from GitHub release
|
||||
SPECEOF
|
||||
|
||||
sed -i "s/VERSION_PLACEHOLDER/${VERSION}/g" ~/rpmbuild/SPECS/dms.spec
|
||||
sed -i "s/RELEASE_PLACEHOLDER/${RELEASE}/g" ~/rpmbuild/SPECS/dms.spec
|
||||
sed -i "s/CHANGELOG_DATE_PLACEHOLDER/${CHANGELOG_DATE}/g" ~/rpmbuild/SPECS/dms.spec
|
||||
|
||||
echo "✅ Spec file generated for v${VERSION}-${RELEASE}"
|
||||
echo "✅ Spec file generated for ${PACKAGE} v${VERSION}-${RELEASE}"
|
||||
echo ""
|
||||
echo "=== Spec file preview ==="
|
||||
head -40 ~/rpmbuild/SPECS/dms.spec
|
||||
head -40 ~/rpmbuild/SPECS/${PACKAGE}.spec
|
||||
|
||||
- name: Build SRPM
|
||||
id: build
|
||||
run: |
|
||||
cd ~/rpmbuild/SPECS
|
||||
PACKAGE="${{ matrix.package }}"
|
||||
|
||||
echo "🔨 Building SRPM..."
|
||||
rpmbuild -bs dms.spec
|
||||
echo "🔨 Building SRPM for ${PACKAGE}..."
|
||||
rpmbuild -bs ${PACKAGE}.spec
|
||||
|
||||
SRPM=$(ls ~/rpmbuild/SRPMS/*.src.rpm | tail -n 1)
|
||||
SRPM=$(ls ~/rpmbuild/SRPMS/${PACKAGE}-*.src.rpm | tail -n 1)
|
||||
SRPM_NAME=$(basename "$SRPM")
|
||||
|
||||
echo "srpm_path=$SRPM" >> $GITHUB_OUTPUT
|
||||
@@ -232,9 +134,9 @@ jobs:
|
||||
rpm -qpi "$SRPM"
|
||||
|
||||
- name: Upload SRPM artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: dms-stable-srpm-${{ steps.version.outputs.version }}
|
||||
name: ${{ matrix.package }}-stable-srpm-${{ steps.version.outputs.version }}
|
||||
path: ${{ steps.build.outputs.srpm_path }}
|
||||
retention-days: 90
|
||||
|
||||
@@ -255,23 +157,40 @@ jobs:
|
||||
|
||||
echo "✅ Copr CLI configured"
|
||||
|
||||
- name: Determine Copr project
|
||||
id: copr_project
|
||||
run: |
|
||||
PACKAGE="${{ matrix.package }}"
|
||||
if [ "$PACKAGE" = "dms" ]; then
|
||||
COPR_PROJECT="avengemedia/dms"
|
||||
elif [ "$PACKAGE" = "dms-greeter" ]; then
|
||||
COPR_PROJECT="avengemedia/danklinux"
|
||||
else
|
||||
echo "❌ Unknown package: $PACKAGE"
|
||||
exit 1
|
||||
fi
|
||||
echo "copr_project=$COPR_PROJECT" >> $GITHUB_OUTPUT
|
||||
echo "✅ Copr project: $COPR_PROJECT"
|
||||
|
||||
- name: Upload to Copr
|
||||
run: |
|
||||
SRPM="${{ steps.build.outputs.srpm_path }}"
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
COPR_PROJECT="${{ steps.copr_project.outputs.copr_project }}"
|
||||
PACKAGE="${{ matrix.package }}"
|
||||
|
||||
echo "🚀 Uploading SRPM to avengemedia/dms..."
|
||||
echo "🚀 Uploading ${PACKAGE} SRPM to ${COPR_PROJECT}..."
|
||||
echo " SRPM: $(basename $SRPM)"
|
||||
echo " Version: $VERSION"
|
||||
|
||||
BUILD_OUTPUT=$(copr-cli build avengemedia/dms "$SRPM" --nowait 2>&1)
|
||||
BUILD_OUTPUT=$(copr-cli build "$COPR_PROJECT" "$SRPM" --nowait 2>&1)
|
||||
echo "$BUILD_OUTPUT"
|
||||
|
||||
BUILD_ID=$(echo "$BUILD_OUTPUT" | grep -oP 'Build was added to.*\K[0-9]+' || echo "unknown")
|
||||
|
||||
if [ "$BUILD_ID" != "unknown" ]; then
|
||||
echo "✅ Build submitted successfully!"
|
||||
echo "🔗 https://copr.fedorainfracloud.org/coprs/avengemedia/dms/build/$BUILD_ID/"
|
||||
echo "🔗 https://copr.fedorainfracloud.org/coprs/${COPR_PROJECT}/build/$BUILD_ID/"
|
||||
else
|
||||
echo "⚠️ Could not extract build ID, but upload may have succeeded"
|
||||
fi
|
||||
@@ -279,10 +198,13 @@ jobs:
|
||||
- name: Build summary
|
||||
if: always()
|
||||
run: |
|
||||
echo "### 🎉 DMS Stable Build Summary" >> $GITHUB_STEP_SUMMARY
|
||||
PACKAGE="${{ matrix.package }}"
|
||||
COPR_PROJECT="${{ steps.copr_project.outputs.copr_project }}"
|
||||
echo "### 🎉 ${PACKAGE} Stable Build Summary" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Package:** ${PACKAGE}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Version:** ${{ steps.version.outputs.version }}-${{ steps.version.outputs.release }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **SRPM:** ${{ steps.build.outputs.srpm_name }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Project:** https://copr.fedorainfracloud.org/coprs/avengemedia/dms/" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Project:** https://copr.fedorainfracloud.org/coprs/${COPR_PROJECT}/" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Stable release has been built and uploaded to Copr!" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
+232
-69
@@ -4,23 +4,27 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
package:
|
||||
description: "Package to update (dms, dms-git, or all)"
|
||||
required: false
|
||||
default: "all"
|
||||
description: "Package to update"
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- dms
|
||||
- dms-greeter
|
||||
- dms-git
|
||||
- all
|
||||
default: "dms"
|
||||
rebuild_release:
|
||||
description: "Release number for rebuilds (e.g., 2, 3, 4 to increment spec Release)"
|
||||
required: false
|
||||
default: ""
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
schedule:
|
||||
- cron: "0 */3 * * *" # Every 3 hours for dms-git builds
|
||||
- cron: "0 2,5,14,17,20,23 * * *" # 9am, 12pm, 3pm, 6pm, 9pm, 12am EST (UTC times shown)
|
||||
|
||||
jobs:
|
||||
check-updates:
|
||||
name: Check for updates
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository == 'AvengeMedia/DankMaterialShell'
|
||||
|
||||
outputs:
|
||||
has_updates: ${{ steps.check.outputs.has_updates }}
|
||||
@@ -29,7 +33,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -55,8 +59,9 @@ jobs:
|
||||
}
|
||||
|
||||
# Helper function to check dms stable tag
|
||||
# Sets LATEST_TAG variable in parent scope if update needed
|
||||
check_dms_stable() {
|
||||
local LATEST_TAG=$(curl -s https://api.github.com/repos/AvengeMedia/DankMaterialShell/releases/latest | grep '"tag_name"' | sed 's/.*"tag_name": "v\?\([^"]*\)".*/\1/' || echo "")
|
||||
LATEST_TAG=$(curl -s https://api.github.com/repos/AvengeMedia/DankMaterialShell/releases/latest | grep '"tag_name"' | sed 's/.*"tag_name": "\([^"]*\)".*/\1/' || echo "")
|
||||
local OBS_SPEC=$(curl -s -u "$OBS_USERNAME:$OBS_PASSWORD" "https://api.opensuse.org/source/home:AvengeMedia:dms/dms/dms.spec" 2>/dev/null || echo "")
|
||||
local OBS_VERSION=$(echo "$OBS_SPEC" | grep "^Version:" | awk '{print $2}' | xargs || echo "")
|
||||
|
||||
@@ -69,12 +74,27 @@ jobs:
|
||||
fi
|
||||
}
|
||||
|
||||
# Helper function to check dms-greeter stable tag
|
||||
check_dms_greeter_stable() {
|
||||
LATEST_TAG=$(curl -s https://api.github.com/repos/AvengeMedia/DankMaterialShell/releases/latest | grep '"tag_name"' | sed 's/.*"tag_name": "\([^"]*\)".*/\1/' || echo "")
|
||||
local OBS_SPEC=$(curl -s -u "$OBS_USERNAME:$OBS_PASSWORD" "https://api.opensuse.org/source/home:AvengeMedia:danklinux/dms-greeter/dms-greeter.spec" 2>/dev/null || echo "")
|
||||
local OBS_VERSION=$(echo "$OBS_SPEC" | grep "^Version:" | awk '{print $2}' | xargs | sed 's/^v//')
|
||||
|
||||
if [[ -n "$LATEST_TAG" && "$LATEST_TAG" == "v$OBS_VERSION" ]]; then
|
||||
echo "📋 dms-greeter: Tag $LATEST_TAG already exists, skipping"
|
||||
return 1 # No update needed
|
||||
else
|
||||
echo "📋 dms-greeter: New tag ${LATEST_TAG:-unknown} (OBS has ${OBS_VERSION:-none})"
|
||||
return 0 # Update needed
|
||||
fi
|
||||
}
|
||||
|
||||
# Main logic
|
||||
REBUILD="${{ github.event.inputs.rebuild_release }}"
|
||||
|
||||
if [[ "${{ github.event_name }}" == "push" && "${{ github.ref }}" =~ ^refs/tags/ ]]; then
|
||||
# Tag push - always update stable package
|
||||
echo "packages=dms" >> $GITHUB_OUTPUT
|
||||
if [[ "${{ github.ref }}" =~ ^refs/tags/ ]] && [[ -z "${{ github.event.inputs.package }}" ]]; then
|
||||
# Run from tag with no package specified - update both stable packages
|
||||
echo "packages=dms dms-greeter" >> $GITHUB_OUTPUT
|
||||
VERSION="${GITHUB_REF#refs/tags/}"
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
||||
@@ -97,13 +117,21 @@ jobs:
|
||||
# Rebuild requested - always proceed
|
||||
echo "packages=$PKG" >> $GITHUB_OUTPUT
|
||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
||||
echo "🔄 Manual rebuild requested: $PKG (ppa$REBUILD)"
|
||||
echo "🔄 Manual rebuild requested: $PKG (db$REBUILD)"
|
||||
|
||||
elif [[ "$PKG" == "all" ]]; then
|
||||
# Check each package and build list of those needing updates
|
||||
# Check each stable package and build list of those needing updates
|
||||
PACKAGES_TO_UPDATE=()
|
||||
check_dms_git && PACKAGES_TO_UPDATE+=("dms-git")
|
||||
check_dms_stable && PACKAGES_TO_UPDATE+=("dms")
|
||||
if check_dms_stable; then
|
||||
PACKAGES_TO_UPDATE+=("dms")
|
||||
if [[ -n "$LATEST_TAG" ]]; then
|
||||
echo "version=$LATEST_TAG" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
fi
|
||||
if check_dms_greeter_stable; then
|
||||
PACKAGES_TO_UPDATE+=("dms-greeter")
|
||||
[[ -n "$LATEST_TAG" ]] && echo "version=$LATEST_TAG" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
if [[ ${#PACKAGES_TO_UPDATE[@]} -gt 0 ]]; then
|
||||
echo "packages=${PACKAGES_TO_UPDATE[*]}" >> $GITHUB_OUTPUT
|
||||
@@ -112,7 +140,7 @@ jobs:
|
||||
else
|
||||
echo "packages=" >> $GITHUB_OUTPUT
|
||||
echo "has_updates=false" >> $GITHUB_OUTPUT
|
||||
echo "✓ All packages up to date"
|
||||
echo "✓ Both packages up to date"
|
||||
fi
|
||||
|
||||
elif [[ "$PKG" == "dms-git" ]]; then
|
||||
@@ -128,6 +156,21 @@ jobs:
|
||||
if check_dms_stable; then
|
||||
echo "packages=$PKG" >> $GITHUB_OUTPUT
|
||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
||||
if [[ -n "$LATEST_TAG" ]]; then
|
||||
echo "version=$LATEST_TAG" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
else
|
||||
echo "packages=" >> $GITHUB_OUTPUT
|
||||
echo "has_updates=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
elif [[ "$PKG" == "dms-greeter" ]]; then
|
||||
if check_dms_greeter_stable; then
|
||||
echo "packages=$PKG" >> $GITHUB_OUTPUT
|
||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
||||
if [[ -n "$LATEST_TAG" ]]; then
|
||||
echo "version=$LATEST_TAG" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
else
|
||||
echo "packages=" >> $GITHUB_OUTPUT
|
||||
echo "has_updates=false" >> $GITHUB_OUTPUT
|
||||
@@ -153,24 +196,44 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Wait before OBS upload
|
||||
run: sleep 3
|
||||
|
||||
- name: Determine packages to update
|
||||
id: packages
|
||||
run: |
|
||||
if [[ "${{ github.event_name }}" == "push" && "${{ github.ref }}" =~ ^refs/tags/ ]]; then
|
||||
echo "packages=dms" >> $GITHUB_OUTPUT
|
||||
VERSION="${GITHUB_REF#refs/tags/}"
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Triggered by tag: $VERSION"
|
||||
# Use check-updates outputs when available
|
||||
if [[ -n "${{ needs.check-updates.outputs.version }}" ]]; then
|
||||
# Use version from check-updates job
|
||||
echo "packages=${{ needs.check-updates.outputs.packages }}" >> $GITHUB_OUTPUT
|
||||
echo "version=${{ needs.check-updates.outputs.version }}" >> $GITHUB_OUTPUT
|
||||
echo "Using version from check-updates: ${{ needs.check-updates.outputs.version }}"
|
||||
elif [[ "${{ github.event_name }}" == "schedule" ]]; then
|
||||
# Scheduled run - dms-git only
|
||||
echo "packages=${{ needs.check-updates.outputs.packages }}" >> $GITHUB_OUTPUT
|
||||
echo "Triggered by schedule: updating git package"
|
||||
elif [[ -n "${{ github.event.inputs.package }}" ]]; then
|
||||
# Use filtered packages from check-updates when package="all" and no rebuild requested
|
||||
if [[ "${{ github.event.inputs.package }}" == "all" ]] && [[ -z "${{ github.event.inputs.rebuild_release }}" ]]; then
|
||||
# Manual workflow dispatch
|
||||
|
||||
# Determine version for dms stable and dms-greeter 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 }}" == "dms-greeter" ]] || [[ "${{ 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 "")
|
||||
if [[ -n "$LATEST_TAG" ]]; then
|
||||
echo "version=$LATEST_TAG" >> $GITHUB_OUTPUT
|
||||
echo "Using latest release from API: $LATEST_TAG"
|
||||
else
|
||||
echo "ERROR: Could not fetch latest release from API"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Use filtered packages from check-updates when package="all" and no rebuild/tag specified
|
||||
if [[ "${{ github.event.inputs.package }}" == "all" ]] && [[ -z "${{ github.event.inputs.rebuild_release }}" ]] && [[ ! "${{ github.ref }}" =~ ^refs/tags/ ]]; then
|
||||
echo "packages=${{ needs.check-updates.outputs.packages }}" >> $GITHUB_OUTPUT
|
||||
echo "Manual trigger: all (filtered to: ${{ needs.check-updates.outputs.packages }})"
|
||||
else
|
||||
@@ -179,14 +242,17 @@ jobs:
|
||||
fi
|
||||
else
|
||||
echo "packages=${{ needs.check-updates.outputs.packages }}" >> $GITHUB_OUTPUT
|
||||
if [[ -n "${{ needs.check-updates.outputs.version }}" ]]; then
|
||||
echo "version=${{ needs.check-updates.outputs.version }}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Update dms-git spec version
|
||||
if: contains(steps.packages.outputs.packages, 'dms-git') || steps.packages.outputs.packages == 'all'
|
||||
if: contains(steps.packages.outputs.packages, 'dms-git')
|
||||
run: |
|
||||
COMMIT_HASH=$(git rev-parse --short=8 HEAD)
|
||||
COMMIT_COUNT=$(git rev-list --count HEAD)
|
||||
BASE_VERSION=$(grep -oP '^Version:\s+\K[0-9.]+' distro/opensuse/dms.spec | head -1 || echo "0.6.2")
|
||||
BASE_VERSION=$(grep -oP '^Version:\s+\K[0-9.]+' distro/opensuse/dms.spec | head -1 || echo "1.0.2")
|
||||
NEW_VERSION="${BASE_VERSION}+git${COMMIT_COUNT}.${COMMIT_HASH}"
|
||||
echo "📦 Updating dms-git.spec to version: $NEW_VERSION"
|
||||
|
||||
@@ -203,72 +269,88 @@ jobs:
|
||||
} > distro/opensuse/dms-git.spec
|
||||
|
||||
- name: Update Debian dms-git changelog version
|
||||
if: contains(steps.packages.outputs.packages, 'dms-git') || steps.packages.outputs.packages == 'all'
|
||||
if: contains(steps.packages.outputs.packages, 'dms-git')
|
||||
run: |
|
||||
COMMIT_HASH=$(git rev-parse --short=8 HEAD)
|
||||
COMMIT_COUNT=$(git rev-list --count HEAD)
|
||||
BASE_VERSION=$(grep -oP '^Version:\s+\K[0-9.]+' distro/opensuse/dms.spec | head -1 || echo "0.6.2")
|
||||
BASE_VERSION=$(grep -oP '^Version:\s+\K[0-9.]+' distro/opensuse/dms.spec | head -1 || echo "1.0.2")
|
||||
NEW_VERSION="${BASE_VERSION}+git${COMMIT_COUNT}.${COMMIT_HASH}"
|
||||
echo "📦 Updating Debian dms-git changelog to version: $NEW_VERSION"
|
||||
|
||||
# Single changelog entry (git snapshots don't need history)
|
||||
CHANGELOG_DATE=$(date -R)
|
||||
{
|
||||
echo "dms-git ($NEW_VERSION) nightly; urgency=medium"
|
||||
echo "dms-git (${NEW_VERSION}db1) nightly; urgency=medium"
|
||||
echo ""
|
||||
echo " * Git snapshot (commit $COMMIT_COUNT: $COMMIT_HASH)"
|
||||
echo ""
|
||||
echo " -- Avenge Media <AvengeMedia.US@gmail.com> $CHANGELOG_DATE"
|
||||
} > "distro/debian/dms-git/debian/changelog"
|
||||
|
||||
- name: Update dms stable version
|
||||
- name: Update stable version (dms + dms-greeter)
|
||||
if: steps.packages.outputs.version != ''
|
||||
run: |
|
||||
VERSION="${{ steps.packages.outputs.version }}"
|
||||
VERSION_NO_V="${VERSION#v}"
|
||||
echo "Updating packaging to version $VERSION_NO_V"
|
||||
PACKAGES="${{ steps.packages.outputs.packages }}"
|
||||
echo "==> Updating packaging files to version: $VERSION_NO_V"
|
||||
|
||||
sed -i "s/^Version:.*/Version: $VERSION_NO_V/" distro/opensuse/dms.spec
|
||||
# Update dms spec and changelog when dms is in the upload list
|
||||
if [[ "$PACKAGES" == *"dms"* ]]; then
|
||||
sed -i "s/^Version:.*/Version: $VERSION_NO_V/" distro/opensuse/dms.spec
|
||||
UPDATED_VERSION=$(grep -oP '^Version:\s+\K[0-9.]+' distro/opensuse/dms.spec | head -1)
|
||||
echo "✓ dms spec now shows Version: $UPDATED_VERSION"
|
||||
|
||||
# Single changelog entry (full history on OBS website)
|
||||
DATE_STR=$(date "+%a %b %d %Y")
|
||||
LOCAL_SPEC_HEAD=$(sed -n '1,/%changelog/{ /%changelog/d; p }' distro/opensuse/dms.spec)
|
||||
{
|
||||
echo "$LOCAL_SPEC_HEAD"
|
||||
echo "%changelog"
|
||||
echo "* $DATE_STR AvengeMedia <maintainer@avengemedia.com> - ${VERSION_NO_V}-1"
|
||||
echo "- Update to stable $VERSION release"
|
||||
} > distro/opensuse/dms.spec
|
||||
DATE_STR=$(date "+%a %b %d %Y")
|
||||
LOCAL_SPEC_HEAD=$(sed -n '1,/%changelog/{ /%changelog/d; p }' distro/opensuse/dms.spec)
|
||||
{
|
||||
echo "$LOCAL_SPEC_HEAD"
|
||||
echo "%changelog"
|
||||
echo "* $DATE_STR AvengeMedia <maintainer@avengemedia.com> - ${VERSION_NO_V}-1"
|
||||
echo "- Update to stable $VERSION release"
|
||||
} > distro/opensuse/dms.spec
|
||||
|
||||
# Update Debian _service files (both tar_scm and download_url formats)
|
||||
if [[ -f "distro/debian/dms/debian/changelog" ]]; then
|
||||
CHANGELOG_DATE=$(date -R)
|
||||
{
|
||||
echo "dms (${VERSION_NO_V}db1) stable; urgency=medium"
|
||||
echo ""
|
||||
echo " * Update to $VERSION stable release"
|
||||
echo ""
|
||||
echo " -- Avenge Media <AvengeMedia.US@gmail.com> $CHANGELOG_DATE"
|
||||
} > "distro/debian/dms/debian/changelog"
|
||||
echo "✓ Updated dms changelog to ${VERSION_NO_V}db1"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Update dms-greeter changelog when dms-greeter is in the upload list
|
||||
if [[ "$PACKAGES" == *"dms-greeter"* ]] && [[ -f "distro/debian/dms-greeter/debian/changelog" ]]; then
|
||||
CHANGELOG_DATE=$(date -R)
|
||||
{
|
||||
echo "dms-greeter (${VERSION_NO_V}db1) unstable; urgency=medium"
|
||||
echo ""
|
||||
echo " * Update to $VERSION stable release"
|
||||
echo ""
|
||||
echo " -- Avenge Media <AvengeMedia.US@gmail.com> $CHANGELOG_DATE"
|
||||
} > "distro/debian/dms-greeter/debian/changelog"
|
||||
echo "✓ Updated dms-greeter changelog to ${VERSION_NO_V}db1"
|
||||
fi
|
||||
|
||||
# Update Debian _service files for packages in upload list (download_url paths)
|
||||
for service in distro/debian/*/_service; do
|
||||
if [[ -f "$service" ]]; then
|
||||
# Update tar_scm revision parameter (for dms-git)
|
||||
sed -i "s|<param name=\"revision\">v[0-9.]*</param>|<param name=\"revision\">$VERSION</param>|" "$service"
|
||||
|
||||
# Update download_url paths (for dms stable)
|
||||
# Update download_url paths (for dms, dms-greeter stable)
|
||||
sed -i "s|/v[0-9.]\+/|/$VERSION/|g" "$service"
|
||||
sed -i "s|/tags/v[0-9.]\+\.tar\.gz|/tags/$VERSION.tar.gz|g" "$service"
|
||||
fi
|
||||
done
|
||||
|
||||
# Update Debian changelog for dms stable (single entry, history on OBS website)
|
||||
if [[ -f "distro/debian/dms/debian/changelog" ]]; then
|
||||
CHANGELOG_DATE=$(date -R)
|
||||
{
|
||||
echo "dms ($VERSION_NO_V) stable; urgency=medium"
|
||||
echo ""
|
||||
echo " * Update to $VERSION stable release"
|
||||
echo ""
|
||||
echo " -- Avenge Media <AvengeMedia.US@gmail.com> $CHANGELOG_DATE"
|
||||
} > "distro/debian/dms/debian/changelog"
|
||||
echo "✓ Updated Debian changelog to $VERSION_NO_V"
|
||||
fi
|
||||
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.24"
|
||||
go-version-file: ./core/go.mod
|
||||
|
||||
- name: Install OSC
|
||||
run: |
|
||||
@@ -286,40 +368,82 @@ jobs:
|
||||
EOF
|
||||
chmod 600 ~/.config/osc/oscrc
|
||||
|
||||
# Cache OBS bundled Go toolchains
|
||||
- name: Cache OBS bundled Go toolchains (dms-git)
|
||||
if: contains(steps.packages.outputs.packages, 'dms-git')
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: /home/runner/.cache/dms-obs-go-toolchain
|
||||
key: dms-obs-go-toolchain-${{ runner.os }}-${{ hashFiles('core/go.mod') }}
|
||||
restore-keys: |
|
||||
dms-obs-go-toolchain-${{ runner.os }}-
|
||||
|
||||
- name: Upload to OBS
|
||||
id: upload
|
||||
env:
|
||||
REBUILD_RELEASE: ${{ github.event.inputs.rebuild_release }}
|
||||
TAG_VERSION: ${{ steps.packages.outputs.version }}
|
||||
run: |
|
||||
PACKAGES="${{ steps.packages.outputs.packages }}"
|
||||
|
||||
if [[ -z "$PACKAGES" ]]; then
|
||||
echo "✓ No packages need uploading. All up to date!"
|
||||
echo "uploaded_packages=" >> $GITHUB_OUTPUT
|
||||
echo "skipped_packages=" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
|
||||
MESSAGE="Automated update from GitHub Actions"
|
||||
if [[ -n "${{ steps.packages.outputs.version }}" ]]; then
|
||||
MESSAGE="Update to ${{ steps.packages.outputs.version }}"
|
||||
echo "==> Version being uploaded: ${{ steps.packages.outputs.version }}"
|
||||
fi
|
||||
|
||||
# PACKAGES can be space-separated list (e.g., "dms-git dms" from "all" check)
|
||||
UPLOADED_PACKAGES=()
|
||||
SKIPPED_PACKAGES=()
|
||||
|
||||
# PACKAGES can be space-separated list (e.g., "dms dms-greeter" from "all" check)
|
||||
# Loop through each package and upload
|
||||
for PKG in $PACKAGES; do
|
||||
echo ""
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "Uploading $PKG to OBS..."
|
||||
if [[ -n "$REBUILD_RELEASE" ]]; then
|
||||
echo "🔄 Using rebuild release number: ppa$REBUILD_RELEASE"
|
||||
echo "🔄 Using rebuild release number: db$REBUILD_RELEASE"
|
||||
fi
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
|
||||
LOG_FILE=$(mktemp)
|
||||
set +e
|
||||
if [[ "$PKG" == "dms-git" ]]; then
|
||||
bash distro/scripts/obs-upload.sh dms-git "Automated git update"
|
||||
bash distro/scripts/obs-upload.sh dms-git "Automated git update" >"$LOG_FILE" 2>&1
|
||||
else
|
||||
bash distro/scripts/obs-upload.sh "$PKG" "$MESSAGE"
|
||||
bash distro/scripts/obs-upload.sh "$PKG" "$MESSAGE" >"$LOG_FILE" 2>&1
|
||||
fi
|
||||
STATUS=$?
|
||||
set -e
|
||||
|
||||
cat "$LOG_FILE"
|
||||
|
||||
if [[ $STATUS -ne 0 ]]; then
|
||||
rm -f "$LOG_FILE"
|
||||
echo "❌ Upload failed for $PKG"
|
||||
exit $STATUS
|
||||
fi
|
||||
|
||||
if grep -Eq "Exiting gracefully \(no changes needed\)|No changes needed for this package\. Exiting gracefully\." "$LOG_FILE"; then
|
||||
echo "ℹ️ $PKG is already up to date. Skipped."
|
||||
SKIPPED_PACKAGES+=("$PKG")
|
||||
else
|
||||
UPLOADED_PACKAGES+=("$PKG")
|
||||
fi
|
||||
|
||||
rm -f "$LOG_FILE"
|
||||
done
|
||||
|
||||
echo "uploaded_packages=${UPLOADED_PACKAGES[*]}" >> $GITHUB_OUTPUT
|
||||
echo "skipped_packages=${SKIPPED_PACKAGES[*]}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
@@ -333,24 +457,63 @@ jobs:
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "All packages are current. Run completed successfully." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "**Packages Uploaded:**" >> $GITHUB_STEP_SUMMARY
|
||||
UPLOADED_PACKAGES="${{ steps.upload.outputs.uploaded_packages }}"
|
||||
SKIPPED_PACKAGES="${{ steps.upload.outputs.skipped_packages }}"
|
||||
TOTAL_COUNT=$(wc -w <<<"$PACKAGES" | tr -d ' ')
|
||||
UPLOADED_COUNT=0
|
||||
SKIPPED_COUNT=0
|
||||
if [[ -n "$UPLOADED_PACKAGES" ]]; then
|
||||
UPLOADED_COUNT=$(wc -w <<<"$UPLOADED_PACKAGES" | tr -d ' ')
|
||||
fi
|
||||
if [[ -n "$SKIPPED_PACKAGES" ]]; then
|
||||
SKIPPED_COUNT=$(wc -w <<<"$SKIPPED_PACKAGES" | tr -d ' ')
|
||||
fi
|
||||
in_list() {
|
||||
local item="$1"
|
||||
local list="$2"
|
||||
[[ " $list " == *" $item "* ]]
|
||||
}
|
||||
|
||||
if [[ "${{ job.status }}" == "success" ]]; then
|
||||
echo "**Status:** ✅ Completed successfully" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "**Status:** ❌ Completed with errors" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Processed:** $TOTAL_COUNT package(s)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Uploaded:** $UPLOADED_COUNT package(s)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Skipped (up to date):** $SKIPPED_COUNT package(s)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Packages:**" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
for PKG in $PACKAGES; do
|
||||
STATUS_ICON="✅"
|
||||
STATUS_TEXT="uploaded"
|
||||
if in_list "$PKG" "$SKIPPED_PACKAGES"; then
|
||||
STATUS_ICON="ℹ️"
|
||||
STATUS_TEXT="up to date (skipped)"
|
||||
elif ! in_list "$PKG" "$UPLOADED_PACKAGES"; then
|
||||
STATUS_ICON="❌"
|
||||
STATUS_TEXT="failed"
|
||||
fi
|
||||
|
||||
case "$PKG" in
|
||||
dms)
|
||||
echo "- ✅ **dms** → [View builds](https://build.opensuse.org/package/show/home:AvengeMedia:dms/dms)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- $STATUS_ICON **dms** ($STATUS_TEXT) → [View builds](https://build.opensuse.org/package/show/home:AvengeMedia:dms/dms)" >> $GITHUB_STEP_SUMMARY
|
||||
;;
|
||||
dms-git)
|
||||
echo "- ✅ **dms-git** → [View builds](https://build.opensuse.org/package/show/home:AvengeMedia:dms-git/dms-git)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- $STATUS_ICON **dms-git** ($STATUS_TEXT) → [View builds](https://build.opensuse.org/package/show/home:AvengeMedia:dms-git/dms-git)" >> $GITHUB_STEP_SUMMARY
|
||||
;;
|
||||
dms-greeter)
|
||||
echo "- $STATUS_ICON **dms-greeter** ($STATUS_TEXT) → [View builds](https://build.opensuse.org/package/show/home:AvengeMedia:danklinux/dms-greeter)" >> $GITHUB_STEP_SUMMARY
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
if [[ -n "${{ github.event.inputs.rebuild_release }}" ]]; then
|
||||
echo "**Rebuild Number:** ppa${{ github.event.inputs.rebuild_release }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Rebuild Number:** db${{ github.event.inputs.rebuild_release }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
|
||||
+80
-208
@@ -4,160 +4,101 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
package:
|
||||
description: "Package to upload (dms, dms-git, dms-greeter, or all)"
|
||||
required: false
|
||||
default: "dms-git"
|
||||
description: "Package to upload"
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- dms
|
||||
- dms-greeter
|
||||
- dms-git
|
||||
- all
|
||||
default: "dms"
|
||||
rebuild_release:
|
||||
description: "Release number for rebuilds (e.g., 2, 3, 4 for ppa2, ppa3, ppa4)"
|
||||
required: false
|
||||
default: ""
|
||||
schedule:
|
||||
- cron: "0 */3 * * *" # Every 3 hours for dms-git builds
|
||||
- cron: "0 2,5,14,17,20,23 * * *" # 9am, 12pm, 3pm, 6pm, 9pm, 12am EST (UTC times shown)
|
||||
|
||||
jobs:
|
||||
check-updates:
|
||||
name: Check for updates
|
||||
name: Check package/series updates
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository == 'AvengeMedia/DankMaterialShell'
|
||||
|
||||
outputs:
|
||||
has_updates: ${{ steps.check.outputs.has_updates }}
|
||||
packages: ${{ steps.check.outputs.packages }}
|
||||
targets: ${{ steps.check.outputs.targets }}
|
||||
targets_json: ${{ steps.check.outputs.targets_json }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y jq curl git
|
||||
|
||||
- name: Check for updates
|
||||
id: check
|
||||
run: |
|
||||
# Helper function to check dms-git commit
|
||||
check_dms_git() {
|
||||
local CURRENT_COMMIT=$(git rev-parse --short=8 HEAD)
|
||||
local PPA_VERSION=$(curl -s "https://api.launchpad.net/1.0/~avengemedia/+archive/ubuntu/dms-git?ws.op=getPublishedSources&source_name=dms-git&status=Published" | grep -oP '"source_package_version":\s*"\K[^"]+' | head -1 || echo "")
|
||||
local PPA_COMMIT=$(echo "$PPA_VERSION" | grep -oP '\.[a-f0-9]{8}' | tr -d '.' || echo "")
|
||||
|
||||
if [[ -n "$PPA_COMMIT" && "$CURRENT_COMMIT" == "$PPA_COMMIT" ]]; then
|
||||
echo "📋 dms-git: Commit $CURRENT_COMMIT already exists, skipping"
|
||||
return 1 # No update needed
|
||||
else
|
||||
echo "📋 dms-git: New commit $CURRENT_COMMIT (PPA has ${PPA_COMMIT:-none})"
|
||||
return 0 # Update needed
|
||||
fi
|
||||
}
|
||||
|
||||
# Helper function to check stable package tag
|
||||
check_stable_package() {
|
||||
local PKG="$1"
|
||||
local PPA_NAME="$2"
|
||||
local LATEST_TAG=$(curl -s https://api.github.com/repos/AvengeMedia/DankMaterialShell/releases/latest | grep '"tag_name"' | sed 's/.*"tag_name": "v\?\([^"]*\)".*/\1/' || echo "")
|
||||
local PPA_VERSION=$(curl -s "https://api.launchpad.net/1.0/~avengemedia/+archive/ubuntu/$PPA_NAME?ws.op=getPublishedSources&source_name=$PKG&status=Published" | grep -oP '"source_package_version":\s*"\K[^"]+' | head -1 || echo "")
|
||||
local PPA_BASE_VERSION=$(echo "$PPA_VERSION" | sed 's/ppa[0-9]*$//')
|
||||
|
||||
if [[ -n "$LATEST_TAG" && "$LATEST_TAG" == "$PPA_BASE_VERSION" ]]; then
|
||||
echo "📋 $PKG: Tag $LATEST_TAG already exists, skipping"
|
||||
return 1 # No update needed
|
||||
else
|
||||
echo "📋 $PKG: New tag ${LATEST_TAG:-unknown} (PPA has ${PPA_BASE_VERSION:-none})"
|
||||
return 0 # Update needed
|
||||
fi
|
||||
}
|
||||
|
||||
# Main logic
|
||||
REBUILD="${{ github.event.inputs.rebuild_release }}"
|
||||
chmod +x distro/scripts/ppa-sync-plan.sh
|
||||
|
||||
if [[ "${{ github.event_name }}" == "schedule" ]]; then
|
||||
# Scheduled run - check dms-git only
|
||||
echo "packages=dms-git" >> $GITHUB_OUTPUT
|
||||
if check_dms_git; then
|
||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "has_updates=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
elif [[ -n "${{ github.event.inputs.package }}" ]]; then
|
||||
# Manual workflow trigger
|
||||
PKG="${{ github.event.inputs.package }}"
|
||||
|
||||
if [[ -n "$REBUILD" ]]; then
|
||||
# Rebuild requested - always proceed
|
||||
echo "packages=$PKG" >> $GITHUB_OUTPUT
|
||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
||||
echo "🔄 Manual rebuild requested: $PKG (ppa$REBUILD)"
|
||||
|
||||
elif [[ "$PKG" == "all" ]]; then
|
||||
# Check each package and build list of those needing updates
|
||||
PACKAGES_TO_UPDATE=()
|
||||
check_dms_git && PACKAGES_TO_UPDATE+=("dms-git")
|
||||
check_stable_package "dms" "dms" && PACKAGES_TO_UPDATE+=("dms")
|
||||
check_stable_package "dms-greeter" "danklinux" && PACKAGES_TO_UPDATE+=("dms-greeter")
|
||||
|
||||
if [[ ${#PACKAGES_TO_UPDATE[@]} -gt 0 ]]; then
|
||||
echo "packages=${PACKAGES_TO_UPDATE[*]}" >> $GITHUB_OUTPUT
|
||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
||||
echo "✓ Packages to update: ${PACKAGES_TO_UPDATE[*]}"
|
||||
else
|
||||
echo "packages=" >> $GITHUB_OUTPUT
|
||||
echo "has_updates=false" >> $GITHUB_OUTPUT
|
||||
echo "✓ All packages up to date"
|
||||
fi
|
||||
|
||||
elif [[ "$PKG" == "dms-git" ]]; then
|
||||
if check_dms_git; then
|
||||
echo "packages=$PKG" >> $GITHUB_OUTPUT
|
||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "packages=" >> $GITHUB_OUTPUT
|
||||
echo "has_updates=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
elif [[ "$PKG" == "dms" ]]; then
|
||||
if check_stable_package "dms" "dms"; then
|
||||
echo "packages=$PKG" >> $GITHUB_OUTPUT
|
||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "packages=" >> $GITHUB_OUTPUT
|
||||
echo "has_updates=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
elif [[ "$PKG" == "dms-greeter" ]]; then
|
||||
if check_stable_package "dms-greeter" "danklinux"; then
|
||||
echo "packages=$PKG" >> $GITHUB_OUTPUT
|
||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "packages=" >> $GITHUB_OUTPUT
|
||||
echo "has_updates=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
else
|
||||
# Unknown package - proceed anyway
|
||||
echo "packages=$PKG" >> $GITHUB_OUTPUT
|
||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
||||
echo "Manual trigger: $PKG"
|
||||
fi
|
||||
PACKAGE="dms-git"
|
||||
else
|
||||
# Fallback
|
||||
echo "packages=dms-git" >> $GITHUB_OUTPUT
|
||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
||||
PACKAGE="${{ github.event.inputs.package }}"
|
||||
fi
|
||||
|
||||
REBUILD_RELEASE="${{ github.event.inputs.rebuild_release }}"
|
||||
ARGS=(--package "$PACKAGE" --json)
|
||||
if [[ -n "$REBUILD_RELEASE" ]]; then
|
||||
ARGS+=(--rebuild "$REBUILD_RELEASE")
|
||||
fi
|
||||
|
||||
TARGETS_JSON=$(distro/scripts/ppa-sync-plan.sh "${ARGS[@]}" 2> ppa-audit.log)
|
||||
cat ppa-audit.log
|
||||
|
||||
TARGETS=$(echo "$TARGETS_JSON" | jq -r 'join(" ")')
|
||||
if [[ "$TARGETS_JSON" != "[]" ]]; then
|
||||
echo "has_updates=true" >> "$GITHUB_OUTPUT"
|
||||
echo "targets=$TARGETS" >> "$GITHUB_OUTPUT"
|
||||
echo "targets_json=$TARGETS_JSON" >> "$GITHUB_OUTPUT"
|
||||
echo "Package/series targets: $TARGETS"
|
||||
else
|
||||
echo "has_updates=false" >> "$GITHUB_OUTPUT"
|
||||
echo "targets=" >> "$GITHUB_OUTPUT"
|
||||
echo "targets_json=[]" >> "$GITHUB_OUTPUT"
|
||||
echo "No package/series uploads needed"
|
||||
fi
|
||||
|
||||
upload-ppa:
|
||||
name: Upload to PPA
|
||||
name: Upload ${{ matrix.target }}
|
||||
needs: check-updates
|
||||
runs-on: ubuntu-latest
|
||||
if: needs.check-updates.outputs.has_updates == 'true'
|
||||
timeout-minutes: 120
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
target: ${{ fromJson(needs.check-updates.outputs.targets_json) }}
|
||||
concurrency:
|
||||
group: ppa-dms-${{ matrix.target }}
|
||||
cancel-in-progress: false
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.24"
|
||||
go-version-file: ./core/go.mod
|
||||
cache: false
|
||||
|
||||
- name: Install build dependencies
|
||||
@@ -170,7 +111,8 @@ jobs:
|
||||
lftp \
|
||||
build-essential \
|
||||
fakeroot \
|
||||
dpkg-dev
|
||||
dpkg-dev \
|
||||
openssh-client
|
||||
|
||||
- name: Configure GPG
|
||||
env:
|
||||
@@ -178,102 +120,32 @@ jobs:
|
||||
run: |
|
||||
echo "$GPG_KEY" | gpg --import
|
||||
GPG_KEY_ID=$(gpg --list-secret-keys --keyid-format LONG | grep sec | awk '{print $2}' | cut -d'/' -f2)
|
||||
echo "DEBSIGN_KEYID=$GPG_KEY_ID" >> $GITHUB_ENV
|
||||
echo "DEBSIGN_KEYID=$GPG_KEY_ID" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Determine packages to upload
|
||||
id: packages
|
||||
- name: Upload target
|
||||
env:
|
||||
TARGET: ${{ matrix.target }}
|
||||
LAUNCHPAD_SSH_PRIVATE_KEY: ${{ secrets.LAUNCHPAD_SSH_PRIVATE_KEY }}
|
||||
LAUNCHPAD_SSH_LOGIN: ${{ secrets.LAUNCHPAD_SSH_LOGIN }}
|
||||
run: |
|
||||
# Use packages determined by check-updates job
|
||||
echo "packages=${{ needs.check-updates.outputs.packages }}" >> $GITHUB_OUTPUT
|
||||
IFS=':' read -r PACKAGE UBUNTU_SERIES PPA_NUM <<< "$TARGET"
|
||||
|
||||
if [[ "${{ github.event_name }}" == "schedule" ]]; then
|
||||
echo "Triggered by schedule: uploading git package"
|
||||
elif [[ -n "${{ github.event.inputs.package }}" ]]; then
|
||||
echo "Manual trigger: ${{ needs.check-updates.outputs.packages }}"
|
||||
fi
|
||||
case "$PACKAGE" in
|
||||
dms) PPA_NAME="dms" ;;
|
||||
dms-git) PPA_NAME="dms-git" ;;
|
||||
dms-greeter) PPA_NAME="danklinux" ;;
|
||||
*) echo "::error::Unknown package $PACKAGE"; exit 1 ;;
|
||||
esac
|
||||
|
||||
- name: Upload to PPA
|
||||
run: |
|
||||
PACKAGES="${{ steps.packages.outputs.packages }}"
|
||||
REBUILD_RELEASE="${{ github.event.inputs.rebuild_release }}"
|
||||
|
||||
if [[ -z "$PACKAGES" ]]; then
|
||||
echo "✓ No packages need uploading. All up to date!"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Export REBUILD_RELEASE so ppa-build.sh can use it
|
||||
if [[ -n "$REBUILD_RELEASE" ]]; then
|
||||
export REBUILD_RELEASE
|
||||
echo "✓ Using rebuild release number: ppa$REBUILD_RELEASE"
|
||||
fi
|
||||
|
||||
# PACKAGES can be space-separated list (e.g., "dms-git dms" from "all" check)
|
||||
# Loop through each package and upload
|
||||
for PKG in $PACKAGES; do
|
||||
# Map package to PPA name
|
||||
case "$PKG" in
|
||||
dms)
|
||||
PPA_NAME="dms"
|
||||
;;
|
||||
dms-git)
|
||||
PPA_NAME="dms-git"
|
||||
;;
|
||||
dms-greeter)
|
||||
PPA_NAME="danklinux"
|
||||
;;
|
||||
*)
|
||||
echo "⚠️ Unknown package: $PKG, skipping"
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
|
||||
echo ""
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "Uploading $PKG to PPA $PPA_NAME..."
|
||||
if [[ -n "$REBUILD_RELEASE" ]]; then
|
||||
echo "🔄 Using rebuild release number: ppa$REBUILD_RELEASE"
|
||||
fi
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
bash distro/scripts/ppa-upload.sh "$PKG" "$PPA_NAME" questing ${REBUILD_RELEASE:+"$REBUILD_RELEASE"}
|
||||
done
|
||||
echo "Uploading $PACKAGE to $PPA_NAME/$UBUNTU_SERIES with ppa$PPA_NUM"
|
||||
bash distro/scripts/ppa-upload.sh "$PACKAGE" "$PPA_NAME" "$UBUNTU_SERIES" "$PPA_NUM"
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
echo "### PPA Package Upload Summary" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
PACKAGES="${{ steps.packages.outputs.packages }}"
|
||||
|
||||
if [[ -z "$PACKAGES" ]]; then
|
||||
echo "**Status:** ✅ All packages up to date (no uploads needed)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "All packages are current. Run will complete successfully." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "**Packages Uploaded:**" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
for PKG in $PACKAGES; do
|
||||
case "$PKG" in
|
||||
dms)
|
||||
echo "- ✅ **dms** → [View builds](https://launchpad.net/~avengemedia/+archive/ubuntu/dms/+packages)" >> $GITHUB_STEP_SUMMARY
|
||||
;;
|
||||
dms-git)
|
||||
echo "- ✅ **dms-git** → [View builds](https://launchpad.net/~avengemedia/+archive/ubuntu/dms-git/+packages)" >> $GITHUB_STEP_SUMMARY
|
||||
;;
|
||||
dms-greeter)
|
||||
echo "- ✅ **dms-greeter** → [View builds](https://launchpad.net/~avengemedia/+archive/ubuntu/danklinux/+packages)" >> $GITHUB_STEP_SUMMARY
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
if [[ -n "${{ github.event.inputs.rebuild_release }}" ]]; then
|
||||
echo "**Rebuild Number:** ppa${{ github.event.inputs.rebuild_release }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
echo "Builds will appear once Launchpad processes the uploads." >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
echo "### PPA Package Upload" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- **Target:** ${{ matrix.target }}" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- **DMS PPA:** https://launchpad.net/~avengemedia/+archive/ubuntu/dms/+packages" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- **DMS-Git PPA:** https://launchpad.net/~avengemedia/+archive/ubuntu/dms-git/+packages" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- **DankLinux PPA:** https://launchpad.net/~avengemedia/+archive/ubuntu/danklinux/+packages" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
name: Void Linux XBPS Repository
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 2,5,14,17,20,23 * * *" # 9am, 12pm, 3pm, 6pm, 9pm, 12am EST (UTC times shown)
|
||||
# release trigger disabled; dispatch manually after a release
|
||||
# release:
|
||||
# types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Stable release tag/version to build (e.g. v1.5.0 or 1.5.0). Leave blank to use the selected ref/template version'
|
||||
type: string
|
||||
required: false
|
||||
force_rebuild:
|
||||
description: 'Force rebuilding packages even if they already exist in the repository'
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
build_git:
|
||||
description: 'Build dms-git package'
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
build_dms:
|
||||
description: 'Build stable dms package'
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
build_greeter:
|
||||
description: 'Build stable dms-greeter package'
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: void-xbps-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
preflight:
|
||||
name: Check for unpublished changes
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
should_build: ${{ steps.check.outputs.should_build }}
|
||||
steps:
|
||||
- name: Check published commit
|
||||
id: check
|
||||
env:
|
||||
MANIFEST_URL: https://void.danklinux.com/dms/current/manifest.json
|
||||
run: |
|
||||
SHOULD_BUILD=true
|
||||
if [ "${{ github.event_name }}" = "schedule" ]; then
|
||||
PUBLISHED_COMMIT="$(curl -fsSL --retry 3 "$MANIFEST_URL" | jq -r '.source_commit // empty' 2>/dev/null || true)"
|
||||
if [ "$PUBLISHED_COMMIT" = "${{ github.sha }}" ]; then
|
||||
SHOULD_BUILD=false
|
||||
echo "${{ github.sha }} is already published; skipping the scheduled build."
|
||||
fi
|
||||
fi
|
||||
echo "should_build=$SHOULD_BUILD" >> "$GITHUB_OUTPUT"
|
||||
|
||||
build-and-deploy:
|
||||
name: Build & Deploy XBPS packages
|
||||
runs-on: ubuntu-latest
|
||||
needs: preflight
|
||||
if: github.repository == 'AvengeMedia/DankMaterialShell' && 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 }}
|
||||
AWS_DEFAULT_REGION: auto
|
||||
AWS_EC2_METADATA_DISABLED: true
|
||||
R2_BUCKET: danklinux-void
|
||||
R2_ENDPOINT: https://${{ secrets.CLOUDFLARE_ACCOUNT_ID }}.r2.cloudflarestorage.com
|
||||
R2_PREFIX: dms
|
||||
REPOSITORY_DIR: ${{ github.workspace }}/r2-repo
|
||||
SOURCE_COMMIT: ${{ github.sha }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.release.tag_name || (github.event.inputs.version && (startsWith(github.event.inputs.version, 'v') && github.event.inputs.version || format('v{0}', github.event.inputs.version))) || github.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Record checked-out source commit
|
||||
run: echo "SOURCE_COMMIT=$(git rev-parse HEAD)" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Download current R2 repository
|
||||
run: |
|
||||
aws --version
|
||||
bash .github/scripts/publish-void-r2.sh download
|
||||
|
||||
- name: Install XBPS Static Utilities
|
||||
run: |
|
||||
mkdir -p ${{ github.workspace }}/xbps
|
||||
curl -s -L https://repo-default.voidlinux.org/static/xbps-static-latest.x86_64-musl.tar.xz | tar -xJ -C ${{ github.workspace }}/xbps
|
||||
echo "${{ github.workspace }}/xbps/usr/bin" >> $GITHUB_PATH
|
||||
|
||||
- name: Clone void-packages
|
||||
run: |
|
||||
git clone --depth=1 https://github.com/void-linux/void-packages.git
|
||||
|
||||
- name: Inject templates
|
||||
run: |
|
||||
cp -R distro/void/srcpkgs/dms void-packages/srcpkgs/
|
||||
cp -R distro/void/srcpkgs/dms-greeter void-packages/srcpkgs/
|
||||
cp -R distro/void/srcpkgs/dms-git void-packages/srcpkgs/
|
||||
|
||||
- name: Enable unprivileged user namespaces (Ubuntu 24.04)
|
||||
run: |
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true
|
||||
|
||||
- name: Bootstrap xbps-src
|
||||
run: |
|
||||
cd void-packages
|
||||
./xbps-src binary-bootstrap
|
||||
|
||||
- name: Configure signing keys and trust
|
||||
run: |
|
||||
# Write private key; extract and register the public key
|
||||
echo "${{ secrets.XBPS_PRIVATE_KEY }}" > /tmp/xbps_privkey.pem
|
||||
chmod 600 /tmp/xbps_privkey.pem
|
||||
|
||||
# Extract public key in PEM format
|
||||
openssl rsa -in /tmp/xbps_privkey.pem -pubout -out /tmp/dms-key.pub
|
||||
rm -f /tmp/xbps_privkey.pem
|
||||
|
||||
# Compute MD5 fingerprint in colon-separated hex format
|
||||
FINGERPRINT=$(openssl rsa -pubin -in /tmp/dms-key.pub -outform DER 2>/dev/null | openssl dgst -md5 -c | tr '[:upper:]' '[:lower:]' | awk '{print $NF}')
|
||||
|
||||
# Format key in XML property list (plist) format as expected by xbps
|
||||
mkdir -p /tmp/keys
|
||||
cat <<EOF > "/tmp/keys/${FINGERPRINT}.plist"
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>public-key</key>
|
||||
<data>$(base64 -w0 /tmp/dms-key.pub)</data>
|
||||
<key>public-key-size</key>
|
||||
<integer>4096</integer>
|
||||
<key>signature-by</key>
|
||||
<string>AvengeMedia</string>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
|
||||
# Copy keys to all host and chroot trust stores
|
||||
sudo mkdir -p /var/db/xbps/keys
|
||||
sudo cp "/tmp/keys/${FINGERPRINT}.plist" "/var/db/xbps/keys/${FINGERPRINT}.plist"
|
||||
|
||||
mkdir -p void-packages/masterdir/var/db/xbps/keys
|
||||
cp "/tmp/keys/${FINGERPRINT}.plist" "void-packages/masterdir/var/db/xbps/keys/${FINGERPRINT}.plist"
|
||||
|
||||
mkdir -p void-packages/etc/xbps.d/keys
|
||||
cp "/tmp/keys/${FINGERPRINT}.plist" "void-packages/etc/xbps.d/keys/${FINGERPRINT}.plist"
|
||||
|
||||
mkdir -p void-packages/common/repo-keys
|
||||
cp "/tmp/keys/${FINGERPRINT}.plist" "void-packages/common/repo-keys/${FINGERPRINT}.plist"
|
||||
|
||||
rm -rf /tmp/keys /tmp/dms-key.pub
|
||||
|
||||
- name: Configure repositories
|
||||
run: |
|
||||
# Append the repository to repos-remote templates so xbps-src translates it automatically
|
||||
echo "repository=https://void.danklinux.com/danklinux/current" >> void-packages/etc/xbps.d/repos-remote.conf
|
||||
echo "repository=https://void.danklinux.com/danklinux/current" >> void-packages/etc/xbps.d/repos-remote-x86_64-multilib.conf
|
||||
|
||||
# Add any existing compiled packages to the build cache directory to avoid rebuilds
|
||||
if [ -d "r2-repo/current" ]; then
|
||||
mkdir -p void-packages/hostdir/binpkgs
|
||||
cp -L r2-repo/current/*.xbps void-packages/hostdir/binpkgs/ 2>/dev/null || true
|
||||
xbps-rindex -a void-packages/hostdir/binpkgs/*.xbps 2>/dev/null || true
|
||||
fi
|
||||
|
||||
- name: Build packages
|
||||
run: |
|
||||
mkdir -p r2-repo/current
|
||||
|
||||
BUILD_DMS="${{ github.event.inputs.build_dms || 'true' }}"
|
||||
BUILD_GREETER="${{ github.event.inputs.build_greeter || 'true' }}"
|
||||
BUILD_GIT="${{ github.event.inputs.build_git || 'true' }}"
|
||||
FORCE_REBUILD="${{ github.event.inputs.force_rebuild || 'false' }}"
|
||||
|
||||
if [ "${{ github.event_name }}" = "schedule" ]; then
|
||||
BUILD_DMS="false"
|
||||
BUILD_GREETER="false"
|
||||
BUILD_GIT="true"
|
||||
fi
|
||||
|
||||
if [ "${{ github.event_name }}" = "release" ]; then
|
||||
BUILD_DMS="true"
|
||||
BUILD_GREETER="true"
|
||||
BUILD_GIT="false"
|
||||
fi
|
||||
|
||||
RELEASE_TAG=""
|
||||
if [ "${{ github.event_name }}" = "release" ]; then
|
||||
RELEASE_TAG="${{ github.event.release.tag_name }}"
|
||||
elif [ -n "${{ github.event.inputs.version || '' }}" ]; then
|
||||
RELEASE_TAG="${{ github.event.inputs.version }}"
|
||||
elif [ "${{ github.ref_type }}" = "tag" ]; then
|
||||
RELEASE_TAG="${{ github.ref_name }}"
|
||||
fi
|
||||
|
||||
if [ -n "$RELEASE_TAG" ]; then
|
||||
RELEASE_VER="${RELEASE_TAG#v}"
|
||||
ARCHIVE_TAG="v${RELEASE_VER}"
|
||||
echo "Stable release tag: $ARCHIVE_TAG"
|
||||
else
|
||||
RELEASE_VER=""
|
||||
ARCHIVE_TAG=""
|
||||
echo "Stable release tag: (template version)"
|
||||
fi
|
||||
|
||||
echo "=== Starting Builds ==="
|
||||
echo "DMS stable build enabled: $BUILD_DMS"
|
||||
echo "Greeter stable build enabled: $BUILD_GREETER"
|
||||
echo "Git build enabled: $BUILD_GIT"
|
||||
echo "Force rebuild: $FORCE_REBUILD"
|
||||
|
||||
cd void-packages
|
||||
|
||||
if [ -n "$RELEASE_VER" ] && { [ "$BUILD_DMS" = "true" ] || [ "$BUILD_GREETER" = "true" ]; }; then
|
||||
echo "🔧 Updating stable templates for $ARCHIVE_TAG"
|
||||
TARBALL="$(mktemp)"
|
||||
curl -fsSL -o "$TARBALL" "https://github.com/${{ github.repository }}/archive/refs/tags/${ARCHIVE_TAG}.tar.gz"
|
||||
RELEASE_CHECKSUM="$(sha256sum "$TARBALL" | cut -d' ' -f1)"
|
||||
rm -f "$TARBALL"
|
||||
|
||||
for pkg in dms dms-greeter; do
|
||||
sed -i "s/^version=.*/version=${RELEASE_VER}/" "srcpkgs/${pkg}/template"
|
||||
sed -i "s/^checksum=.*/checksum=${RELEASE_CHECKSUM}/" "srcpkgs/${pkg}/template"
|
||||
done
|
||||
fi
|
||||
|
||||
# 1. Build dms-git (development package)
|
||||
if [ "$BUILD_GIT" = "true" ]; then
|
||||
# Calculate dynamic git version (tag.commits.hash)
|
||||
GIT_VER=$(git -C .. describe --tags --always | sed 's/^v//; s/-/./g')
|
||||
echo "🔨 Preparing dms-git version $GIT_VER"
|
||||
|
||||
# Stage source tarball in the xbps-src source cache.
|
||||
# --transform adds a top-level directory so xbps-src can extract
|
||||
# into $wrksrc (create_wrksrc=yes handles the rest).
|
||||
SRC_CACHE="hostdir/sources/dms-git-${GIT_VER}"
|
||||
mkdir -p "$SRC_CACHE"
|
||||
tar -czf "${SRC_CACHE}/dms-git-${GIT_VER}.tar.gz" \
|
||||
--exclude=void-packages \
|
||||
--exclude=r2-repo \
|
||||
--exclude=.git \
|
||||
--exclude=danklinux \
|
||||
-C .. .
|
||||
|
||||
CHECKSUM=$(sha256sum "${SRC_CACHE}/dms-git-${GIT_VER}.tar.gz" | cut -d' ' -f1)
|
||||
|
||||
# Dynamically patch template version, checksum, and distfiles
|
||||
sed -i "s/^version=.*/version=${GIT_VER}/" srcpkgs/dms-git/template
|
||||
sed -i "s/^checksum=.*/checksum=${CHECKSUM}/" srcpkgs/dms-git/template
|
||||
sed -i "s|^distfiles=.*|distfiles=\"dms-git-${GIT_VER}.tar.gz\"|" srcpkgs/dms-git/template
|
||||
|
||||
EXPECTED_GIT_FILE="dms-git-${GIT_VER}_1.x86_64.xbps"
|
||||
|
||||
if [ -f "../r2-repo/current/$EXPECTED_GIT_FILE" ] && [ "$FORCE_REBUILD" != "true" ]; then
|
||||
echo "✅ $EXPECTED_GIT_FILE already exists, skipping build."
|
||||
else
|
||||
echo "🔨 Compiling dms-git..."
|
||||
./xbps-src pkg dms-git
|
||||
rm -f "../r2-repo/current/${EXPECTED_GIT_FILE}"
|
||||
cp -L "hostdir/binpkgs/${EXPECTED_GIT_FILE}" ../r2-repo/current/
|
||||
fi
|
||||
fi
|
||||
|
||||
# 2. Build stable dms package
|
||||
if [ "$BUILD_DMS" = "true" ]; then
|
||||
STABLE_VER=$(grep -E '^version=' srcpkgs/dms/template | cut -d= -f2 | tr -d '"')
|
||||
STABLE_REV=$(grep -E '^revision=' srcpkgs/dms/template | cut -d= -f2 | tr -d '"')
|
||||
|
||||
EXPECTED_DMS_FILE="dms-${STABLE_VER}_${STABLE_REV}.x86_64.xbps"
|
||||
if [ -f "../r2-repo/current/$EXPECTED_DMS_FILE" ] && [ "$FORCE_REBUILD" != "true" ]; then
|
||||
echo "✅ $EXPECTED_DMS_FILE already exists, skipping build."
|
||||
else
|
||||
echo "🔨 Compiling dms ($STABLE_VER)..."
|
||||
./xbps-src pkg dms
|
||||
rm -f "../r2-repo/current/${EXPECTED_DMS_FILE}"
|
||||
cp -L hostdir/binpkgs/dms-${STABLE_VER}_${STABLE_REV}.x86_64.xbps ../r2-repo/current/
|
||||
fi
|
||||
fi
|
||||
|
||||
# 3. Build stable dms-greeter package
|
||||
if [ "$BUILD_GREETER" = "true" ]; then
|
||||
GREETER_VER=$(grep -E '^version=' srcpkgs/dms-greeter/template | cut -d= -f2 | tr -d '"')
|
||||
GREETER_REV=$(grep -E '^revision=' srcpkgs/dms-greeter/template | cut -d= -f2 | tr -d '"')
|
||||
|
||||
EXPECTED_GREETER_FILE="dms-greeter-${GREETER_VER}_${GREETER_REV}.x86_64.xbps"
|
||||
if [ -f "../r2-repo/current/$EXPECTED_GREETER_FILE" ] && [ "$FORCE_REBUILD" != "true" ]; then
|
||||
echo "✅ $EXPECTED_GREETER_FILE already exists, skipping build."
|
||||
else
|
||||
echo "🔨 Compiling dms-greeter ($GREETER_VER)..."
|
||||
./xbps-src pkg dms-greeter
|
||||
rm -f "../r2-repo/current/${EXPECTED_GREETER_FILE}"
|
||||
cp -L hostdir/binpkgs/dms-greeter-${GREETER_VER}_${GREETER_REV}.x86_64.xbps ../r2-repo/current/
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Index and sign repository
|
||||
run: |
|
||||
cd r2-repo/current
|
||||
|
||||
rm -f *.sig
|
||||
|
||||
# Guard: nothing to index if no .xbps files exist
|
||||
if ! ls *.xbps 1>/dev/null 2>&1; then
|
||||
echo "⚠️ No .xbps files found to index, skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Regenerate repo index
|
||||
xbps-rindex -a $(pwd)/*.xbps
|
||||
xbps-rindex --remove-obsoletes $(pwd)
|
||||
|
||||
# Remove signatures left behind by obsolete packages. Signatures for
|
||||
# unchanged immutable packages are retained byte-for-byte.
|
||||
for sig in *.sig2; do
|
||||
[ -e "$sig" ] || break
|
||||
[ -f "${sig%.sig2}" ] || rm -f "$sig"
|
||||
done
|
||||
|
||||
# Sign repository
|
||||
echo "${{ secrets.XBPS_PRIVATE_KEY }}" > /tmp/xbps_privkey.pem
|
||||
chmod 600 /tmp/xbps_privkey.pem
|
||||
|
||||
xbps-rindex --sign --signedby "AvengeMedia <AvengeMedia.US@gmail.com>" --privkey /tmp/xbps_privkey.pem $(pwd)
|
||||
xbps-rindex --sign-pkg --privkey /tmp/xbps_privkey.pem $(pwd)/*.xbps
|
||||
|
||||
rm -f /tmp/xbps_privkey.pem
|
||||
|
||||
- name: Publish repository to R2
|
||||
run: bash .github/scripts/publish-void-r2.sh publish
|
||||
@@ -1,19 +0,0 @@
|
||||
name: Update stable branch
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
jobs:
|
||||
update-stable:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Push to stable branch
|
||||
run: git push origin HEAD:refs/heads/stable --force
|
||||
@@ -18,13 +18,13 @@ 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 }}
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ steps.app_token.outputs.token }}
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
echo "Build succeeded, no hash update needed"
|
||||
exit 0
|
||||
fi
|
||||
new_hash=$(echo "$output" | grep -oP "got:\s+\K\S+" | head -n1)
|
||||
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; }
|
||||
@@ -59,8 +59,8 @@ jobs:
|
||||
git config user.email "dms-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 master
|
||||
git push https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git HEAD:master
|
||||
git pull --rebase origin ${{ github.ref_name }}
|
||||
git push https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git HEAD:${{ github.ref_name }}
|
||||
else
|
||||
echo "No changes to flake.nix"
|
||||
fi
|
||||
|
||||
+23
-1
@@ -56,6 +56,8 @@ UNUSED
|
||||
|
||||
CLAUDE-activeContext.md
|
||||
CLAUDE-temp.md
|
||||
AGENTS-activeContext.md
|
||||
AGENTS-temp.md
|
||||
|
||||
# Auto-generated theme files
|
||||
*.generated.*
|
||||
@@ -96,14 +98,34 @@ go.work
|
||||
go.work.sum
|
||||
|
||||
# env file
|
||||
.env
|
||||
.env*
|
||||
|
||||
# Editor/IDE
|
||||
# .idea/
|
||||
# .vscode/
|
||||
vim/
|
||||
|
||||
bin/
|
||||
|
||||
# Core dumps
|
||||
core.*
|
||||
|
||||
# prek-installed local git hooks (generated from .pre-commit-config.yaml)
|
||||
.githooks/
|
||||
|
||||
# direnv
|
||||
.envrc
|
||||
.direnv/
|
||||
quickshell/dms-plugins
|
||||
__pycache__
|
||||
|
||||
.vscode/
|
||||
|
||||
# Void (xbps) build artifacts
|
||||
*.xbps
|
||||
distro/void/temp/
|
||||
distro/void/hostdir/
|
||||
distro/void/masterdir*/
|
||||
|
||||
# Often gets built
|
||||
core/dms
|
||||
|
||||
+37
-3
@@ -5,8 +5,42 @@ repos:
|
||||
- id: trailing-whitespace
|
||||
- id: check-yaml
|
||||
- id: end-of-file-fixer
|
||||
- repo: https://github.com/shellcheck-py/shellcheck-py
|
||||
rev: v0.10.0.1
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: shellcheck
|
||||
args: [-e, SC2164, -e, SC2001, -e, SC2012, -e, SC2317]
|
||||
name: shellcheck
|
||||
entry: shellcheck -e SC2164 -e SC2001 -e SC2012 -e SC2317
|
||||
language: system
|
||||
types: [shell]
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: go-mod-tidy
|
||||
name: go mod tidy
|
||||
entry: bash -c 'cd core && go mod tidy'
|
||||
language: system
|
||||
files: ^core/.*\.(go|mod|sum)$
|
||||
pass_filenames: false
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: settings-search-index
|
||||
name: settings search index is up to date
|
||||
entry: bash -c 'python3 quickshell/translations/extract_settings_index.py >/dev/null || exit 1; if ! git diff --exit-code -- quickshell/translations/settings_search_index.json; then echo "settings_search_index.json is out of date; run quickshell/translations/extract_settings_index.py and stage the result" >&2; exit 1; fi'
|
||||
language: system
|
||||
files: ^quickshell/(Modules/Settings/.*\.qml|Modals/Settings/SettingsSidebar\.qml|translations/extract_settings_index\.py)$
|
||||
pass_filenames: false
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: i18n-term-freeze
|
||||
name: i18n term freeze (no new I18n.tr/qsTr terms)
|
||||
entry: python3 quickshell/translations/check_term_freeze.py
|
||||
language: system
|
||||
files: ^quickshell/(.*\.qml|translations/(term_freeze\.json|check_term_freeze\.py|extract_translations\.py))$
|
||||
pass_filenames: false
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: no-console-in-qml
|
||||
name: no console.* in QML (use Log service)
|
||||
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/)
|
||||
|
||||
@@ -1,6 +1,41 @@
|
||||
This file is more of a quick reference so I know what to account for before next releases.
|
||||
|
||||
# 1.5.0
|
||||
- Overhauled shadows
|
||||
- App ID changed to com.danklinux.dms - breaking for window rules
|
||||
- Greeter stuff
|
||||
- Terminal mux
|
||||
- Locale overrides
|
||||
- new neovim theming
|
||||
|
||||
# 1.4.0
|
||||
|
||||
- Overhauled system monitor, graphs, styling
|
||||
- dbus API for plugins, KDEConnect
|
||||
- new dank16 algorithm
|
||||
- launcher actions, customize env, args, name, icon
|
||||
- launcher v2 - omega stuff, GIF search, supa powerful
|
||||
- dock on bar
|
||||
- window rule manager, with IPC - #TODO verify RTL layout (niri only)
|
||||
|
||||
# 1.2.0
|
||||
|
||||
- Added clipboard and clipboard history integration
|
||||
- Added swipe to dismiss notification popups and from center
|
||||
- Added paste from clipboard history view - requires wtype
|
||||
- Optimize surface damage of OSD & Toast
|
||||
- Add monitor configurator (niri, Hyprland, MangoWC)
|
||||
- **BREAKING** ghostty theme changed to ~/.config/ghostty/themes/danktheme
|
||||
- requires intervention and doc update
|
||||
- Added desktop widget plugins
|
||||
- dev guidance available
|
||||
- builtin clock & dgop widgets
|
||||
- new IPC targets
|
||||
- Initial RTL support/i18n
|
||||
- Theme registry
|
||||
- Notification persistence & history
|
||||
- **BREAKING** vscode theme needs re-installed
|
||||
- dms doctor cmd
|
||||
- niri/hypr/mango gaps/window/border overrides
|
||||
- settings search
|
||||
- notification display ops on lock screen
|
||||
|
||||
+38
-3
@@ -22,7 +22,7 @@ nix develop
|
||||
|
||||
This will provide:
|
||||
|
||||
- Go 1.24 toolchain (go, gopls, delve, go-tools) and GNU Make
|
||||
- Go 1.25+ toolchain (go, gopls, delve, go-tools) and GNU Make
|
||||
- Quickshell and required QML packages
|
||||
- Properly configured QML2_IMPORT_PATH
|
||||
|
||||
@@ -37,10 +37,43 @@ This is a monorepo, the easiest thing to do is to open an editor in either `quic
|
||||
1. Install the [QML Extension](https://doc.qt.io/vscodeext/)
|
||||
2. Configure `ctrl+shift+p` -> user preferences (json) with qmlls path
|
||||
|
||||
**Note:** Paths may vary by distribution. Below are examples for Arch Linux and Fedora.
|
||||
|
||||
**Arch Linux:**
|
||||
|
||||
```json
|
||||
{
|
||||
"[qml]": {
|
||||
"editor.defaultFormatter": "qt-project.qmlls",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"qt-qml.doNotAskForQmllsDownload": true,
|
||||
"qt-qml.qmlls.customExePath": "/usr/lib/qt6/bin/qmlls"
|
||||
"qt-qml.qmlls.customExePath": "/usr/lib/qt6/bin/qmlls",
|
||||
"qt-core.additionalQtPaths": [
|
||||
{
|
||||
"name": "Qt-6.x-linux-g++",
|
||||
"path": "/usr/bin/qmake"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Fedora:**
|
||||
|
||||
```json
|
||||
{
|
||||
"[qml]": {
|
||||
"editor.defaultFormatter": "qt-project.qmlls",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"qt-qml.doNotAskForQmllsDownload": true,
|
||||
"qt-qml.qmlls.customExePath": "/usr/bin/qmlls",
|
||||
"qt-core.additionalQtPaths": [
|
||||
{
|
||||
"name": "Qt-6.x-Fedora-linux-g++",
|
||||
"path": "/usr/bin/qmake6"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -53,7 +86,9 @@ touch .qmlls.ini
|
||||
|
||||
4. Restart dms to generate the `.qmlls.ini` file
|
||||
|
||||
5. Make your changes, test, and open a pull request.
|
||||
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.
|
||||
|
||||
### I18n/Localization
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ BINARY_NAME=dms
|
||||
CORE_DIR=core
|
||||
BUILD_DIR=$(CORE_DIR)/bin
|
||||
PREFIX ?= /usr/local
|
||||
INSTALL_DIR=$(PREFIX)/bin
|
||||
DATA_DIR=$(PREFIX)/share
|
||||
INSTALL_DIR=$(DESTDIR)$(PREFIX)/bin
|
||||
DATA_DIR=$(DESTDIR)$(PREFIX)/share
|
||||
ICON_DIR=$(DATA_DIR)/icons/hicolor/scalable/apps
|
||||
|
||||
USER_HOME := $(if $(SUDO_USER),$(shell getent passwd $(SUDO_USER) | cut -d: -f6),$(HOME))
|
||||
@@ -18,7 +18,7 @@ SHELL_INSTALL_DIR=$(DATA_DIR)/quickshell/dms
|
||||
ASSETS_DIR=assets
|
||||
APPLICATIONS_DIR=$(DATA_DIR)/applications
|
||||
|
||||
.PHONY: all build clean 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 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
|
||||
|
||||
all: build
|
||||
|
||||
@@ -32,6 +32,9 @@ clean:
|
||||
@$(MAKE) -C $(CORE_DIR) clean
|
||||
@echo "Clean complete"
|
||||
|
||||
lint-qml:
|
||||
@./quickshell/scripts/qmllint-entrypoints.sh
|
||||
|
||||
# Installation targets
|
||||
install-bin:
|
||||
@echo "Installing $(BINARY_NAME) to $(INSTALL_DIR)..."
|
||||
@@ -43,7 +46,6 @@ install-shell:
|
||||
@mkdir -p $(SHELL_INSTALL_DIR)
|
||||
@cp -r $(SHELL_DIR)/* $(SHELL_INSTALL_DIR)/
|
||||
@rm -rf $(SHELL_INSTALL_DIR)/.git* $(SHELL_INSTALL_DIR)/.github
|
||||
@$(MAKE) --no-print-directory -C $(CORE_DIR) print-version > $(SHELL_INSTALL_DIR)/VERSION
|
||||
@echo "Shell files installed"
|
||||
|
||||
install-completions:
|
||||
@@ -59,10 +61,10 @@ install-completions:
|
||||
install-systemd:
|
||||
@echo "Installing systemd user service..."
|
||||
@mkdir -p $(SYSTEMD_USER_DIR)
|
||||
@if [ -n "$(SUDO_USER)" ]; then chown -R $(SUDO_USER):$(SUDO_USER) $(SYSTEMD_USER_DIR); fi
|
||||
@sed 's|/usr/bin/dms|$(INSTALL_DIR)/dms|g' $(ASSETS_DIR)/systemd/dms.service > $(SYSTEMD_USER_DIR)/dms.service
|
||||
@if [ -n "$(SUDO_USER)" ]; then chown -R $(SUDO_USER):"$(id -gn $SUDO_USER)" $(SYSTEMD_USER_DIR); fi
|
||||
@sed 's|/usr/bin/dms|$(PREFIX)/bin/dms|g' $(ASSETS_DIR)/systemd/dms.service > $(SYSTEMD_USER_DIR)/dms.service
|
||||
@chmod 644 $(SYSTEMD_USER_DIR)/dms.service
|
||||
@if [ -n "$(SUDO_USER)" ]; then chown $(SUDO_USER):$(SUDO_USER) $(SYSTEMD_USER_DIR)/dms.service; fi
|
||||
@if [ -n "$(SUDO_USER)" ]; then chown $(SUDO_USER):"$(id -gn $SUDO_USER)" $(SYSTEMD_USER_DIR)/dms.service; fi
|
||||
@echo "Systemd service installed to $(SYSTEMD_USER_DIR)/dms.service"
|
||||
|
||||
install-icon:
|
||||
@@ -72,12 +74,14 @@ install-icon:
|
||||
@echo "Icon installed"
|
||||
|
||||
install-desktop:
|
||||
@echo "Installing desktop entry..."
|
||||
@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
|
||||
@update-desktop-database -q $(APPLICATIONS_DIR) 2>/dev/null || true
|
||||
@echo "Desktop entry installed"
|
||||
@echo "Desktop entries installed"
|
||||
|
||||
install: build install-bin install-shell install-completions install-systemd install-icon install-desktop
|
||||
install: install-bin install-shell install-completions install-systemd install-icon install-desktop
|
||||
@echo ""
|
||||
@echo "Installation complete!"
|
||||
@echo ""
|
||||
@@ -114,10 +118,12 @@ uninstall-icon:
|
||||
@echo "Icon removed"
|
||||
|
||||
uninstall-desktop:
|
||||
@echo "Removing desktop entry..."
|
||||
@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
|
||||
@update-desktop-database -q $(APPLICATIONS_DIR) 2>/dev/null || true
|
||||
@echo "Desktop entry removed"
|
||||
@echo "Desktop entries removed"
|
||||
|
||||
uninstall: uninstall-systemd uninstall-desktop uninstall-icon uninstall-completions uninstall-shell uninstall-bin
|
||||
@echo ""
|
||||
@@ -131,6 +137,7 @@ help:
|
||||
@echo " all (default) - Build the DMS binary"
|
||||
@echo " build - Same as 'all'"
|
||||
@echo " clean - Clean build artifacts"
|
||||
@echo " lint-qml - Run qmllint on shell entrypoints using the Quickshell tooling VFS"
|
||||
@echo ""
|
||||
@echo "Install:"
|
||||
@echo " install - Build and install everything (requires sudo)"
|
||||
|
||||
@@ -13,13 +13,13 @@ Built with [Quickshell](https://quickshell.org/) and [Go](https://go.dev/)
|
||||
[](https://github.com/AvengeMedia/DankMaterialShell/stargazers)
|
||||
[](https://github.com/AvengeMedia/DankMaterialShell/blob/master/LICENSE)
|
||||
[](https://github.com/AvengeMedia/DankMaterialShell/releases)
|
||||
[](https://aur.archlinux.org/packages/dms-shell-bin)
|
||||
[](https://archlinux.org/packages/extra/x86_64/dms-shell/)
|
||||
[>)](https://aur.archlinux.org/packages/dms-shell-git)
|
||||
[](https://ko-fi.com/danklinux)
|
||||
|
||||
</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), and other Wayland compositors. It replaces waybar, swaylock, swayidle, mako, fuzzel, polkit, and everything else you'd normally stitch together to make a desktop.
|
||||
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.
|
||||
|
||||
## Repository Structure
|
||||
|
||||
@@ -105,7 +105,7 @@ Extend functionality with the [plugin registry](https://plugins.danklinux.com).
|
||||
|
||||
## 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/), and [Scroll](https://github.com/dawsers/scroll) with full workspace switching, overview integration, and monitor management. Other Wayland compositors work with reduced features.
|
||||
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)
|
||||
|
||||
@@ -163,7 +163,7 @@ quickshell -p quickshell/
|
||||
inputs.dms.url = "github:AvengeMedia/DankMaterialShell";
|
||||
|
||||
# Use in home-manager or NixOS configuration
|
||||
imports = [ inputs.dms.homeModules.dankMaterialShell.default ];
|
||||
imports = [ inputs.dms.homeModules.dank-material-shell ];
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=DankMaterialShell
|
||||
GenericName=Desktop Shell
|
||||
Comment=Material 3 inspired shell for Wayland compositors
|
||||
Exec=dms run
|
||||
Icon=danklogo
|
||||
Terminal=false
|
||||
NoDisplay=true
|
||||
StartupNotify=false
|
||||
Categories=Utility;
|
||||
@@ -0,0 +1,12 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=DMS Notepad
|
||||
GenericName=Text Editor
|
||||
Comment=Quick notes and text files in the dank desktop shell
|
||||
Exec=dms ipc call notepad openFile %f
|
||||
Icon=danklogo
|
||||
Terminal=false
|
||||
NoDisplay=true
|
||||
Categories=Utility;TextEditor;
|
||||
MimeType=text/plain;text/markdown;
|
||||
StartupNotify=false
|
||||
@@ -1,10 +1,10 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=DMS Application Picker
|
||||
Name=DMS
|
||||
Comment=Select an application to open links and files
|
||||
Exec=dms open %u
|
||||
Icon=danklogo
|
||||
Terminal=false
|
||||
NoDisplay=true
|
||||
MimeType=x-scheme-handler/http;x-scheme-handler/https;text/html;application/xhtml+xml;
|
||||
MimeType=x-scheme-handler/http;x-scheme-handler/https;x-scheme-handler/dms;text/html;application/xhtml+xml;
|
||||
Categories=Utility;
|
||||
|
||||
@@ -9,7 +9,7 @@ Type=dbus
|
||||
BusName=org.freedesktop.Notifications
|
||||
ExecStart=/usr/bin/dms run --session
|
||||
ExecReload=/usr/bin/pkill -USR1 -x dms
|
||||
Restart=always
|
||||
Restart=on-failure
|
||||
RestartSec=1.23
|
||||
TimeoutStopSec=10
|
||||
|
||||
|
||||
+66
-51
@@ -1,70 +1,85 @@
|
||||
with-expecter: true
|
||||
dir: "internal/mocks/{{.InterfaceDirRelative}}"
|
||||
mockname: "Mock{{.InterfaceName}}"
|
||||
outpkg: "{{.PackageName}}"
|
||||
dir: internal/mocks/{{.InterfaceDirRelative}}
|
||||
structname: Mock{{.InterfaceName}}
|
||||
pkgname: '{{.SrcPackageName}}'
|
||||
filename: 'mock_{{.InterfaceName}}.go'
|
||||
template: testify
|
||||
template-data:
|
||||
unroll-variadic: true
|
||||
packages:
|
||||
github.com/Wifx/gonetworkmanager/v2:
|
||||
interfaces:
|
||||
NetworkManager:
|
||||
Device:
|
||||
DeviceWireless:
|
||||
AccessPoint:
|
||||
Connection:
|
||||
Settings:
|
||||
ActiveConnection:
|
||||
IP4Config:
|
||||
net:
|
||||
interfaces:
|
||||
Conn:
|
||||
github.com/AvengeMedia/danklinux/internal/plugins:
|
||||
interfaces:
|
||||
GitClient:
|
||||
github.com/godbus/dbus/v5:
|
||||
interfaces:
|
||||
BusObject:
|
||||
github.com/AvengeMedia/danklinux/internal/server/brightness:
|
||||
github.com/AvengeMedia/DankMaterialShell/core/internal/geolocation:
|
||||
config:
|
||||
dir: "internal/mocks/brightness"
|
||||
outpkg: mocks_brightness
|
||||
dir: internal/mocks/geolocation
|
||||
pkgname: mocks_geolocation
|
||||
interfaces:
|
||||
DBusConn:
|
||||
github.com/AvengeMedia/DankMaterialShell/core/internal/server/network:
|
||||
config:
|
||||
dir: "internal/mocks/network"
|
||||
outpkg: mocks_network
|
||||
interfaces:
|
||||
Backend:
|
||||
Client: {}
|
||||
github.com/AvengeMedia/DankMaterialShell/core/internal/server/cups:
|
||||
config:
|
||||
dir: "internal/mocks/cups"
|
||||
outpkg: mocks_cups
|
||||
dir: internal/mocks/cups
|
||||
pkgname: mocks_cups
|
||||
interfaces:
|
||||
CUPSClientInterface:
|
||||
CUPSClientInterface: {}
|
||||
PkHelper:
|
||||
config:
|
||||
dir: "internal/mocks/cups_pkhelper"
|
||||
outpkg: mocks_cups_pkhelper
|
||||
dir: internal/mocks/cups_pkhelper
|
||||
pkgname: mocks_cups_pkhelper
|
||||
github.com/AvengeMedia/DankMaterialShell/core/internal/server/evdev:
|
||||
config:
|
||||
dir: "internal/mocks/evdev"
|
||||
outpkg: mocks_evdev
|
||||
dir: internal/mocks/evdev
|
||||
pkgname: mocks_evdev
|
||||
interfaces:
|
||||
EvdevDevice:
|
||||
github.com/AvengeMedia/DankMaterialShell/core/internal/version:
|
||||
EvdevDevice: {}
|
||||
github.com/AvengeMedia/DankMaterialShell/core/internal/server/network:
|
||||
config:
|
||||
dir: "internal/mocks/version"
|
||||
outpkg: mocks_version
|
||||
dir: internal/mocks/network
|
||||
pkgname: mocks_network
|
||||
interfaces:
|
||||
VersionFetcher:
|
||||
Backend: {}
|
||||
github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext:
|
||||
config:
|
||||
dir: "internal/mocks/wlcontext"
|
||||
outpkg: mocks_wlcontext
|
||||
dir: internal/mocks/wlcontext
|
||||
pkgname: mocks_wlcontext
|
||||
interfaces:
|
||||
WaylandContext:
|
||||
WaylandContext: {}
|
||||
github.com/AvengeMedia/DankMaterialShell/core/internal/utils:
|
||||
config:
|
||||
dir: internal/mocks/utils
|
||||
pkgname: mocks_utils
|
||||
interfaces:
|
||||
AppChecker: {}
|
||||
github.com/AvengeMedia/DankMaterialShell/core/internal/version:
|
||||
config:
|
||||
dir: internal/mocks/version
|
||||
pkgname: mocks_version
|
||||
interfaces:
|
||||
VersionFetcher: {}
|
||||
github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client:
|
||||
config:
|
||||
dir: "internal/mocks/wlclient"
|
||||
outpkg: mocks_wlclient
|
||||
dir: internal/mocks/wlclient
|
||||
pkgname: mocks_wlclient
|
||||
interfaces:
|
||||
WaylandDisplay:
|
||||
WaylandDisplay: {}
|
||||
github.com/AvengeMedia/DankMaterialShell/core/internal/plugins:
|
||||
interfaces:
|
||||
GitClient: {}
|
||||
github.com/AvengeMedia/DankMaterialShell/core/internal/server/brightness:
|
||||
config:
|
||||
dir: internal/mocks/brightness
|
||||
pkgname: mocks_brightness
|
||||
interfaces:
|
||||
DBusConn: {}
|
||||
github.com/Wifx/gonetworkmanager/v2:
|
||||
interfaces:
|
||||
AccessPoint: {}
|
||||
ActiveConnection: {}
|
||||
Connection: {}
|
||||
Device: {}
|
||||
DeviceWireless: {}
|
||||
IP4Config: {}
|
||||
NetworkManager: {}
|
||||
Settings: {}
|
||||
github.com/godbus/dbus/v5:
|
||||
interfaces:
|
||||
BusObject: {}
|
||||
net:
|
||||
interfaces:
|
||||
Conn: {}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
repos:
|
||||
- repo: https://github.com/golangci/golangci-lint
|
||||
rev: v2.6.2
|
||||
rev: v2.12.2
|
||||
hooks:
|
||||
- id: golangci-lint-fmt
|
||||
require_serial: true
|
||||
|
||||
+3
-3
@@ -63,19 +63,19 @@ endif
|
||||
|
||||
build-all: build dankinstall
|
||||
|
||||
install: build
|
||||
install:
|
||||
@echo "Installing $(BINARY_NAME) to $(INSTALL_DIR)..."
|
||||
@install -D -m 755 $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_DIR)/$(BINARY_NAME)
|
||||
@echo "Installation complete"
|
||||
|
||||
install-all: build-all
|
||||
install-all:
|
||||
@echo "Installing $(BINARY_NAME) to $(INSTALL_DIR)..."
|
||||
@install -D -m 755 $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_DIR)/$(BINARY_NAME)
|
||||
@echo "Installing $(BINARY_NAME_INSTALL) to $(INSTALL_DIR)..."
|
||||
@install -D -m 755 $(BUILD_DIR)/$(BINARY_NAME_INSTALL) $(INSTALL_DIR)/$(BINARY_NAME_INSTALL)
|
||||
@echo "Installation complete"
|
||||
|
||||
install-dankinstall: dankinstall
|
||||
install-dankinstall:
|
||||
@echo "Installing $(BINARY_NAME_INSTALL) to $(INSTALL_DIR)..."
|
||||
@install -D -m 755 $(BUILD_DIR)/$(BINARY_NAME_INSTALL) $(INSTALL_DIR)/$(BINARY_NAME_INSTALL)
|
||||
@echo "Installation complete"
|
||||
|
||||
+102
-24
@@ -10,38 +10,67 @@ Go-based backend for DankMaterialShell providing system integration, IPC, and in
|
||||
Command-line interface and daemon for shell management and system control.
|
||||
|
||||
**dankinstall**
|
||||
Distribution-aware installer with TUI for deploying DMS and compositor configurations on Arch, Fedora, Debian, Ubuntu, openSUSE, and Gentoo.
|
||||
Distribution-aware installer for deploying DMS and compositor configurations on Arch, Fedora, Debian, Ubuntu, openSUSE, Gentoo, and Void. Supports both an interactive TUI and a headless (unattended) mode via CLI flags.
|
||||
|
||||
## System Integration
|
||||
|
||||
**Wayland Protocols**
|
||||
- `wlr-gamma-control-unstable-v1` - Night mode and gamma control
|
||||
- `wlr-screencopy-unstable-v1` - Screen capture for color picker
|
||||
- `wlr-layer-shell-unstable-v1` - Overlay surfaces for color picker
|
||||
- `wp-viewporter` - Fractional scaling support
|
||||
- `dwl-ipc-unstable-v2` - dwl/MangoWC workspace integration
|
||||
- `ext-workspace-v1` - Workspace protocol support
|
||||
- `wlr-output-management-unstable-v1` - Display configuration
|
||||
### Wayland Protocols (Client)
|
||||
|
||||
**DBus Interfaces**
|
||||
- NetworkManager/iwd - Network management
|
||||
- logind - Session control and inhibit locks
|
||||
- accountsservice - User account information
|
||||
- CUPS - Printer management
|
||||
- Custom IPC via unix socket (JSON API)
|
||||
All Wayland protocols are consumed as a client - connecting to the compositor.
|
||||
|
||||
**Hardware Control**
|
||||
- DDC/CI protocol - External monitor brightness control (like `ddcutil`)
|
||||
- Backlight control - Internal display brightness via `login1` or sysfs
|
||||
- LED control - Keyboard/device LED management
|
||||
- evdev input monitoring - Keyboard state tracking (caps lock, etc.)
|
||||
| Protocol | Purpose |
|
||||
| ----------------------------------------- | ----------------------------------------------------------- |
|
||||
| `wlr-gamma-control-unstable-v1` | Night mode color temperature control |
|
||||
| `wlr-screencopy-unstable-v1` | Screen capture for color picker/screenshot |
|
||||
| `wlr-layer-shell-unstable-v1` | Overlay surfaces for color picker UI/screenshot |
|
||||
| `wlr-output-management-unstable-v1` | Display configuration |
|
||||
| `wlr-output-power-management-unstable-v1` | DPMS on/off CLI |
|
||||
| `wp-viewporter` | Fractional scaling support (color picker/screenshot UIs) |
|
||||
| `keyboard-shortcuts-inhibit-unstable-v1` | Inhibit compositor shortcuts during color picker/screenshot |
|
||||
| `ext-data-control-v1` | Clipboard history and persistence |
|
||||
| `ext-workspace-v1` | Workspace integration |
|
||||
| `dwl-ipc-unstable-v2` | dwl/MangoWC IPC for tags, outputs, etc. |
|
||||
|
||||
### DBus Interfaces
|
||||
|
||||
**Client (consuming external services):**
|
||||
|
||||
| Interface | Purpose |
|
||||
| -------------------------------- | --------------------------------------------- |
|
||||
| `org.bluez` | Bluetooth management with pairing agent |
|
||||
| `org.freedesktop.NetworkManager` | Network management |
|
||||
| `net.connman.iwd` | iwd Wi-Fi backend |
|
||||
| `org.freedesktop.network1` | systemd-networkd integration |
|
||||
| `org.freedesktop.login1` | Session control, sleep inhibitors, brightness |
|
||||
| `org.freedesktop.Accounts` | User account information |
|
||||
| `org.freedesktop.portal.Desktop` | Desktop appearance settings (color scheme) |
|
||||
| CUPS via IPP + D-Bus | Printer management with job notifications |
|
||||
|
||||
**Server (implementing interfaces):**
|
||||
|
||||
| Interface | Purpose |
|
||||
| ----------------------------- | -------------------------------------- |
|
||||
| `org.freedesktop.ScreenSaver` | Screensaver inhibit for video playback |
|
||||
|
||||
Custom IPC via unix socket (JSON API) for shell communication.
|
||||
|
||||
### Hardware Control
|
||||
|
||||
| Subsystem | Method | Purpose |
|
||||
| --------- | ------------------- | ---------------------------------- |
|
||||
| DDC/CI | I2C direct | External monitor brightness |
|
||||
| Backlight | logind or sysfs | Internal display brightness |
|
||||
| evdev | `/dev/input/event*` | Keyboard state (caps lock LED) |
|
||||
| udev | netlink monitor | Backlight device updates (for OSD) |
|
||||
|
||||
### Plugin System
|
||||
|
||||
**Plugin System**
|
||||
- Plugin registry integration
|
||||
- Plugin lifecycle management
|
||||
- Settings persistence
|
||||
|
||||
## CLI Commands
|
||||
|
||||
- `dms run [-d]` - Start shell (optionally as daemon)
|
||||
- `dms restart` / `dms kill` - Manage running processes
|
||||
- `dms ipc <command>` - Send IPC commands (toggle launcher, notifications, etc.)
|
||||
@@ -67,9 +96,10 @@ The on-screen preview displays the selected format. JSON output includes hex, RG
|
||||
|
||||
## Building
|
||||
|
||||
Requires Go 1.24+
|
||||
Requires Go 1.25+
|
||||
|
||||
**Development build:**
|
||||
|
||||
```bash
|
||||
make # Build dms CLI
|
||||
make dankinstall # Build installer
|
||||
@@ -77,6 +107,7 @@ make test # Run tests
|
||||
```
|
||||
|
||||
**Distribution build:**
|
||||
|
||||
```bash
|
||||
make dist # Build without update/greeter features
|
||||
```
|
||||
@@ -84,6 +115,7 @@ make dist # Build without update/greeter features
|
||||
Produces `bin/dms-linux-amd64` and `bin/dms-linux-arm64`
|
||||
|
||||
**Installation:**
|
||||
|
||||
```bash
|
||||
sudo make install # Install to /usr/local/bin/dms
|
||||
```
|
||||
@@ -91,6 +123,7 @@ sudo make install # Install to /usr/local/bin/dms
|
||||
## Development
|
||||
|
||||
**Setup pre-commit hooks:**
|
||||
|
||||
```bash
|
||||
git config core.hooksPath .githooks
|
||||
```
|
||||
@@ -98,6 +131,7 @@ git config core.hooksPath .githooks
|
||||
This runs gofmt, golangci-lint, tests, and builds before each commit when `core/` files are staged.
|
||||
|
||||
**Regenerating Wayland Protocol Bindings:**
|
||||
|
||||
```bash
|
||||
go install github.com/rajveermalviya/go-wayland/cmd/go-wayland-scanner@latest
|
||||
go-wayland-scanner -i internal/proto/xml/wlr-gamma-control-unstable-v1.xml \
|
||||
@@ -105,6 +139,7 @@ go-wayland-scanner -i internal/proto/xml/wlr-gamma-control-unstable-v1.xml \
|
||||
```
|
||||
|
||||
**Module Structure:**
|
||||
|
||||
- `cmd/` - Binary entrypoints (dms, dankinstall)
|
||||
- `internal/distros/` - Distribution-specific installation logic
|
||||
- `internal/proto/` - Wayland protocol bindings
|
||||
@@ -112,13 +147,53 @@ go-wayland-scanner -i internal/proto/xml/wlr-gamma-control-unstable-v1.xml \
|
||||
|
||||
## Installation via dankinstall
|
||||
|
||||
**Interactive (TUI):**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://install.danklinux.com | sh
|
||||
```
|
||||
|
||||
**Headless (unattended):**
|
||||
|
||||
Headless mode requires cached sudo credentials. 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 --include-deps dms-greeter -y
|
||||
```
|
||||
|
||||
| Flag | Short | Description |
|
||||
|------|-------|-------------|
|
||||
| `--compositor <niri|hyprland>` | `-c` | Compositor/WM to install (required for headless) |
|
||||
| `--term <ghostty|kitty|alacritty>` | `-t` | Terminal emulator (required for headless) |
|
||||
| `--include-deps <name,...>` | | Enable optional dependencies (e.g. `dms-greeter`) |
|
||||
| `--exclude-deps <name,...>` | | Skip specific dependencies |
|
||||
| `--replace-configs <name,...>` | | Replace specific configuration files (mutually exclusive with `--replace-configs-all`) |
|
||||
| `--replace-configs-all` | | Replace all configuration files (mutually exclusive with `--replace-configs`) |
|
||||
| `--yes` | `-y` | Required for headless mode — confirms installation without interactive prompts |
|
||||
|
||||
Headless mode requires `--yes` to proceed; without it, the installer exits with an error.
|
||||
Configuration files are not replaced by default unless `--replace-configs` or `--replace-configs-all` is specified.
|
||||
`dms-greeter` is disabled by default; use `--include-deps dms-greeter` to enable it.
|
||||
|
||||
When no flags are provided, `dankinstall` launches the interactive TUI.
|
||||
|
||||
### Headless mode validation rules
|
||||
|
||||
Headless mode activates when `--compositor` or `--term` is provided.
|
||||
|
||||
- Both `--compositor` and `--term` are required; providing only one results in an error.
|
||||
- Headless-only flags (`--include-deps`, `--exclude-deps`, `--replace-configs`, `--replace-configs-all`, `--yes`) are rejected in TUI mode.
|
||||
- Positional arguments are not accepted.
|
||||
|
||||
### Log file location
|
||||
|
||||
`dankinstall` writes logs to `/tmp` by default.
|
||||
Set the `DANKINSTALL_LOG_DIR` environment variable to override the log directory.
|
||||
|
||||
## Supported Distributions
|
||||
|
||||
Arch, Fedora, Debian, Ubuntu, openSUSE, Gentoo (and derivatives)
|
||||
Arch, Fedora, Debian, Ubuntu, openSUSE, Gentoo, Void (and derivatives)
|
||||
|
||||
**Arch Linux**
|
||||
Uses `pacman` for system packages, builds AUR packages via `makepkg`, no AUR helper dependency.
|
||||
@@ -131,7 +206,7 @@ Uses COPR repositories (`avengemedia/danklinux`, `avengemedia/dms`).
|
||||
Requires PPA support. Most packages built from source (slow first install).
|
||||
|
||||
**Debian**
|
||||
Debian 13+ (Trixie). niri only, no Hyprland support. Builds from source.
|
||||
Debian 13+ (Trixie), testing, and sid. Stable quickshell from Debian (trixie-backports on 13). Other companions via OBS.
|
||||
|
||||
**openSUSE**
|
||||
Most packages available in standard repos. Minimal building required.
|
||||
@@ -139,4 +214,7 @@ Most packages available in standard repos. Minimal building required.
|
||||
**Gentoo**
|
||||
Uses Portage with GURU overlay. Automatically configures USE flags. Variable success depending on system configuration.
|
||||
|
||||
**Void Linux**
|
||||
Uses XBPS with the DMS and DankLinux self-hosted repositories.
|
||||
|
||||
See installer output for distribution-specific details during installation.
|
||||
|
||||
@@ -3,20 +3,160 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"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/spf13/cobra"
|
||||
)
|
||||
|
||||
var Version = "dev"
|
||||
|
||||
// Flag variables bound via pflag
|
||||
var (
|
||||
compositor string
|
||||
term string
|
||||
includeDeps []string
|
||||
excludeDeps []string
|
||||
replaceConfigs []string
|
||||
replaceConfigsAll bool
|
||||
yes bool
|
||||
danksearch bool
|
||||
dankcalendar bool
|
||||
)
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "dankinstall",
|
||||
Short: "Install DankMaterialShell and its dependencies",
|
||||
Long: `dankinstall sets up DankMaterialShell 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.
|
||||
|
||||
Headless mode requires cached sudo credentials. Run 'sudo -v' beforehand, or
|
||||
configure passwordless sudo for your user.`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: runDankinstall,
|
||||
SilenceErrors: true,
|
||||
SilenceUsage: true,
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.Flags().StringVarP(&compositor, "compositor", "c", "", "Compositor/WM to install: niri, hyprland, or mango (enables headless mode)")
|
||||
rootCmd.Flags().StringVarP(&term, "term", "t", "", "Terminal emulator to install: ghostty, kitty, or alacritty (enables headless mode)")
|
||||
rootCmd.Flags().StringSliceVar(&includeDeps, "include-deps", []string{}, "Optional deps to enable (e.g. dms-greeter)")
|
||||
rootCmd.Flags().StringSliceVar(&excludeDeps, "exclude-deps", []string{}, "Deps to skip during installation")
|
||||
rootCmd.Flags().StringSliceVar(&replaceConfigs, "replace-configs", []string{}, "Deploy only named configs (e.g. niri,ghostty)")
|
||||
rootCmd.Flags().BoolVar(&replaceConfigsAll, "replace-configs-all", false, "Deploy and replace all configurations")
|
||||
rootCmd.Flags().BoolVarP(&yes, "yes", "y", false, "Auto-confirm all prompts")
|
||||
rootCmd.Flags().BoolVar(&danksearch, "danksearch", false, "Install danksearch and enable its user indexing service")
|
||||
rootCmd.Flags().BoolVar(&dankcalendar, "dankcalendar", false, "Install dankcalendar")
|
||||
}
|
||||
|
||||
func main() {
|
||||
if os.Getuid() == 0 {
|
||||
fmt.Fprintln(os.Stderr, "Error: dankinstall must not be run as root")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func runDankinstall(cmd *cobra.Command, args []string) error {
|
||||
headlessMode := compositor != "" || term != ""
|
||||
|
||||
if !headlessMode {
|
||||
// Reject headless-only flags when running in TUI mode.
|
||||
headlessOnly := []string{
|
||||
"include-deps",
|
||||
"exclude-deps",
|
||||
"replace-configs",
|
||||
"replace-configs-all",
|
||||
"yes",
|
||||
"danksearch",
|
||||
"dankcalendar",
|
||||
}
|
||||
var set []string
|
||||
for _, name := range headlessOnly {
|
||||
if cmd.Flags().Changed(name) {
|
||||
set = append(set, "--"+name)
|
||||
}
|
||||
}
|
||||
if len(set) > 0 {
|
||||
return fmt.Errorf("flags %s are only valid in headless mode (requires both --compositor and --term)", strings.Join(set, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
if headlessMode {
|
||||
return runHeadless()
|
||||
}
|
||||
return runTUI()
|
||||
}
|
||||
|
||||
func runHeadless() error {
|
||||
// Validate required flags
|
||||
if compositor == "" {
|
||||
return fmt.Errorf("--compositor is required for headless mode (niri, hyprland, or mango)")
|
||||
}
|
||||
if term == "" {
|
||||
return fmt.Errorf("--term is required for headless mode (ghostty, kitty, or alacritty)")
|
||||
}
|
||||
|
||||
cfg := headless.Config{
|
||||
Compositor: compositor,
|
||||
Terminal: term,
|
||||
IncludeDeps: includeDeps,
|
||||
ExcludeDeps: excludeDeps,
|
||||
ReplaceConfigs: replaceConfigs,
|
||||
ReplaceConfigsAll: replaceConfigsAll,
|
||||
Yes: yes,
|
||||
DankSearch: danksearch,
|
||||
DankCalendar: dankcalendar,
|
||||
}
|
||||
|
||||
runner := headless.NewRunner(cfg)
|
||||
|
||||
// Set up file logging
|
||||
fileLogger, err := log.NewFileLogger()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: Failed to create log file: %v\n", err)
|
||||
}
|
||||
|
||||
if fileLogger != nil {
|
||||
fmt.Printf("Logging to: %s\n", fileLogger.GetLogPath())
|
||||
fileLogger.StartListening(runner.GetLogChan())
|
||||
defer func() {
|
||||
if err := fileLogger.Close(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: Failed to close log file: %v\n", err)
|
||||
}
|
||||
}()
|
||||
} else {
|
||||
// Drain the log channel to prevent blocking sends from deadlocking
|
||||
// downstream components (distros, config deployer) that write to it.
|
||||
// Use an explicit stop signal because this code does not own the
|
||||
// runner log channel and cannot assume it will be closed.
|
||||
defer drainLogChan(runner.GetLogChan())()
|
||||
}
|
||||
|
||||
if err := runner.Run(); err != nil {
|
||||
if fileLogger != nil {
|
||||
fmt.Fprintf(os.Stderr, "\nFull logs are available at: %s\n", fileLogger.GetLogPath())
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if fileLogger != nil {
|
||||
fmt.Printf("\nFull logs are available at: %s\n", fileLogger.GetLogPath())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runTUI() error {
|
||||
fileLogger, err := log.NewFileLogger()
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: Failed to create log file: %v\n", err)
|
||||
@@ -38,18 +178,50 @@ func main() {
|
||||
|
||||
if fileLogger != nil {
|
||||
fileLogger.StartListening(model.GetLogChan())
|
||||
} else {
|
||||
// Drain the log channel to prevent blocking sends from deadlocking
|
||||
// downstream components (distros, config deployer) that write to it.
|
||||
// Use an explicit stop signal because this code does not own the
|
||||
// model log channel and cannot assume it will be closed.
|
||||
defer drainLogChan(model.GetLogChan())()
|
||||
}
|
||||
|
||||
p := tea.NewProgram(model, tea.WithAltScreen())
|
||||
if _, err := p.Run(); err != nil {
|
||||
fmt.Printf("Error running program: %v\n", err)
|
||||
if logFilePath != "" {
|
||||
fmt.Printf("\nFull logs are available at: %s\n", logFilePath)
|
||||
fmt.Fprintf(os.Stderr, "\nFull logs are available at: %s\n", logFilePath)
|
||||
}
|
||||
os.Exit(1)
|
||||
return fmt.Errorf("error running program: %w", err)
|
||||
}
|
||||
|
||||
if logFilePath != "" {
|
||||
fmt.Printf("\nFull logs are available at: %s\n", logFilePath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// drainLogChan starts a goroutine that discards all messages from logCh,
|
||||
// preventing blocking sends from deadlocking downstream components. It returns
|
||||
// a cleanup function that signals the goroutine to stop and waits for it to
|
||||
// exit. Callers should defer the returned function.
|
||||
func drainLogChan(logCh <-chan string) func() {
|
||||
drainStop := make(chan struct{})
|
||||
drainDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(drainDone)
|
||||
for {
|
||||
select {
|
||||
case <-drainStop:
|
||||
return
|
||||
case _, ok := <-logCh:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
return func() {
|
||||
close(drainStop)
|
||||
<-drainDone
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"policy_version": 1,
|
||||
"blocked_commands": [
|
||||
"greeter install",
|
||||
"greeter enable",
|
||||
"greeter uninstall",
|
||||
"setup"
|
||||
],
|
||||
"message": "This command is disabled on immutable/image-based systems. Use your distro-native workflow for system-level changes."
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
sharedpam "github.com/AvengeMedia/DankMaterialShell/core/internal/pam"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var authCmd = &cobra.Command{
|
||||
Use: "auth",
|
||||
Short: "Manage DMS authentication sync",
|
||||
Long: "Manage shared PAM/authentication setup for DMS greeter and lock screen",
|
||||
}
|
||||
|
||||
var authSyncCmd = &cobra.Command{
|
||||
Use: "sync",
|
||||
Short: "Sync DMS authentication configuration",
|
||||
Long: "Apply shared PAM/authentication changes for the lock screen and greeter based on current DMS settings",
|
||||
PreRunE: preRunPrivileged,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
yes, _ := cmd.Flags().GetBool("yes")
|
||||
term, _ := cmd.Flags().GetBool("terminal")
|
||||
if term {
|
||||
if err := syncAuthInTerminal(yes); err != nil {
|
||||
log.Fatalf("Error launching auth sync in terminal: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := syncAuth(yes); err != nil {
|
||||
log.Fatalf("Error syncing authentication: %v", err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
var authResolveLockCmd = &cobra.Command{
|
||||
Use: "resolve-lock",
|
||||
Short: "Generate the lock-screen PAM config from the system auth stack",
|
||||
Long: "Resolve the distribution's PAM auth stack into a self-contained lock-screen config under the user state directory.\n" +
|
||||
"Runs unprivileged (reads /etc/pam.d, writes to the user's state dir) and is used by the shell as a fallback when /etc/pam.d/dankshell is not managed.\n" +
|
||||
"Prints the path of the generated file.",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
quiet, _ := cmd.Flags().GetBool("quiet")
|
||||
logFunc := func(msg string) {
|
||||
if !quiet {
|
||||
fmt.Println(msg)
|
||||
}
|
||||
}
|
||||
path, err := sharedpam.WriteUserLockscreenPamConfig(logFunc)
|
||||
if err != nil {
|
||||
log.Fatalf("Error resolving lock-screen PAM config: %v", err)
|
||||
}
|
||||
fmt.Println(path)
|
||||
},
|
||||
}
|
||||
|
||||
var authListServicesCmd = &cobra.Command{
|
||||
Use: "list-services",
|
||||
Short: "List candidate lock-screen PAM services available on this system",
|
||||
Long: "Enumerate the lock-screen PAM services that exist on this system and report their resolved auth stack (whether it has an auth directive and whether fingerprint/U2F modules appear inline).",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
asJSON, _ := cmd.Flags().GetBool("json")
|
||||
services := sharedpam.ListLockscreenPamServices()
|
||||
|
||||
if asJSON {
|
||||
payload := struct {
|
||||
Services []sharedpam.LockscreenPamServiceInfo `json:"services"`
|
||||
}{Services: services}
|
||||
data, err := json.MarshalIndent(payload, "", " ")
|
||||
if err != nil {
|
||||
log.Fatalf("Error encoding services: %v", err)
|
||||
}
|
||||
fmt.Println(string(data))
|
||||
return
|
||||
}
|
||||
|
||||
if len(services) == 0 {
|
||||
fmt.Println("No candidate lock-screen PAM services found.")
|
||||
return
|
||||
}
|
||||
for _, s := range services {
|
||||
fmt.Printf("%-20s %-30s auth=%-5t fingerprint=%-5t u2f=%t\n", s.Name, s.Path, s.HasAuth, s.InlineFingerprint, s.InlineU2f)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
var authValidateCmd = &cobra.Command{
|
||||
Use: "validate",
|
||||
Short: "Validate a PAM service file for use by the DMS lock screen",
|
||||
Long: "Validate one PAM service (by --service NAME or --path /abs/file) for use as the DMS lock-screen password or dedicated U2F stack. Exits 1 when the file is not usable.",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
path, _ := cmd.Flags().GetString("path")
|
||||
service, _ := cmd.Flags().GetString("service")
|
||||
purpose, _ := cmd.Flags().GetString("purpose")
|
||||
asJSON, _ := cmd.Flags().GetBool("json")
|
||||
|
||||
if (path == "") == (service == "") {
|
||||
log.Fatalf("Error: exactly one of --path or --service is required")
|
||||
}
|
||||
|
||||
if purpose != "password" && purpose != "u2f" {
|
||||
log.Fatalf("Error: --purpose must be password or u2f")
|
||||
}
|
||||
|
||||
var result sharedpam.LockscreenPamValidation
|
||||
switch {
|
||||
case service != "":
|
||||
if purpose == "u2f" {
|
||||
result = sharedpam.ValidateLockscreenU2fPamService(service)
|
||||
} else {
|
||||
result = sharedpam.ValidateLockscreenPamService(service)
|
||||
}
|
||||
case !filepath.IsAbs(path):
|
||||
result = sharedpam.LockscreenPamValidation{
|
||||
Path: path,
|
||||
MissingModules: []string{},
|
||||
Warnings: []string{},
|
||||
Errors: []string{"--path must be an absolute file path"},
|
||||
}
|
||||
default:
|
||||
if purpose == "u2f" {
|
||||
result = sharedpam.ValidateLockscreenU2fPamPath(path)
|
||||
} else {
|
||||
result = sharedpam.ValidateLockscreenPamPath(path)
|
||||
}
|
||||
}
|
||||
|
||||
if asJSON {
|
||||
data, err := json.MarshalIndent(result, "", " ")
|
||||
if err != nil {
|
||||
log.Fatalf("Error encoding validation: %v", err)
|
||||
}
|
||||
fmt.Println(string(data))
|
||||
} else {
|
||||
printLockscreenPamValidation(result)
|
||||
}
|
||||
|
||||
if !result.Valid {
|
||||
os.Exit(1)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func printLockscreenPamValidation(result sharedpam.LockscreenPamValidation) {
|
||||
fmt.Printf("Path: %s\n", result.Path)
|
||||
fmt.Printf("Valid: %t\n", result.Valid)
|
||||
fmt.Printf("Has auth: %t\n", result.HasAuth)
|
||||
fmt.Printf("Inline fingerprint: %t\n", result.InlineFingerprint)
|
||||
fmt.Printf("Inline U2F: %t\n", result.InlineU2f)
|
||||
if len(result.MissingModules) > 0 {
|
||||
fmt.Printf("Missing modules: %s\n", strings.Join(result.MissingModules, ", "))
|
||||
}
|
||||
for _, w := range result.Warnings {
|
||||
fmt.Println("⚠ " + w)
|
||||
}
|
||||
for _, e := range result.Errors {
|
||||
fmt.Println("✗ " + e)
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
authSyncCmd.Flags().BoolP("yes", "y", false, "Non-interactive mode: skip prompts")
|
||||
authSyncCmd.Flags().BoolP("terminal", "t", false, "Run auth sync in a new terminal (for entering sudo password)")
|
||||
authResolveLockCmd.Flags().BoolP("quiet", "q", false, "Only print the resulting file path")
|
||||
|
||||
authListServicesCmd.Flags().Bool("json", false, "Output as JSON")
|
||||
|
||||
authValidateCmd.Flags().String("path", "", "Absolute path to a PAM service file to validate")
|
||||
authValidateCmd.Flags().String("service", "", "Name of a PAM service to resolve across the system PAM dirs")
|
||||
authValidateCmd.Flags().String("purpose", "password", "Validation purpose: password or u2f")
|
||||
authValidateCmd.Flags().Bool("json", false, "Output as JSON")
|
||||
}
|
||||
|
||||
func syncAuth(nonInteractive bool) error {
|
||||
if !nonInteractive {
|
||||
fmt.Println("=== DMS Authentication Sync ===")
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
logFunc := func(msg string) {
|
||||
fmt.Println(msg)
|
||||
}
|
||||
|
||||
if err := sharedpam.SyncAuthConfig(logFunc, "", sharedpam.SyncAuthOptions{}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !nonInteractive {
|
||||
fmt.Println("\n=== Authentication Sync Complete ===")
|
||||
fmt.Println("\nAuthentication changes have been applied.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncAuthInTerminal(nonInteractive bool) error {
|
||||
syncFlags := make([]string, 0, 1)
|
||||
if nonInteractive {
|
||||
syncFlags = append(syncFlags, "--yes")
|
||||
}
|
||||
|
||||
shellSyncCmd := "dms auth sync"
|
||||
if len(syncFlags) > 0 {
|
||||
shellSyncCmd += " " + strings.Join(syncFlags, " ")
|
||||
}
|
||||
shellCmd := shellSyncCmd + `; echo; echo "Authentication sync finished. Closing in 3 seconds..."; sleep 3`
|
||||
return runCommandInTerminal(shellCmd)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/blur"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var blurCmd = &cobra.Command{
|
||||
Use: "blur",
|
||||
Short: "Background blur utilities",
|
||||
}
|
||||
|
||||
var blurCheckCmd = &cobra.Command{
|
||||
Use: "check",
|
||||
Short: "Check if the compositor supports background blur (ext-background-effect-v1)",
|
||||
Args: cobra.NoArgs,
|
||||
Run: runBlurCheck,
|
||||
}
|
||||
|
||||
func init() {
|
||||
blurCmd.AddCommand(blurCheckCmd)
|
||||
}
|
||||
|
||||
func runBlurCheck(cmd *cobra.Command, args []string) {
|
||||
supported, err := blur.ProbeSupport()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
switch supported {
|
||||
case true:
|
||||
fmt.Println("supported")
|
||||
default:
|
||||
fmt.Println("unsupported")
|
||||
}
|
||||
}
|
||||
@@ -179,7 +179,7 @@ func runBrightnessList(cmd *cobra.Command, args []string) {
|
||||
fmt.Printf("%-*s %-12s %-*s %s\n", idPad, "Device", "Class", namePad, "Name", "Brightness")
|
||||
|
||||
sepLen := idPad + 2 + 12 + 2 + namePad + 2 + 15
|
||||
for i := 0; i < sepLen; i++ {
|
||||
for range sepLen {
|
||||
fmt.Print("─")
|
||||
}
|
||||
fmt.Println()
|
||||
@@ -236,6 +236,7 @@ func runBrightnessSet(cmd *cobra.Command, args []string) {
|
||||
defer ddc.Close()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
if err := ddc.SetBrightnessWithExponent(deviceID, percent, exponential, exponent, nil); err == nil {
|
||||
ddc.WaitPending()
|
||||
fmt.Printf("Set %s to %d%%\n", deviceID, percent)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/alecthomas/chroma/v2"
|
||||
"github.com/alecthomas/chroma/v2/formatters/html"
|
||||
"github.com/alecthomas/chroma/v2/lexers"
|
||||
"github.com/alecthomas/chroma/v2/styles"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/yuin/goldmark"
|
||||
highlighting "github.com/yuin/goldmark-highlighting/v2"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
"github.com/yuin/goldmark/parser"
|
||||
ghtml "github.com/yuin/goldmark/renderer/html"
|
||||
)
|
||||
|
||||
var (
|
||||
chromaLanguage string
|
||||
chromaStyle string
|
||||
chromaInline bool
|
||||
chromaMarkdown bool
|
||||
chromaLineNumbers bool
|
||||
|
||||
// Caching layer for performance
|
||||
lexerCache = make(map[string]chroma.Lexer)
|
||||
styleCache = make(map[string]*chroma.Style)
|
||||
formatterCache = make(map[string]*html.Formatter)
|
||||
cacheMutex sync.RWMutex
|
||||
maxFileSize = int64(5 * 1024 * 1024) // 5MB default
|
||||
)
|
||||
|
||||
var chromaCmd = &cobra.Command{
|
||||
Use: "chroma [file]",
|
||||
Short: "Syntax highlight source code",
|
||||
Long: `Generate syntax-highlighted HTML from source code.
|
||||
|
||||
Reads from file or stdin, outputs HTML with syntax highlighting.
|
||||
Language is auto-detected from filename or can be specified with --language.
|
||||
|
||||
Examples:
|
||||
dms chroma main.go
|
||||
dms chroma --language python script.py
|
||||
echo "def foo(): pass" | dms chroma -l python
|
||||
cat code.rs | dms chroma -l rust --style dracula
|
||||
dms chroma --markdown README.md
|
||||
dms chroma --markdown --style github-dark notes.md
|
||||
dms chroma list-languages
|
||||
dms chroma list-styles`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
Run: runChroma,
|
||||
}
|
||||
|
||||
var chromaListLanguagesCmd = &cobra.Command{
|
||||
Use: "list-languages",
|
||||
Short: "List all supported languages",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
for _, name := range lexers.Names(true) {
|
||||
fmt.Println(name)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
var chromaListStylesCmd = &cobra.Command{
|
||||
Use: "list-styles",
|
||||
Short: "List all available color styles",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
for _, name := range styles.Names() {
|
||||
fmt.Println(name)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
chromaCmd.Flags().StringVarP(&chromaLanguage, "language", "l", "", "Language for highlighting (auto-detect if not specified)")
|
||||
chromaCmd.Flags().StringVarP(&chromaStyle, "style", "s", "monokai", "Color style (monokai, dracula, github, etc.)")
|
||||
chromaCmd.Flags().BoolVar(&chromaInline, "inline", false, "Output inline styles instead of CSS classes")
|
||||
chromaCmd.Flags().BoolVar(&chromaLineNumbers, "line-numbers", false, "Show line numbers in output")
|
||||
chromaCmd.Flags().BoolVarP(&chromaMarkdown, "markdown", "m", false, "Render markdown with syntax-highlighted code blocks")
|
||||
chromaCmd.Flags().Int64Var(&maxFileSize, "max-size", 5*1024*1024, "Maximum file size to process without warning (bytes)")
|
||||
|
||||
chromaCmd.AddCommand(chromaListLanguagesCmd)
|
||||
chromaCmd.AddCommand(chromaListStylesCmd)
|
||||
}
|
||||
|
||||
func getCachedLexer(key string, fallbackFunc func() chroma.Lexer) chroma.Lexer {
|
||||
cacheMutex.RLock()
|
||||
if lexer, ok := lexerCache[key]; ok {
|
||||
cacheMutex.RUnlock()
|
||||
return lexer
|
||||
}
|
||||
cacheMutex.RUnlock()
|
||||
|
||||
lexer := fallbackFunc()
|
||||
if lexer != nil {
|
||||
cacheMutex.Lock()
|
||||
lexerCache[key] = lexer
|
||||
cacheMutex.Unlock()
|
||||
}
|
||||
return lexer
|
||||
}
|
||||
|
||||
func getCachedStyle(name string) *chroma.Style {
|
||||
cacheMutex.RLock()
|
||||
if style, ok := styleCache[name]; ok {
|
||||
cacheMutex.RUnlock()
|
||||
return style
|
||||
}
|
||||
cacheMutex.RUnlock()
|
||||
|
||||
style := styles.Get(name)
|
||||
if style == nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: Style '%s' not found, using fallback\n", name)
|
||||
style = styles.Fallback
|
||||
}
|
||||
|
||||
cacheMutex.Lock()
|
||||
styleCache[name] = style
|
||||
cacheMutex.Unlock()
|
||||
return style
|
||||
}
|
||||
|
||||
func getCachedFormatter(inline bool, lineNumbers bool) *html.Formatter {
|
||||
key := fmt.Sprintf("inline=%t,lineNumbers=%t", inline, lineNumbers)
|
||||
|
||||
cacheMutex.RLock()
|
||||
if formatter, ok := formatterCache[key]; ok {
|
||||
cacheMutex.RUnlock()
|
||||
return formatter
|
||||
}
|
||||
cacheMutex.RUnlock()
|
||||
|
||||
var opts []html.Option
|
||||
if inline {
|
||||
opts = append(opts, html.WithClasses(false))
|
||||
} else {
|
||||
opts = append(opts, html.WithClasses(true))
|
||||
}
|
||||
opts = append(opts, html.TabWidth(4))
|
||||
|
||||
if lineNumbers {
|
||||
opts = append(opts, html.WithLineNumbers(true))
|
||||
opts = append(opts, html.LineNumbersInTable(false))
|
||||
opts = append(opts, html.WithLinkableLineNumbers(false, ""))
|
||||
}
|
||||
|
||||
formatter := html.New(opts...)
|
||||
|
||||
cacheMutex.Lock()
|
||||
formatterCache[key] = formatter
|
||||
cacheMutex.Unlock()
|
||||
return formatter
|
||||
}
|
||||
|
||||
func runChroma(cmd *cobra.Command, args []string) {
|
||||
var source string
|
||||
var filename string
|
||||
|
||||
// Read from file or stdin
|
||||
if len(args) > 0 {
|
||||
filename = args[0]
|
||||
|
||||
// Check file size before reading
|
||||
fileInfo, err := os.Stat(filename)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error reading file info: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if fileInfo.Size() > maxFileSize {
|
||||
fmt.Fprintf(os.Stderr, "Warning: File size (%d bytes) exceeds recommended limit (%d bytes)\n",
|
||||
fileInfo.Size(), maxFileSize)
|
||||
fmt.Fprintf(os.Stderr, "Processing may be slow. Consider using smaller files.\n")
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error reading file: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
source = string(content)
|
||||
} else {
|
||||
stat, _ := os.Stdin.Stat()
|
||||
if (stat.Mode() & os.ModeCharDevice) != 0 {
|
||||
_ = cmd.Help()
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
content, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error reading stdin: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
source = string(content)
|
||||
}
|
||||
|
||||
// Handle empty input
|
||||
if strings.TrimSpace(source) == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Handle Markdown rendering
|
||||
if chromaMarkdown {
|
||||
md := goldmark.New(
|
||||
goldmark.WithExtensions(
|
||||
extension.GFM,
|
||||
highlighting.NewHighlighting(
|
||||
highlighting.WithStyle(chromaStyle),
|
||||
highlighting.WithFormatOptions(
|
||||
html.WithClasses(!chromaInline),
|
||||
),
|
||||
),
|
||||
),
|
||||
goldmark.WithParserOptions(
|
||||
parser.WithAutoHeadingID(),
|
||||
),
|
||||
goldmark.WithRendererOptions(
|
||||
ghtml.WithHardWraps(),
|
||||
ghtml.WithXHTML(),
|
||||
),
|
||||
)
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := md.Convert([]byte(source), &buf); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Markdown rendering error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Print(buf.String())
|
||||
return
|
||||
}
|
||||
|
||||
// Detect or use specified lexer
|
||||
var lexer chroma.Lexer
|
||||
if chromaLanguage != "" {
|
||||
lexer = getCachedLexer(chromaLanguage, func() chroma.Lexer {
|
||||
l := lexers.Get(chromaLanguage)
|
||||
if l == nil {
|
||||
fmt.Fprintf(os.Stderr, "Unknown language: %s\n", chromaLanguage)
|
||||
os.Exit(1)
|
||||
}
|
||||
return l
|
||||
})
|
||||
} else if filename != "" {
|
||||
lexer = getCachedLexer("file:"+filename, func() chroma.Lexer {
|
||||
return lexers.Match(filename)
|
||||
})
|
||||
}
|
||||
|
||||
// Try content analysis if no lexer found (limit to first 1KB for performance)
|
||||
if lexer == nil {
|
||||
analyzeContent := source
|
||||
if len(source) > 1024 {
|
||||
analyzeContent = source[:1024]
|
||||
}
|
||||
lexer = lexers.Analyse(analyzeContent)
|
||||
}
|
||||
|
||||
// Fallback to plaintext
|
||||
if lexer == nil {
|
||||
lexer = lexers.Fallback
|
||||
}
|
||||
|
||||
lexer = chroma.Coalesce(lexer)
|
||||
|
||||
// Get cached style
|
||||
style := getCachedStyle(chromaStyle)
|
||||
|
||||
// Get cached formatter
|
||||
formatter := getCachedFormatter(chromaInline, chromaLineNumbers)
|
||||
|
||||
// Tokenize
|
||||
iterator, err := lexer.Tokenise(nil, source)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Tokenization error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Format and output
|
||||
if chromaLineNumbers {
|
||||
var buf bytes.Buffer
|
||||
if err := formatter.Format(&buf, style, iterator); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Formatting error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
// Add spacing between line numbers
|
||||
output := buf.String()
|
||||
output = strings.ReplaceAll(output, "</span><span>", "</span>\u00A0\u00A0<span>")
|
||||
fmt.Print(output)
|
||||
} else {
|
||||
if err := formatter.Format(os.Stdout, style, iterator); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Formatting error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,33 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
bolt "go.etcd.io/bbolt"
|
||||
_ "golang.org/x/image/bmp"
|
||||
_ "golang.org/x/image/tiff"
|
||||
_ "golang.org/x/image/webp"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||
@@ -36,6 +52,7 @@ var (
|
||||
clipCopyForeground bool
|
||||
clipCopyPasteOnce bool
|
||||
clipCopyType string
|
||||
clipCopyDownload bool
|
||||
clipJSONOutput bool
|
||||
)
|
||||
|
||||
@@ -46,6 +63,15 @@ var clipPasteCmd = &cobra.Command{
|
||||
Run: runClipPaste,
|
||||
}
|
||||
|
||||
var clipSendPasteCmd = &cobra.Command{
|
||||
Use: "send-paste",
|
||||
Short: "Send a paste keystroke to the focused window",
|
||||
Long: "Emulate ctrl+v (or ctrl+shift+v with --shift) via a virtual keyboard. Works without server.",
|
||||
Run: runClipSendPaste,
|
||||
}
|
||||
|
||||
var clipSendPasteShift bool
|
||||
|
||||
var clipWatchCmd = &cobra.Command{
|
||||
Use: "watch [command]",
|
||||
Short: "Watch clipboard for changes",
|
||||
@@ -95,6 +121,7 @@ var clipClearCmd = &cobra.Command{
|
||||
}
|
||||
|
||||
var clipWatchStore bool
|
||||
var clipWatchMimes bool
|
||||
|
||||
var clipSearchCmd = &cobra.Command{
|
||||
Use: "search [query]",
|
||||
@@ -142,21 +169,42 @@ var (
|
||||
clipConfigNoClearStartup bool
|
||||
clipConfigDisabled bool
|
||||
clipConfigEnabled bool
|
||||
clipConfigDisableHistory bool
|
||||
clipConfigEnableHistory bool
|
||||
clipConfigDisablePersist bool
|
||||
clipConfigEnablePersist bool
|
||||
)
|
||||
|
||||
var clipExportCmd = &cobra.Command{
|
||||
Use: "export [file]",
|
||||
Short: "Export clipboard history to JSON",
|
||||
Long: "Export clipboard history to JSON file. If no file specified, writes to stdout.",
|
||||
Run: runClipExport,
|
||||
}
|
||||
|
||||
var clipImportCmd = &cobra.Command{
|
||||
Use: "import <file>",
|
||||
Short: "Import clipboard history from JSON",
|
||||
Long: "Import clipboard history from JSON file exported by 'dms cl export'.",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: runClipImport,
|
||||
}
|
||||
|
||||
var clipMigrateCmd = &cobra.Command{
|
||||
Use: "cliphist-migrate [db-path]",
|
||||
Short: "Migrate from cliphist",
|
||||
Long: "Migrate clipboard history from cliphist. Uses default cliphist path if not specified.",
|
||||
Run: runClipMigrate,
|
||||
}
|
||||
|
||||
var clipMigrateDelete bool
|
||||
|
||||
func init() {
|
||||
clipCopyCmd.Flags().BoolVarP(&clipCopyForeground, "foreground", "f", false, "Stay in foreground instead of forking")
|
||||
clipCopyCmd.Flags().BoolVarP(&clipCopyPasteOnce, "paste-once", "o", false, "Exit after first paste")
|
||||
clipCopyCmd.Flags().StringVarP(&clipCopyType, "type", "t", "text/plain;charset=utf-8", "MIME type")
|
||||
clipCopyCmd.Flags().BoolVarP(&clipCopyDownload, "download", "d", false, "Download URL as image and copy as file")
|
||||
|
||||
clipWatchCmd.Flags().BoolVar(&clipJSONOutput, "json", false, "Output as JSON")
|
||||
clipHistoryCmd.Flags().BoolVar(&clipJSONOutput, "json", false, "Output as JSON")
|
||||
clipGetCmd.Flags().BoolVar(&clipJSONOutput, "json", false, "Output as JSON")
|
||||
clipGetCmd.Flags().BoolVarP(&clipGetCopy, "copy", "c", false, "Copy entry to clipboard")
|
||||
clipGetCmd.Flags().BoolVarP(&clipGetCopy, "copy", "C", false, "Copy entry to clipboard")
|
||||
|
||||
clipSearchCmd.Flags().IntVarP(&clipSearchLimit, "limit", "l", 50, "Max results")
|
||||
clipSearchCmd.Flags().IntVarP(&clipSearchOffset, "offset", "o", 0, "Result offset")
|
||||
@@ -169,30 +217,65 @@ func init() {
|
||||
clipConfigSetCmd.Flags().IntVar(&clipConfigAutoClearDays, "auto-clear-days", -1, "Auto-clear entries older than N days (0 to disable)")
|
||||
clipConfigSetCmd.Flags().BoolVar(&clipConfigClearAtStartup, "clear-at-startup", false, "Clear history on startup")
|
||||
clipConfigSetCmd.Flags().BoolVar(&clipConfigNoClearStartup, "no-clear-at-startup", false, "Don't clear history on startup")
|
||||
clipConfigSetCmd.Flags().BoolVar(&clipConfigDisabled, "disable", false, "Disable clipboard manager entirely")
|
||||
clipConfigSetCmd.Flags().BoolVar(&clipConfigEnabled, "enable", false, "Enable clipboard manager")
|
||||
clipConfigSetCmd.Flags().BoolVar(&clipConfigDisableHistory, "disable-history", false, "Disable clipboard history persistence")
|
||||
clipConfigSetCmd.Flags().BoolVar(&clipConfigEnableHistory, "enable-history", false, "Enable clipboard history persistence")
|
||||
clipConfigSetCmd.Flags().BoolVar(&clipConfigDisablePersist, "disable-persist", false, "Disable clipboard ownership persistence")
|
||||
clipConfigSetCmd.Flags().BoolVar(&clipConfigEnablePersist, "enable-persist", false, "Enable clipboard ownership persistence")
|
||||
clipConfigSetCmd.Flags().BoolVar(&clipConfigDisabled, "disable", false, "Disable clipboard tracking")
|
||||
clipConfigSetCmd.Flags().BoolVar(&clipConfigEnabled, "enable", false, "Enable clipboard tracking")
|
||||
|
||||
clipWatchCmd.Flags().BoolVarP(&clipWatchStore, "store", "s", false, "Store clipboard changes to history (no server required)")
|
||||
clipWatchCmd.Flags().BoolVarP(&clipWatchMimes, "mimes", "m", false, "Show all offered MIME types")
|
||||
|
||||
clipMigrateCmd.Flags().BoolVar(&clipMigrateDelete, "delete", false, "Delete cliphist db after successful migration")
|
||||
|
||||
clipSendPasteCmd.Flags().BoolVarP(&clipSendPasteShift, "shift", "s", false, "Send ctrl+shift+v (terminal paste)")
|
||||
|
||||
clipConfigCmd.AddCommand(clipConfigGetCmd, clipConfigSetCmd)
|
||||
clipboardCmd.AddCommand(clipCopyCmd, clipPasteCmd, clipWatchCmd, clipHistoryCmd, clipGetCmd, clipDeleteCmd, clipClearCmd, clipSearchCmd, clipConfigCmd)
|
||||
clipboardCmd.AddCommand(clipCopyCmd, clipPasteCmd, clipSendPasteCmd, clipWatchCmd, clipHistoryCmd, clipGetCmd, clipDeleteCmd, clipClearCmd, clipSearchCmd, clipConfigCmd, clipExportCmd, clipImportCmd, clipMigrateCmd)
|
||||
}
|
||||
|
||||
func runClipCopy(cmd *cobra.Command, args []string) {
|
||||
var data []byte
|
||||
copyFromStdin := false
|
||||
|
||||
if len(args) > 0 {
|
||||
switch {
|
||||
case len(args) > 0:
|
||||
data = []byte(args[0])
|
||||
} else {
|
||||
case clipCopyDownload || clipCopyType == "__multi__":
|
||||
var err error
|
||||
data, err = io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
log.Fatalf("read stdin: %v", err)
|
||||
}
|
||||
default:
|
||||
copyFromStdin = true
|
||||
}
|
||||
|
||||
if clipCopyDownload {
|
||||
filePath, err := downloadToTempFile(strings.TrimSpace(string(data)))
|
||||
if err != nil {
|
||||
log.Fatalf("download: %v", err)
|
||||
}
|
||||
if err := copyFileToClipboard(filePath); err != nil {
|
||||
log.Fatalf("copy file: %v", err)
|
||||
}
|
||||
fmt.Printf("Downloaded and copied: %s\n", filePath)
|
||||
return
|
||||
}
|
||||
|
||||
if clipCopyType == "__multi__" {
|
||||
offers, err := parseMultiOffers(data)
|
||||
if err != nil {
|
||||
log.Fatalf("parse multi offers: %v", err)
|
||||
}
|
||||
if err := clipboard.CopyMulti(offers, true, clipCopyPasteOnce); err != nil {
|
||||
log.Fatalf("copy multi: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if copyFromStdin {
|
||||
if err := clipboard.CopyReader(os.Stdin, clipCopyType, clipCopyForeground, clipCopyPasteOnce); err != nil {
|
||||
log.Fatalf("copy: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := clipboard.CopyOpts(data, clipCopyType, clipCopyForeground, clipCopyPasteOnce); err != nil {
|
||||
@@ -200,6 +283,40 @@ func runClipCopy(cmd *cobra.Command, args []string) {
|
||||
}
|
||||
}
|
||||
|
||||
func parseMultiOffers(data []byte) ([]clipboard.Offer, error) {
|
||||
var offers []clipboard.Offer
|
||||
pos := 0
|
||||
|
||||
for pos < len(data) {
|
||||
mimeEnd := bytes.IndexByte(data[pos:], 0)
|
||||
if mimeEnd == -1 {
|
||||
break
|
||||
}
|
||||
mimeType := string(data[pos : pos+mimeEnd])
|
||||
pos += mimeEnd + 1
|
||||
|
||||
lenEnd := bytes.IndexByte(data[pos:], 0)
|
||||
if lenEnd == -1 {
|
||||
break
|
||||
}
|
||||
dataLen, err := strconv.Atoi(string(data[pos : pos+lenEnd]))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse length: %w", err)
|
||||
}
|
||||
pos += lenEnd + 1
|
||||
|
||||
if pos+dataLen > len(data) {
|
||||
return nil, fmt.Errorf("data truncated")
|
||||
}
|
||||
offerData := data[pos : pos+dataLen]
|
||||
pos += dataLen
|
||||
|
||||
offers = append(offers, clipboard.Offer{MimeType: mimeType, Data: offerData})
|
||||
}
|
||||
|
||||
return offers, nil
|
||||
}
|
||||
|
||||
func runClipPaste(cmd *cobra.Command, args []string) {
|
||||
data, _, err := clipboard.Paste()
|
||||
if err != nil {
|
||||
@@ -208,6 +325,12 @@ func runClipPaste(cmd *cobra.Command, args []string) {
|
||||
os.Stdout.Write(data)
|
||||
}
|
||||
|
||||
func runClipSendPaste(cmd *cobra.Command, args []string) {
|
||||
if err := clipboard.SendPasteKeystroke(clipSendPasteShift); err != nil {
|
||||
log.Fatalf("send-paste: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func runClipWatch(cmd *cobra.Command, args []string) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
@@ -234,6 +357,30 @@ func runClipWatch(cmd *cobra.Command, args []string) {
|
||||
}); err != nil && err != context.Canceled {
|
||||
log.Fatalf("Watch error: %v", err)
|
||||
}
|
||||
case clipWatchMimes:
|
||||
if err := clipboard.WatchAll(ctx, func(data []byte, mimeType string, allMimes []string) {
|
||||
if clipJSONOutput {
|
||||
out := map[string]any{
|
||||
"data": string(data),
|
||||
"mimeType": mimeType,
|
||||
"mimeTypes": allMimes,
|
||||
"timestamp": time.Now().Format(time.RFC3339),
|
||||
"size": len(data),
|
||||
}
|
||||
j, _ := json.Marshal(out)
|
||||
fmt.Println(string(j))
|
||||
return
|
||||
}
|
||||
fmt.Printf("=== Clipboard Change ===\n")
|
||||
fmt.Printf("Selected: %s\n", mimeType)
|
||||
fmt.Printf("All MIME types:\n")
|
||||
for _, m := range allMimes {
|
||||
fmt.Printf(" - %s\n", m)
|
||||
}
|
||||
fmt.Printf("Size: %d bytes\n\n", len(data))
|
||||
}); err != nil && err != context.Canceled {
|
||||
log.Fatalf("Watch error: %v", err)
|
||||
}
|
||||
case clipJSONOutput:
|
||||
if err := clipboard.Watch(ctx, func(data []byte, mimeType string) {
|
||||
out := map[string]any{
|
||||
@@ -356,16 +503,13 @@ func runClipGet(cmd *cobra.Command, args []string) {
|
||||
req := models.Request{
|
||||
ID: 1,
|
||||
Method: "clipboard.copyEntry",
|
||||
Params: map[string]any{
|
||||
"id": id,
|
||||
},
|
||||
Params: map[string]any{"id": id},
|
||||
}
|
||||
|
||||
resp, err := sendServerRequest(req)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to copy clipboard entry: %v", err)
|
||||
}
|
||||
|
||||
if resp.Error != "" {
|
||||
log.Fatalf("Error: %s", resp.Error)
|
||||
}
|
||||
@@ -591,18 +735,6 @@ func runClipConfigSet(cmd *cobra.Command, args []string) {
|
||||
if clipConfigEnabled {
|
||||
params["disabled"] = false
|
||||
}
|
||||
if clipConfigDisableHistory {
|
||||
params["disableHistory"] = true
|
||||
}
|
||||
if clipConfigEnableHistory {
|
||||
params["disableHistory"] = false
|
||||
}
|
||||
if clipConfigDisablePersist {
|
||||
params["disablePersist"] = true
|
||||
}
|
||||
if clipConfigEnablePersist {
|
||||
params["disablePersist"] = false
|
||||
}
|
||||
|
||||
if len(params) == 0 {
|
||||
fmt.Println("No config options specified")
|
||||
@@ -626,3 +758,264 @@ func runClipConfigSet(cmd *cobra.Command, args []string) {
|
||||
|
||||
fmt.Println("Config updated")
|
||||
}
|
||||
|
||||
func runClipExport(cmd *cobra.Command, args []string) {
|
||||
req := models.Request{
|
||||
ID: 1,
|
||||
Method: "clipboard.getHistory",
|
||||
}
|
||||
|
||||
resp, err := sendServerRequest(req)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to get clipboard history: %v", err)
|
||||
}
|
||||
if resp.Error != "" {
|
||||
log.Fatalf("Error: %s", resp.Error)
|
||||
}
|
||||
if resp.Result == nil {
|
||||
log.Fatal("No clipboard history")
|
||||
}
|
||||
|
||||
out, err := json.MarshalIndent(resp.Result, "", " ")
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to marshal: %v", err)
|
||||
}
|
||||
|
||||
if len(args) == 0 {
|
||||
fmt.Println(string(out))
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.WriteFile(args[0], out, 0o644); err != nil {
|
||||
log.Fatalf("Failed to write file: %v", err)
|
||||
}
|
||||
fmt.Printf("Exported to %s\n", args[0])
|
||||
}
|
||||
|
||||
func runClipImport(cmd *cobra.Command, args []string) {
|
||||
data, err := os.ReadFile(args[0])
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to read file: %v", err)
|
||||
}
|
||||
|
||||
var entries []map[string]any
|
||||
if err := json.Unmarshal(data, &entries); err != nil {
|
||||
log.Fatalf("Failed to parse JSON: %v", err)
|
||||
}
|
||||
|
||||
var imported int
|
||||
for _, entry := range entries {
|
||||
dataStr, ok := entry["data"].(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
mimeType, _ := entry["mimeType"].(string)
|
||||
if mimeType == "" {
|
||||
mimeType = "text/plain"
|
||||
}
|
||||
|
||||
var entryData []byte
|
||||
if decoded, err := base64.StdEncoding.DecodeString(dataStr); err == nil {
|
||||
entryData = decoded
|
||||
} else {
|
||||
entryData = []byte(dataStr)
|
||||
}
|
||||
|
||||
if err := clipboard.Store(entryData, mimeType); err != nil {
|
||||
log.Errorf("Failed to store entry: %v", err)
|
||||
continue
|
||||
}
|
||||
imported++
|
||||
}
|
||||
|
||||
fmt.Printf("Imported %d entries\n", imported)
|
||||
}
|
||||
|
||||
func runClipMigrate(cmd *cobra.Command, args []string) {
|
||||
dbPath := getCliphistPath()
|
||||
if len(args) > 0 {
|
||||
dbPath = args[0]
|
||||
}
|
||||
|
||||
if _, err := os.Stat(dbPath); err != nil {
|
||||
log.Fatalf("Cliphist db not found: %s", dbPath)
|
||||
}
|
||||
|
||||
db, err := bolt.Open(dbPath, 0o644, &bolt.Options{
|
||||
ReadOnly: true,
|
||||
Timeout: 1 * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to open cliphist db: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
var migrated int
|
||||
err = db.View(func(tx *bolt.Tx) error {
|
||||
b := tx.Bucket([]byte("b"))
|
||||
if b == nil {
|
||||
return fmt.Errorf("cliphist bucket not found")
|
||||
}
|
||||
|
||||
c := b.Cursor()
|
||||
for k, v := c.First(); k != nil; k, v = c.Next() {
|
||||
if len(v) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
mimeType := detectMimeType(v)
|
||||
if err := clipboard.Store(v, mimeType); err != nil {
|
||||
log.Errorf("Failed to store entry %d: %v", btoi(k), err)
|
||||
continue
|
||||
}
|
||||
migrated++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("Migration failed: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Migrated %d entries from cliphist\n", migrated)
|
||||
|
||||
if !clipMigrateDelete {
|
||||
return
|
||||
}
|
||||
|
||||
db.Close()
|
||||
if err := os.Remove(dbPath); err != nil {
|
||||
log.Errorf("Failed to delete cliphist db: %v", err)
|
||||
return
|
||||
}
|
||||
os.Remove(filepath.Dir(dbPath))
|
||||
fmt.Println("Deleted cliphist db")
|
||||
}
|
||||
|
||||
func getCliphistPath() string {
|
||||
cacheDir, err := os.UserCacheDir()
|
||||
if err != nil {
|
||||
return filepath.Join(os.Getenv("HOME"), ".cache", "cliphist", "db")
|
||||
}
|
||||
return filepath.Join(cacheDir, "cliphist", "db")
|
||||
}
|
||||
|
||||
func detectMimeType(data []byte) string {
|
||||
if _, _, err := image.DecodeConfig(bytes.NewReader(data)); err == nil {
|
||||
return "image/png"
|
||||
}
|
||||
return "text/plain"
|
||||
}
|
||||
|
||||
func btoi(v []byte) uint64 {
|
||||
return binary.BigEndian.Uint64(v)
|
||||
}
|
||||
|
||||
func downloadToTempFile(rawURL string) (string, error) {
|
||||
if !strings.HasPrefix(rawURL, "http://") && !strings.HasPrefix(rawURL, "https://") {
|
||||
return "", fmt.Errorf("invalid URL: %s", rawURL)
|
||||
}
|
||||
|
||||
parsedURL, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse URL: %w", err)
|
||||
}
|
||||
|
||||
ext := filepath.Ext(parsedURL.Path)
|
||||
if ext == "" {
|
||||
ext = ".png"
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
|
||||
var data []byte
|
||||
var contentType string
|
||||
var lastErr error
|
||||
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
if attempt > 0 {
|
||||
time.Sleep(time.Duration(attempt) * 500 * time.Millisecond)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", rawURL, nil)
|
||||
if err != nil {
|
||||
lastErr = fmt.Errorf("create request: %w", err)
|
||||
continue
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||
req.Header.Set("Accept", "image/*,video/*,*/*")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
lastErr = fmt.Errorf("download (attempt %d): %w", attempt+1, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
resp.Body.Close()
|
||||
lastErr = fmt.Errorf("download failed (attempt %d): status %d", attempt+1, resp.StatusCode)
|
||||
continue
|
||||
}
|
||||
|
||||
data, err = io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
lastErr = fmt.Errorf("read response (attempt %d): %w", attempt+1, err)
|
||||
continue
|
||||
}
|
||||
|
||||
contentType = resp.Header.Get("Content-Type")
|
||||
if idx := strings.Index(contentType, ";"); idx != -1 {
|
||||
contentType = strings.TrimSpace(contentType[:idx])
|
||||
}
|
||||
|
||||
lastErr = nil
|
||||
break
|
||||
}
|
||||
|
||||
if lastErr != nil {
|
||||
return "", lastErr
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
return "", fmt.Errorf("downloaded empty file")
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(contentType, "image/") && !strings.HasPrefix(contentType, "video/") {
|
||||
if _, _, err := image.DecodeConfig(bytes.NewReader(data)); err != nil {
|
||||
return "", fmt.Errorf("not a valid media file (content-type: %s)", contentType)
|
||||
}
|
||||
}
|
||||
|
||||
cacheDir, err := os.UserCacheDir()
|
||||
if err != nil {
|
||||
cacheDir = "/tmp"
|
||||
}
|
||||
clipDir := filepath.Join(cacheDir, "dms", "clipboard")
|
||||
if err := os.MkdirAll(clipDir, 0o755); err != nil {
|
||||
return "", fmt.Errorf("create cache dir: %w", err)
|
||||
}
|
||||
|
||||
filePath := filepath.Join(clipDir, fmt.Sprintf("%d%s", time.Now().UnixNano(), ext))
|
||||
if err := os.WriteFile(filePath, data, 0o644); err != nil {
|
||||
return "", fmt.Errorf("write file: %w", err)
|
||||
}
|
||||
|
||||
return filePath, nil
|
||||
}
|
||||
|
||||
func copyFileToClipboard(filePath string) error {
|
||||
req := models.Request{
|
||||
ID: 1,
|
||||
Method: "clipboard.copyFile",
|
||||
Params: map[string]any{"filePath": filePath},
|
||||
}
|
||||
|
||||
resp, err := sendServerRequest(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("server request: %w", err)
|
||||
}
|
||||
if resp.Error != "" {
|
||||
return fmt.Errorf("server error: %s", resp.Error)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -19,7 +19,12 @@ var (
|
||||
var colorCmd = &cobra.Command{
|
||||
Use: "color",
|
||||
Short: "Color utilities",
|
||||
Long: "Color utilities including picking colors from the screen",
|
||||
Long: `Color utilities including picking colors from the screen.
|
||||
|
||||
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`,
|
||||
}
|
||||
|
||||
var colorPickCmd = &cobra.Command{
|
||||
@@ -29,6 +34,9 @@ var colorPickCmd = &cobra.Command{
|
||||
|
||||
Click on any pixel to capture its color, or press Escape to cancel.
|
||||
|
||||
This is the screen eyedropper CLI. To open the in-shell color modal, use:
|
||||
dms ipc call color-picker toggle
|
||||
|
||||
Output format flags (mutually exclusive, default: --hex):
|
||||
--hex - Hexadecimal (#RRGGBB)
|
||||
--rgb - RGB values (R G B)
|
||||
@@ -37,6 +45,9 @@ Output format flags (mutually exclusive, default: --hex):
|
||||
--cmyk - CMYK values (C% M% Y% K%)
|
||||
--json - JSON with all formats
|
||||
|
||||
Optional:
|
||||
--raw - Removes ANSI escape codes and background colors. Use this when piping to other commands
|
||||
|
||||
Examples:
|
||||
dms color pick # Pick color, output as hex
|
||||
dms color pick --rgb # Output as RGB
|
||||
@@ -53,6 +64,7 @@ func init() {
|
||||
colorPickCmd.Flags().Bool("hsv", false, "Output as HSV (H S% V%)")
|
||||
colorPickCmd.Flags().Bool("cmyk", false, "Output as CMYK (C% M% Y% K%)")
|
||||
colorPickCmd.Flags().Bool("json", false, "Output all formats as JSON")
|
||||
colorPickCmd.Flags().Bool("raw", false, "Removes ANSI escape codes and background colors. Use this when piping to other commands")
|
||||
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")
|
||||
@@ -113,7 +125,15 @@ func runColorPick(cmd *cobra.Command, args []string) {
|
||||
|
||||
if jsonOutput {
|
||||
fmt.Println(output)
|
||||
} else if color.IsDark() {
|
||||
return
|
||||
}
|
||||
|
||||
if raw, _ := cmd.Flags().GetBool("raw"); raw {
|
||||
fmt.Printf("%s\n", output)
|
||||
return
|
||||
}
|
||||
|
||||
if color.IsDark() {
|
||||
fmt.Printf("\033[48;2;%d;%d;%dm\033[97m %s \033[0m\n", color.R, color.G, color.B, output)
|
||||
} else {
|
||||
fmt.Printf("\033[48;2;%d;%d;%dm\033[30m %s \033[0m\n", color.R, color.G, color.B, output)
|
||||
|
||||
+273
-12
@@ -6,6 +6,7 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/plugins"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server"
|
||||
@@ -26,6 +27,18 @@ var runCmd = &cobra.Command{
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
daemon, _ := cmd.Flags().GetBool("daemon")
|
||||
session, _ := cmd.Flags().GetBool("session")
|
||||
if v, _ := cmd.Flags().GetString("log-level"); v != "" {
|
||||
if err := os.Setenv("DMS_LOG_LEVEL", v); err != nil {
|
||||
log.Fatalf("Failed to set DMS_LOG_LEVEL: %v", err)
|
||||
}
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("log-file"); v != "" {
|
||||
if err := os.Setenv("DMS_LOG_FILE", v); err != nil {
|
||||
log.Fatalf("Failed to set DMS_LOG_FILE: %v", err)
|
||||
}
|
||||
}
|
||||
log.ApplyEnvOverrides()
|
||||
config.CleanupStrayHyprlandConfFile(log.Infof)
|
||||
if daemon {
|
||||
runShellDaemon(session)
|
||||
} else {
|
||||
@@ -64,12 +77,15 @@ var killCmd = &cobra.Command{
|
||||
}
|
||||
|
||||
var ipcCmd = &cobra.Command{
|
||||
Use: "ipc",
|
||||
Short: "Send IPC commands to running DMS shell",
|
||||
Long: "Send IPC commands to running DMS shell (qs -c dms ipc <args>)",
|
||||
PreRunE: findConfig,
|
||||
Use: "ipc",
|
||||
Short: "Send IPC commands to running DMS shell",
|
||||
Long: `Send IPC commands to the running DMS shell.
|
||||
|
||||
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`,
|
||||
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
_ = findConfig(cmd, args)
|
||||
return getShellIPCCompletions(args, toComplete), cobra.ShellCompDirectiveNoFileComp
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
@@ -77,6 +93,23 @@ var ipcCmd = &cobra.Command{
|
||||
},
|
||||
}
|
||||
|
||||
var ipcListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all IPC targets and functions",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
printIPCHelp()
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
ipcCmd.AddCommand(ipcListCmd)
|
||||
ipcCmd.SetHelpFunc(func(cmd *cobra.Command, args []string) {
|
||||
printIPCHelp()
|
||||
})
|
||||
pluginsUpdateCmd.Flags().BoolP("all", "a", false, "Update all installed plugins")
|
||||
pluginsUpdateCmd.Flags().Bool("check", false, "Check for available updates without applying them")
|
||||
}
|
||||
|
||||
var debugSrvCmd = &cobra.Command{
|
||||
Use: "debug-srv",
|
||||
Short: "Start the debug server",
|
||||
@@ -153,10 +186,22 @@ var pluginsUninstallCmd = &cobra.Command{
|
||||
}
|
||||
|
||||
var pluginsUpdateCmd = &cobra.Command{
|
||||
Use: "update <plugin-id>",
|
||||
Short: "Update a plugin by ID",
|
||||
Long: "Update an installed DMS plugin using its ID (e.g., 'myPlugin'). Plugin names are also supported.",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Use: "update [plugin-id]",
|
||||
Short: "Update a plugin by ID, or all plugins",
|
||||
Long: "Update an installed DMS plugin using its ID (e.g., 'myPlugin'). If --all or -a is specified, all installed plugins will be updated.",
|
||||
Args: func(cmd *cobra.Command, args []string) error {
|
||||
updateAll, _ := cmd.Flags().GetBool("all")
|
||||
if updateAll {
|
||||
if len(args) > 0 {
|
||||
return fmt.Errorf("cannot specify plugin ID when using --all/-a")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if len(args) != 1 {
|
||||
return fmt.Errorf("requires exactly 1 arg (plugin ID) or use --all/-a")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
if len(args) != 0 {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
@@ -164,6 +209,26 @@ var pluginsUpdateCmd = &cobra.Command{
|
||||
return getInstalledPluginIDs(), cobra.ShellCompDirectiveNoFileComp
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
checkOnly, _ := cmd.Flags().GetBool("check")
|
||||
updateAll, _ := cmd.Flags().GetBool("all")
|
||||
if checkOnly {
|
||||
if updateAll {
|
||||
if err := checkAllPluginsCLI(); err != nil {
|
||||
log.Fatalf("Error checking updates: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := checkPluginCLI(args[0]); err != nil {
|
||||
log.Fatalf("Error checking update: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if updateAll {
|
||||
if err := updateAllPluginsCLI(); err != nil {
|
||||
log.Fatalf("Error updating plugins: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := updatePluginCLI(args[0]); err != nil {
|
||||
log.Fatalf("Error updating plugin: %v", err)
|
||||
}
|
||||
@@ -171,7 +236,6 @@ var pluginsUpdateCmd = &cobra.Command{
|
||||
}
|
||||
|
||||
func runVersion(cmd *cobra.Command, args []string) {
|
||||
printASCII()
|
||||
fmt.Printf("%s\n", formatVersion(Version))
|
||||
}
|
||||
|
||||
@@ -220,7 +284,7 @@ func getBaseVersion() string {
|
||||
}
|
||||
|
||||
// Fallback
|
||||
return "0.6.2"
|
||||
return "1.0.2"
|
||||
}
|
||||
|
||||
func startDebugServer() error {
|
||||
@@ -250,6 +314,13 @@ func browsePlugins() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
feedback := plugins.FetchFeedback()
|
||||
|
||||
nameByID := make(map[string]string, len(pluginList))
|
||||
for _, plugin := range pluginList {
|
||||
nameByID[plugin.ID] = plugin.Name
|
||||
}
|
||||
|
||||
fmt.Printf("\nAvailable Plugins (%d):\n\n", len(pluginList))
|
||||
for _, plugin := range pluginList {
|
||||
installed, _ := manager.IsInstalled(plugin)
|
||||
@@ -273,6 +344,26 @@ func browsePlugins() error {
|
||||
if len(plugin.Dependencies) > 0 {
|
||||
fmt.Printf(" Dependencies: %s\n", strings.Join(plugin.Dependencies, ", "))
|
||||
}
|
||||
if fb, ok := feedback[plugin.ID]; ok {
|
||||
fmt.Printf(" Upvotes: %d\n", fb.Upvotes)
|
||||
if len(fb.Status) > 0 {
|
||||
fmt.Printf(" Status: %s\n", strings.Join(fb.Status, ", "))
|
||||
}
|
||||
if fb.IssueURL != "" {
|
||||
fmt.Printf(" Discuss: %s\n", fb.IssueURL)
|
||||
}
|
||||
if len(fb.Similar) > 0 {
|
||||
names := make([]string, len(fb.Similar))
|
||||
for i, id := range fb.Similar {
|
||||
if name, ok := nameByID[id]; ok {
|
||||
names[i] = name
|
||||
} else {
|
||||
names[i] = id
|
||||
}
|
||||
}
|
||||
fmt.Printf(" Related: %s\n", strings.Join(names, ", "))
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
@@ -313,7 +404,11 @@ func listInstalledPlugins() error {
|
||||
fmt.Printf("\nInstalled Plugins (%d):\n\n", len(installedNames))
|
||||
for _, id := range installedNames {
|
||||
if plugin, ok := pluginMap[id]; ok {
|
||||
fmt.Printf(" %s\n", plugin.Name)
|
||||
hasUpdateStr := ""
|
||||
if hasUpdates, _, err := manager.HasUpdates(id, plugin); err == nil && hasUpdates {
|
||||
hasUpdateStr = " (update available)"
|
||||
}
|
||||
fmt.Printf(" %s%s\n", plugin.Name, hasUpdateStr)
|
||||
fmt.Printf(" ID: %s\n", plugin.ID)
|
||||
fmt.Printf(" Category: %s\n", plugin.Category)
|
||||
fmt.Printf(" Author: %s\n", plugin.Author)
|
||||
@@ -493,6 +588,160 @@ func updatePluginCLI(idOrName string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateAllPluginsCLI() error {
|
||||
manager, err := plugins.NewManager()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create manager: %w", err)
|
||||
}
|
||||
|
||||
registry, err := plugins.NewRegistry()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create registry: %w", err)
|
||||
}
|
||||
|
||||
installed, err := manager.ListInstalled()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list installed plugins: %w", err)
|
||||
}
|
||||
|
||||
pluginList, _ := registry.List()
|
||||
|
||||
var errs []error
|
||||
for _, pluginID := range installed {
|
||||
plugin := plugins.FindByIDOrName(pluginID, pluginList)
|
||||
if plugin != nil {
|
||||
fmt.Printf("Updating plugin: %s (ID: %s)\n", plugin.Name, plugin.ID)
|
||||
if err := manager.Update(*plugin); err != nil {
|
||||
if strings.Contains(err.Error(), "cannot update system plugin") {
|
||||
fmt.Printf("Skipping system plugin: %s\n", plugin.Name)
|
||||
} else {
|
||||
errs = append(errs, fmt.Errorf("failed to update %s: %w", plugin.Name, err))
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("Plugin updated successfully: %s\n", plugin.Name)
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("Updating plugin: %s\n", pluginID)
|
||||
if err := manager.UpdateByIDOrName(pluginID); err != nil {
|
||||
if strings.Contains(err.Error(), "cannot update system plugin") {
|
||||
fmt.Printf("Skipping system plugin: %s\n", pluginID)
|
||||
} else {
|
||||
errs = append(errs, fmt.Errorf("failed to update %s: %w", pluginID, err))
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("Plugin updated successfully: %s\n", pluginID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
for _, err := range errs {
|
||||
fmt.Fprintf(os.Stderr, "%v\n", err)
|
||||
}
|
||||
return fmt.Errorf("failed to update some plugins")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkPluginCLI(idOrName string) error {
|
||||
manager, err := plugins.NewManager()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create manager: %w", err)
|
||||
}
|
||||
|
||||
registry, err := plugins.NewRegistry()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create registry: %w", err)
|
||||
}
|
||||
|
||||
pluginList, _ := registry.List()
|
||||
plugin := plugins.FindByIDOrName(idOrName, pluginList)
|
||||
|
||||
if plugin != nil {
|
||||
installed, err := manager.IsInstalled(*plugin)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check install status: %w", err)
|
||||
}
|
||||
if !installed {
|
||||
return fmt.Errorf("plugin not installed: %s", plugin.Name)
|
||||
}
|
||||
|
||||
hasUpdates, _, err := manager.HasUpdates(plugin.ID, *plugin)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check updates: %w", err)
|
||||
}
|
||||
|
||||
if hasUpdates {
|
||||
fmt.Printf("Update available for plugin: %s (ID: %s)\n", plugin.Name, plugin.ID)
|
||||
} else {
|
||||
fmt.Printf("Plugin is up to date: %s\n", plugin.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
dummyPlugin := plugins.Plugin{ID: idOrName}
|
||||
hasUpdates, _, err := manager.HasUpdates(idOrName, dummyPlugin)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check updates: %w", err)
|
||||
}
|
||||
|
||||
if hasUpdates {
|
||||
fmt.Printf("Update available for plugin: %s\n", idOrName)
|
||||
} else {
|
||||
fmt.Printf("Plugin is up to date: %s\n", idOrName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkAllPluginsCLI() error {
|
||||
manager, err := plugins.NewManager()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create manager: %w", err)
|
||||
}
|
||||
|
||||
registry, err := plugins.NewRegistry()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create registry: %w", err)
|
||||
}
|
||||
|
||||
installed, err := manager.ListInstalled()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list installed plugins: %w", err)
|
||||
}
|
||||
|
||||
pluginList, _ := registry.List()
|
||||
|
||||
var count int
|
||||
for _, pluginID := range installed {
|
||||
plugin := plugins.FindByIDOrName(pluginID, pluginList)
|
||||
var hasUpdates bool
|
||||
var name string
|
||||
|
||||
if plugin != nil {
|
||||
name = plugin.Name
|
||||
hasUpdates, _, _ = manager.HasUpdates(pluginID, *plugin)
|
||||
} else {
|
||||
name = pluginID
|
||||
dummyPlugin := plugins.Plugin{ID: pluginID}
|
||||
hasUpdates, _, _ = manager.HasUpdates(pluginID, dummyPlugin)
|
||||
}
|
||||
|
||||
if hasUpdates {
|
||||
fmt.Printf("Update available for plugin: %s (ID: %s)\n", name, pluginID)
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
fmt.Printf("\nFound %d plugin(s) with available updates.\n", count)
|
||||
} else {
|
||||
fmt.Println("All plugins are up to date.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getCommonCommands() []*cobra.Command {
|
||||
return []*cobra.Command{
|
||||
versionCmd,
|
||||
@@ -510,9 +759,21 @@ func getCommonCommands() []*cobra.Command {
|
||||
greeterCmd,
|
||||
setupCmd,
|
||||
colorCmd,
|
||||
qrCmd,
|
||||
screenshotCmd,
|
||||
notifyActionCmd,
|
||||
notifyCmd,
|
||||
genericNotifyActionCmd,
|
||||
matugenCmd,
|
||||
clipboardCmd,
|
||||
chromaCmd,
|
||||
doctorCmd,
|
||||
configCmd,
|
||||
dlCmd,
|
||||
randrCmd,
|
||||
blurCmd,
|
||||
trashCmd,
|
||||
systemCmd,
|
||||
switchUserCmd,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"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/spf13/cobra"
|
||||
)
|
||||
|
||||
var configCmd = &cobra.Command{
|
||||
Use: "config",
|
||||
Short: "Configuration utilities",
|
||||
}
|
||||
|
||||
var resolveIncludeCmd = &cobra.Command{
|
||||
Use: "resolve-include <compositor> <filename>",
|
||||
Short: "Check if a file is included in compositor config",
|
||||
Long: "Recursively check if a file is included/sourced in compositor configuration. Returns JSON with exists and included status.",
|
||||
Args: cobra.ExactArgs(2),
|
||||
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
switch len(args) {
|
||||
case 0:
|
||||
return []string{"hyprland", "niri", "mangowc"}, cobra.ShellCompDirectiveNoFileComp
|
||||
case 1:
|
||||
return []string{
|
||||
"binds.lua",
|
||||
"binds-user.lua",
|
||||
"colors.lua",
|
||||
"layout.lua",
|
||||
"outputs.lua",
|
||||
"cursor.lua",
|
||||
"windowrules.lua",
|
||||
"cursor.kdl",
|
||||
"layout.kdl",
|
||||
"outputs.kdl",
|
||||
"binds.kdl",
|
||||
"cursor.conf",
|
||||
"layout.conf",
|
||||
"outputs.conf",
|
||||
"binds.conf",
|
||||
}, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
},
|
||||
Run: runResolveInclude,
|
||||
}
|
||||
|
||||
func init() {
|
||||
configCmd.AddCommand(resolveIncludeCmd)
|
||||
}
|
||||
|
||||
type IncludeResult struct {
|
||||
Exists bool `json:"exists"`
|
||||
Included bool `json:"included"`
|
||||
ConfigFormat string `json:"configFormat,omitempty"`
|
||||
ReadOnly bool `json:"readOnly,omitempty"`
|
||||
}
|
||||
|
||||
func runResolveInclude(cmd *cobra.Command, args []string) {
|
||||
compositor := strings.ToLower(args[0])
|
||||
filename := args[1]
|
||||
|
||||
var result IncludeResult
|
||||
var err error
|
||||
|
||||
switch compositor {
|
||||
case "hyprland":
|
||||
result, err = checkHyprlandInclude(filename)
|
||||
case "niri":
|
||||
result, err = checkNiriInclude(filename)
|
||||
case "mangowc", "mango":
|
||||
result, err = checkMangoWCInclude(filename)
|
||||
default:
|
||||
log.Fatalf("Unknown compositor: %s", compositor)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Fatalf("Error checking include: %v", err)
|
||||
}
|
||||
|
||||
output, _ := json.Marshal(result)
|
||||
fmt.Fprintln(os.Stdout, string(output))
|
||||
}
|
||||
|
||||
func checkHyprlandInclude(filename string) (IncludeResult, error) {
|
||||
configDir := filepath.Join(utils.XDGConfigHome(), "hypr")
|
||||
|
||||
targetPath := filepath.Join(configDir, "dms", filename)
|
||||
result := IncludeResult{}
|
||||
|
||||
if _, err := os.Stat(targetPath); err == nil {
|
||||
result.Exists = true
|
||||
}
|
||||
|
||||
targetAbs, err := filepath.Abs(targetPath)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func hyprlandFindIncludeHyprlang(filePath, target string, processed map[string]bool) bool {
|
||||
absPath, err := filepath.Abs(filePath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if processed[absPath] {
|
||||
return false
|
||||
}
|
||||
processed[absPath] = true
|
||||
|
||||
data, err := os.ReadFile(absPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
baseDir := filepath.Dir(absPath)
|
||||
lines := strings.Split(string(data), "\n")
|
||||
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "#") || trimmed == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(trimmed, "source") {
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.SplitN(trimmed, "=", 2)
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
sourcePath := strings.TrimSpace(parts[1])
|
||||
if matchesTarget(sourcePath, target) {
|
||||
return true
|
||||
}
|
||||
|
||||
fullPath := sourcePath
|
||||
if !filepath.IsAbs(sourcePath) {
|
||||
fullPath = filepath.Join(baseDir, sourcePath)
|
||||
}
|
||||
|
||||
expanded, err := utils.ExpandPath(fullPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if hyprlandFindIncludeHyprlang(expanded, target, processed) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func checkNiriInclude(filename string) (IncludeResult, error) {
|
||||
configDir := filepath.Join(utils.XDGConfigHome(), "niri")
|
||||
|
||||
targetPath := filepath.Join(configDir, "dms", filename)
|
||||
result := IncludeResult{}
|
||||
|
||||
if _, err := os.Stat(targetPath); err == nil {
|
||||
result.Exists = true
|
||||
}
|
||||
|
||||
mainConfig := filepath.Join(configDir, "config.kdl")
|
||||
if _, err := os.Stat(mainConfig); os.IsNotExist(err) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
processed := make(map[string]bool)
|
||||
result.Included = niriFindInclude(mainConfig, "dms/"+filename, processed)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func niriFindInclude(filePath, target string, processed map[string]bool) bool {
|
||||
absPath, err := filepath.Abs(filePath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if processed[absPath] {
|
||||
return false
|
||||
}
|
||||
processed[absPath] = true
|
||||
|
||||
data, err := os.ReadFile(absPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
baseDir := filepath.Dir(absPath)
|
||||
content := string(data)
|
||||
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "//") || trimmed == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(trimmed, "include") {
|
||||
continue
|
||||
}
|
||||
|
||||
startQuote := strings.Index(trimmed, "\"")
|
||||
if startQuote == -1 {
|
||||
continue
|
||||
}
|
||||
endQuote := strings.LastIndex(trimmed, "\"")
|
||||
if endQuote <= startQuote {
|
||||
continue
|
||||
}
|
||||
|
||||
includePath := trimmed[startQuote+1 : endQuote]
|
||||
if matchesTarget(includePath, target) {
|
||||
return true
|
||||
}
|
||||
|
||||
fullPath := includePath
|
||||
if !filepath.IsAbs(includePath) {
|
||||
fullPath = filepath.Join(baseDir, includePath)
|
||||
}
|
||||
|
||||
if niriFindInclude(fullPath, target, processed) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func checkMangoWCInclude(filename string) (IncludeResult, error) {
|
||||
configDir := filepath.Join(utils.XDGConfigHome(), "mango")
|
||||
|
||||
targetPath := filepath.Join(configDir, "dms", filename)
|
||||
result := IncludeResult{}
|
||||
|
||||
if _, err := os.Stat(targetPath); err == nil {
|
||||
result.Exists = true
|
||||
}
|
||||
|
||||
mainConfig := filepath.Join(configDir, "config.conf")
|
||||
if _, err := os.Stat(mainConfig); os.IsNotExist(err) {
|
||||
mainConfig = filepath.Join(configDir, "mango.conf")
|
||||
}
|
||||
if _, err := os.Stat(mainConfig); os.IsNotExist(err) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
processed := make(map[string]bool)
|
||||
result.Included = mangowcFindInclude(mainConfig, "dms/"+filename, processed)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func mangowcFindInclude(filePath, target string, processed map[string]bool) bool {
|
||||
absPath, err := filepath.Abs(filePath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if processed[absPath] {
|
||||
return false
|
||||
}
|
||||
processed[absPath] = true
|
||||
|
||||
data, err := os.ReadFile(absPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
baseDir := filepath.Dir(absPath)
|
||||
lines := strings.Split(string(data), "\n")
|
||||
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "#") || trimmed == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(trimmed, "source") {
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.SplitN(trimmed, "=", 2)
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
sourcePath := strings.TrimSpace(parts[1])
|
||||
if matchesTarget(sourcePath, target) {
|
||||
return true
|
||||
}
|
||||
|
||||
fullPath := sourcePath
|
||||
if !filepath.IsAbs(sourcePath) {
|
||||
fullPath = filepath.Join(baseDir, sourcePath)
|
||||
}
|
||||
|
||||
expanded, err := utils.ExpandPath(fullPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if mangowcFindInclude(expanded, target, processed) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func matchesTarget(path, target string) bool {
|
||||
path = strings.TrimPrefix(path, "./")
|
||||
target = strings.TrimPrefix(target, "./")
|
||||
return path == target || strings.HasSuffix(path, "/"+target)
|
||||
}
|
||||
@@ -22,6 +22,7 @@ func init() {
|
||||
dank16Cmd.Flags().Bool("json", false, "Output in JSON format")
|
||||
dank16Cmd.Flags().Bool("kitty", false, "Output in Kitty terminal format")
|
||||
dank16Cmd.Flags().Bool("foot", false, "Output in Foot terminal format")
|
||||
dank16Cmd.Flags().Bool("neovim", false, "Output in Neovim plugin format")
|
||||
dank16Cmd.Flags().Bool("alacritty", false, "Output in Alacritty terminal format")
|
||||
dank16Cmd.Flags().Bool("ghostty", false, "Output in Ghostty terminal format")
|
||||
dank16Cmd.Flags().Bool("wezterm", false, "Output in Wezterm terminal format")
|
||||
@@ -40,6 +41,7 @@ func runDank16(cmd *cobra.Command, args []string) {
|
||||
isJson, _ := cmd.Flags().GetBool("json")
|
||||
isKitty, _ := cmd.Flags().GetBool("kitty")
|
||||
isFoot, _ := cmd.Flags().GetBool("foot")
|
||||
isNeovim, _ := cmd.Flags().GetBool("neovim")
|
||||
isAlacritty, _ := cmd.Flags().GetBool("alacritty")
|
||||
isGhostty, _ := cmd.Flags().GetBool("ghostty")
|
||||
isWezterm, _ := cmd.Flags().GetBool("wezterm")
|
||||
@@ -116,6 +118,8 @@ func runDank16(cmd *cobra.Command, args []string) {
|
||||
fmt.Print(dank16.GenerateGhosttyTheme(colors))
|
||||
} else if isWezterm {
|
||||
fmt.Print(dank16.GenerateWeztermTheme(colors))
|
||||
} else if isNeovim {
|
||||
fmt.Print(dank16.GenerateNeovimTheme(colors))
|
||||
} else {
|
||||
fmt.Print(dank16.GenerateGhosttyTheme(colors))
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,99 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var dlOutput string
|
||||
var dlUserAgent string
|
||||
var dlTimeout int
|
||||
var dlIPv4Only bool
|
||||
|
||||
var dlCmd = &cobra.Command{
|
||||
Use: "dl <url>",
|
||||
Short: "Download a URL to stdout or file",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if err := runDownload(args[0]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
dlCmd.Flags().StringVarP(&dlOutput, "output", "o", "", "Output file path (default: stdout)")
|
||||
dlCmd.Flags().StringVar(&dlUserAgent, "user-agent", "", "Custom User-Agent header")
|
||||
dlCmd.Flags().IntVar(&dlTimeout, "timeout", 10, "Request timeout in seconds")
|
||||
dlCmd.Flags().BoolVarP(&dlIPv4Only, "ipv4", "4", false, "Force IPv4 only")
|
||||
}
|
||||
|
||||
func runDownload(url string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(dlTimeout)*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid request: %w", err)
|
||||
}
|
||||
|
||||
switch {
|
||||
case dlUserAgent != "":
|
||||
req.Header.Set("User-Agent", dlUserAgent)
|
||||
default:
|
||||
req.Header.Set("User-Agent", "DankMaterialShell/1.0 (Linux)")
|
||||
}
|
||||
|
||||
dialer := &net.Dialer{Timeout: 5 * time.Second}
|
||||
transport := &http.Transport{DialContext: dialer.DialContext}
|
||||
if dlIPv4Only {
|
||||
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return dialer.DialContext(ctx, "tcp4", addr)
|
||||
}
|
||||
}
|
||||
client := &http.Client{Transport: transport}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("download failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
if dlOutput == "" {
|
||||
_, err = io.Copy(os.Stdout, resp.Body)
|
||||
return err
|
||||
}
|
||||
|
||||
if dir := filepath.Dir(dlOutput); dir != "." {
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("mkdir failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
f, err := os.Create(dlOutput)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create failed: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if _, err := io.Copy(f, resp.Body); err != nil {
|
||||
os.Remove(dlOutput)
|
||||
return fmt.Errorf("write failed: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println(dlOutput)
|
||||
return nil
|
||||
}
|
||||
@@ -4,6 +4,7 @@ package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
"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/privesc"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/version"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -109,16 +111,37 @@ func updateArchLinux() error {
|
||||
}
|
||||
|
||||
var packageName string
|
||||
if isArchPackageInstalled("dms-shell-bin") {
|
||||
packageName = "dms-shell-bin"
|
||||
var isAUR bool
|
||||
if isArchPackageInstalled("dms-shell") {
|
||||
packageName = "dms-shell"
|
||||
} else if isArchPackageInstalled("dms-shell-git") {
|
||||
packageName = "dms-shell-git"
|
||||
isAUR = true
|
||||
} else if isArchPackageInstalled("dms-shell-bin") {
|
||||
packageName = "dms-shell-bin"
|
||||
isAUR = true
|
||||
} else {
|
||||
fmt.Println("Info: Neither dms-shell-bin nor dms-shell-git package found.")
|
||||
fmt.Println("Info: No dms-shell package found.")
|
||||
fmt.Println("Info: Falling back to git-based update method...")
|
||||
return updateOtherDistros()
|
||||
}
|
||||
|
||||
if !isAUR {
|
||||
fmt.Printf("This will update %s using pacman.\n", packageName)
|
||||
if !confirmUpdate() {
|
||||
return errdefs.ErrUpdateCancelled
|
||||
}
|
||||
|
||||
fmt.Printf("\nRunning: pacman -S %s\n", packageName)
|
||||
if err := privesc.Run(context.Background(), "", "pacman", "-S", "--noconfirm", packageName); err != nil {
|
||||
fmt.Printf("Error: Failed to update using pacman: %v\n", err)
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("dms successfully updated")
|
||||
return nil
|
||||
}
|
||||
|
||||
var helper string
|
||||
var updateCmd *exec.Cmd
|
||||
|
||||
@@ -377,7 +400,7 @@ func updateDMSBinary() error {
|
||||
}
|
||||
|
||||
version := ""
|
||||
for _, line := range strings.Split(string(output), "\n") {
|
||||
for line := range strings.SplitSeq(string(output), "\n") {
|
||||
if strings.Contains(line, "\"tag_name\"") {
|
||||
parts := strings.Split(line, "\"")
|
||||
if len(parts) >= 4 {
|
||||
@@ -443,7 +466,7 @@ func updateDMSBinary() error {
|
||||
|
||||
decompressedPath := filepath.Join(tempDir, "dms")
|
||||
|
||||
if err := os.Chmod(decompressedPath, 0755); err != nil {
|
||||
if err := os.Chmod(decompressedPath, 0o755); err != nil {
|
||||
return fmt.Errorf("failed to make binary executable: %w", err)
|
||||
}
|
||||
|
||||
@@ -454,11 +477,7 @@ func updateDMSBinary() error {
|
||||
|
||||
fmt.Printf("Installing to %s...\n", currentPath)
|
||||
|
||||
replaceCmd := exec.Command("sudo", "install", "-m", "0755", decompressedPath, currentPath)
|
||||
replaceCmd.Stdin = os.Stdin
|
||||
replaceCmd.Stdout = os.Stdout
|
||||
replaceCmd.Stderr = os.Stderr
|
||||
if err := replaceCmd.Run(); err != nil {
|
||||
if err := privesc.Run(context.Background(), "", "install", "-m", "0755", decompressedPath, currentPath); err != nil {
|
||||
return fmt.Errorf("failed to replace binary: %w", err)
|
||||
}
|
||||
|
||||
|
||||
+1606
-184
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,150 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
sharedpam "github.com/AvengeMedia/DankMaterialShell/core/internal/pam"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestSyncGreeterConfigsAndAuthDelegatesSharedAuth(t *testing.T) {
|
||||
origGreeterConfigSyncFn := greeterConfigSyncFn
|
||||
origSharedAuthSyncFn := sharedAuthSyncFn
|
||||
t.Cleanup(func() {
|
||||
greeterConfigSyncFn = origGreeterConfigSyncFn
|
||||
sharedAuthSyncFn = origSharedAuthSyncFn
|
||||
})
|
||||
|
||||
var calls []string
|
||||
greeterConfigSyncFn = func(dmsPath, compositor string, logFunc func(string), sudoPassword string) error {
|
||||
if dmsPath != "/tmp/dms" {
|
||||
t.Fatalf("unexpected dmsPath %q", dmsPath)
|
||||
}
|
||||
if compositor != "niri" {
|
||||
t.Fatalf("unexpected compositor %q", compositor)
|
||||
}
|
||||
if sudoPassword != "" {
|
||||
t.Fatalf("expected empty sudoPassword, got %q", sudoPassword)
|
||||
}
|
||||
calls = append(calls, "configs")
|
||||
return nil
|
||||
}
|
||||
|
||||
var gotOptions sharedpam.SyncAuthOptions
|
||||
sharedAuthSyncFn = func(logFunc func(string), sudoPassword string, options sharedpam.SyncAuthOptions) error {
|
||||
if sudoPassword != "" {
|
||||
t.Fatalf("expected empty sudoPassword, got %q", sudoPassword)
|
||||
}
|
||||
gotOptions = options
|
||||
calls = append(calls, "auth")
|
||||
return nil
|
||||
}
|
||||
|
||||
err := syncGreeterConfigsAndAuth("/tmp/dms", "niri", func(string) {}, sharedpam.SyncAuthOptions{
|
||||
ForceGreeterAuth: true,
|
||||
}, func() {
|
||||
calls = append(calls, "before-auth")
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("syncGreeterConfigsAndAuth returned error: %v", err)
|
||||
}
|
||||
|
||||
wantCalls := []string{"configs", "before-auth", "auth"}
|
||||
if !reflect.DeepEqual(calls, wantCalls) {
|
||||
t.Fatalf("call order = %v, want %v", calls, wantCalls)
|
||||
}
|
||||
if !gotOptions.ForceGreeterAuth {
|
||||
t.Fatalf("expected ForceGreeterAuth to be true, got %+v", gotOptions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncGreeterConfigsAndAuthStopsOnConfigError(t *testing.T) {
|
||||
origGreeterConfigSyncFn := greeterConfigSyncFn
|
||||
origSharedAuthSyncFn := sharedAuthSyncFn
|
||||
t.Cleanup(func() {
|
||||
greeterConfigSyncFn = origGreeterConfigSyncFn
|
||||
sharedAuthSyncFn = origSharedAuthSyncFn
|
||||
})
|
||||
|
||||
greeterConfigSyncFn = func(string, string, func(string), string) error {
|
||||
return errors.New("config sync failed")
|
||||
}
|
||||
|
||||
authCalled := false
|
||||
sharedAuthSyncFn = func(func(string), string, sharedpam.SyncAuthOptions) error {
|
||||
authCalled = true
|
||||
return nil
|
||||
}
|
||||
|
||||
err := syncGreeterConfigsAndAuth("/tmp/dms", "niri", func(string) {}, sharedpam.SyncAuthOptions{}, nil)
|
||||
if err == nil || err.Error() != "config sync failed" {
|
||||
t.Fatalf("expected config sync error, got %v", err)
|
||||
}
|
||||
if authCalled {
|
||||
t.Fatal("expected auth sync not to run after config sync failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGreeterStatusStateDirUsesNixOSDefault(t *testing.T) {
|
||||
if got := greeterStatusStateDir("", true); got != nixOSGreeterStateDir {
|
||||
t.Fatalf("greeterStatusStateDir() = %q, want %q", got, nixOSGreeterStateDir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGreeterStatusStateDirHonorsExplicitOverrideOnNixOS(t *testing.T) {
|
||||
command := "dms-greeter --cache-dir /srv/dms-greeter --command niri"
|
||||
if got := greeterStatusStateDir(command, true); got != "/srv/dms-greeter" {
|
||||
t.Fatalf("greeterStatusStateDir() = %q, want %q", got, "/srv/dms-greeter")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractGreeterWrapperFromEnvCommand(t *testing.T) {
|
||||
command := "env LIBSEAT_BACKEND=logind DMS_VOID=1 /usr/bin/dms-greeter --command niri"
|
||||
if got := extractGreeterWrapperFromCommand(command); got != "/usr/bin/dms-greeter" {
|
||||
t.Fatalf("extractGreeterWrapperFromCommand() = %q, want %q", got, "/usr/bin/dms-greeter")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectNixOSGreeterMutationBlocksImperativeCommands(t *testing.T) {
|
||||
origGreeterIsNixOSFn := greeterIsNixOSFn
|
||||
greeterIsNixOSFn = func() bool { return true }
|
||||
t.Cleanup(func() {
|
||||
greeterIsNixOSFn = origGreeterIsNixOSFn
|
||||
})
|
||||
|
||||
for _, commandName := range []string{"install", "enable", "sync", "uninstall"} {
|
||||
t.Run(commandName, func(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dms"}
|
||||
greeterCommand := &cobra.Command{Use: "greeter"}
|
||||
mutationCommand := &cobra.Command{Use: commandName}
|
||||
root.AddCommand(greeterCommand)
|
||||
greeterCommand.AddCommand(mutationCommand)
|
||||
|
||||
err := rejectNixOSGreeterMutation(mutationCommand)
|
||||
if err == nil {
|
||||
t.Fatalf("expected NixOS greeter %s to be rejected", commandName)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "dms greeter "+commandName+" is disabled on NixOS") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "/var/cache/dms-greeter") {
|
||||
t.Fatalf("NixOS remediation should not recommend the non-NixOS cache path: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectNixOSGreeterMutationAllowsOtherDistros(t *testing.T) {
|
||||
origGreeterIsNixOSFn := greeterIsNixOSFn
|
||||
greeterIsNixOSFn = func() bool { return false }
|
||||
t.Cleanup(func() {
|
||||
greeterIsNixOSFn = origGreeterIsNixOSFn
|
||||
})
|
||||
|
||||
if err := rejectNixOSGreeterMutation(&cobra.Command{Use: "sync"}); err != nil {
|
||||
t.Fatalf("expected non-NixOS greeter command to be allowed, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds/providers"
|
||||
@@ -50,24 +51,36 @@ var keybindsSetCmd = &cobra.Command{
|
||||
|
||||
var keybindsRemoveCmd = &cobra.Command{
|
||||
Use: "remove <provider> <key>",
|
||||
Short: "Remove a keybind override",
|
||||
Long: "Remove a keybind override from the specified provider",
|
||||
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,
|
||||
}
|
||||
|
||||
var keybindsResetCmd = &cobra.Command{
|
||||
Use: "reset <provider> <key>",
|
||||
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,
|
||||
}
|
||||
|
||||
func init() {
|
||||
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")
|
||||
keybindsSetCmd.Flags().Bool("allow-when-locked", false, "Allow when screen is locked")
|
||||
keybindsSetCmd.Flags().Int("cooldown-ms", 0, "Cooldown in milliseconds")
|
||||
keybindsSetCmd.Flags().Bool("no-repeat", false, "Disable key repeat")
|
||||
keybindsSetCmd.Flags().Bool("no-inhibiting", false, "Keep bind active when shortcuts are inhibited (allow-inhibiting=false)")
|
||||
keybindsSetCmd.Flags().String("replace-key", "", "Original key to replace (removes old key)")
|
||||
keybindsSetCmd.Flags().String("flags", "", "Hyprland bind flags (e.g., 'e' for repeat, 'l' for locked, 'r' for release)")
|
||||
|
||||
keybindsCmd.AddCommand(keybindsListCmd)
|
||||
keybindsCmd.AddCommand(keybindsShowCmd)
|
||||
keybindsCmd.AddCommand(keybindsSetCmd)
|
||||
keybindsCmd.AddCommand(keybindsRemoveCmd)
|
||||
keybindsCmd.AddCommand(keybindsResetCmd)
|
||||
|
||||
keybinds.SetJSONProviderFactory(func(filePath string) (keybinds.Provider, error) {
|
||||
return providers.NewJSONFileProvider(filePath)
|
||||
@@ -79,24 +92,35 @@ func init() {
|
||||
func initializeProviders() {
|
||||
registry := keybinds.GetDefaultRegistry()
|
||||
|
||||
hyprlandProvider := providers.NewHyprlandProvider("$HOME/.config/hypr")
|
||||
hyprlandProvider := providers.NewHyprlandProvider("")
|
||||
if err := registry.Register(hyprlandProvider); err != nil {
|
||||
log.Warnf("Failed to register Hyprland provider: %v", err)
|
||||
}
|
||||
|
||||
mangowcProvider := providers.NewMangoWCProvider("$HOME/.config/mango")
|
||||
mangowcProvider := providers.NewMangoWCProvider("")
|
||||
if err := registry.Register(mangowcProvider); err != nil {
|
||||
log.Warnf("Failed to register MangoWC provider: %v", err)
|
||||
}
|
||||
|
||||
scrollProvider := providers.NewSwayProvider("$HOME/.config/scroll")
|
||||
if err := registry.Register(scrollProvider); err != nil {
|
||||
log.Warnf("Failed to register Scroll provider: %v", err)
|
||||
configDir, _ := os.UserConfigDir()
|
||||
|
||||
if configDir != "" {
|
||||
scrollProvider := providers.NewSwayProvider(filepath.Join(configDir, "scroll"))
|
||||
if err := registry.Register(scrollProvider); err != nil {
|
||||
log.Warnf("Failed to register Scroll provider: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
swayProvider := providers.NewSwayProvider("$HOME/.config/sway")
|
||||
if err := registry.Register(swayProvider); err != nil {
|
||||
log.Warnf("Failed to register Sway provider: %v", err)
|
||||
miracleProvider := providers.NewMiracleProvider("")
|
||||
if err := registry.Register(miracleProvider); err != nil {
|
||||
log.Warnf("Failed to register Miracle WM provider: %v", err)
|
||||
}
|
||||
|
||||
if configDir != "" {
|
||||
swayProvider := providers.NewSwayProvider(filepath.Join(configDir, "sway"))
|
||||
if err := registry.Register(swayProvider); err != nil {
|
||||
log.Warnf("Failed to register Sway provider: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
niriProvider := providers.NewNiriProvider("")
|
||||
@@ -110,12 +134,21 @@ func initializeProviders() {
|
||||
}
|
||||
}
|
||||
|
||||
func runKeybindsList(_ *cobra.Command, _ []string) {
|
||||
func runKeybindsList(cmd *cobra.Command, _ []string) {
|
||||
providerList := keybinds.GetDefaultRegistry().List()
|
||||
asJSON, _ := cmd.Flags().GetBool("json")
|
||||
|
||||
if asJSON {
|
||||
output, _ := json.Marshal(providerList)
|
||||
fmt.Fprintln(os.Stdout, string(output))
|
||||
return
|
||||
}
|
||||
|
||||
if len(providerList) == 0 {
|
||||
fmt.Fprintln(os.Stdout, "No providers available")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprintln(os.Stdout, "Available providers:")
|
||||
for _, name := range providerList {
|
||||
fmt.Fprintf(os.Stdout, " - %s\n", name)
|
||||
@@ -132,6 +165,8 @@ func makeProviderWithPath(name, path string) keybinds.Provider {
|
||||
return providers.NewSwayProvider(path)
|
||||
case "scroll":
|
||||
return providers.NewSwayProvider(path)
|
||||
case "miracle":
|
||||
return providers.NewMiracleProvider(path)
|
||||
case "niri":
|
||||
return providers.NewNiriProvider(path)
|
||||
default:
|
||||
@@ -201,6 +236,12 @@ func runKeybindsSet(cmd *cobra.Command, args []string) {
|
||||
if v, _ := cmd.Flags().GetBool("no-repeat"); v {
|
||||
options["repeat"] = false
|
||||
}
|
||||
if v, _ := cmd.Flags().GetBool("no-inhibiting"); v {
|
||||
options["allow-inhibiting"] = false
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("flags"); v != "" {
|
||||
options["flags"] = v
|
||||
}
|
||||
|
||||
desc, _ := cmd.Flags().GetString("desc")
|
||||
if err := writable.SetBind(key, action, desc, options); err != nil {
|
||||
@@ -231,3 +272,19 @@ func runKeybindsRemove(_ *cobra.Command, args []string) {
|
||||
}, "", " ")
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -2,7 +2,10 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
@@ -29,9 +32,23 @@ var matugenQueueCmd = &cobra.Command{
|
||||
Run: runMatugenQueue,
|
||||
}
|
||||
|
||||
var matugenCheckCmd = &cobra.Command{
|
||||
Use: "check",
|
||||
Short: "Check which template apps are detected",
|
||||
Run: runMatugenCheck,
|
||||
}
|
||||
|
||||
var matugenPreviewCmd = &cobra.Command{
|
||||
Use: "preview",
|
||||
Short: "Preview Matugen scheme colors without applying them",
|
||||
Run: runMatugenPreview,
|
||||
}
|
||||
|
||||
func init() {
|
||||
matugenCmd.AddCommand(matugenGenerateCmd)
|
||||
matugenCmd.AddCommand(matugenQueueCmd)
|
||||
matugenCmd.AddCommand(matugenCheckCmd)
|
||||
matugenCmd.AddCommand(matugenPreviewCmd)
|
||||
|
||||
for _, cmd := range []*cobra.Command{matugenGenerateCmd, matugenQueueCmd} {
|
||||
cmd.Flags().String("state-dir", "", "State directory for cache files")
|
||||
@@ -47,10 +64,13 @@ func init() {
|
||||
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().String("skip-templates", "", "Comma-separated list of templates to skip")
|
||||
cmd.Flags().Float64("contrast", 0, "Contrast value from -1 to 1 (0 = standard)")
|
||||
}
|
||||
|
||||
matugenQueueCmd.Flags().Bool("wait", true, "Wait for completion")
|
||||
matugenQueueCmd.Flags().Duration("timeout", 30*time.Second, "Timeout for waiting")
|
||||
matugenQueueCmd.Flags().Duration("timeout", 90*time.Second, "Timeout for waiting")
|
||||
matugenPreviewCmd.Flags().String("source-color", "", "Source color used to generate previews")
|
||||
matugenPreviewCmd.Flags().Float64("contrast", 0, "Contrast value from -1 to 1 (0 = standard)")
|
||||
}
|
||||
|
||||
func buildMatugenOptions(cmd *cobra.Command) matugen.Options {
|
||||
@@ -67,6 +87,7 @@ func buildMatugenOptions(cmd *cobra.Command) matugen.Options {
|
||||
syncModeWithPortal, _ := cmd.Flags().GetBool("sync-mode-with-portal")
|
||||
terminalsAlwaysDark, _ := cmd.Flags().GetBool("terminals-always-dark")
|
||||
skipTemplates, _ := cmd.Flags().GetString("skip-templates")
|
||||
contrast, _ := cmd.Flags().GetFloat64("contrast")
|
||||
|
||||
return matugen.Options{
|
||||
StateDir: stateDir,
|
||||
@@ -74,9 +95,10 @@ func buildMatugenOptions(cmd *cobra.Command) matugen.Options {
|
||||
ConfigDir: configDir,
|
||||
Kind: kind,
|
||||
Value: value,
|
||||
Mode: mode,
|
||||
Mode: matugen.ColorMode(mode),
|
||||
IconTheme: iconTheme,
|
||||
MatugenType: matugenType,
|
||||
Contrast: contrast,
|
||||
RunUserTemplates: runUserTemplates,
|
||||
StockColors: stockColors,
|
||||
SyncModeWithPortal: syncModeWithPortal,
|
||||
@@ -87,7 +109,11 @@ func buildMatugenOptions(cmd *cobra.Command) matugen.Options {
|
||||
|
||||
func runMatugenGenerate(cmd *cobra.Command, args []string) {
|
||||
opts := buildMatugenOptions(cmd)
|
||||
if err := matugen.Run(opts); err != nil {
|
||||
err := matugen.Run(opts)
|
||||
switch {
|
||||
case errors.Is(err, matugen.ErrNoChanges):
|
||||
os.Exit(2)
|
||||
case err != nil:
|
||||
log.Fatalf("Theme generation failed: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -114,6 +140,7 @@ func runMatugenQueue(cmd *cobra.Command, args []string) {
|
||||
"syncModeWithPortal": opts.SyncModeWithPortal,
|
||||
"terminalsAlwaysDark": opts.TerminalsAlwaysDark,
|
||||
"skipTemplates": opts.SkipTemplates,
|
||||
"contrast": opts.Contrast,
|
||||
"wait": wait,
|
||||
},
|
||||
}
|
||||
@@ -121,7 +148,11 @@ func runMatugenQueue(cmd *cobra.Command, args []string) {
|
||||
if !wait {
|
||||
if err := sendServerRequestFireAndForget(request); err != nil {
|
||||
log.Info("Server unavailable, running synchronously")
|
||||
if err := matugen.Run(opts); err != nil {
|
||||
err := matugen.Run(opts)
|
||||
switch {
|
||||
case errors.Is(err, matugen.ErrNoChanges):
|
||||
os.Exit(2)
|
||||
case err != nil:
|
||||
log.Fatalf("Theme generation failed: %v", err)
|
||||
}
|
||||
return
|
||||
@@ -138,11 +169,15 @@ func runMatugenQueue(cmd *cobra.Command, args []string) {
|
||||
resp, ok := tryServerRequest(request)
|
||||
if !ok {
|
||||
log.Info("Server unavailable, running synchronously")
|
||||
if err := matugen.Run(opts); err != nil {
|
||||
err := matugen.Run(opts)
|
||||
switch {
|
||||
case errors.Is(err, matugen.ErrNoChanges):
|
||||
resultCh <- matugen.ErrNoChanges
|
||||
case err != nil:
|
||||
resultCh <- err
|
||||
return
|
||||
default:
|
||||
resultCh <- nil
|
||||
}
|
||||
resultCh <- nil
|
||||
return
|
||||
}
|
||||
if resp.Error != "" {
|
||||
@@ -154,7 +189,10 @@ func runMatugenQueue(cmd *cobra.Command, args []string) {
|
||||
|
||||
select {
|
||||
case err := <-resultCh:
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, matugen.ErrNoChanges):
|
||||
os.Exit(2)
|
||||
case err != nil:
|
||||
log.Fatalf("Theme generation failed: %v", err)
|
||||
}
|
||||
fmt.Println("Theme generation completed")
|
||||
@@ -162,3 +200,26 @@ func runMatugenQueue(cmd *cobra.Command, args []string) {
|
||||
log.Fatalf("Timeout waiting for theme generation")
|
||||
}
|
||||
}
|
||||
|
||||
func runMatugenCheck(cmd *cobra.Command, args []string) {
|
||||
checks := matugen.CheckTemplates(nil)
|
||||
data, err := json.Marshal(checks)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to marshal check results: %v", err)
|
||||
}
|
||||
fmt.Println(string(data))
|
||||
}
|
||||
|
||||
func runMatugenPreview(cmd *cobra.Command, args []string) {
|
||||
sourceColor, _ := cmd.Flags().GetString("source-color")
|
||||
contrast, _ := cmd.Flags().GetFloat64("contrast")
|
||||
previews, err := matugen.PreviewSchemes(sourceColor, contrast)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to generate Matugen previews: %v", err)
|
||||
}
|
||||
data, err := json.Marshal(previews)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to marshal Matugen previews: %v", err)
|
||||
}
|
||||
fmt.Println(string(data))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/notify"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
notifyAppName string
|
||||
notifyIcon string
|
||||
notifyFile string
|
||||
notifyTimeout int
|
||||
)
|
||||
|
||||
var notifyCmd = &cobra.Command{
|
||||
Use: "notify <summary> [body]",
|
||||
Short: "Send a desktop notification",
|
||||
Long: `Send a desktop notification with optional actions.
|
||||
|
||||
If --file is provided, the notification will have "Open" and "Open Folder" actions.
|
||||
|
||||
Examples:
|
||||
dms notify "Hello" "World"
|
||||
dms notify "File received" "photo.jpg" --file ~/Downloads/photo.jpg --icon smartphone
|
||||
dms notify "Download complete" --file ~/Downloads/file.zip --app "My App"`,
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
Run: runNotify,
|
||||
}
|
||||
|
||||
var genericNotifyActionCmd = &cobra.Command{
|
||||
Use: "notify-action-generic",
|
||||
Hidden: true,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
notify.RunActionListener(args)
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
notifyCmd.Flags().StringVar(¬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")
|
||||
}
|
||||
|
||||
func runNotify(cmd *cobra.Command, args []string) {
|
||||
summary := args[0]
|
||||
body := ""
|
||||
if len(args) > 1 {
|
||||
body = args[1]
|
||||
}
|
||||
|
||||
n := notify.Notification{
|
||||
AppName: notifyAppName,
|
||||
Icon: notifyIcon,
|
||||
Summary: summary,
|
||||
Body: body,
|
||||
FilePath: notifyFile,
|
||||
Timeout: int32(notifyTimeout),
|
||||
}
|
||||
|
||||
if err := notify.Send(n); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -28,9 +28,9 @@ with flags to handle different MIME types or application categories.
|
||||
|
||||
Examples:
|
||||
dms open https://example.com # Open URL with browser picker
|
||||
dms open file.pdf --mime application/pdf # Open PDF with compatible apps
|
||||
dms open document.odt --category Office # Open with office applications
|
||||
dms open --mime image/png image.png # Open image with image viewers`,
|
||||
dms open file.pdf # Open file (MIME auto-detected)
|
||||
dms open file.pdf --mime application/pdf # Override MIME detection
|
||||
dms open document.odt --category Office # Open with office applications`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
runOpen(args[0])
|
||||
@@ -47,117 +47,58 @@ func init() {
|
||||
})
|
||||
}
|
||||
|
||||
// mimeTypeToCategories maps MIME types to desktop file categories
|
||||
func mimeTypeToCategories(mimeType string) []string {
|
||||
// Split MIME type to get the main type
|
||||
parts := strings.Split(mimeType, "/")
|
||||
if len(parts) < 1 {
|
||||
return nil
|
||||
func detectMimeFromPath(path string) string {
|
||||
ext := filepath.Ext(path)
|
||||
if ext == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
mainType := parts[0]
|
||||
|
||||
switch mainType {
|
||||
case "image":
|
||||
return []string{"Graphics", "Viewer"}
|
||||
case "video":
|
||||
return []string{"Video", "AudioVideo"}
|
||||
case "audio":
|
||||
return []string{"Audio", "AudioVideo"}
|
||||
case "text":
|
||||
if strings.Contains(mimeType, "html") {
|
||||
return []string{"WebBrowser"}
|
||||
}
|
||||
return []string{"TextEditor", "Office"}
|
||||
case "application":
|
||||
if strings.Contains(mimeType, "pdf") {
|
||||
return []string{"Office", "Viewer"}
|
||||
}
|
||||
if strings.Contains(mimeType, "document") || strings.Contains(mimeType, "spreadsheet") ||
|
||||
strings.Contains(mimeType, "presentation") || strings.Contains(mimeType, "msword") ||
|
||||
strings.Contains(mimeType, "ms-excel") || strings.Contains(mimeType, "ms-powerpoint") ||
|
||||
strings.Contains(mimeType, "opendocument") {
|
||||
return []string{"Office"}
|
||||
}
|
||||
if strings.Contains(mimeType, "zip") || strings.Contains(mimeType, "tar") ||
|
||||
strings.Contains(mimeType, "gzip") || strings.Contains(mimeType, "compress") {
|
||||
return []string{"Archiving", "Utility"}
|
||||
}
|
||||
return []string{"Office", "Viewer"}
|
||||
}
|
||||
|
||||
return nil
|
||||
return mime.TypeByExtension(ext)
|
||||
}
|
||||
|
||||
func runOpen(target string) {
|
||||
// Parse file:// URIs to extract the actual file path
|
||||
actualTarget := target
|
||||
detectedMimeType := openMimeType
|
||||
detectedCategories := openCategories
|
||||
detectedRequestType := openRequestType
|
||||
|
||||
log.Infof("Processing target: %s", target)
|
||||
|
||||
if parsedURL, err := url.Parse(target); err == nil && parsedURL.Scheme == "file" {
|
||||
// Extract file path from file:// URI and convert to absolute path
|
||||
actualTarget = parsedURL.Path
|
||||
if absPath, err := filepath.Abs(actualTarget); err == nil {
|
||||
actualTarget = absPath
|
||||
switch {
|
||||
case isScheme(target, "file://"):
|
||||
parsedURL, err := url.Parse(target)
|
||||
if err == nil {
|
||||
actualTarget = parsedURL.Path
|
||||
}
|
||||
if abs, err := filepath.Abs(actualTarget); err == nil {
|
||||
actualTarget = abs
|
||||
}
|
||||
|
||||
if detectedRequestType == "url" || detectedRequestType == "" {
|
||||
detectedRequestType = "file"
|
||||
}
|
||||
|
||||
log.Infof("Detected file:// URI, extracted absolute path: %s", actualTarget)
|
||||
|
||||
// Auto-detect MIME type if not provided
|
||||
if detectedMimeType == "" {
|
||||
ext := filepath.Ext(actualTarget)
|
||||
if ext != "" {
|
||||
detectedMimeType = mime.TypeByExtension(ext)
|
||||
log.Infof("Detected MIME type from extension %s: %s", ext, detectedMimeType)
|
||||
}
|
||||
detectedMimeType = detectMimeFromPath(actualTarget)
|
||||
}
|
||||
log.Infof("Detected file:// URI, absolute path: %s", actualTarget)
|
||||
|
||||
// Auto-detect categories based on MIME type if not provided
|
||||
if len(detectedCategories) == 0 && detectedMimeType != "" {
|
||||
detectedCategories = mimeTypeToCategories(detectedMimeType)
|
||||
log.Infof("Detected categories from MIME type: %v", detectedCategories)
|
||||
}
|
||||
} else if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") {
|
||||
// Handle HTTP(S) URLs
|
||||
case isScheme(target, "http://"), isScheme(target, "https://"), isScheme(target, "dms://"):
|
||||
if detectedRequestType == "" {
|
||||
detectedRequestType = "url"
|
||||
}
|
||||
log.Infof("Detected HTTP(S) URL")
|
||||
} else if _, err := os.Stat(target); err == nil {
|
||||
// Handle local file paths directly (not file:// URIs)
|
||||
// Convert to absolute path
|
||||
if absPath, err := filepath.Abs(target); err == nil {
|
||||
actualTarget = absPath
|
||||
}
|
||||
log.Infof("Detected URL: %s", target)
|
||||
|
||||
default:
|
||||
if _, err := os.Stat(target); err != nil {
|
||||
break
|
||||
}
|
||||
if abs, err := filepath.Abs(target); err == nil {
|
||||
actualTarget = abs
|
||||
}
|
||||
if detectedRequestType == "url" || detectedRequestType == "" {
|
||||
detectedRequestType = "file"
|
||||
}
|
||||
|
||||
log.Infof("Detected local file path, converted to absolute: %s", actualTarget)
|
||||
|
||||
// Auto-detect MIME type if not provided
|
||||
if detectedMimeType == "" {
|
||||
ext := filepath.Ext(actualTarget)
|
||||
if ext != "" {
|
||||
detectedMimeType = mime.TypeByExtension(ext)
|
||||
log.Infof("Detected MIME type from extension %s: %s", ext, detectedMimeType)
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-detect categories based on MIME type if not provided
|
||||
if len(detectedCategories) == 0 && detectedMimeType != "" {
|
||||
detectedCategories = mimeTypeToCategories(detectedMimeType)
|
||||
log.Infof("Detected categories from MIME type: %v", detectedCategories)
|
||||
detectedMimeType = detectMimeFromPath(actualTarget)
|
||||
}
|
||||
log.Infof("Detected local file path: %s", actualTarget)
|
||||
}
|
||||
|
||||
params := map[string]any{
|
||||
@@ -168,8 +109,8 @@ func runOpen(target string) {
|
||||
params["mimeType"] = detectedMimeType
|
||||
}
|
||||
|
||||
if len(detectedCategories) > 0 {
|
||||
params["categories"] = detectedCategories
|
||||
if len(openCategories) > 0 {
|
||||
params["categories"] = openCategories
|
||||
}
|
||||
|
||||
if detectedRequestType != "" {
|
||||
@@ -177,7 +118,7 @@ func runOpen(target string) {
|
||||
}
|
||||
|
||||
method := "apppicker.open"
|
||||
if detectedMimeType == "" && len(detectedCategories) == 0 && (strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://")) {
|
||||
if detectedMimeType == "" && len(openCategories) == 0 && (isScheme(target, "http://") || isScheme(target, "https://") || isScheme(target, "dms://")) {
|
||||
method = "browser.open"
|
||||
params["url"] = target
|
||||
}
|
||||
@@ -197,3 +138,7 @@ func runOpen(target string) {
|
||||
|
||||
log.Infof("Request sent successfully")
|
||||
}
|
||||
|
||||
func isScheme(target, prefix string) bool {
|
||||
return strings.HasPrefix(target, prefix)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"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/mattn/go-isatty"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
qrEcc string
|
||||
qrVersion int
|
||||
qrOutput string
|
||||
qrStdout bool
|
||||
qrClipboard bool
|
||||
qrCopyText bool
|
||||
qrRender bool
|
||||
qrNoRender bool
|
||||
qrInvert bool
|
||||
qrQuietZone int
|
||||
qrModuleSize int
|
||||
qrFg string
|
||||
qrBg string
|
||||
qrTransparent bool
|
||||
qrBorder int
|
||||
qrShape string
|
||||
qrLogo string
|
||||
qrLogoScale int
|
||||
|
||||
qrWifiPassword string
|
||||
qrWifiSecurity string
|
||||
qrWifiHidden bool
|
||||
)
|
||||
|
||||
var qrCmd = &cobra.Command{
|
||||
Use: "qr [text]",
|
||||
Short: "Generate QR codes",
|
||||
Long: `Generate a QR code from text, stdin, or a WiFi network.
|
||||
|
||||
By default the code is rendered to the terminal when stdout is a TTY, or
|
||||
written as PNG bytes to stdout when piped. Use flags to also copy to the
|
||||
clipboard, save a PNG, or tune encoding and colors.
|
||||
|
||||
Input:
|
||||
dms qr "https://example.com" # encode an argument
|
||||
echo -n "data" | dms qr # encode stdin
|
||||
dms qr - # encode stdin explicitly
|
||||
|
||||
Output (combine freely):
|
||||
dms qr "text" --clipboard # copy PNG image to clipboard
|
||||
dms qr "text" --copy-text # copy the source text to clipboard
|
||||
dms qr "text" -o code.png # save a PNG file
|
||||
dms qr "text" > code.png # PNG to stdout (piped)
|
||||
dms qr "text" --render # force terminal render
|
||||
|
||||
Encoding & style:
|
||||
--ecc L|M|Q|H # error correction (default M)
|
||||
--qr-version 10 # force symbol version (1-40)
|
||||
--module-size 12 --fg '#000' ... # PNG sizing and colors
|
||||
--shape circle # round modules
|
||||
--logo icon.png # center logo (bumps --ecc to H)
|
||||
--invert # flip colors for light terminals
|
||||
|
||||
WiFi:
|
||||
dms qr wifi MySSID -p secret # build from an explicit password
|
||||
dms qr wifi MySSID # pull the saved secret from the shell`,
|
||||
Args: cobra.ArbitraryArgs,
|
||||
Run: runQR,
|
||||
}
|
||||
|
||||
var qrWifiCmd = &cobra.Command{
|
||||
Use: "wifi <ssid>",
|
||||
Short: "Generate a WiFi QR code",
|
||||
Long: `Generate a QR code that joins a WiFi network when scanned.
|
||||
|
||||
With --password the code is built entirely offline. Without it, the saved
|
||||
credentials are fetched from the running DMS shell (like the network panel).`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: runQRWifi,
|
||||
}
|
||||
|
||||
func init() {
|
||||
qrCmd.PersistentFlags().StringVar(&qrEcc, "ecc", "", "Error correction level: L, M, Q, H (default M, or H with --logo)")
|
||||
qrCmd.PersistentFlags().IntVar(&qrVersion, "qr-version", 0, "Force QR symbol version 1-40 (0 = auto)")
|
||||
qrCmd.PersistentFlags().StringVarP(&qrOutput, "output", "o", "", "Write a PNG to this file")
|
||||
qrCmd.PersistentFlags().BoolVar(&qrStdout, "stdout", false, "Write PNG bytes to stdout")
|
||||
qrCmd.PersistentFlags().BoolVar(&qrClipboard, "clipboard", false, "Copy the PNG image to the clipboard")
|
||||
qrCmd.PersistentFlags().BoolVar(&qrCopyText, "copy-text", false, "Copy the source text to the clipboard")
|
||||
qrCmd.PersistentFlags().BoolVar(&qrRender, "render", false, "Force terminal rendering")
|
||||
qrCmd.PersistentFlags().BoolVar(&qrNoRender, "no-render", false, "Never render to the terminal")
|
||||
qrCmd.PersistentFlags().BoolVar(&qrInvert, "invert", false, "Swap colors (for light terminals)")
|
||||
qrCmd.PersistentFlags().IntVar(&qrQuietZone, "quiet-zone", 2, "Terminal margin in modules")
|
||||
qrCmd.PersistentFlags().IntVar(&qrModuleSize, "module-size", 0, "PNG pixels per module (0 = auto)")
|
||||
qrCmd.PersistentFlags().StringVar(&qrFg, "fg", "", "Dark module color (#RGB or #RRGGBB)")
|
||||
qrCmd.PersistentFlags().StringVar(&qrBg, "bg", "", "Light module color (#RGB or #RRGGBB)")
|
||||
qrCmd.PersistentFlags().BoolVar(&qrTransparent, "transparent", false, "Transparent PNG background")
|
||||
qrCmd.PersistentFlags().IntVar(&qrBorder, "border", -1, "PNG border in pixels (-1 = auto)")
|
||||
qrCmd.PersistentFlags().StringVar(&qrShape, "shape", "square", "PNG module shape (square, circle)")
|
||||
qrCmd.PersistentFlags().StringVar(&qrLogo, "logo", "", "Center a PNG/JPEG logo on the PNG output")
|
||||
qrCmd.PersistentFlags().IntVar(&qrLogoScale, "logo-scale", 0, "Max logo size as 1/N of the code (0 = library default of 5)")
|
||||
|
||||
qrWifiCmd.Flags().StringVarP(&qrWifiPassword, "password", "p", "", "WiFi password (offline build)")
|
||||
qrWifiCmd.Flags().StringVar(&qrWifiSecurity, "security", "WPA", "Security type (WPA, WEP, nopass)")
|
||||
qrWifiCmd.Flags().BoolVar(&qrWifiHidden, "hidden", false, "Mark the network as hidden")
|
||||
|
||||
qrCmd.AddCommand(qrWifiCmd)
|
||||
}
|
||||
|
||||
func runQR(cmd *cobra.Command, args []string) {
|
||||
text := strings.Join(args, " ")
|
||||
if text == "" || text == "-" {
|
||||
data, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
fatalf("Error reading stdin: %v", err)
|
||||
}
|
||||
text = strings.TrimRight(string(data), "\n")
|
||||
}
|
||||
if text == "" {
|
||||
fatalf("Error: no input (provide text, pipe stdin, or use a subcommand)")
|
||||
}
|
||||
emitQR(text)
|
||||
}
|
||||
|
||||
func runQRWifi(cmd *cobra.Command, args []string) {
|
||||
ssid := args[0]
|
||||
if qrWifiPassword != "" || strings.EqualFold(qrWifiSecurity, "nopass") {
|
||||
emitQR(qrcode.WiFiString(qrWifiSecurity, ssid, qrWifiPassword, qrWifiHidden))
|
||||
return
|
||||
}
|
||||
|
||||
content, err := fetchWifiQRContent(ssid)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
fmt.Fprintln(os.Stderr, "Hint: pass --password to build the code without the shell.")
|
||||
os.Exit(1)
|
||||
}
|
||||
if qrWifiHidden {
|
||||
content = strings.TrimSuffix(content, ";") + "H:true;;"
|
||||
}
|
||||
emitQR(content)
|
||||
}
|
||||
|
||||
func emitQR(text string) {
|
||||
renderTerm := shouldRenderTerminal()
|
||||
pngToStdout := qrStdout || (!renderTerm && qrOutput == "" && !qrClipboard && !qrCopyText)
|
||||
|
||||
if pngToStdout || qrOutput != "" || qrClipboard {
|
||||
png, err := qrcode.RenderPNG(text, qrcode.ImageOptions{
|
||||
ECC: effectiveEcc(),
|
||||
Version: qrVersion,
|
||||
ModuleSize: qrModuleSize,
|
||||
Fg: qrFg,
|
||||
Bg: qrBg,
|
||||
Transparent: qrTransparent,
|
||||
Border: qrBorder,
|
||||
Shape: qrShape,
|
||||
Logo: qrLogo,
|
||||
LogoScale: qrLogoScale,
|
||||
})
|
||||
if err != nil {
|
||||
fatalf("Error encoding QR: %v", err)
|
||||
}
|
||||
emitPNG(png, pngToStdout)
|
||||
}
|
||||
|
||||
if qrCopyText {
|
||||
if err := clipboard.CopyText(text); err != nil {
|
||||
fatalf("Error copying text: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if !renderTerm {
|
||||
return
|
||||
}
|
||||
out, err := qrcode.RenderTerminal(text, qrcode.TermOptions{
|
||||
ECC: effectiveEcc(),
|
||||
Version: qrVersion,
|
||||
QuietZone: qrQuietZone,
|
||||
Invert: qrInvert,
|
||||
Fg: qrFg,
|
||||
Bg: qrBg,
|
||||
})
|
||||
if err != nil {
|
||||
fatalf("Error rendering QR: %v", err)
|
||||
}
|
||||
dst := os.Stdout
|
||||
if pngToStdout {
|
||||
dst = os.Stderr
|
||||
}
|
||||
fmt.Fprint(dst, out)
|
||||
}
|
||||
|
||||
func emitPNG(png []byte, toStdout bool) {
|
||||
if qrOutput != "" {
|
||||
if err := os.WriteFile(qrOutput, png, 0o644); err != nil {
|
||||
fatalf("Error writing file: %v", err)
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, qrOutput)
|
||||
}
|
||||
if toStdout {
|
||||
os.Stdout.Write(png)
|
||||
}
|
||||
if qrClipboard {
|
||||
if err := clipboard.Copy(png, "image/png"); err != nil {
|
||||
fatalf("Error copying image: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func shouldRenderTerminal() bool {
|
||||
switch {
|
||||
case qrNoRender:
|
||||
return false
|
||||
case qrRender:
|
||||
return true
|
||||
case qrStdout, qrOutput != "", qrClipboard, qrCopyText:
|
||||
return false
|
||||
default:
|
||||
return isatty.IsTerminal(os.Stdout.Fd())
|
||||
}
|
||||
}
|
||||
|
||||
func effectiveEcc() string {
|
||||
switch {
|
||||
case qrEcc != "":
|
||||
return qrEcc
|
||||
case qrLogo != "":
|
||||
return "H"
|
||||
default:
|
||||
return "M"
|
||||
}
|
||||
}
|
||||
|
||||
func fatalf(format string, args ...any) {
|
||||
fmt.Fprintf(os.Stderr, format+"\n", args...)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func fetchWifiQRContent(ssid string) (string, error) {
|
||||
resp, err := sendServerRequest(models.Request{
|
||||
ID: 1,
|
||||
Method: "network.qrcode-content",
|
||||
Params: map[string]any{"ssid": ssid},
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if resp.Error != "" {
|
||||
return "", fmt.Errorf("%s", resp.Error)
|
||||
}
|
||||
if resp.Result == nil {
|
||||
return "", fmt.Errorf("empty response")
|
||||
}
|
||||
content, ok := (*resp.Result).(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("unexpected response format")
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var randrCmd = &cobra.Command{
|
||||
Use: "randr",
|
||||
Short: "Query output display information",
|
||||
Long: "Query Wayland compositor for output names, scales, resolutions and refresh rates via zwlr-output-management",
|
||||
Run: runRandr,
|
||||
}
|
||||
|
||||
func init() {
|
||||
randrCmd.Flags().Bool("json", false, "Output in JSON format")
|
||||
}
|
||||
|
||||
type randrJSON struct {
|
||||
Outputs []randrOutput `json:"outputs"`
|
||||
}
|
||||
|
||||
func runRandr(cmd *cobra.Command, args []string) {
|
||||
outputs, err := queryRandr()
|
||||
if err != nil {
|
||||
log.Fatalf("%v", err)
|
||||
}
|
||||
|
||||
jsonFlag, _ := cmd.Flags().GetBool("json")
|
||||
|
||||
if jsonFlag {
|
||||
data, err := json.Marshal(randrJSON{Outputs: outputs})
|
||||
if err != nil {
|
||||
log.Fatalf("failed to marshal JSON: %v", err)
|
||||
}
|
||||
fmt.Println(string(data))
|
||||
return
|
||||
}
|
||||
|
||||
for i, out := range outputs {
|
||||
if i > 0 {
|
||||
fmt.Println()
|
||||
}
|
||||
status := "enabled"
|
||||
if !out.Enabled {
|
||||
status = "disabled"
|
||||
}
|
||||
fmt.Printf("%s (%s)\n", out.Name, status)
|
||||
fmt.Printf(" Scale: %.4g\n", out.Scale)
|
||||
fmt.Printf(" Resolution: %dx%d\n", out.Width, out.Height)
|
||||
if out.Refresh > 0 {
|
||||
fmt.Printf(" Refresh: %.2f Hz\n", float64(out.Refresh)/1000.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/dms"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -20,11 +18,9 @@ var rootCmd = &cobra.Command{
|
||||
Use: "dms",
|
||||
Short: "dms CLI",
|
||||
Long: "dms is the DankMaterialShell management CLI and backend server.",
|
||||
Run: runInteractiveMode,
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Add the -c flag
|
||||
rootCmd.PersistentFlags().StringVarP(&customConfigPath, "config", "c", "", "Specify a custom path to the DMS config directory")
|
||||
}
|
||||
|
||||
@@ -38,7 +34,7 @@ func findConfig(cmd *cobra.Command, args []string) error {
|
||||
if statErr == nil && !info.IsDir() {
|
||||
configPath = customConfigPath
|
||||
log.Debug("Using config from: %s", configPath)
|
||||
return nil // <-- Guard statement
|
||||
return nil
|
||||
}
|
||||
|
||||
if statErr != nil {
|
||||
@@ -50,15 +46,18 @@ func findConfig(cmd *cobra.Command, args []string) error {
|
||||
|
||||
configStateFile := filepath.Join(getRuntimeDir(), "danklinux.path")
|
||||
if data, readErr := os.ReadFile(configStateFile); readErr == nil {
|
||||
statePath := strings.TrimSpace(string(data))
|
||||
shellPath := filepath.Join(statePath, "shell.qml")
|
||||
|
||||
if info, statErr := os.Stat(shellPath); statErr == nil && !info.IsDir() {
|
||||
log.Debug("Using config from active session state file: %s", statePath)
|
||||
configPath = statePath
|
||||
log.Debug("Using config from: %s", configPath)
|
||||
return nil // <-- Guard statement
|
||||
if len(getAllDMSPIDs()) == 0 {
|
||||
os.Remove(configStateFile)
|
||||
} else {
|
||||
statePath := strings.TrimSpace(string(data))
|
||||
shellPath := filepath.Join(statePath, "shell.qml")
|
||||
|
||||
if info, statErr := os.Stat(shellPath); statErr == nil && !info.IsDir() {
|
||||
log.Debug("Using config from active session state file: %s", statePath)
|
||||
configPath = statePath
|
||||
log.Debug("Using config from: %s", configPath)
|
||||
return nil
|
||||
}
|
||||
os.Remove(configStateFile)
|
||||
}
|
||||
}
|
||||
@@ -73,18 +72,3 @@ func findConfig(cmd *cobra.Command, args []string) error {
|
||||
log.Debug("Using config from: %s", configPath)
|
||||
return nil
|
||||
}
|
||||
func runInteractiveMode(cmd *cobra.Command, args []string) {
|
||||
detector, _ := dms.NewDetector()
|
||||
|
||||
if !detector.IsDMSInstalled() {
|
||||
log.Error("DankMaterialShell (DMS) is not detected as installed on this system.")
|
||||
log.Info("Please install DMS using dankinstall before using this management interface.")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
model := dms.NewModel(Version)
|
||||
p := tea.NewProgram(model, tea.WithAltScreen())
|
||||
if _, err := p.Run(); err != nil {
|
||||
log.Fatalf("Error running program: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,12 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
|
||||
@@ -13,18 +16,31 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
ssOutputName string
|
||||
ssIncludeCursor bool
|
||||
ssFormat string
|
||||
ssQuality int
|
||||
ssOutputDir string
|
||||
ssFilename string
|
||||
ssNoClipboard bool
|
||||
ssNoFile bool
|
||||
ssNoNotify bool
|
||||
ssStdout bool
|
||||
ssOutputName string
|
||||
ssCursor string
|
||||
ssFormat string
|
||||
ssQuality int
|
||||
ssOutputDir string
|
||||
ssFilename string
|
||||
ssNoClipboard bool
|
||||
ssNoFile bool
|
||||
ssNoNotify bool
|
||||
ssNoConfirm bool
|
||||
ssReset bool
|
||||
ssStdout bool
|
||||
ssJSON bool
|
||||
)
|
||||
|
||||
type screenshotMetadata struct {
|
||||
Status string `json:"status"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Width int `json:"width,omitempty"`
|
||||
Height int `json:"height,omitempty"`
|
||||
Scale float64 `json:"scale,omitempty"`
|
||||
Mime string `json:"mime,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
var screenshotCmd = &cobra.Command{
|
||||
Use: "screenshot",
|
||||
Short: "Capture screenshots",
|
||||
@@ -35,8 +51,9 @@ Modes:
|
||||
full - Capture the focused output
|
||||
all - Capture all outputs combined
|
||||
output - Capture a specific output by name
|
||||
window - Capture the focused window (Hyprland/DWL)
|
||||
window - Capture the focused window (Hyprland/Mango/niri)
|
||||
last - Capture the last selected region
|
||||
scroll - Select a region, then scroll to capture a stitched tall image
|
||||
|
||||
Output format (--format):
|
||||
png - PNG format (default)
|
||||
@@ -50,10 +67,15 @@ Examples:
|
||||
dms screenshot output -o DP-1 # Specific output
|
||||
dms screenshot window # Focused window (Hyprland)
|
||||
dms screenshot last # Last region (pre-selected)
|
||||
dms screenshot --reset # Reset last region pre-selection
|
||||
dms screenshot --no-clipboard # Save file only
|
||||
dms screenshot --no-file # Clipboard only
|
||||
dms screenshot --cursor # Include cursor
|
||||
dms screenshot -f jpg -q 85 # JPEG with quality 85`,
|
||||
dms screenshot --no-confirm # Region capture on mouse release
|
||||
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 scroll # Scroll capture, Enter finishes / Esc cancels
|
||||
dms screenshot scroll --interval 250`,
|
||||
}
|
||||
|
||||
var ssRegionCmd = &cobra.Command{
|
||||
@@ -91,10 +113,33 @@ 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 and DWL.`,
|
||||
Long: `Capture the currently focused window. Supported on Hyprland, Mango, and niri.`,
|
||||
Run: runScreenshotWindow,
|
||||
}
|
||||
|
||||
var ssScrollInterval int
|
||||
|
||||
var ssScrollCmd = &cobra.Command{
|
||||
Use: "scroll",
|
||||
Short: "Capture a scrolling region stitched into one tall image",
|
||||
Long: `Select a region, then scroll the content beneath with the mouse wheel or
|
||||
touchpad while frames are captured and stitched vertically. Finish with the
|
||||
on-screen done button; cancel with the cancel button. Enter and Esc work
|
||||
everywhere: most compositors hold the keyboard on the overlay (keyboard
|
||||
scrolling does not reach the app there), while Hyprland leaves the keyboard
|
||||
with the application — keyboard scrolling works, and Enter/Esc act through
|
||||
temporary global binds for the session. The cursor is never included in
|
||||
frames.
|
||||
|
||||
Frames are stitched continuously while scrolling, and revisited content is
|
||||
never duplicated — scrolling up past the starting point extends the image
|
||||
upward. Content jumped past faster than capture can follow is skipped rather
|
||||
than stitched incorrectly.
|
||||
|
||||
Rotated outputs are not supported.`,
|
||||
Run: runScreenshotScroll,
|
||||
}
|
||||
|
||||
var ssListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List available outputs",
|
||||
@@ -111,7 +156,7 @@ var notifyActionCmd = &cobra.Command{
|
||||
|
||||
func init() {
|
||||
screenshotCmd.PersistentFlags().StringVarP(&ssOutputName, "output", "o", "", "Output name for 'output' mode")
|
||||
screenshotCmd.PersistentFlags().BoolVar(&ssIncludeCursor, "cursor", false, "Include cursor in screenshot")
|
||||
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)")
|
||||
screenshotCmd.PersistentFlags().StringVarP(&ssOutputDir, "dir", "d", "", "Output directory")
|
||||
@@ -119,9 +164,15 @@ func init() {
|
||||
screenshotCmd.PersistentFlags().BoolVar(&ssNoClipboard, "no-clipboard", false, "Don't copy to clipboard")
|
||||
screenshotCmd.PersistentFlags().BoolVar(&ssNoFile, "no-file", false, "Don't save to file")
|
||||
screenshotCmd.PersistentFlags().BoolVar(&ssNoNotify, "no-notify", false, "Don't show notification")
|
||||
screenshotCmd.PersistentFlags().BoolVar(&ssNoConfirm, "no-confirm", false, "Region mode: capture on mouse release without Enter/Space confirmation")
|
||||
screenshotCmd.PersistentFlags().BoolVar(&ssReset, "reset", false, "Reset saved last-region preselection before capturing")
|
||||
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")
|
||||
|
||||
ssScrollCmd.Flags().IntVar(&ssScrollInterval, "interval", 45, "Capture interval in milliseconds (30-1000)")
|
||||
|
||||
screenshotCmd.AddCommand(ssRegionCmd)
|
||||
screenshotCmd.AddCommand(ssScrollCmd)
|
||||
screenshotCmd.AddCommand(ssFullCmd)
|
||||
screenshotCmd.AddCommand(ssAllCmd)
|
||||
screenshotCmd.AddCommand(ssOutputCmd)
|
||||
@@ -136,10 +187,14 @@ func getScreenshotConfig(mode screenshot.Mode) screenshot.Config {
|
||||
config := screenshot.DefaultConfig()
|
||||
config.Mode = mode
|
||||
config.OutputName = ssOutputName
|
||||
config.IncludeCursor = ssIncludeCursor
|
||||
if strings.EqualFold(ssCursor, "on") {
|
||||
config.Cursor = screenshot.CursorOn
|
||||
}
|
||||
config.Clipboard = !ssNoClipboard
|
||||
config.SaveFile = !ssNoFile
|
||||
config.Notify = !ssNoNotify
|
||||
config.NoConfirm = ssNoConfirm
|
||||
config.Reset = ssReset
|
||||
config.Stdout = ssStdout
|
||||
|
||||
if ssOutputDir != "" {
|
||||
@@ -169,15 +224,76 @@ func getScreenshotConfig(mode screenshot.Mode) screenshot.Config {
|
||||
return config
|
||||
}
|
||||
|
||||
// setPopoutScreenshotMode toggles the shell handshake so popouts drop their keyboard grab during region select. Best-effort.
|
||||
func setPopoutScreenshotMode(begin bool) {
|
||||
fn := "end"
|
||||
if begin {
|
||||
fn = "begin"
|
||||
}
|
||||
cmdArgs := []string{"ipc"}
|
||||
if pid, ok := getFirstDMSPID(); ok {
|
||||
cmdArgs = append(cmdArgs, "--pid", strconv.Itoa(pid))
|
||||
} else {
|
||||
if err := findConfig(nil, nil); err != nil {
|
||||
return
|
||||
}
|
||||
if qsHasAnyDisplay() {
|
||||
cmdArgs = append(cmdArgs, "--any-display")
|
||||
}
|
||||
cmdArgs = append(cmdArgs, "-p", configPath)
|
||||
}
|
||||
cmdArgs = append(cmdArgs, "call", "screenshot", fn)
|
||||
_ = exec.Command("qs", cmdArgs...).Run()
|
||||
}
|
||||
|
||||
func writeScreenshotJSON(meta screenshotMetadata) {
|
||||
_ = json.NewEncoder(os.Stdout).Encode(meta)
|
||||
}
|
||||
|
||||
func exitScreenshotError(context string, err error) {
|
||||
if ssJSON {
|
||||
writeScreenshotJSON(screenshotMetadata{Status: "error", Error: err.Error()})
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "Error%s: %v\n", context, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func formatMime(format screenshot.Format) string {
|
||||
switch format {
|
||||
case screenshot.FormatJPEG:
|
||||
return "image/jpeg"
|
||||
case screenshot.FormatPPM:
|
||||
return "image/x-portable-pixmap"
|
||||
default:
|
||||
return "image/png"
|
||||
}
|
||||
}
|
||||
|
||||
func runScreenshot(config screenshot.Config) {
|
||||
sc := screenshot.New(config)
|
||||
result, err := sc.Run()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
if ssJSON && config.Stdout {
|
||||
fmt.Fprintln(os.Stderr, "Error: --json cannot be combined with --stdout")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Region select needs the keyboard; drop popout grabs for its duration.
|
||||
result, err := func() (*screenshot.CaptureResult, error) {
|
||||
interactive := config.Mode == screenshot.ModeRegion || config.Mode == screenshot.ModeLastRegion || config.Mode == screenshot.ModeScroll
|
||||
if interactive {
|
||||
setPopoutScreenshotMode(true)
|
||||
defer setPopoutScreenshotMode(false)
|
||||
}
|
||||
return screenshot.New(config).Run()
|
||||
}()
|
||||
|
||||
if err != nil {
|
||||
exitScreenshotError("", err)
|
||||
}
|
||||
|
||||
if result == nil {
|
||||
if ssJSON {
|
||||
writeScreenshotJSON(screenshotMetadata{Status: "aborted", Error: "User cancelled selection"})
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
@@ -189,8 +305,7 @@ func runScreenshot(config screenshot.Config) {
|
||||
|
||||
if config.Stdout {
|
||||
if err := writeImageToStdout(result.Buffer, config.Format, config.Quality, result.Format); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error writing to stdout: %v\n", err)
|
||||
os.Exit(1)
|
||||
exitScreenshotError(" writing to stdout", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -210,22 +325,37 @@ func runScreenshot(config screenshot.Config) {
|
||||
|
||||
filePath = filepath.Join(outputDir, filename)
|
||||
if err := screenshot.WriteToFileWithFormat(result.Buffer, filePath, config.Format, config.Quality, result.Format); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error writing file: %v\n", err)
|
||||
os.Exit(1)
|
||||
exitScreenshotError(" writing file", err)
|
||||
}
|
||||
if !ssJSON {
|
||||
fmt.Println(filePath)
|
||||
}
|
||||
fmt.Println(filePath)
|
||||
}
|
||||
|
||||
if config.Clipboard {
|
||||
if err := copyImageToClipboard(result.Buffer, config.Format, config.Quality, result.Format); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error copying to clipboard: %v\n", err)
|
||||
os.Exit(1)
|
||||
exitScreenshotError(" copying to clipboard", err)
|
||||
}
|
||||
if !config.SaveFile {
|
||||
if !ssJSON && !config.SaveFile {
|
||||
fmt.Println("Copied to clipboard")
|
||||
}
|
||||
}
|
||||
|
||||
if ssJSON {
|
||||
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),
|
||||
})
|
||||
}
|
||||
|
||||
if config.Notify {
|
||||
thumbData, thumbW, thumbH := bufferToRGBThumbnail(result.Buffer, 256, result.Format)
|
||||
screenshot.SendNotification(screenshot.NotifyResult{
|
||||
@@ -336,6 +466,12 @@ func runScreenshotRegion(cmd *cobra.Command, args []string) {
|
||||
runScreenshot(config)
|
||||
}
|
||||
|
||||
func runScreenshotScroll(cmd *cobra.Command, args []string) {
|
||||
config := getScreenshotConfig(screenshot.ModeScroll)
|
||||
config.IntervalMs = min(max(ssScrollInterval, 30), 1000)
|
||||
runScreenshot(config)
|
||||
}
|
||||
|
||||
func runScreenshotFull(cmd *cobra.Command, args []string) {
|
||||
config := getScreenshotConfig(screenshot.ModeFullScreen)
|
||||
runScreenshot(config)
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var switchUserCmd = &cobra.Command{
|
||||
Use: "switch-user [target]",
|
||||
Short: "Switch to another active session on this seat",
|
||||
Long: `Switch the active VT to another running session.
|
||||
|
||||
With no target, prints the list of switchable sessions. Pass a username or a
|
||||
numeric session ID to switch directly. Requires the target to already be a
|
||||
running session on the same seat (use the greeter for a fresh login).`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
Run: runSwitchUser,
|
||||
}
|
||||
|
||||
type sessionInfo struct {
|
||||
ID string
|
||||
Name string
|
||||
Seat string
|
||||
TTY string
|
||||
Type string
|
||||
Class string
|
||||
Active bool
|
||||
State string
|
||||
Current bool
|
||||
}
|
||||
|
||||
func runSwitchUser(cmd *cobra.Command, args []string) {
|
||||
currentID := os.Getenv("XDG_SESSION_ID")
|
||||
sessions, err := listSessions(currentID)
|
||||
if err != nil {
|
||||
log.Fatalf("%v", err)
|
||||
}
|
||||
|
||||
switchable := make([]sessionInfo, 0, len(sessions))
|
||||
for _, s := range sessions {
|
||||
if s.Class != "user" || s.State == "closing" || s.Current {
|
||||
continue
|
||||
}
|
||||
switchable = append(switchable, s)
|
||||
}
|
||||
|
||||
if len(args) == 0 {
|
||||
if len(switchable) == 0 {
|
||||
fmt.Println("No other active sessions on this seat.")
|
||||
return
|
||||
}
|
||||
printSessions(switchable)
|
||||
return
|
||||
}
|
||||
|
||||
target := args[0]
|
||||
picked, err := pickSession(switchable, target)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
if len(switchable) == 0 {
|
||||
fmt.Fprintln(os.Stderr, "No other active sessions on this seat. Only already-running sessions can be switched to.")
|
||||
} else {
|
||||
fmt.Fprintln(os.Stderr, "\nSwitchable sessions:")
|
||||
printSessions(switchable)
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := activateSession(picked.ID); err != nil {
|
||||
log.Fatalf("loginctl activate %s: %v", picked.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func listSessions(currentID string) ([]sessionInfo, error) {
|
||||
listOut, err := exec.Command("loginctl", "list-sessions", "--no-legend").Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("loginctl list-sessions: %w", err)
|
||||
}
|
||||
|
||||
var ids []string
|
||||
scanner := bufio.NewScanner(strings.NewReader(string(listOut)))
|
||||
for scanner.Scan() {
|
||||
fields := strings.Fields(scanner.Text())
|
||||
if len(fields) == 0 {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, fields[0])
|
||||
}
|
||||
|
||||
out := make([]sessionInfo, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
s, err := showSession(id)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
s.Current = currentID != "" && s.ID == currentID
|
||||
out = append(out, s)
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].Name != out[j].Name {
|
||||
return out[i].Name < out[j].Name
|
||||
}
|
||||
return out[i].ID < out[j].ID
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func showSession(id string) (sessionInfo, error) {
|
||||
out, err := exec.Command("loginctl", "show-session", id,
|
||||
"-p", "Id", "-p", "Name", "-p", "Seat", "-p", "TTY",
|
||||
"-p", "Type", "-p", "Class", "-p", "Active", "-p", "State").Output()
|
||||
if err != nil {
|
||||
return sessionInfo{}, err
|
||||
}
|
||||
fields := map[string]string{}
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
idx := strings.IndexByte(line, '=')
|
||||
if idx <= 0 {
|
||||
continue
|
||||
}
|
||||
fields[line[:idx]] = line[idx+1:]
|
||||
}
|
||||
if fields["Id"] == "" {
|
||||
return sessionInfo{}, fmt.Errorf("session %s: no Id", id)
|
||||
}
|
||||
return sessionInfo{
|
||||
ID: fields["Id"],
|
||||
Name: fields["Name"],
|
||||
Seat: fields["Seat"],
|
||||
TTY: fields["TTY"],
|
||||
Type: fields["Type"],
|
||||
Class: fields["Class"],
|
||||
Active: fields["Active"] == "yes",
|
||||
State: fields["State"],
|
||||
}, nil
|
||||
}
|
||||
|
||||
func pickSession(sessions []sessionInfo, target string) (sessionInfo, error) {
|
||||
for _, s := range sessions {
|
||||
if s.ID == target {
|
||||
return s, nil
|
||||
}
|
||||
}
|
||||
matches := make([]sessionInfo, 0, 2)
|
||||
for _, s := range sessions {
|
||||
if s.Name == target {
|
||||
matches = append(matches, s)
|
||||
}
|
||||
}
|
||||
if len(matches) == 1 {
|
||||
return matches[0], nil
|
||||
}
|
||||
if len(matches) > 1 {
|
||||
ids := make([]string, len(matches))
|
||||
for i, m := range matches {
|
||||
ids[i] = m.ID
|
||||
}
|
||||
return sessionInfo{}, fmt.Errorf("%s has multiple active sessions (%s); pass a session ID instead", target, strings.Join(ids, ", "))
|
||||
}
|
||||
return sessionInfo{}, fmt.Errorf("no switchable session matches %q", target)
|
||||
}
|
||||
|
||||
func activateSession(id string) error {
|
||||
return exec.Command("loginctl", "activate", id).Run()
|
||||
}
|
||||
|
||||
func printSessions(sessions []sessionInfo) {
|
||||
fmt.Printf("%-6s %-12s %-8s %-8s %-8s\n", "ID", "USER", "TYPE", "SEAT", "TTY")
|
||||
for _, s := range sessions {
|
||||
tty := s.TTY
|
||||
if tty == "" {
|
||||
tty = "-"
|
||||
}
|
||||
seat := s.Seat
|
||||
if seat == "" {
|
||||
seat = "-"
|
||||
}
|
||||
fmt.Printf("%-6s %-12s %-8s %-8s %-8s\n", s.ID, s.Name, s.Type, seat, tty)
|
||||
}
|
||||
}
|
||||
+341
-11
@@ -4,19 +4,25 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"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/greeter"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var setupCmd = &cobra.Command{
|
||||
Use: "setup",
|
||||
Short: "Deploy DMS configurations",
|
||||
Long: "Deploy compositor and terminal configurations with interactive prompts",
|
||||
Use: "setup",
|
||||
Short: "Deploy DMS configurations",
|
||||
Long: "Deploy compositor and terminal configurations with interactive prompts",
|
||||
PersistentPreRunE: preRunPrivileged,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if err := runSetup(); err != nil {
|
||||
log.Fatalf("Error during setup: %v", err)
|
||||
@@ -24,12 +30,282 @@ var setupCmd = &cobra.Command{
|
||||
},
|
||||
}
|
||||
|
||||
var setupBindsCmd = &cobra.Command{
|
||||
Use: "binds",
|
||||
Short: "Deploy default keybinds config",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if err := runSetupDmsConfig("binds"); err != nil {
|
||||
log.Fatalf("Error: %v", err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
var setupLayoutCmd = &cobra.Command{
|
||||
Use: "layout",
|
||||
Short: "Deploy default layout config",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if err := runSetupDmsConfig("layout"); err != nil {
|
||||
log.Fatalf("Error: %v", err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
var setupColorsCmd = &cobra.Command{
|
||||
Use: "colors",
|
||||
Short: "Deploy default colors config",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if err := runSetupDmsConfig("colors"); err != nil {
|
||||
log.Fatalf("Error: %v", err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
var setupAlttabCmd = &cobra.Command{
|
||||
Use: "alttab",
|
||||
Short: "Deploy default alt-tab config (niri only)",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if err := runSetupDmsConfig("alttab"); err != nil {
|
||||
log.Fatalf("Error: %v", err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
var setupOutputsCmd = &cobra.Command{
|
||||
Use: "outputs",
|
||||
Short: "Deploy default outputs config",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if err := runSetupDmsConfig("outputs"); err != nil {
|
||||
log.Fatalf("Error: %v", err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
var setupCursorCmd = &cobra.Command{
|
||||
Use: "cursor",
|
||||
Short: "Deploy default cursor config",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if err := runSetupDmsConfig("cursor"); err != nil {
|
||||
log.Fatalf("Error: %v", err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
var setupWindowrulesCmd = &cobra.Command{
|
||||
Use: "windowrules",
|
||||
Short: "Deploy default window rules config",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if err := runSetupDmsConfig("windowrules"); err != nil {
|
||||
log.Fatalf("Error: %v", err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
type dmsConfigSpec struct {
|
||||
niriFile string
|
||||
hyprFile string
|
||||
mangoFile string
|
||||
niriContent func(terminal string) string
|
||||
hyprContent func(terminal string) string
|
||||
mangoContent func(terminal string) string
|
||||
}
|
||||
|
||||
var dmsConfigSpecs = map[string]dmsConfigSpec{
|
||||
"binds": {
|
||||
niriFile: "binds.kdl",
|
||||
hyprFile: "binds.lua",
|
||||
mangoFile: "binds.conf",
|
||||
niriContent: func(t string) string {
|
||||
return strings.ReplaceAll(config.NiriBindsConfig, "{{TERMINAL_COMMAND}}", t)
|
||||
},
|
||||
hyprContent: func(t string) string {
|
||||
return strings.ReplaceAll(config.DMSBindsLuaConfig, "{{TERMINAL_COMMAND}}", t)
|
||||
},
|
||||
mangoContent: func(t string) string {
|
||||
return strings.ReplaceAll(config.MangoBindsConfig, "{{TERMINAL_COMMAND}}", t)
|
||||
},
|
||||
},
|
||||
"layout": {
|
||||
niriFile: "layout.kdl",
|
||||
hyprFile: "layout.lua",
|
||||
mangoFile: "layout.conf",
|
||||
niriContent: func(_ string) string { return config.NiriLayoutConfig },
|
||||
hyprContent: func(_ string) string { return config.DMSLayoutLuaConfig },
|
||||
mangoContent: func(_ string) string { return config.MangoLayoutConfig },
|
||||
},
|
||||
"colors": {
|
||||
niriFile: "colors.kdl",
|
||||
hyprFile: "colors.lua",
|
||||
mangoFile: "colors.conf",
|
||||
niriContent: func(_ string) string { return config.NiriColorsConfig },
|
||||
hyprContent: func(_ string) string { return config.DMSColorsLuaConfig },
|
||||
mangoContent: func(_ string) string { return config.MangoColorsConfig },
|
||||
},
|
||||
"alttab": {
|
||||
niriFile: "alttab.kdl",
|
||||
niriContent: func(_ string) string { return config.NiriAlttabConfig },
|
||||
},
|
||||
"outputs": {
|
||||
niriFile: "outputs.kdl",
|
||||
hyprFile: "outputs.lua",
|
||||
mangoFile: "outputs.conf",
|
||||
niriContent: func(_ string) string { return "" },
|
||||
hyprContent: func(_ string) string { return config.DMSOutputsLuaConfig },
|
||||
mangoContent: func(_ string) string { return "" },
|
||||
},
|
||||
"cursor": {
|
||||
niriFile: "cursor.kdl",
|
||||
hyprFile: "cursor.lua",
|
||||
mangoFile: "cursor.conf",
|
||||
niriContent: func(_ string) string { return "" },
|
||||
hyprContent: func(_ string) string { return config.DMSCursorLuaConfig },
|
||||
mangoContent: func(_ string) string { return "" },
|
||||
},
|
||||
"windowrules": {
|
||||
niriFile: "windowrules.kdl",
|
||||
hyprFile: "windowrules.lua",
|
||||
mangoFile: "windowrules.conf",
|
||||
niriContent: func(_ string) string { return "" },
|
||||
hyprContent: func(_ string) string { return config.DMSWindowRulesLuaConfig },
|
||||
mangoContent: func(_ string) string { return "" },
|
||||
},
|
||||
}
|
||||
|
||||
func detectTerminal() (string, error) {
|
||||
terminals := []string{"ghostty", "foot", "kitty", "alacritty"}
|
||||
var found []string
|
||||
for _, t := range terminals {
|
||||
if utils.CommandExists(t) {
|
||||
found = append(found, t)
|
||||
}
|
||||
}
|
||||
|
||||
switch len(found) {
|
||||
case 0:
|
||||
return "ghostty", nil
|
||||
case 1:
|
||||
return found[0], nil
|
||||
}
|
||||
|
||||
fmt.Println("Multiple terminals detected:")
|
||||
for i, t := range found {
|
||||
fmt.Printf("%d) %s\n", i+1, t)
|
||||
}
|
||||
fmt.Printf("\nChoice (1-%d): ", len(found))
|
||||
|
||||
var response string
|
||||
fmt.Scanln(&response)
|
||||
response = strings.TrimSpace(response)
|
||||
|
||||
choice := 0
|
||||
fmt.Sscanf(response, "%d", &choice)
|
||||
if choice < 1 || choice > len(found) {
|
||||
return "", fmt.Errorf("invalid choice")
|
||||
}
|
||||
return found[choice-1], nil
|
||||
}
|
||||
|
||||
func detectCompositorForSetup() (string, error) {
|
||||
compositors := greeter.DetectCompositors()
|
||||
|
||||
switch len(compositors) {
|
||||
case 0:
|
||||
return "", fmt.Errorf("no supported compositors found (niri, Hyprland, or mango required)")
|
||||
case 1:
|
||||
return strings.ToLower(compositors[0]), nil
|
||||
}
|
||||
|
||||
selected, err := greeter.PromptCompositorChoice(compositors)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.ToLower(selected), nil
|
||||
}
|
||||
|
||||
func runSetupDmsConfig(name string) error {
|
||||
spec, ok := dmsConfigSpecs[name]
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown config: %s", name)
|
||||
}
|
||||
|
||||
compositor, err := detectCompositorForSetup()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var filename string
|
||||
var contentFn func(string) string
|
||||
switch compositor {
|
||||
case "niri":
|
||||
filename = spec.niriFile
|
||||
contentFn = spec.niriContent
|
||||
case "hyprland":
|
||||
filename = spec.hyprFile
|
||||
contentFn = spec.hyprContent
|
||||
case "mango", "mangowc":
|
||||
filename = spec.mangoFile
|
||||
contentFn = spec.mangoContent
|
||||
default:
|
||||
return fmt.Errorf("unsupported compositor: %s", compositor)
|
||||
}
|
||||
|
||||
if filename == "" {
|
||||
return fmt.Errorf("%s is not supported for %s", name, compositor)
|
||||
}
|
||||
|
||||
var dmsDir string
|
||||
switch compositor {
|
||||
case "niri":
|
||||
dmsDir = filepath.Join(utils.XDGConfigHome(), "niri", "dms")
|
||||
case "hyprland":
|
||||
dmsDir = filepath.Join(utils.XDGConfigHome(), "hypr", "dms")
|
||||
case "mango", "mangowc":
|
||||
dmsDir = filepath.Join(utils.XDGConfigHome(), "mango", "dms")
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create dms directory: %w", err)
|
||||
}
|
||||
|
||||
path := filepath.Join(dmsDir, filename)
|
||||
if info, err := os.Stat(path); err == nil && info.Size() > 0 {
|
||||
return fmt.Errorf("%s already exists and is not empty: %s", name, path)
|
||||
}
|
||||
|
||||
terminal := "ghostty"
|
||||
if contentFn != nil && name == "binds" {
|
||||
terminal, err = detectTerminal()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
content := contentFn(terminal)
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
return fmt.Errorf("failed to write %s: %w", filename, err)
|
||||
}
|
||||
|
||||
fmt.Printf("Deployed %s to %s\n", name, path)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runSetup() error {
|
||||
fmt.Println("=== DMS Configuration Setup ===")
|
||||
|
||||
ensureInputGroup()
|
||||
|
||||
wm, wmSelected := promptCompositor()
|
||||
terminal, terminalSelected := promptTerminal()
|
||||
useSystemd := promptSystemd()
|
||||
useSystemd := true
|
||||
if wmSelected {
|
||||
if wm == deps.WindowManagerMango {
|
||||
useSystemd = false
|
||||
} else if isVoidSetup() {
|
||||
useSystemd = false
|
||||
fmt.Println("\nVoid Linux detected; deploying non-systemd session config.")
|
||||
} else {
|
||||
useSystemd = promptSystemd()
|
||||
}
|
||||
}
|
||||
|
||||
if !wmSelected && !terminalSelected {
|
||||
fmt.Println("No configurations selected. Exiting.")
|
||||
@@ -100,14 +376,55 @@ func runSetup() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func isVoidSetup() bool {
|
||||
osInfo, err := distros.GetOSInfo()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
config, exists := distros.Registry[osInfo.Distribution.ID]
|
||||
return exists && config.Family == distros.FamilyVoid
|
||||
}
|
||||
|
||||
// Add user to the input group for the evdev manager for inut state tracking.
|
||||
// Caps Lock OSD and the Caps Lock bar indicator.
|
||||
func ensureInputGroup() {
|
||||
if !utils.HasGroup("input") {
|
||||
return
|
||||
}
|
||||
currentUser := os.Getenv("USER")
|
||||
if currentUser == "" {
|
||||
currentUser = os.Getenv("LOGNAME")
|
||||
}
|
||||
if currentUser == "" {
|
||||
return
|
||||
}
|
||||
out, err := execGroups(currentUser)
|
||||
if err == nil && strings.Contains(out, "input") {
|
||||
fmt.Printf("✓ %s is already in the input group (Caps Lock OSD enabled)\n", currentUser)
|
||||
return
|
||||
}
|
||||
fmt.Println("Adding user to input group for Caps Lock OSD support...")
|
||||
if err := privesc.Run(context.Background(), "", "usermod", "-aG", "input", currentUser); err != nil {
|
||||
fmt.Printf("⚠ Could not add %s to input group (Caps Lock OSD will be unavailable): %v\n", currentUser, err)
|
||||
} else {
|
||||
fmt.Printf("✓ Added %s to input group (logout/login required to take effect)\n", currentUser)
|
||||
}
|
||||
}
|
||||
|
||||
func execGroups(user string) (string, error) {
|
||||
out, err := exec.Command("groups", user).Output()
|
||||
return string(out), err
|
||||
}
|
||||
|
||||
func promptCompositor() (deps.WindowManager, bool) {
|
||||
fmt.Println("Select compositor:")
|
||||
fmt.Println("1) Niri")
|
||||
fmt.Println("2) Hyprland")
|
||||
fmt.Println("3) None")
|
||||
fmt.Println("3) Mango")
|
||||
fmt.Println("4) None")
|
||||
|
||||
var response string
|
||||
fmt.Print("\nChoice (1-3): ")
|
||||
fmt.Print("\nChoice (1-4): ")
|
||||
fmt.Scanln(&response)
|
||||
response = strings.TrimSpace(response)
|
||||
|
||||
@@ -116,6 +433,8 @@ func promptCompositor() (deps.WindowManager, bool) {
|
||||
return deps.WindowManagerNiri, true
|
||||
case "2":
|
||||
return deps.WindowManagerHyprland, true
|
||||
case "3":
|
||||
return deps.WindowManagerMango, true
|
||||
default:
|
||||
return deps.WindowManagerNiri, false
|
||||
}
|
||||
@@ -163,16 +482,27 @@ func checkExistingConfigs(wm deps.WindowManager, wmSelected bool, terminal deps.
|
||||
willBackup := false
|
||||
|
||||
if wmSelected {
|
||||
var configPath string
|
||||
var configPaths []string
|
||||
switch wm {
|
||||
case deps.WindowManagerNiri:
|
||||
configPath = filepath.Join(homeDir, ".config", "niri", "config.kdl")
|
||||
configPaths = []string{filepath.Join(homeDir, ".config", "niri", "config.kdl")}
|
||||
case deps.WindowManagerHyprland:
|
||||
configPath = filepath.Join(homeDir, ".config", "hypr", "hyprland.conf")
|
||||
configPaths = []string{
|
||||
filepath.Join(homeDir, ".config", "hypr", "hyprland.lua"),
|
||||
filepath.Join(homeDir, ".config", "hypr", "hyprland.conf"),
|
||||
}
|
||||
case deps.WindowManagerMango:
|
||||
configPaths = []string{
|
||||
filepath.Join(homeDir, ".config", "mango", "config.conf"),
|
||||
filepath.Join(homeDir, ".config", "mango", "mango.conf"),
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := os.Stat(configPath); err == nil {
|
||||
willBackup = true
|
||||
for _, configPath := range configPaths {
|
||||
if _, err := os.Stat(configPath); err == nil {
|
||||
willBackup = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"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/spf13/cobra"
|
||||
)
|
||||
|
||||
var systemCmd = &cobra.Command{
|
||||
Use: "system",
|
||||
Short: "System operations",
|
||||
Long: "System-level operations (updates, etc.). Runs against installed package managers directly; does not require the DMS server.",
|
||||
}
|
||||
|
||||
var systemUpdateCmd = &cobra.Command{
|
||||
Use: "update",
|
||||
Short: "Apply or list system updates",
|
||||
Long: `Apply or list system updates across detected package managers.
|
||||
|
||||
Default behavior is to apply available updates after prompting for confirmation.
|
||||
Use --check to list updates without applying.
|
||||
|
||||
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 --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`,
|
||||
Run: runSystemUpdate,
|
||||
}
|
||||
|
||||
var (
|
||||
sysUpdateCheck bool
|
||||
sysUpdateNoConfirm 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(&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().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)")
|
||||
|
||||
systemCmd.AddCommand(systemUpdateCmd)
|
||||
}
|
||||
|
||||
func runSystemUpdate(cmd *cobra.Command, args []string) {
|
||||
switch {
|
||||
case sysUpdateIntervalS >= 0:
|
||||
runSystemUpdateSetInterval(sysUpdateIntervalS)
|
||||
case sysUpdateCheck:
|
||||
runSystemUpdateCheck()
|
||||
default:
|
||||
runSystemUpdateApply()
|
||||
}
|
||||
}
|
||||
|
||||
func selectBackends(ctx context.Context) []sysupdate.Backend {
|
||||
sel := sysupdate.Select(ctx)
|
||||
backends := sel.All()
|
||||
if !sysUpdateNoFlatpak {
|
||||
return backends
|
||||
}
|
||||
out := backends[:0]
|
||||
for _, b := range backends {
|
||||
if b.Repo() == sysupdate.RepoFlatpak {
|
||||
continue
|
||||
}
|
||||
out = append(out, b)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func runSystemUpdateCheck() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), sysUpdateListPmTime)
|
||||
defer cancel()
|
||||
|
||||
backends := selectBackends(ctx)
|
||||
if len(backends) == 0 {
|
||||
log.Fatal("No supported package manager found")
|
||||
}
|
||||
|
||||
stopSpin := startSpinner("Checking for updates… ")
|
||||
allPkgs, firstErr := collectUpdates(ctx, backends)
|
||||
stopSpin()
|
||||
allPkgs = filterUpdateTargets(allPkgs)
|
||||
|
||||
if sysUpdateJSON {
|
||||
out, _ := json.MarshalIndent(map[string]any{
|
||||
"backends": backendResults(backends, allPkgs),
|
||||
"packages": allPkgs,
|
||||
"error": errOrEmpty(firstErr),
|
||||
"count": len(allPkgs),
|
||||
}, "", " ")
|
||||
fmt.Println(string(out))
|
||||
return
|
||||
}
|
||||
|
||||
printBackends(backends)
|
||||
fmt.Printf("Updates: %d\n", len(allPkgs))
|
||||
if firstErr != nil {
|
||||
fmt.Printf("Error: %v\n", firstErr)
|
||||
}
|
||||
if len(allPkgs) == 0 {
|
||||
return
|
||||
}
|
||||
fmt.Println()
|
||||
for _, p := range allPkgs {
|
||||
printPackage(p)
|
||||
}
|
||||
}
|
||||
|
||||
type backendResult struct {
|
||||
ID string `json:"id"`
|
||||
Display string `json:"displayName"`
|
||||
Packages []sysupdate.Package `json:"packages"`
|
||||
}
|
||||
|
||||
func backendResults(backends []sysupdate.Backend, pkgs []sysupdate.Package) []backendResult {
|
||||
results := make([]backendResult, 0, len(backends))
|
||||
for _, b := range backends {
|
||||
var backendPkgs []sysupdate.Package
|
||||
for _, p := range pkgs {
|
||||
if sysupdate.BackendHasTargets(b, []sysupdate.Package{p}, true, true) {
|
||||
backendPkgs = append(backendPkgs, p)
|
||||
}
|
||||
}
|
||||
results = append(results, backendResult{ID: b.ID(), Display: b.DisplayName(), Packages: backendPkgs})
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func runSystemUpdateApply() {
|
||||
checkCtx, checkCancel := context.WithTimeout(context.Background(), sysUpdateListPmTime)
|
||||
defer checkCancel()
|
||||
|
||||
backends := selectBackends(checkCtx)
|
||||
if len(backends) == 0 {
|
||||
log.Fatal("No supported package manager found")
|
||||
}
|
||||
|
||||
stopSpin := startSpinner("Checking for updates…")
|
||||
pkgs, firstErr := collectUpdates(checkCtx, backends)
|
||||
stopSpin()
|
||||
pkgs = filterUpdateTargets(pkgs)
|
||||
if firstErr != nil {
|
||||
fmt.Printf("Warning: %v\n\n", firstErr)
|
||||
}
|
||||
|
||||
printBackends(backends)
|
||||
fmt.Printf("Updates: %d\n", len(pkgs))
|
||||
if len(pkgs) == 0 {
|
||||
fmt.Println("Nothing to upgrade.")
|
||||
return
|
||||
}
|
||||
fmt.Println()
|
||||
for _, p := range pkgs {
|
||||
printPackage(p)
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
if !sysUpdateNoConfirm && !sysUpdateDry {
|
||||
if !promptYesNo("Proceed with upgrade? [Y/n]: ") {
|
||||
fmt.Println("Aborted.")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
opts := sysupdate.UpgradeOptions{
|
||||
Targets: pkgs,
|
||||
IncludeFlatpak: !sysUpdateNoFlatpak,
|
||||
IncludeAUR: !sysUpdateNoAUR,
|
||||
Ignored: sysUpdateIgnore,
|
||||
DryRun: sysUpdateDry,
|
||||
UseSudo: true,
|
||||
}
|
||||
opts.AttachStdio = sysupdate.UpgradeNeedsPrivilege(backends, pkgs, opts)
|
||||
|
||||
onLine := func(line string) { fmt.Println(line) }
|
||||
ran := false
|
||||
for _, b := range backends {
|
||||
if !sysupdate.BackendHasTargets(b, pkgs, opts.IncludeAUR, opts.IncludeFlatpak) {
|
||||
continue
|
||||
}
|
||||
ran = true
|
||||
fmt.Printf("\n== %s ==\n", b.DisplayName())
|
||||
if err := b.Upgrade(ctx, opts, onLine); err != nil {
|
||||
log.Fatalf("%s upgrade failed: %v", b.ID(), err)
|
||||
}
|
||||
}
|
||||
if !ran {
|
||||
fmt.Println("Nothing to upgrade.")
|
||||
return
|
||||
}
|
||||
if sysUpdateDry {
|
||||
fmt.Println("\nDry run complete (no changes applied).")
|
||||
return
|
||||
}
|
||||
fmt.Println("\nUpgrade complete.")
|
||||
}
|
||||
|
||||
func collectUpdates(ctx context.Context, backends []sysupdate.Backend) ([]sysupdate.Package, error) {
|
||||
var all []sysupdate.Package
|
||||
var firstErr error
|
||||
for _, b := range backends {
|
||||
pkgs, err := b.CheckUpdates(ctx)
|
||||
if err != nil && firstErr == nil {
|
||||
firstErr = fmt.Errorf("%s: %w", b.ID(), err)
|
||||
}
|
||||
all = append(all, pkgs...)
|
||||
}
|
||||
return all, firstErr
|
||||
}
|
||||
|
||||
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] {
|
||||
continue
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func runSystemUpdateSetInterval(seconds int) {
|
||||
resp, err := sendServerRequest(models.Request{
|
||||
ID: 1,
|
||||
Method: "sysupdate.setInterval",
|
||||
Params: map[string]any{"seconds": float64(seconds)},
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("Failed: %v (is dms server running?)", err)
|
||||
}
|
||||
if resp.Error != "" {
|
||||
log.Fatalf("Error: %s", resp.Error)
|
||||
}
|
||||
fmt.Printf("Interval set to %d seconds.\n", seconds)
|
||||
}
|
||||
|
||||
func promptYesNo(prompt string) bool {
|
||||
if !stdinIsTTY() {
|
||||
log.Fatal("Refusing to apply updates non-interactively. Re-run with --noconfirm or --check.")
|
||||
}
|
||||
fmt.Print(prompt)
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(line)) {
|
||||
case "n", "no":
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func printBackends(backends []sysupdate.Backend) {
|
||||
if len(backends) == 0 {
|
||||
return
|
||||
}
|
||||
names := make([]string, 0, len(backends))
|
||||
for _, b := range backends {
|
||||
names = append(names, b.DisplayName())
|
||||
}
|
||||
fmt.Printf("Backends: %s\n", strings.Join(names, ", "))
|
||||
}
|
||||
|
||||
func stdinIsTTY() bool {
|
||||
fi, err := os.Stdin.Stat()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return (fi.Mode() & os.ModeCharDevice) != 0
|
||||
}
|
||||
|
||||
func stdoutIsTTY() bool {
|
||||
fi, err := os.Stdout.Stat()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return (fi.Mode() & os.ModeCharDevice) != 0
|
||||
}
|
||||
|
||||
// startSpinner prints an animated spinner to stdout for progress indication
|
||||
func startSpinner(msg string) func() {
|
||||
if !stdoutIsTTY() {
|
||||
return func() {}
|
||||
}
|
||||
frames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
for i := 0; ; i++ {
|
||||
select {
|
||||
case <-done:
|
||||
fmt.Print("\r\033[K")
|
||||
return
|
||||
case <-time.After(80 * time.Millisecond):
|
||||
fmt.Printf("\r%s %s", frames[i%len(frames)], msg)
|
||||
}
|
||||
}
|
||||
}()
|
||||
return func() { close(done) }
|
||||
}
|
||||
|
||||
var (
|
||||
styleRepo = lipgloss.NewStyle().Foreground(lipgloss.Color("244")).Bold(false)
|
||||
styleName = lipgloss.NewStyle().Foreground(lipgloss.Color("39")).Bold(true)
|
||||
styleFrom = lipgloss.NewStyle().Foreground(lipgloss.Color("243"))
|
||||
styleArrow = lipgloss.NewStyle().Foreground(lipgloss.Color("244"))
|
||||
styleTo = lipgloss.NewStyle().Foreground(lipgloss.Color("76")).Bold(true)
|
||||
)
|
||||
|
||||
func printPackage(p sysupdate.Package) {
|
||||
if !stdoutIsTTY() {
|
||||
fmt.Printf(" [%s] %s %s -> %s\n", p.Repo, p.Name, defaultIfEmpty(p.FromVersion, "?"), defaultIfEmpty(p.ToVersion, "?"))
|
||||
return
|
||||
}
|
||||
fmt.Printf(" %s %s %s %s %s\n",
|
||||
styleRepo.Render("["+string(p.Repo)+"]"),
|
||||
styleName.Render(p.Name),
|
||||
styleFrom.Render(defaultIfEmpty(p.FromVersion, "?")),
|
||||
styleArrow.Render("->"),
|
||||
styleTo.Render(defaultIfEmpty(p.ToVersion, "?")),
|
||||
)
|
||||
}
|
||||
|
||||
func errOrEmpty(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
func defaultIfEmpty(s, def string) string {
|
||||
if s == "" {
|
||||
return def
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/trash"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var trashCmd = &cobra.Command{
|
||||
Use: "trash",
|
||||
Short: "Manage the user's trash (XDG Trash spec 1.0)",
|
||||
}
|
||||
|
||||
var trashPutCmd = &cobra.Command{
|
||||
Use: "put <path...>",
|
||||
Short: "Move files or directories into the trash",
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
Run: runTrashPut,
|
||||
}
|
||||
|
||||
var trashListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List trashed items across all known trash directories",
|
||||
Run: runTrashList,
|
||||
}
|
||||
|
||||
var trashCountCmd = &cobra.Command{
|
||||
Use: "count",
|
||||
Short: "Print the total number of trashed items",
|
||||
Run: runTrashCount,
|
||||
}
|
||||
|
||||
var trashEmptyCmd = &cobra.Command{
|
||||
Use: "empty",
|
||||
Short: "Permanently delete every trashed item",
|
||||
Run: runTrashEmpty,
|
||||
}
|
||||
|
||||
var trashRestoreCmd = &cobra.Command{
|
||||
Use: "restore <name>",
|
||||
Short: "Restore a trashed item to its original location",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: runTrashRestore,
|
||||
}
|
||||
|
||||
var (
|
||||
trashJSONOutput bool
|
||||
trashRestoreDir string
|
||||
)
|
||||
|
||||
func init() {
|
||||
trashListCmd.Flags().BoolVar(&trashJSONOutput, "json", false, "Output as JSON")
|
||||
trashRestoreCmd.Flags().StringVar(&trashRestoreDir, "trash-dir", "", "Trash directory containing the item (default: home trash)")
|
||||
trashCmd.AddCommand(trashPutCmd, trashListCmd, trashCountCmd, trashEmptyCmd, trashRestoreCmd)
|
||||
}
|
||||
|
||||
func runTrashPut(cmd *cobra.Command, args []string) {
|
||||
var failed int
|
||||
for _, p := range args {
|
||||
if _, err := trash.Put(p); err != nil {
|
||||
log.Errorf("trash %s: %v", p, err)
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
fmt.Println(p)
|
||||
}
|
||||
if failed > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func runTrashList(cmd *cobra.Command, args []string) {
|
||||
entries, err := trash.List()
|
||||
if err != nil {
|
||||
log.Fatalf("list trash: %v", err)
|
||||
}
|
||||
|
||||
if trashJSONOutput {
|
||||
if entries == nil {
|
||||
entries = []trash.Entry{}
|
||||
}
|
||||
out, _ := json.MarshalIndent(entries, "", " ")
|
||||
fmt.Println(string(out))
|
||||
return
|
||||
}
|
||||
|
||||
if len(entries) == 0 {
|
||||
fmt.Println("Trash is empty")
|
||||
return
|
||||
}
|
||||
for _, e := range entries {
|
||||
marker := "F"
|
||||
if e.IsDir {
|
||||
marker = "D"
|
||||
}
|
||||
fmt.Printf("%s %s %s %s\n", marker, e.DeletionDate, e.Name, e.OriginalPath)
|
||||
}
|
||||
}
|
||||
|
||||
func runTrashCount(cmd *cobra.Command, args []string) {
|
||||
n, err := trash.Count()
|
||||
if err != nil {
|
||||
log.Fatalf("count trash: %v", err)
|
||||
}
|
||||
fmt.Println(n)
|
||||
}
|
||||
|
||||
func runTrashEmpty(cmd *cobra.Command, args []string) {
|
||||
if err := trash.Empty(); err != nil {
|
||||
log.Fatalf("empty trash: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func runTrashRestore(cmd *cobra.Command, args []string) {
|
||||
if err := trash.Restore(args[0], trashRestoreDir); err != nil {
|
||||
log.Fatalf("restore: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/windowrules"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/windowrules/providers"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var windowrulesCmd = &cobra.Command{
|
||||
Use: "windowrules",
|
||||
Short: "Manage window rules",
|
||||
}
|
||||
|
||||
var windowrulesListCmd = &cobra.Command{
|
||||
Use: "list [compositor]",
|
||||
Short: "List all window rules",
|
||||
Long: "List all window rules from compositor config file. Returns JSON with rules and DMS status.",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
if len(args) == 0 {
|
||||
return []string{"hyprland", "niri", "mango"}, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
},
|
||||
Run: runWindowrulesList,
|
||||
}
|
||||
|
||||
var windowrulesAddCmd = &cobra.Command{
|
||||
Use: "add <compositor> '<json>'",
|
||||
Short: "Add a window rule to DMS file",
|
||||
Long: "Add a new window rule to the DMS-managed rules file.",
|
||||
Args: cobra.ExactArgs(2),
|
||||
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
if len(args) == 0 {
|
||||
return []string{"hyprland", "niri", "mango"}, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
},
|
||||
Run: runWindowrulesAdd,
|
||||
}
|
||||
|
||||
var windowrulesUpdateCmd = &cobra.Command{
|
||||
Use: "update <compositor> <id> '<json>'",
|
||||
Short: "Update a window rule in DMS file",
|
||||
Long: "Update an existing window rule in the DMS-managed rules file.",
|
||||
Args: cobra.ExactArgs(3),
|
||||
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
if len(args) == 0 {
|
||||
return []string{"hyprland", "niri", "mango"}, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
},
|
||||
Run: runWindowrulesUpdate,
|
||||
}
|
||||
|
||||
var windowrulesRemoveCmd = &cobra.Command{
|
||||
Use: "remove <compositor> <id>",
|
||||
Short: "Remove a window rule from DMS file",
|
||||
Long: "Remove a window rule from the DMS-managed rules file.",
|
||||
Args: cobra.ExactArgs(2),
|
||||
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
if len(args) == 0 {
|
||||
return []string{"hyprland", "niri", "mango"}, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
},
|
||||
Run: runWindowrulesRemove,
|
||||
}
|
||||
|
||||
var windowrulesReorderCmd = &cobra.Command{
|
||||
Use: "reorder <compositor> '<json-array-of-ids>'",
|
||||
Short: "Reorder window rules in DMS file",
|
||||
Long: "Reorder window rules by providing a JSON array of rule IDs in the desired order.",
|
||||
Args: cobra.ExactArgs(2),
|
||||
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
if len(args) == 0 {
|
||||
return []string{"hyprland", "niri", "mango"}, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
},
|
||||
Run: runWindowrulesReorder,
|
||||
}
|
||||
|
||||
func init() {
|
||||
configCmd.AddCommand(windowrulesCmd)
|
||||
windowrulesCmd.AddCommand(windowrulesListCmd)
|
||||
windowrulesCmd.AddCommand(windowrulesAddCmd)
|
||||
windowrulesCmd.AddCommand(windowrulesUpdateCmd)
|
||||
windowrulesCmd.AddCommand(windowrulesRemoveCmd)
|
||||
windowrulesCmd.AddCommand(windowrulesReorderCmd)
|
||||
}
|
||||
|
||||
type WindowRulesListResult struct {
|
||||
Rules []windowrules.WindowRule `json:"rules"`
|
||||
DMSStatus *windowrules.DMSRulesStatus `json:"dmsStatus,omitempty"`
|
||||
}
|
||||
|
||||
type WindowRuleWriteResult struct {
|
||||
Success bool `json:"success"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func getCompositor(args []string) string {
|
||||
if len(args) > 0 {
|
||||
return strings.ToLower(args[0])
|
||||
}
|
||||
if os.Getenv("NIRI_SOCKET") != "" {
|
||||
return "niri"
|
||||
}
|
||||
if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") != "" {
|
||||
return "hyprland"
|
||||
}
|
||||
if os.Getenv("MANGO_INSTANCE_SIGNATURE") != "" {
|
||||
return "mango"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func writeRuleError(errMsg string) {
|
||||
result := WindowRuleWriteResult{Success: false, Error: errMsg}
|
||||
output, _ := json.Marshal(result)
|
||||
fmt.Fprintln(os.Stdout, string(output))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func writeRuleSuccess(id, path string) {
|
||||
result := WindowRuleWriteResult{Success: true, ID: id, Path: path}
|
||||
output, _ := json.Marshal(result)
|
||||
fmt.Fprintln(os.Stdout, string(output))
|
||||
}
|
||||
|
||||
func runWindowrulesList(cmd *cobra.Command, args []string) {
|
||||
compositor := getCompositor(args)
|
||||
if compositor == "" {
|
||||
log.Fatalf("Could not detect compositor. Please specify: hyprland, niri, or mango")
|
||||
}
|
||||
|
||||
var result WindowRulesListResult
|
||||
|
||||
switch compositor {
|
||||
case "niri":
|
||||
configDir := filepath.Join(utils.XDGConfigHome(), "niri")
|
||||
|
||||
parseResult, err := providers.ParseNiriWindowRules(configDir)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to parse niri window rules: %v", err)
|
||||
}
|
||||
|
||||
allRules := providers.ConvertNiriRulesToWindowRules(parseResult.Rules)
|
||||
|
||||
provider := providers.NewNiriWritableProvider(configDir)
|
||||
dmsRulesPath := provider.GetOverridePath()
|
||||
dmsRules, _ := provider.LoadDMSRules()
|
||||
|
||||
dmsRuleMap := make(map[int]windowrules.WindowRule)
|
||||
for i, dr := range dmsRules {
|
||||
dmsRuleMap[i] = dr
|
||||
}
|
||||
|
||||
dmsIdx := 0
|
||||
for i, r := range allRules {
|
||||
if r.Source == dmsRulesPath {
|
||||
if dmr, ok := dmsRuleMap[dmsIdx]; ok {
|
||||
allRules[i].ID = dmr.ID
|
||||
allRules[i].Name = dmr.Name
|
||||
}
|
||||
dmsIdx++
|
||||
}
|
||||
}
|
||||
|
||||
result.Rules = allRules
|
||||
result.DMSStatus = parseResult.DMSStatus
|
||||
|
||||
case "hyprland":
|
||||
configDir := filepath.Join(utils.XDGConfigHome(), "hypr")
|
||||
|
||||
parseResult, err := providers.ParseHyprlandWindowRules(configDir)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to parse hyprland window rules: %v", err)
|
||||
}
|
||||
|
||||
allRules := providers.ConvertHyprlandRulesToWindowRules(parseResult.Rules)
|
||||
|
||||
provider := providers.NewHyprlandWritableProvider(configDir)
|
||||
dmsRulesPath := provider.GetOverridePath()
|
||||
dmsRules, _ := provider.LoadDMSRules()
|
||||
|
||||
dmsRuleMap := make(map[int]windowrules.WindowRule)
|
||||
for i, dr := range dmsRules {
|
||||
dmsRuleMap[i] = dr
|
||||
}
|
||||
|
||||
dmsIdx := 0
|
||||
for i, r := range allRules {
|
||||
if r.Source == dmsRulesPath {
|
||||
if dmr, ok := dmsRuleMap[dmsIdx]; ok {
|
||||
allRules[i].ID = dmr.ID
|
||||
allRules[i].Name = dmr.Name
|
||||
}
|
||||
dmsIdx++
|
||||
}
|
||||
}
|
||||
|
||||
result.Rules = allRules
|
||||
result.DMSStatus = parseResult.DMSStatus
|
||||
|
||||
case "mango", "mangowc":
|
||||
configDir := filepath.Join(utils.XDGConfigHome(), "mango")
|
||||
|
||||
parseResult, err := providers.ParseMangoWindowRules(configDir)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to parse mango window rules: %v", err)
|
||||
}
|
||||
|
||||
allRules := providers.ConvertMangoRulesToWindowRules(parseResult.Rules)
|
||||
|
||||
provider := providers.NewMangoWritableProvider(configDir)
|
||||
dmsRules, _ := provider.LoadDMSRules()
|
||||
|
||||
dmsRuleMap := make(map[int]windowrules.WindowRule)
|
||||
for i, dr := range dmsRules {
|
||||
dmsRuleMap[i] = dr
|
||||
}
|
||||
|
||||
dmsIdx := 0
|
||||
for i, r := range allRules {
|
||||
if r.Source == "dms/windowrules.conf" {
|
||||
if dmr, ok := dmsRuleMap[dmsIdx]; ok {
|
||||
allRules[i].ID = dmr.ID
|
||||
allRules[i].Name = dmr.Name
|
||||
}
|
||||
dmsIdx++
|
||||
}
|
||||
}
|
||||
|
||||
result.Rules = allRules
|
||||
result.DMSStatus = parseResult.DMSStatus
|
||||
|
||||
default:
|
||||
log.Fatalf("Unknown compositor: %s", compositor)
|
||||
}
|
||||
|
||||
output, _ := json.Marshal(result)
|
||||
fmt.Fprintln(os.Stdout, string(output))
|
||||
}
|
||||
|
||||
func runWindowrulesAdd(cmd *cobra.Command, args []string) {
|
||||
compositor := strings.ToLower(args[0])
|
||||
ruleJSON := args[1]
|
||||
|
||||
var rule windowrules.WindowRule
|
||||
if err := json.Unmarshal([]byte(ruleJSON), &rule); err != nil {
|
||||
writeRuleError(fmt.Sprintf("Invalid JSON: %v", err))
|
||||
}
|
||||
|
||||
if rule.ID == "" {
|
||||
rule.ID = generateRuleID()
|
||||
}
|
||||
rule.Enabled = true
|
||||
|
||||
provider := getWindowRulesProvider(compositor)
|
||||
if provider == nil {
|
||||
writeRuleError(fmt.Sprintf("Unknown compositor: %s", compositor))
|
||||
}
|
||||
|
||||
if err := provider.SetRule(rule); err != nil {
|
||||
writeRuleError(err.Error())
|
||||
}
|
||||
|
||||
writeRuleSuccess(rule.ID, provider.GetOverridePath())
|
||||
}
|
||||
|
||||
func runWindowrulesUpdate(cmd *cobra.Command, args []string) {
|
||||
compositor := strings.ToLower(args[0])
|
||||
ruleID := args[1]
|
||||
ruleJSON := args[2]
|
||||
|
||||
var rule windowrules.WindowRule
|
||||
if err := json.Unmarshal([]byte(ruleJSON), &rule); err != nil {
|
||||
writeRuleError(fmt.Sprintf("Invalid JSON: %v", err))
|
||||
}
|
||||
|
||||
rule.ID = ruleID
|
||||
|
||||
provider := getWindowRulesProvider(compositor)
|
||||
if provider == nil {
|
||||
writeRuleError(fmt.Sprintf("Unknown compositor: %s", compositor))
|
||||
}
|
||||
|
||||
if err := provider.SetRule(rule); err != nil {
|
||||
writeRuleError(err.Error())
|
||||
}
|
||||
|
||||
writeRuleSuccess(rule.ID, provider.GetOverridePath())
|
||||
}
|
||||
|
||||
func runWindowrulesRemove(cmd *cobra.Command, args []string) {
|
||||
compositor := strings.ToLower(args[0])
|
||||
ruleID := args[1]
|
||||
|
||||
provider := getWindowRulesProvider(compositor)
|
||||
if provider == nil {
|
||||
writeRuleError(fmt.Sprintf("Unknown compositor: %s", compositor))
|
||||
}
|
||||
|
||||
if err := provider.RemoveRule(ruleID); err != nil {
|
||||
writeRuleError(err.Error())
|
||||
}
|
||||
|
||||
writeRuleSuccess(ruleID, provider.GetOverridePath())
|
||||
}
|
||||
|
||||
func runWindowrulesReorder(cmd *cobra.Command, args []string) {
|
||||
compositor := strings.ToLower(args[0])
|
||||
idsJSON := args[1]
|
||||
|
||||
var ids []string
|
||||
if err := json.Unmarshal([]byte(idsJSON), &ids); err != nil {
|
||||
writeRuleError(fmt.Sprintf("Invalid JSON array: %v", err))
|
||||
}
|
||||
|
||||
provider := getWindowRulesProvider(compositor)
|
||||
if provider == nil {
|
||||
writeRuleError(fmt.Sprintf("Unknown compositor: %s", compositor))
|
||||
}
|
||||
|
||||
if err := provider.ReorderRules(ids); err != nil {
|
||||
writeRuleError(err.Error())
|
||||
}
|
||||
|
||||
writeRuleSuccess("", provider.GetOverridePath())
|
||||
}
|
||||
|
||||
func getWindowRulesProvider(compositor string) windowrules.WritableProvider {
|
||||
switch compositor {
|
||||
case "niri":
|
||||
configDir := filepath.Join(utils.XDGConfigHome(), "niri")
|
||||
return providers.NewNiriWritableProvider(configDir)
|
||||
case "hyprland":
|
||||
configDir := filepath.Join(utils.XDGConfigHome(), "hypr")
|
||||
return providers.NewHyprlandWritableProvider(configDir)
|
||||
case "mango", "mangowc":
|
||||
configDir := filepath.Join(utils.XDGConfigHome(), "mango")
|
||||
return providers.NewMangoWritableProvider(configDir)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func generateRuleID() string {
|
||||
return fmt.Sprintf("wr_%d", time.Now().UnixNano())
|
||||
}
|
||||
@@ -87,20 +87,14 @@ func newDPMSClient() (*dpmsClient, error) {
|
||||
switch e.Interface {
|
||||
case wlr_output_power.ZwlrOutputPowerManagerV1InterfaceName:
|
||||
powerMgr := wlr_output_power.NewZwlrOutputPowerManagerV1(c.ctx)
|
||||
version := e.Version
|
||||
if version > 1 {
|
||||
version = 1
|
||||
}
|
||||
version := min(e.Version, 1)
|
||||
if err := registry.Bind(e.Name, e.Interface, version, powerMgr); err == nil {
|
||||
c.powerMgr = powerMgr
|
||||
}
|
||||
|
||||
case "wl_output":
|
||||
output := wlclient.NewOutput(c.ctx)
|
||||
version := e.Version
|
||||
if version > 4 {
|
||||
version = 4
|
||||
}
|
||||
version := min(e.Version, 4)
|
||||
if err := registry.Bind(e.Name, e.Interface, version, output); err == nil {
|
||||
outputID := fmt.Sprintf("output-%d", output.ID())
|
||||
state := &outputState{
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const (
|
||||
cliPolicyPackagedPath = "/usr/share/dms/cli-policy.json"
|
||||
cliPolicyAdminPath = "/etc/dms/cli-policy.json"
|
||||
)
|
||||
|
||||
var (
|
||||
immutablePolicyOnce sync.Once
|
||||
immutablePolicy immutableCommandPolicy
|
||||
immutablePolicyErr error
|
||||
)
|
||||
|
||||
//go:embed assets/cli-policy.default.json
|
||||
var defaultCLIPolicyJSON []byte
|
||||
|
||||
type immutableCommandPolicy struct {
|
||||
ImmutableSystem bool
|
||||
ImmutableReason string
|
||||
BlockedCommands []string
|
||||
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 ")
|
||||
return strings.Join(strings.Fields(normalized), " ")
|
||||
}
|
||||
|
||||
func normalizeBlockedCommands(raw []string) []string {
|
||||
normalized := make([]string, 0, len(raw))
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for _, cmd := range raw {
|
||||
spec := normalizeCommandSpec(cmd)
|
||||
if spec == "" || seen[spec] {
|
||||
continue
|
||||
}
|
||||
seen[spec] = true
|
||||
normalized = append(normalized, spec)
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
func commandBlockedByPolicy(commandPath string, blocked []string) bool {
|
||||
normalizedPath := normalizeCommandSpec(commandPath)
|
||||
if normalizedPath == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, entry := range blocked {
|
||||
spec := normalizeCommandSpec(entry)
|
||||
if spec == "" {
|
||||
continue
|
||||
}
|
||||
if normalizedPath == spec || strings.HasPrefix(normalizedPath, spec+" ") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if policyFile == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if policyFile.ImmutableSystem != nil {
|
||||
base.ImmutableSystem = *policyFile.ImmutableSystem
|
||||
}
|
||||
if policyFile.BlockedCommands != nil {
|
||||
base.BlockedCommands = normalizeBlockedCommands(*policyFile.BlockedCommands)
|
||||
}
|
||||
if policyFile.Message != nil {
|
||||
msg := strings.TrimSpace(*policyFile.Message)
|
||||
if msg != "" {
|
||||
base.Message = msg
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func readOSReleaseMap(path string) map[string]string {
|
||||
values := make(map[string]string)
|
||||
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return values
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
key := strings.ToUpper(strings.TrimSpace(parts[0]))
|
||||
value := strings.Trim(strings.TrimSpace(parts[1]), "\"")
|
||||
values[key] = strings.ToLower(value)
|
||||
}
|
||||
|
||||
return values
|
||||
}
|
||||
|
||||
func hasAnyToken(text string, tokens ...string) bool {
|
||||
if text == "" {
|
||||
return false
|
||||
}
|
||||
for _, token := range tokens {
|
||||
if strings.Contains(text, token) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func detectImmutableSystem() (bool, string) {
|
||||
if _, err := os.Stat("/run/ostree-booted"); err == nil {
|
||||
return true, "/run/ostree-booted is present"
|
||||
}
|
||||
|
||||
osRelease := readOSReleaseMap("/etc/os-release")
|
||||
if len(osRelease) == 0 {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
id := osRelease["ID"]
|
||||
idLike := osRelease["ID_LIKE"]
|
||||
variantID := osRelease["VARIANT_ID"]
|
||||
name := osRelease["NAME"]
|
||||
prettyName := osRelease["PRETTY_NAME"]
|
||||
|
||||
immutableIDs := map[string]bool{
|
||||
"bluefin": true,
|
||||
"bazzite": true,
|
||||
"silverblue": true,
|
||||
"kinoite": true,
|
||||
"sericea": true,
|
||||
"onyx": true,
|
||||
"aurora": true,
|
||||
"fedora-iot": true,
|
||||
"fedora-coreos": true,
|
||||
}
|
||||
if immutableIDs[id] {
|
||||
return true, "os-release ID=" + id
|
||||
}
|
||||
|
||||
markers := []string{"silverblue", "kinoite", "sericea", "onyx", "bazzite", "bluefin", "aurora", "ostree", "atomic"}
|
||||
if hasAnyToken(variantID, markers...) {
|
||||
return true, "os-release VARIANT_ID=" + variantID
|
||||
}
|
||||
if hasAnyToken(idLike, "ostree", "rpm-ostree") {
|
||||
return true, "os-release ID_LIKE=" + idLike
|
||||
}
|
||||
if hasAnyToken(name, markers...) || hasAnyToken(prettyName, markers...) {
|
||||
return true, "os-release identifies an atomic/ostree variant"
|
||||
}
|
||||
|
||||
return false, ""
|
||||
}
|
||||
|
||||
func getImmutablePolicy() (*immutableCommandPolicy, error) {
|
||||
immutablePolicyOnce.Do(func() {
|
||||
detectedImmutable, reason := detectImmutableSystem()
|
||||
immutablePolicy = immutableCommandPolicy{
|
||||
ImmutableSystem: detectedImmutable,
|
||||
ImmutableReason: reason,
|
||||
BlockedCommands: []string{"greeter install", "greeter enable", "setup"},
|
||||
Message: "This command is disabled on immutable/image-based systems. Use your distro-native workflow for system-level changes.",
|
||||
}
|
||||
|
||||
var defaultPolicy cliPolicyFile
|
||||
if err := json.Unmarshal(defaultCLIPolicyJSON, &defaultPolicy); err != nil {
|
||||
immutablePolicyErr = fmt.Errorf("failed to parse embedded default CLI policy: %w", err)
|
||||
return
|
||||
}
|
||||
if defaultPolicy.BlockedCommands != nil {
|
||||
immutablePolicy.BlockedCommands = normalizeBlockedCommands(*defaultPolicy.BlockedCommands)
|
||||
}
|
||||
if defaultPolicy.Message != nil {
|
||||
msg := strings.TrimSpace(*defaultPolicy.Message)
|
||||
if msg != "" {
|
||||
immutablePolicy.Message = msg
|
||||
}
|
||||
}
|
||||
|
||||
if err := mergePolicyFile(&immutablePolicy, cliPolicyPackagedPath); err != nil {
|
||||
immutablePolicyErr = err
|
||||
return
|
||||
}
|
||||
if err := mergePolicyFile(&immutablePolicy, cliPolicyAdminPath); err != nil {
|
||||
immutablePolicyErr = err
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
if immutablePolicyErr != nil {
|
||||
return nil, immutablePolicyErr
|
||||
}
|
||||
return &immutablePolicy, nil
|
||||
}
|
||||
|
||||
func requireMutableSystemCommand(cmd *cobra.Command, _ []string) error {
|
||||
policy, err := getImmutablePolicy()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !policy.ImmutableSystem {
|
||||
return nil
|
||||
}
|
||||
|
||||
commandPath := normalizeCommandSpec(cmd.CommandPath())
|
||||
if !commandBlockedByPolicy(commandPath, policy.BlockedCommands) {
|
||||
return nil
|
||||
}
|
||||
|
||||
reason := ""
|
||||
if policy.ImmutableReason != "" {
|
||||
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)
|
||||
}
|
||||
|
||||
// preRunPrivileged combines the immutable-system check with a privesc tool
|
||||
// selection prompt (shown only when multiple tools are available and the
|
||||
// $DMS_PRIVESC env var isn't set).
|
||||
func preRunPrivileged(cmd *cobra.Command, args []string) error {
|
||||
if err := requireMutableSystemCommand(cmd, args); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := privesc.PromptCLI(os.Stdout, os.Stdin); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefaultImmutablePolicyAllowsSyncButBlocksEnable(t *testing.T) {
|
||||
var policyFile cliPolicyFile
|
||||
if err := json.Unmarshal(defaultCLIPolicyJSON, &policyFile); err != nil {
|
||||
t.Fatalf("failed to parse embedded CLI policy: %v", err)
|
||||
}
|
||||
if policyFile.BlockedCommands == nil {
|
||||
t.Fatal("embedded CLI policy has no blocked_commands")
|
||||
}
|
||||
|
||||
blocked := normalizeBlockedCommands(*policyFile.BlockedCommands)
|
||||
if !commandBlockedByPolicy("greeter enable", blocked) {
|
||||
t.Fatal("expected greeter enable to remain blocked on immutable/image-based systems")
|
||||
}
|
||||
if commandBlockedByPolicy("greeter sync", blocked) {
|
||||
t.Fatal("expected greeter sync to remain available on immutable/image-based systems")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/greeter"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
|
||||
)
|
||||
|
||||
// runit (Void Linux) service helpers. Services live in /etc/sv and are "enabled"
|
||||
// by symlinking them into the /var/service supervision dir, so the greeter
|
||||
// commands branch on isRunit() instead of shelling systemctl.
|
||||
|
||||
const (
|
||||
runitSvDir = "/etc/sv"
|
||||
runitServiceDir = "/var/service"
|
||||
)
|
||||
|
||||
// isRunit reports whether this system is supervised by runit (Void Linux).
|
||||
func isRunit() bool {
|
||||
if fi, err := os.Stat("/run/runit"); err == nil && fi.IsDir() {
|
||||
return true
|
||||
}
|
||||
if _, err := os.Stat("/run/systemd/system"); err == nil {
|
||||
return false
|
||||
}
|
||||
if fi, err := os.Stat(runitServiceDir); err == nil && fi.IsDir() {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func runitServiceInstalled(name string) bool {
|
||||
fi, err := os.Stat(runitSvDir + "/" + name)
|
||||
return err == nil && fi.IsDir()
|
||||
}
|
||||
|
||||
func runitServiceEnabled(name string) bool {
|
||||
_, err := os.Lstat(runitServiceDir + "/" + name)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// enableRunitService links a service into /var/service (idempotent).
|
||||
func enableRunitService(name string) error {
|
||||
if !runitServiceInstalled(name) {
|
||||
return fmt.Errorf("runit service %q not found in %s", name, runitSvDir)
|
||||
}
|
||||
if runitServiceEnabled(name) {
|
||||
return nil
|
||||
}
|
||||
return privesc.Run(context.Background(), "", "ln", "-sf",
|
||||
runitSvDir+"/"+name, runitServiceDir+"/"+name)
|
||||
}
|
||||
|
||||
// disableRunitService removes a service's supervision symlink.
|
||||
func disableRunitService(name string) error {
|
||||
if !runitServiceEnabled(name) {
|
||||
return nil
|
||||
}
|
||||
return privesc.Run(context.Background(), "", "rm", "-f",
|
||||
runitServiceDir+"/"+name)
|
||||
}
|
||||
|
||||
// ensureRunitSeat sets up the seat access a Wayland greeter needs on runit (the
|
||||
// equivalent of logind on systemd): enables seatd and adds the greeter user to
|
||||
// the seat/video/input groups. Failures are reported but non-fatal.
|
||||
func ensureRunitSeat(greeterUser string) {
|
||||
if runitServiceInstalled("seatd") {
|
||||
if err := enableRunitService("seatd"); err != nil {
|
||||
fmt.Printf(" ⚠ could not enable seatd: %v\n", err)
|
||||
} else {
|
||||
fmt.Println(" ✓ seatd enabled")
|
||||
}
|
||||
} else {
|
||||
fmt.Println(" ⚠ seatd not installed — the greeter compositor needs it for GPU/seat access")
|
||||
}
|
||||
if err := privesc.Run(context.Background(), "", "usermod", "-aG", "_seatd,video,input", greeterUser); err != nil {
|
||||
fmt.Printf(" ⚠ could not add %s to seat groups: %v\n", greeterUser, err)
|
||||
} else {
|
||||
fmt.Printf(" ✓ %s added to seat groups (_seatd, video, input)\n", greeterUser)
|
||||
}
|
||||
}
|
||||
|
||||
// ensureVoidLogindGreeter configures the elogind-backed greeter on Void.
|
||||
func ensureVoidLogindGreeter(greeterUser string) {
|
||||
for _, service := range []string{"dbus", "elogind"} {
|
||||
if err := enableRunitService(service); err != nil {
|
||||
fmt.Printf(" ⚠ could not enable %s: %v\n", service, err)
|
||||
} else {
|
||||
fmt.Printf(" ✓ %s enabled\n", service)
|
||||
}
|
||||
}
|
||||
greeter.EnsureVoidGreetdRunScript(func(msg string) { fmt.Println(" " + msg) }, "")
|
||||
if runitServiceEnabled("seatd") {
|
||||
if err := disableRunitService("seatd"); err != nil {
|
||||
fmt.Printf(" ⚠ could not disable seatd: %v\n", err)
|
||||
} else {
|
||||
fmt.Println(" ✓ seatd disabled (elogind manages the seat)")
|
||||
}
|
||||
}
|
||||
if err := privesc.Run(context.Background(), "", "usermod", "-aG", "video,input", greeterUser); err != nil {
|
||||
fmt.Printf(" ⚠ could not add %s to video/input groups: %v\n", greeterUser, err)
|
||||
} else {
|
||||
fmt.Printf(" ✓ %s added to video/input groups (elogind manages the seat)\n", greeterUser)
|
||||
}
|
||||
}
|
||||
|
||||
// ensureGreetdPamRundir provides XDG_RUNTIME_DIR to runit greeter sessions.
|
||||
func ensureGreetdPamRundir() {
|
||||
const pamPath = "/etc/pam.d/greetd"
|
||||
data, err := os.ReadFile(pamPath)
|
||||
if err != nil {
|
||||
fmt.Printf(" ⚠ could not read %s: %v\n", pamPath, err)
|
||||
return
|
||||
}
|
||||
if strings.Contains(string(data), "pam_rundir") {
|
||||
return
|
||||
}
|
||||
line := "session optional pam_rundir.so"
|
||||
if err := privesc.Run(context.Background(), "", "sh", "-c",
|
||||
fmt.Sprintf("printf '%%s\\n' %q >> %s", line, pamPath)); err != nil {
|
||||
fmt.Printf(" ⚠ could not add pam_rundir to %s: %v\n", pamPath, err)
|
||||
return
|
||||
}
|
||||
fmt.Println(" ✓ pam_rundir added to greetd PAM (provides XDG_RUNTIME_DIR for the session)")
|
||||
}
|
||||
|
||||
// startGreeterHint returns the init-appropriate "start greetd now" command.
|
||||
func startGreeterHint() string {
|
||||
if isRunit() {
|
||||
return " sudo sv up greetd"
|
||||
}
|
||||
return " sudo systemctl start greetd"
|
||||
}
|
||||
+10
-9
@@ -5,6 +5,7 @@ package main
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
)
|
||||
|
||||
@@ -14,27 +15,27 @@ func init() {
|
||||
runCmd.Flags().BoolP("daemon", "d", false, "Run in daemon mode")
|
||||
runCmd.Flags().Bool("daemon-child", false, "Internal flag for daemon child process")
|
||||
runCmd.Flags().Bool("session", false, "Session managed (like as a systemd unit)")
|
||||
runCmd.Flags().String("log-level", "", "Log level: debug, info, warn, error, fatal (overrides DMS_LOG_LEVEL)")
|
||||
runCmd.Flags().String("log-file", "", "Append logs to this file in addition to stderr (overrides DMS_LOG_FILE)")
|
||||
runCmd.Flags().MarkHidden("daemon-child")
|
||||
|
||||
// Add subcommands to greeter
|
||||
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd)
|
||||
|
||||
// Add subcommands to update
|
||||
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd, greeterLaunchSessionCmd)
|
||||
authCmd.AddCommand(authSyncCmd, authResolveLockCmd, authListServicesCmd, authValidateCmd)
|
||||
setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
|
||||
updateCmd.AddCommand(updateCheckCmd)
|
||||
|
||||
// Add subcommands to plugins
|
||||
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)
|
||||
|
||||
// Add common commands to root
|
||||
rootCmd.AddCommand(getCommonCommands()...)
|
||||
|
||||
rootCmd.AddCommand(authCmd)
|
||||
rootCmd.AddCommand(updateCmd)
|
||||
|
||||
rootCmd.SetHelpTemplate(getHelpTemplate())
|
||||
}
|
||||
|
||||
func main() {
|
||||
if os.Geteuid() == 0 {
|
||||
clipboard.MaybeServeAndExit()
|
||||
|
||||
if os.Geteuid() == 0 && !isReadOnlyCommand(os.Args) {
|
||||
log.Fatal("This program should not be run as root. Exiting.")
|
||||
}
|
||||
|
||||
|
||||
@@ -5,33 +5,34 @@ package main
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
)
|
||||
|
||||
var Version = "dev"
|
||||
|
||||
func init() {
|
||||
// Add flags
|
||||
runCmd.Flags().BoolP("daemon", "d", false, "Run in daemon mode")
|
||||
runCmd.Flags().Bool("daemon-child", false, "Internal flag for daemon child process")
|
||||
runCmd.Flags().Bool("session", false, "Session managed (like as a systemd unit)")
|
||||
runCmd.Flags().String("log-level", "", "Log level: debug, info, warn, error, fatal (overrides DMS_LOG_LEVEL)")
|
||||
runCmd.Flags().String("log-file", "", "Append logs to this file in addition to stderr (overrides DMS_LOG_FILE)")
|
||||
runCmd.Flags().MarkHidden("daemon-child")
|
||||
|
||||
// Add subcommands to greeter
|
||||
greeterCmd.AddCommand(greeterSyncCmd, greeterEnableCmd, greeterStatusCmd)
|
||||
|
||||
// Add subcommands to plugins
|
||||
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd, greeterLaunchSessionCmd)
|
||||
authCmd.AddCommand(authSyncCmd, authResolveLockCmd, authListServicesCmd, authValidateCmd)
|
||||
setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
|
||||
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)
|
||||
|
||||
// Add common commands to root
|
||||
rootCmd.AddCommand(getCommonCommands()...)
|
||||
rootCmd.AddCommand(authCmd)
|
||||
|
||||
rootCmd.SetHelpTemplate(getHelpTemplate())
|
||||
}
|
||||
|
||||
func main() {
|
||||
// Block root
|
||||
if os.Geteuid() == 0 {
|
||||
clipboard.MaybeServeAndExit()
|
||||
|
||||
if os.Geteuid() == 0 && !isReadOnlyCommand(os.Args) {
|
||||
log.Fatal("This program should not be run as root. Exiting.")
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user