mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-05-12 23:32:50 -04:00
Compare commits
846 Commits
6b76b86930
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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,497 @@
|
|||||||
|
---
|
||||||
|
name: dms-plugin-dev
|
||||||
|
description: >
|
||||||
|
Develop plugins for DankMaterialShell (DMS), a QML-based Linux desktop shell built on
|
||||||
|
Quickshell. Supports four plugin types: widget (bar + Control Center), daemon (background
|
||||||
|
service), launcher (search + actions), and desktop (draggable desktop widgets). Covers
|
||||||
|
manifest creation, QML component development, settings UI, data persistence, theme
|
||||||
|
integration, PopoutService usage, 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.0"
|
||||||
|
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/
|
||||||
|
|
||||||
|
**Four 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 |
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## 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 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",
|
||||||
|
"requires_dms": ">=0.1.0",
|
||||||
|
"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`)
|
||||||
|
- `component` must start with `./` and end with `.qml`
|
||||||
|
- `type: "launcher"` requires a `trigger` field
|
||||||
|
- `settings_write` permission is **required** if the plugin has a settings component
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## Step 4: 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 5: 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 |
|
||||||
|
|
||||||
|
- `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 6: 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 7: 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 8: 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 9: 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 10: 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
|
||||||
|
|
||||||
|
**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
|
||||||
|
|
||||||
|
## 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`
|
||||||
|
|
||||||
|
## 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,115 @@
|
|||||||
|
{
|
||||||
|
"$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",
|
||||||
|
"component"
|
||||||
|
],
|
||||||
|
"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",
|
||||||
|
"enum": ["widget", "daemon", "launcher", "desktop"]
|
||||||
|
},
|
||||||
|
"capabilities": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "Array of plugin capabilities",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"minItems": 1
|
||||||
|
},
|
||||||
|
"component": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Relative path to main QML component file",
|
||||||
|
"pattern": "^\\./.*\\.qml$"
|
||||||
|
},
|
||||||
|
"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$"
|
||||||
|
},
|
||||||
|
"requires_dms": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Minimum DMS version requirement (e.g., '>=0.1.18', '>0.1.0')",
|
||||||
|
"pattern": "^(>=?|<=?|=|>|<)\\d+\\.\\d+\\.\\d+$"
|
||||||
|
},
|
||||||
|
"requires": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "Array of required system tools/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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"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,176 @@
|
|||||||
|
# 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 |
|
||||||
|
|
||||||
|
## 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,119 @@
|
|||||||
|
# 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` |
|
||||||
|
| `capabilities` | array | Plugin capabilities | At least 1 string item |
|
||||||
|
| `component` | string | Path to main QML file | Must start with `./`, end with `.qml` |
|
||||||
|
|
||||||
|
## Conditional Requirements
|
||||||
|
|
||||||
|
| Condition | Required Field | Description |
|
||||||
|
|-----------|---------------|-------------|
|
||||||
|
| `type: "launcher"` | `trigger` | Trigger string for launcher activation (e.g., `=`, `#`, `!`) |
|
||||||
|
|
||||||
|
## 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`) |
|
||||||
|
| `requires_dms` | string | Minimum DMS version (e.g., `>=0.1.18`), pattern `^(>=?\|<=?\|=\|>\|<)\d+\.\d+\.\d+$` |
|
||||||
|
| `requires` | array | System tool dependencies (e.g., `["curl", "jq"]`) |
|
||||||
|
| `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
|
||||||
|
|
||||||
|
## 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",
|
||||||
|
"requires_dms": ">=0.1.18",
|
||||||
|
"requires": ["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,369 @@
|
|||||||
|
# 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: 5000 // check every 5 seconds
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -7,32 +7,32 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
## DankMaterialShell Bug Report
|
## DankMaterialShell Bug Report
|
||||||
Limit your report to one issue per submission unless closely related
|
Limit your report to one issue per submission unless similarly related
|
||||||
- type: checkboxes
|
- type: dropdown
|
||||||
id: compositor
|
id: compositor
|
||||||
attributes:
|
attributes:
|
||||||
label: Compositor
|
label: Compositor
|
||||||
options:
|
options:
|
||||||
- label: Niri
|
- Niri
|
||||||
- label: Hyprland
|
- Hyprland
|
||||||
- label: MangoWC (dwl)
|
- MangoWC (dwl)
|
||||||
- label: Sway
|
- Sway
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: checkboxes
|
- type: dropdown
|
||||||
id: distribution
|
id: distribution
|
||||||
attributes:
|
attributes:
|
||||||
label: Distribution
|
label: Distribution
|
||||||
options:
|
options:
|
||||||
- label: Arch Linux
|
- Arch Linux
|
||||||
- label: CachyOS
|
- CachyOS
|
||||||
- label: Fedora
|
- Fedora
|
||||||
- label: NixOS
|
- NixOS
|
||||||
- label: Debian
|
- Debian
|
||||||
- label: Ubuntu
|
- Ubuntu
|
||||||
- label: Gentoo
|
- Gentoo
|
||||||
- label: OpenSUSE
|
- OpenSUSE
|
||||||
- label: Other (specify below)
|
- Other (specify below)
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: input
|
- type: input
|
||||||
@@ -42,12 +42,45 @@ body:
|
|||||||
placeholder: e.g., PikaOS, Void Linux, etc.
|
placeholder: e.g., PikaOS, Void Linux, etc.
|
||||||
validations:
|
validations:
|
||||||
required: false
|
required: false
|
||||||
- type: input
|
- type: dropdown
|
||||||
id: dms_version
|
id: installation_method
|
||||||
attributes:
|
attributes:
|
||||||
label: dms version
|
label: Select your Installation Method
|
||||||
description: Output of dms version command
|
options:
|
||||||
placeholder: e.g., 1.2.3
|
- 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:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: textarea
|
- type: textarea
|
||||||
@@ -69,7 +102,7 @@ body:
|
|||||||
- type: textarea
|
- type: textarea
|
||||||
id: steps_to_reproduce
|
id: steps_to_reproduce
|
||||||
attributes:
|
attributes:
|
||||||
label: Steps to Reproduce & Installation Method
|
label: Steps to Reproduce
|
||||||
description: Please provide detailed steps to reproduce the issue
|
description: Please provide detailed steps to reproduce the issue
|
||||||
placeholder: |
|
placeholder: |
|
||||||
1. ...
|
1. ...
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
name: Feature Request
|
name: Feature Request
|
||||||
description: Suggest a new feature or improvement for DMS
|
description: Suggest a new feature or improvement for DMS. Keep features focused on a single topic with clear benefits, examples, etc. Avoid vague or broad requests, they will be closed.
|
||||||
labels:
|
labels:
|
||||||
- enhancement
|
- enhancement
|
||||||
body:
|
body:
|
||||||
@@ -23,18 +23,25 @@ body:
|
|||||||
placeholder: Why is this feature important?
|
placeholder: Why is this feature important?
|
||||||
validations:
|
validations:
|
||||||
required: false
|
required: false
|
||||||
- type: checkboxes
|
- type: dropdown
|
||||||
id: compositor
|
id: compositor
|
||||||
attributes:
|
attributes:
|
||||||
label: Compositor(s)
|
label: Compositor(s)
|
||||||
description: Is this feature specific to one or more compositors?
|
description: Is this feature specific to one or more compositors?
|
||||||
options:
|
options:
|
||||||
- label: All compositors
|
- All compositors
|
||||||
- label: Niri
|
- Niri
|
||||||
- label: Hyprland
|
- Hyprland
|
||||||
- label: MangoWC (dwl)
|
- MangoWC (dwl)
|
||||||
- label: Sway
|
- Sway
|
||||||
- label: Other (specify below)
|
- Other (specify below)
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: compositor_other
|
||||||
|
attributes:
|
||||||
|
label: If Other, please specify
|
||||||
|
placeholder: e.g., Wayfire, Mutter, etc.
|
||||||
validations:
|
validations:
|
||||||
required: false
|
required: false
|
||||||
- type: textarea
|
- type: textarea
|
||||||
|
|||||||
@@ -7,32 +7,87 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
## DankMaterialShell Support Request
|
## DankMaterialShell Support Request
|
||||||
- type: checkboxes
|
- type: dropdown
|
||||||
id: compositor
|
id: compositor
|
||||||
attributes:
|
attributes:
|
||||||
label: Compositor
|
label: Compositor
|
||||||
options:
|
options:
|
||||||
- label: Niri
|
- Niri
|
||||||
- label: Hyprland
|
- Hyprland
|
||||||
- label: MangoWC (dwl)
|
- MangoWC (dwl)
|
||||||
- label: Sway
|
- Sway
|
||||||
- label: Other (specify below)
|
- Other (specify below)
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: compositor_other
|
||||||
|
attributes:
|
||||||
|
label: If Other, please specify
|
||||||
|
placeholder: e.g., Wayfire, Mutter, etc.
|
||||||
validations:
|
validations:
|
||||||
required: false
|
required: false
|
||||||
- type: input
|
- type: dropdown
|
||||||
id: distribution
|
id: distribution
|
||||||
attributes:
|
attributes:
|
||||||
label: Distribution
|
label: Distribution
|
||||||
description: Which Linux distribution are you using? (e.g., Arch, Fedora, Debian, etc.)
|
options:
|
||||||
placeholder: Your Linux distribution
|
- 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:
|
validations:
|
||||||
required: false
|
required: false
|
||||||
- type: input
|
- type: input
|
||||||
id: dms_version
|
id: original_installation_method_specify
|
||||||
attributes:
|
attributes:
|
||||||
label: dms version
|
label: If no, specify
|
||||||
description: Output of dms version command
|
placeholder: e.g., Distro Packaging, then Source
|
||||||
placeholder: e.g., 1.2.3
|
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:
|
validations:
|
||||||
required: false
|
required: false
|
||||||
- type: textarea
|
- type: textarea
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -20,7 +20,7 @@ jobs:
|
|||||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
token: ${{ steps.app_token.outputs.token }}
|
token: ${{ steps.app_token.outputs.token }}
|
||||||
@@ -26,7 +26,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Install flatpak
|
- name: Install flatpak
|
||||||
run: sudo apt update && sudo apt install -y flatpak
|
run: sudo apt update && sudo apt install -y flatpak
|
||||||
@@ -38,7 +38,7 @@ jobs:
|
|||||||
run: sudo flatpak install -y org.freedesktop.Platform/x86_64/24.08 app.zen_browser.zen
|
run: sudo flatpak install -y org.freedesktop.Platform/x86_64/24.08 app.zen_browser.zen
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version-file: ./core/go.mod
|
go-version-file: ./core/go.mod
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Check nix flake
|
name: Nix flake and NixOS tests
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
@@ -9,15 +9,35 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
check-flake:
|
check-flake:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 120
|
||||||
steps:
|
steps:
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Install Nix
|
- name: Install Nix
|
||||||
uses: cachix/install-nix-action@v31
|
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
|
- 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
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Install flatpak
|
- name: Install flatpak
|
||||||
run: sudo apt update && sudo apt install -y flatpak
|
run: sudo apt update && sudo apt install -y flatpak
|
||||||
@@ -20,5 +20,10 @@ jobs:
|
|||||||
- name: Add a flatpak that mutagen could support
|
- name: Add a flatpak that mutagen could support
|
||||||
run: sudo flatpak install -y org.freedesktop.Platform/x86_64/24.08 app.zen_browser.zen
|
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
|
- name: run pre-commit hooks
|
||||||
uses: j178/prek-action@v1
|
uses: j178/prek-action@v1
|
||||||
|
|||||||
@@ -32,13 +32,13 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
ref: ${{ inputs.tag }}
|
ref: ${{ inputs.tag }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version-file: ./core/go.mod
|
go-version-file: ./core/go.mod
|
||||||
|
|
||||||
@@ -106,7 +106,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload artifacts (${{ matrix.arch }})
|
- name: Upload artifacts (${{ matrix.arch }})
|
||||||
if: matrix.arch == 'arm64'
|
if: matrix.arch == 'arm64'
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v5
|
||||||
with:
|
with:
|
||||||
name: core-assets-${{ matrix.arch }}
|
name: core-assets-${{ matrix.arch }}
|
||||||
path: |
|
path: |
|
||||||
@@ -120,7 +120,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload artifacts with completions
|
- name: Upload artifacts with completions
|
||||||
if: matrix.arch == 'amd64'
|
if: matrix.arch == 'amd64'
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v5
|
||||||
with:
|
with:
|
||||||
name: core-assets-${{ matrix.arch }}
|
name: core-assets-${{ matrix.arch }}
|
||||||
path: |
|
path: |
|
||||||
@@ -147,7 +147,7 @@ jobs:
|
|||||||
# private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
# private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||||
|
|
||||||
# - name: Checkout
|
# - name: Checkout
|
||||||
# uses: actions/checkout@v4
|
# uses: actions/checkout@v6
|
||||||
# with:
|
# with:
|
||||||
# token: ${{ steps.app_token.outputs.token }}
|
# token: ${{ steps.app_token.outputs.token }}
|
||||||
# fetch-depth: 0
|
# fetch-depth: 0
|
||||||
@@ -181,7 +181,7 @@ jobs:
|
|||||||
TAG: ${{ inputs.tag }}
|
TAG: ${{ inputs.tag }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
ref: ${{ inputs.tag }}
|
ref: ${{ inputs.tag }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -191,8 +191,13 @@ jobs:
|
|||||||
git fetch origin --force tag ${TAG}
|
git fetch origin --force tag ${TAG}
|
||||||
git checkout ${TAG}
|
git checkout ${TAG}
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v6
|
||||||
|
with:
|
||||||
|
go-version-file: ./core/go.mod
|
||||||
|
|
||||||
- name: Download core artifacts
|
- name: Download core artifacts
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v5
|
||||||
with:
|
with:
|
||||||
pattern: core-assets-*
|
pattern: core-assets-*
|
||||||
merge-multiple: true
|
merge-multiple: true
|
||||||
@@ -229,6 +234,7 @@ jobs:
|
|||||||
- **`dms-distropkg-arm64.gz`** - DMS CLI binary built with distro_package tag for ARM64 systems
|
- **`dms-distropkg-arm64.gz`** - DMS CLI binary built with distro_package tag for ARM64 systems
|
||||||
- **`dankinstall-amd64.gz`** - Installer binary for x86_64 systems
|
- **`dankinstall-amd64.gz`** - Installer binary for x86_64 systems
|
||||||
- **`dankinstall-arm64.gz`** - Installer binary for ARM64 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
|
- **`dms-qml.tar.gz`** - QML source code only
|
||||||
|
|
||||||
### Checksums
|
### Checksums
|
||||||
@@ -387,6 +393,19 @@ jobs:
|
|||||||
rm -rf _temp_full
|
rm -rf _temp_full
|
||||||
done
|
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
|
- name: Create GitHub Release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Determine version
|
- name: Determine version
|
||||||
id: version
|
id: version
|
||||||
@@ -134,7 +134,7 @@ jobs:
|
|||||||
rpm -qpi "$SRPM"
|
rpm -qpi "$SRPM"
|
||||||
|
|
||||||
- name: Upload SRPM artifact
|
- name: Upload SRPM artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v5
|
||||||
with:
|
with:
|
||||||
name: ${{ matrix.package }}-stable-srpm-${{ steps.version.outputs.version }}
|
name: ${{ matrix.package }}-stable-srpm-${{ steps.version.outputs.version }}
|
||||||
path: ${{ steps.build.outputs.srpm_path }}
|
path: ${{ steps.build.outputs.srpm_path }}
|
||||||
|
|||||||
+212
-93
@@ -4,19 +4,21 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
package:
|
package:
|
||||||
description: "Package to update (dms, dms-git, or all)"
|
description: "Package to update"
|
||||||
required: false
|
required: true
|
||||||
default: "all"
|
type: choice
|
||||||
tag_version:
|
options:
|
||||||
description: "Specific tag version for dms stable (e.g., v1.0.2). Leave empty to auto-detect latest release."
|
- dms
|
||||||
required: false
|
- dms-greeter
|
||||||
default: ""
|
- dms-git
|
||||||
|
- all
|
||||||
|
default: "dms"
|
||||||
rebuild_release:
|
rebuild_release:
|
||||||
description: "Release number for rebuilds (e.g., 2, 3, 4 to increment spec Release)"
|
description: "Release number for rebuilds (e.g., 2, 3, 4 to increment spec Release)"
|
||||||
required: false
|
required: false
|
||||||
default: ""
|
default: ""
|
||||||
schedule:
|
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:
|
jobs:
|
||||||
check-updates:
|
check-updates:
|
||||||
@@ -30,7 +32,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
@@ -56,8 +58,9 @@ jobs:
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Helper function to check dms stable tag
|
# Helper function to check dms stable tag
|
||||||
|
# Sets LATEST_TAG variable in parent scope if update needed
|
||||||
check_dms_stable() {
|
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_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 "")
|
local OBS_VERSION=$(echo "$OBS_SPEC" | grep "^Version:" | awk '{print $2}' | xargs || echo "")
|
||||||
|
|
||||||
@@ -70,12 +73,27 @@ jobs:
|
|||||||
fi
|
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
|
# Main logic
|
||||||
REBUILD="${{ github.event.inputs.rebuild_release }}"
|
REBUILD="${{ github.event.inputs.rebuild_release }}"
|
||||||
|
|
||||||
if [[ "${{ github.event_name }}" == "push" && "${{ github.ref }}" =~ ^refs/tags/ ]]; then
|
if [[ "${{ github.ref }}" =~ ^refs/tags/ ]] && [[ -z "${{ github.event.inputs.package }}" ]]; then
|
||||||
# Tag push - always update stable package
|
# Run from tag with no package specified - update both stable packages
|
||||||
echo "packages=dms" >> $GITHUB_OUTPUT
|
echo "packages=dms dms-greeter" >> $GITHUB_OUTPUT
|
||||||
VERSION="${GITHUB_REF#refs/tags/}"
|
VERSION="${GITHUB_REF#refs/tags/}"
|
||||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
echo "has_updates=true" >> $GITHUB_OUTPUT
|
||||||
@@ -101,10 +119,18 @@ jobs:
|
|||||||
echo "🔄 Manual rebuild requested: $PKG (db$REBUILD)"
|
echo "🔄 Manual rebuild requested: $PKG (db$REBUILD)"
|
||||||
|
|
||||||
elif [[ "$PKG" == "all" ]]; then
|
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=()
|
PACKAGES_TO_UPDATE=()
|
||||||
check_dms_git && PACKAGES_TO_UPDATE+=("dms-git")
|
if check_dms_stable; then
|
||||||
check_dms_stable && PACKAGES_TO_UPDATE+=("dms")
|
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
|
if [[ ${#PACKAGES_TO_UPDATE[@]} -gt 0 ]]; then
|
||||||
echo "packages=${PACKAGES_TO_UPDATE[*]}" >> $GITHUB_OUTPUT
|
echo "packages=${PACKAGES_TO_UPDATE[*]}" >> $GITHUB_OUTPUT
|
||||||
@@ -113,7 +139,7 @@ jobs:
|
|||||||
else
|
else
|
||||||
echo "packages=" >> $GITHUB_OUTPUT
|
echo "packages=" >> $GITHUB_OUTPUT
|
||||||
echo "has_updates=false" >> $GITHUB_OUTPUT
|
echo "has_updates=false" >> $GITHUB_OUTPUT
|
||||||
echo "✓ All packages up to date"
|
echo "✓ Both packages up to date"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
elif [[ "$PKG" == "dms-git" ]]; then
|
elif [[ "$PKG" == "dms-git" ]]; then
|
||||||
@@ -129,6 +155,21 @@ jobs:
|
|||||||
if check_dms_stable; then
|
if check_dms_stable; then
|
||||||
echo "packages=$PKG" >> $GITHUB_OUTPUT
|
echo "packages=$PKG" >> $GITHUB_OUTPUT
|
||||||
echo "has_updates=true" >> $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
|
else
|
||||||
echo "packages=" >> $GITHUB_OUTPUT
|
echo "packages=" >> $GITHUB_OUTPUT
|
||||||
echo "has_updates=false" >> $GITHUB_OUTPUT
|
echo "has_updates=false" >> $GITHUB_OUTPUT
|
||||||
@@ -154,19 +195,22 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Wait before OBS upload
|
||||||
|
run: sleep 3
|
||||||
|
|
||||||
- name: Determine packages to update
|
- name: Determine packages to update
|
||||||
id: packages
|
id: packages
|
||||||
run: |
|
run: |
|
||||||
if [[ "${{ github.event_name }}" == "push" && "${{ github.ref }}" =~ ^refs/tags/ ]]; then
|
# Use check-updates outputs when available
|
||||||
# Tag push event - use the pushed tag
|
if [[ -n "${{ needs.check-updates.outputs.version }}" ]]; then
|
||||||
echo "packages=dms" >> $GITHUB_OUTPUT
|
# Use version from check-updates job
|
||||||
VERSION="${GITHUB_REF#refs/tags/}"
|
echo "packages=${{ needs.check-updates.outputs.packages }}" >> $GITHUB_OUTPUT
|
||||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
echo "version=${{ needs.check-updates.outputs.version }}" >> $GITHUB_OUTPUT
|
||||||
echo "Triggered by tag: $VERSION"
|
echo "Using version from check-updates: ${{ needs.check-updates.outputs.version }}"
|
||||||
elif [[ "${{ github.event_name }}" == "schedule" ]]; then
|
elif [[ "${{ github.event_name }}" == "schedule" ]]; then
|
||||||
# Scheduled run - dms-git only
|
# Scheduled run - dms-git only
|
||||||
echo "packages=${{ needs.check-updates.outputs.packages }}" >> $GITHUB_OUTPUT
|
echo "packages=${{ needs.check-updates.outputs.packages }}" >> $GITHUB_OUTPUT
|
||||||
@@ -174,39 +218,21 @@ jobs:
|
|||||||
elif [[ -n "${{ github.event.inputs.package }}" ]]; then
|
elif [[ -n "${{ github.event.inputs.package }}" ]]; then
|
||||||
# Manual workflow dispatch
|
# Manual workflow dispatch
|
||||||
|
|
||||||
# Determine version for dms stable
|
# Determine version for dms stable and dms-greeter using the API
|
||||||
if [[ "${{ github.event.inputs.package }}" == "dms" ]]; then
|
# GITHUB_REF is unreliable when "Use workflow from" a tag; API works from any ref
|
||||||
# For explicit dms selection, require tag_version
|
if [[ "${{ github.event.inputs.package }}" == "dms" ]] || [[ "${{ github.event.inputs.package }}" == "dms-greeter" ]] || [[ "${{ github.event.inputs.package }}" == "all" ]]; then
|
||||||
if [[ -n "${{ github.event.inputs.tag_version }}" ]]; then
|
LATEST_TAG=$(curl -s https://api.github.com/repos/AvengeMedia/DankMaterialShell/releases/latest | grep '"tag_name"' | sed 's/.*"tag_name": "\([^"]*\)".*/\1/' || echo "")
|
||||||
VERSION="${{ github.event.inputs.tag_version }}"
|
if [[ -n "$LATEST_TAG" ]]; then
|
||||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
echo "version=$LATEST_TAG" >> $GITHUB_OUTPUT
|
||||||
echo "Using specified tag: $VERSION"
|
echo "Using latest release from API: $LATEST_TAG"
|
||||||
else
|
else
|
||||||
echo "ERROR: tag_version is required when package=dms"
|
echo "ERROR: Could not fetch latest release from API"
|
||||||
echo "Please specify a tag version (e.g., v1.0.2) or use package=all for auto-detection"
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
elif [[ "${{ github.event.inputs.package }}" == "all" ]]; then
|
|
||||||
# For "all", auto-detect if tag_version not specified
|
|
||||||
if [[ -n "${{ github.event.inputs.tag_version }}" ]]; then
|
|
||||||
VERSION="${{ github.event.inputs.tag_version }}"
|
|
||||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
|
||||||
echo "Using specified tag: $VERSION"
|
|
||||||
else
|
|
||||||
# Auto-detect latest release for "all"
|
|
||||||
LATEST_TAG=$(curl -s https://api.github.com/repos/AvengeMedia/DankMaterialShell/releases/latest | grep '"tag_name"' | sed 's/.*"tag_name": "\([^"]*\)".*/\1/' || echo "")
|
|
||||||
if [[ -n "$LATEST_TAG" ]]; then
|
|
||||||
echo "version=$LATEST_TAG" >> $GITHUB_OUTPUT
|
|
||||||
echo "Auto-detected latest release: $LATEST_TAG"
|
|
||||||
else
|
|
||||||
echo "ERROR: Could not auto-detect latest release"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Use filtered packages from check-updates when package="all" and no rebuild/tag specified
|
# Use filtered packages from check-updates when package="all" and no rebuild/tag specified
|
||||||
if [[ "${{ github.event.inputs.package }}" == "all" ]] && [[ -z "${{ github.event.inputs.rebuild_release }}" ]] && [[ -z "${{ github.event.inputs.tag_version }}" ]]; then
|
if [[ "${{ github.event.inputs.package }}" == "all" ]] && [[ -z "${{ github.event.inputs.rebuild_release }}" ]] && [[ ! "${{ github.ref }}" =~ ^refs/tags/ ]]; then
|
||||||
echo "packages=${{ needs.check-updates.outputs.packages }}" >> $GITHUB_OUTPUT
|
echo "packages=${{ needs.check-updates.outputs.packages }}" >> $GITHUB_OUTPUT
|
||||||
echo "Manual trigger: all (filtered to: ${{ needs.check-updates.outputs.packages }})"
|
echo "Manual trigger: all (filtered to: ${{ needs.check-updates.outputs.packages }})"
|
||||||
else
|
else
|
||||||
@@ -215,10 +241,13 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
echo "packages=${{ needs.check-updates.outputs.packages }}" >> $GITHUB_OUTPUT
|
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
|
fi
|
||||||
|
|
||||||
- name: Update dms-git spec version
|
- 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: |
|
run: |
|
||||||
COMMIT_HASH=$(git rev-parse --short=8 HEAD)
|
COMMIT_HASH=$(git rev-parse --short=8 HEAD)
|
||||||
COMMIT_COUNT=$(git rev-list --count HEAD)
|
COMMIT_COUNT=$(git rev-list --count HEAD)
|
||||||
@@ -239,7 +268,7 @@ jobs:
|
|||||||
} > distro/opensuse/dms-git.spec
|
} > distro/opensuse/dms-git.spec
|
||||||
|
|
||||||
- name: Update Debian dms-git changelog version
|
- 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: |
|
run: |
|
||||||
COMMIT_HASH=$(git rev-parse --short=8 HEAD)
|
COMMIT_HASH=$(git rev-parse --short=8 HEAD)
|
||||||
COMMIT_COUNT=$(git rev-list --count HEAD)
|
COMMIT_COUNT=$(git rev-list --count HEAD)
|
||||||
@@ -257,59 +286,70 @@ jobs:
|
|||||||
echo " -- Avenge Media <AvengeMedia.US@gmail.com> $CHANGELOG_DATE"
|
echo " -- Avenge Media <AvengeMedia.US@gmail.com> $CHANGELOG_DATE"
|
||||||
} > "distro/debian/dms-git/debian/changelog"
|
} > "distro/debian/dms-git/debian/changelog"
|
||||||
|
|
||||||
- name: Update dms stable version
|
- name: Update stable version (dms + dms-greeter)
|
||||||
if: steps.packages.outputs.version != ''
|
if: steps.packages.outputs.version != ''
|
||||||
run: |
|
run: |
|
||||||
VERSION="${{ steps.packages.outputs.version }}"
|
VERSION="${{ steps.packages.outputs.version }}"
|
||||||
VERSION_NO_V="${VERSION#v}"
|
VERSION_NO_V="${VERSION#v}"
|
||||||
|
PACKAGES="${{ steps.packages.outputs.packages }}"
|
||||||
echo "==> Updating packaging files to version: $VERSION_NO_V"
|
echo "==> Updating packaging files to version: $VERSION_NO_V"
|
||||||
|
|
||||||
# Update spec file
|
# Update dms spec and changelog when dms is in the upload list
|
||||||
sed -i "s/^Version:.*/Version: $VERSION_NO_V/" distro/opensuse/dms.spec
|
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"
|
||||||
|
|
||||||
# Verify the update
|
DATE_STR=$(date "+%a %b %d %Y")
|
||||||
UPDATED_VERSION=$(grep -oP '^Version:\s+\K[0-9.]+' distro/opensuse/dms.spec | head -1)
|
LOCAL_SPEC_HEAD=$(sed -n '1,/%changelog/{ /%changelog/d; p }' distro/opensuse/dms.spec)
|
||||||
echo "✓ Spec file now shows Version: $UPDATED_VERSION"
|
{
|
||||||
|
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
|
||||||
|
|
||||||
# Single changelog entry (full history on OBS website)
|
if [[ -f "distro/debian/dms/debian/changelog" ]]; then
|
||||||
DATE_STR=$(date "+%a %b %d %Y")
|
CHANGELOG_DATE=$(date -R)
|
||||||
LOCAL_SPEC_HEAD=$(sed -n '1,/%changelog/{ /%changelog/d; p }' distro/opensuse/dms.spec)
|
{
|
||||||
{
|
echo "dms (${VERSION_NO_V}db1) stable; urgency=medium"
|
||||||
echo "$LOCAL_SPEC_HEAD"
|
echo ""
|
||||||
echo "%changelog"
|
echo " * Update to $VERSION stable release"
|
||||||
echo "* $DATE_STR AvengeMedia <maintainer@avengemedia.com> - ${VERSION_NO_V}-1"
|
echo ""
|
||||||
echo "- Update to stable $VERSION release"
|
echo " -- Avenge Media <AvengeMedia.US@gmail.com> $CHANGELOG_DATE"
|
||||||
} > distro/opensuse/dms.spec
|
} > "distro/debian/dms/debian/changelog"
|
||||||
|
echo "✓ Updated dms changelog to ${VERSION_NO_V}db1"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# Update Debian _service files (both tar_scm and download_url formats)
|
# 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
|
for service in distro/debian/*/_service; do
|
||||||
if [[ -f "$service" ]]; then
|
if [[ -f "$service" ]]; then
|
||||||
# Update tar_scm revision parameter (for dms-git)
|
# Update tar_scm revision parameter (for dms-git)
|
||||||
sed -i "s|<param name=\"revision\">v[0-9.]*</param>|<param name=\"revision\">$VERSION</param>|" "$service"
|
sed -i "s|<param name=\"revision\">v[0-9.]*</param>|<param name=\"revision\">$VERSION</param>|" "$service"
|
||||||
|
# Update download_url paths (for dms, dms-greeter stable)
|
||||||
# Update download_url paths (for dms stable)
|
|
||||||
sed -i "s|/v[0-9.]\+/|/$VERSION/|g" "$service"
|
sed -i "s|/v[0-9.]\+/|/$VERSION/|g" "$service"
|
||||||
sed -i "s|/tags/v[0-9.]\+\.tar\.gz|/tags/$VERSION.tar.gz|g" "$service"
|
sed -i "s|/tags/v[0-9.]\+\.tar\.gz|/tags/$VERSION.tar.gz|g" "$service"
|
||||||
fi
|
fi
|
||||||
done
|
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}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 Debian changelog to ${VERSION_NO_V}db1"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version: "1.24"
|
go-version-file: ./core/go.mod
|
||||||
|
|
||||||
- name: Install OSC
|
- name: Install OSC
|
||||||
run: |
|
run: |
|
||||||
@@ -327,7 +367,18 @@ jobs:
|
|||||||
EOF
|
EOF
|
||||||
chmod 600 ~/.config/osc/oscrc
|
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
|
- name: Upload to OBS
|
||||||
|
id: upload
|
||||||
env:
|
env:
|
||||||
REBUILD_RELEASE: ${{ github.event.inputs.rebuild_release }}
|
REBUILD_RELEASE: ${{ github.event.inputs.rebuild_release }}
|
||||||
TAG_VERSION: ${{ steps.packages.outputs.version }}
|
TAG_VERSION: ${{ steps.packages.outputs.version }}
|
||||||
@@ -336,6 +387,8 @@ jobs:
|
|||||||
|
|
||||||
if [[ -z "$PACKAGES" ]]; then
|
if [[ -z "$PACKAGES" ]]; then
|
||||||
echo "✓ No packages need uploading. All up to date!"
|
echo "✓ No packages need uploading. All up to date!"
|
||||||
|
echo "uploaded_packages=" >> $GITHUB_OUTPUT
|
||||||
|
echo "skipped_packages=" >> $GITHUB_OUTPUT
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -345,7 +398,10 @@ jobs:
|
|||||||
echo "==> Version being uploaded: ${{ steps.packages.outputs.version }}"
|
echo "==> Version being uploaded: ${{ steps.packages.outputs.version }}"
|
||||||
fi
|
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
|
# Loop through each package and upload
|
||||||
for PKG in $PACKAGES; do
|
for PKG in $PACKAGES; do
|
||||||
echo ""
|
echo ""
|
||||||
@@ -356,13 +412,37 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
|
||||||
|
LOG_FILE=$(mktemp)
|
||||||
|
set +e
|
||||||
if [[ "$PKG" == "dms-git" ]]; then
|
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
|
else
|
||||||
bash distro/scripts/obs-upload.sh "$PKG" "$MESSAGE"
|
bash distro/scripts/obs-upload.sh "$PKG" "$MESSAGE" >"$LOG_FILE" 2>&1
|
||||||
fi
|
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
|
done
|
||||||
|
|
||||||
|
echo "uploaded_packages=${UPLOADED_PACKAGES[*]}" >> $GITHUB_OUTPUT
|
||||||
|
echo "skipped_packages=${SKIPPED_PACKAGES[*]}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Summary
|
- name: Summary
|
||||||
if: always()
|
if: always()
|
||||||
run: |
|
run: |
|
||||||
@@ -376,20 +456,59 @@ jobs:
|
|||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "All packages are current. Run completed successfully." >> $GITHUB_STEP_SUMMARY
|
echo "All packages are current. Run completed successfully." >> $GITHUB_STEP_SUMMARY
|
||||||
else
|
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
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
for PKG in $PACKAGES; do
|
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
|
case "$PKG" in
|
||||||
dms)
|
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)
|
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
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
if [[ -n "${{ github.event.inputs.rebuild_release }}" ]]; then
|
if [[ -n "${{ github.event.inputs.rebuild_release }}" ]]; then
|
||||||
|
|||||||
+79
-209
@@ -4,161 +4,100 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
package:
|
package:
|
||||||
description: "Package to upload (dms, dms-git, dms-greeter, or all)"
|
description: "Package to upload"
|
||||||
required: false
|
required: true
|
||||||
default: "dms-git"
|
type: choice
|
||||||
|
options:
|
||||||
|
- dms
|
||||||
|
- dms-greeter
|
||||||
|
- dms-git
|
||||||
|
- all
|
||||||
|
default: "dms"
|
||||||
rebuild_release:
|
rebuild_release:
|
||||||
description: "Release number for rebuilds (e.g., 2, 3, 4 for ppa2, ppa3, ppa4)"
|
description: "Release number for rebuilds (e.g., 2, 3, 4 for ppa2, ppa3, ppa4)"
|
||||||
required: false
|
required: false
|
||||||
default: ""
|
default: ""
|
||||||
schedule:
|
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:
|
jobs:
|
||||||
check-updates:
|
check-updates:
|
||||||
name: Check for updates
|
name: Check package/series updates
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
outputs:
|
outputs:
|
||||||
has_updates: ${{ steps.check.outputs.has_updates }}
|
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:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y jq curl git
|
||||||
|
|
||||||
- name: Check for updates
|
- name: Check for updates
|
||||||
id: check
|
id: check
|
||||||
run: |
|
run: |
|
||||||
# Helper function to check dms-git commit
|
chmod +x distro/scripts/ppa-sync-plan.sh
|
||||||
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"
|
|
||||||
# Use git ls-remote to find the latest tag, sorted by version (descending)
|
|
||||||
local LATEST_TAG=$(git ls-remote --tags --refs --sort='-v:refname' https://github.com/AvengeMedia/DankMaterialShell.git | head -n1 | awk -F/ '{print $NF}' | sed 's/^v//')
|
|
||||||
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 }}"
|
|
||||||
|
|
||||||
if [[ "${{ github.event_name }}" == "schedule" ]]; then
|
if [[ "${{ github.event_name }}" == "schedule" ]]; then
|
||||||
# Scheduled run - check dms-git only
|
PACKAGE="dms-git"
|
||||||
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
|
|
||||||
else
|
else
|
||||||
# Fallback
|
PACKAGE="${{ github.event.inputs.package }}"
|
||||||
echo "packages=dms-git" >> $GITHUB_OUTPUT
|
fi
|
||||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
|
||||||
|
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
|
fi
|
||||||
|
|
||||||
upload-ppa:
|
upload-ppa:
|
||||||
name: Upload to PPA
|
name: Upload ${{ matrix.target }}
|
||||||
needs: check-updates
|
needs: check-updates
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: needs.check-updates.outputs.has_updates == 'true'
|
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:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version: "1.24"
|
go-version-file: ./core/go.mod
|
||||||
cache: false
|
cache: false
|
||||||
|
|
||||||
- name: Install build dependencies
|
- name: Install build dependencies
|
||||||
@@ -171,7 +110,8 @@ jobs:
|
|||||||
lftp \
|
lftp \
|
||||||
build-essential \
|
build-essential \
|
||||||
fakeroot \
|
fakeroot \
|
||||||
dpkg-dev
|
dpkg-dev \
|
||||||
|
openssh-client
|
||||||
|
|
||||||
- name: Configure GPG
|
- name: Configure GPG
|
||||||
env:
|
env:
|
||||||
@@ -179,102 +119,32 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
echo "$GPG_KEY" | gpg --import
|
echo "$GPG_KEY" | gpg --import
|
||||||
GPG_KEY_ID=$(gpg --list-secret-keys --keyid-format LONG | grep sec | awk '{print $2}' | cut -d'/' -f2)
|
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
|
- name: Upload target
|
||||||
id: packages
|
env:
|
||||||
|
TARGET: ${{ matrix.target }}
|
||||||
|
LAUNCHPAD_SSH_PRIVATE_KEY: ${{ secrets.LAUNCHPAD_SSH_PRIVATE_KEY }}
|
||||||
|
LAUNCHPAD_SSH_LOGIN: ${{ secrets.LAUNCHPAD_SSH_LOGIN }}
|
||||||
run: |
|
run: |
|
||||||
# Use packages determined by check-updates job
|
IFS=':' read -r PACKAGE UBUNTU_SERIES PPA_NUM <<< "$TARGET"
|
||||||
echo "packages=${{ needs.check-updates.outputs.packages }}" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
if [[ "${{ github.event_name }}" == "schedule" ]]; then
|
case "$PACKAGE" in
|
||||||
echo "Triggered by schedule: uploading git package"
|
dms) PPA_NAME="dms" ;;
|
||||||
elif [[ -n "${{ github.event.inputs.package }}" ]]; then
|
dms-git) PPA_NAME="dms-git" ;;
|
||||||
echo "Manual trigger: ${{ needs.check-updates.outputs.packages }}"
|
dms-greeter) PPA_NAME="danklinux" ;;
|
||||||
fi
|
*) echo "::error::Unknown package $PACKAGE"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
- name: Upload to PPA
|
echo "Uploading $PACKAGE to $PPA_NAME/$UBUNTU_SERIES with ppa$PPA_NUM"
|
||||||
run: |
|
bash distro/scripts/ppa-upload.sh "$PACKAGE" "$PPA_NAME" "$UBUNTU_SERIES" "$PPA_NUM"
|
||||||
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
|
|
||||||
|
|
||||||
- name: Summary
|
- name: Summary
|
||||||
if: always()
|
if: always()
|
||||||
run: |
|
run: |
|
||||||
echo "### PPA Package Upload Summary" >> $GITHUB_STEP_SUMMARY
|
echo "### PPA Package Upload" >> "$GITHUB_STEP_SUMMARY"
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
echo "- **Target:** ${{ matrix.target }}" >> "$GITHUB_STEP_SUMMARY"
|
||||||
PACKAGES="${{ steps.packages.outputs.packages }}"
|
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"
|
||||||
if [[ -z "$PACKAGES" ]]; then
|
echo "- **DankLinux PPA:** https://launchpad.net/~avengemedia/+archive/ubuntu/danklinux/+packages" >> "$GITHUB_STEP_SUMMARY"
|
||||||
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
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ jobs:
|
|||||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
token: ${{ steps.app_token.outputs.token }}
|
token: ${{ steps.app_token.outputs.token }}
|
||||||
@@ -40,7 +40,7 @@ jobs:
|
|||||||
echo "Build succeeded, no hash update needed"
|
echo "Build succeeded, no hash update needed"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
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; }
|
[ -n "$new_hash" ] || { echo "Could not extract new vendorHash"; echo "$output"; exit 1; }
|
||||||
current_hash=$(grep -oP 'vendorHash = "\K[^"]+' flake.nix)
|
current_hash=$(grep -oP 'vendorHash = "\K[^"]+' flake.nix)
|
||||||
[ "$current_hash" = "$new_hash" ] && { echo "vendorHash already up to date"; exit 0; }
|
[ "$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 config user.email "dms-ci[bot]@users.noreply.github.com"
|
||||||
git add flake.nix
|
git add flake.nix
|
||||||
git commit -m "nix: update vendorHash for go.mod changes" || exit 0
|
git commit -m "nix: update vendorHash for go.mod changes" || exit 0
|
||||||
git pull --rebase origin master
|
git pull --rebase origin ${{ github.ref_name }}
|
||||||
git push https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git HEAD:master
|
git push https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git HEAD:${{ github.ref_name }}
|
||||||
else
|
else
|
||||||
echo "No changes to flake.nix"
|
echo "No changes to flake.nix"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ UNUSED
|
|||||||
|
|
||||||
CLAUDE-activeContext.md
|
CLAUDE-activeContext.md
|
||||||
CLAUDE-temp.md
|
CLAUDE-temp.md
|
||||||
|
AGENTS-activeContext.md
|
||||||
|
AGENTS-temp.md
|
||||||
|
|
||||||
# Auto-generated theme files
|
# Auto-generated theme files
|
||||||
*.generated.*
|
*.generated.*
|
||||||
|
|||||||
+21
-3
@@ -5,8 +5,26 @@ repos:
|
|||||||
- id: trailing-whitespace
|
- id: trailing-whitespace
|
||||||
- id: check-yaml
|
- id: check-yaml
|
||||||
- id: end-of-file-fixer
|
- id: end-of-file-fixer
|
||||||
- repo: https://github.com/shellcheck-py/shellcheck-py
|
- repo: local
|
||||||
rev: v0.10.0.1
|
|
||||||
hooks:
|
hooks:
|
||||||
- id: shellcheck
|
- 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: 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,5 +1,23 @@
|
|||||||
This file is more of a quick reference so I know what to account for before next releases.
|
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
|
# 1.2.0
|
||||||
|
|
||||||
- Added clipboard and clipboard history integration
|
- Added clipboard and clipboard history integration
|
||||||
|
|||||||
+38
-3
@@ -22,7 +22,7 @@ nix develop
|
|||||||
|
|
||||||
This will provide:
|
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
|
- Quickshell and required QML packages
|
||||||
- Properly configured QML2_IMPORT_PATH
|
- 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/)
|
1. Install the [QML Extension](https://doc.qt.io/vscodeext/)
|
||||||
2. Configure `ctrl+shift+p` -> user preferences (json) with qmlls path
|
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
|
```json
|
||||||
{
|
{
|
||||||
|
"[qml]": {
|
||||||
|
"editor.defaultFormatter": "qt-project.qmlls",
|
||||||
|
"editor.formatOnSave": true
|
||||||
|
},
|
||||||
"qt-qml.doNotAskForQmllsDownload": 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
|
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
|
### I18n/Localization
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ SHELL_INSTALL_DIR=$(DATA_DIR)/quickshell/dms
|
|||||||
ASSETS_DIR=assets
|
ASSETS_DIR=assets
|
||||||
APPLICATIONS_DIR=$(DATA_DIR)/applications
|
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
|
all: build
|
||||||
|
|
||||||
@@ -32,6 +32,9 @@ clean:
|
|||||||
@$(MAKE) -C $(CORE_DIR) clean
|
@$(MAKE) -C $(CORE_DIR) clean
|
||||||
@echo "Clean complete"
|
@echo "Clean complete"
|
||||||
|
|
||||||
|
lint-qml:
|
||||||
|
@./quickshell/scripts/qmllint-entrypoints.sh
|
||||||
|
|
||||||
# Installation targets
|
# Installation targets
|
||||||
install-bin:
|
install-bin:
|
||||||
@echo "Installing $(BINARY_NAME) to $(INSTALL_DIR)..."
|
@echo "Installing $(BINARY_NAME) to $(INSTALL_DIR)..."
|
||||||
@@ -43,7 +46,6 @@ install-shell:
|
|||||||
@mkdir -p $(SHELL_INSTALL_DIR)
|
@mkdir -p $(SHELL_INSTALL_DIR)
|
||||||
@cp -r $(SHELL_DIR)/* $(SHELL_INSTALL_DIR)/
|
@cp -r $(SHELL_DIR)/* $(SHELL_INSTALL_DIR)/
|
||||||
@rm -rf $(SHELL_INSTALL_DIR)/.git* $(SHELL_INSTALL_DIR)/.github
|
@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"
|
@echo "Shell files installed"
|
||||||
|
|
||||||
install-completions:
|
install-completions:
|
||||||
@@ -59,10 +61,10 @@ install-completions:
|
|||||||
install-systemd:
|
install-systemd:
|
||||||
@echo "Installing systemd user service..."
|
@echo "Installing systemd user service..."
|
||||||
@mkdir -p $(SYSTEMD_USER_DIR)
|
@mkdir -p $(SYSTEMD_USER_DIR)
|
||||||
@if [ -n "$(SUDO_USER)" ]; then chown -R $(SUDO_USER):$(SUDO_USER) $(SYSTEMD_USER_DIR); fi
|
@if [ -n "$(SUDO_USER)" ]; then chown -R $(SUDO_USER):"$(id -gn $SUDO_USER)" $(SYSTEMD_USER_DIR); fi
|
||||||
@sed 's|/usr/bin/dms|$(INSTALL_DIR)/dms|g' $(ASSETS_DIR)/systemd/dms.service > $(SYSTEMD_USER_DIR)/dms.service
|
@sed 's|/usr/bin/dms|$(INSTALL_DIR)/dms|g' $(ASSETS_DIR)/systemd/dms.service > $(SYSTEMD_USER_DIR)/dms.service
|
||||||
@chmod 644 $(SYSTEMD_USER_DIR)/dms.service
|
@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"
|
@echo "Systemd service installed to $(SYSTEMD_USER_DIR)/dms.service"
|
||||||
|
|
||||||
install-icon:
|
install-icon:
|
||||||
@@ -77,7 +79,7 @@ install-desktop:
|
|||||||
@update-desktop-database -q $(APPLICATIONS_DIR) 2>/dev/null || true
|
@update-desktop-database -q $(APPLICATIONS_DIR) 2>/dev/null || true
|
||||||
@echo "Desktop entry installed"
|
@echo "Desktop entry 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 ""
|
||||||
@echo "Installation complete!"
|
@echo "Installation complete!"
|
||||||
@echo ""
|
@echo ""
|
||||||
@@ -131,6 +133,7 @@ help:
|
|||||||
@echo " all (default) - Build the DMS binary"
|
@echo " all (default) - Build the DMS binary"
|
||||||
@echo " build - Same as 'all'"
|
@echo " build - Same as 'all'"
|
||||||
@echo " clean - Clean build artifacts"
|
@echo " clean - Clean build artifacts"
|
||||||
|
@echo " lint-qml - Run qmllint on shell entrypoints using the Quickshell tooling VFS"
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "Install:"
|
@echo "Install:"
|
||||||
@echo " install - Build and install everything (requires sudo)"
|
@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/stargazers)
|
||||||
[](https://github.com/AvengeMedia/DankMaterialShell/blob/master/LICENSE)
|
[](https://github.com/AvengeMedia/DankMaterialShell/blob/master/LICENSE)
|
||||||
[](https://github.com/AvengeMedia/DankMaterialShell/releases)
|
[](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://aur.archlinux.org/packages/dms-shell-git)
|
||||||
[](https://ko-fi.com/danklinux)
|
[](https://ko-fi.com/danklinux)
|
||||||
|
|
||||||
</div>
|
</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
|
## Repository Structure
|
||||||
|
|
||||||
@@ -105,7 +105,7 @@ Extend functionality with the [plugin registry](https://plugins.danklinux.com).
|
|||||||
|
|
||||||
## Supported Compositors
|
## 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)
|
[Compositor configuration guide](https://danklinux.com/docs/dankmaterialshell/compositors)
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ packages:
|
|||||||
outpkg: mocks_brightness
|
outpkg: mocks_brightness
|
||||||
interfaces:
|
interfaces:
|
||||||
DBusConn:
|
DBusConn:
|
||||||
|
github.com/AvengeMedia/DankMaterialShell/core/internal/geolocation:
|
||||||
|
config:
|
||||||
|
dir: "internal/mocks/geolocation"
|
||||||
|
outpkg: mocks_geolocation
|
||||||
|
interfaces:
|
||||||
|
Client:
|
||||||
github.com/AvengeMedia/DankMaterialShell/core/internal/server/network:
|
github.com/AvengeMedia/DankMaterialShell/core/internal/server/network:
|
||||||
config:
|
config:
|
||||||
dir: "internal/mocks/network"
|
dir: "internal/mocks/network"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/golangci/golangci-lint
|
- repo: https://github.com/golangci/golangci-lint
|
||||||
rev: v2.6.2
|
rev: v2.10.1
|
||||||
hooks:
|
hooks:
|
||||||
- id: golangci-lint-fmt
|
- id: golangci-lint-fmt
|
||||||
require_serial: true
|
require_serial: true
|
||||||
|
|||||||
+3
-3
@@ -63,19 +63,19 @@ endif
|
|||||||
|
|
||||||
build-all: build dankinstall
|
build-all: build dankinstall
|
||||||
|
|
||||||
install: build
|
install:
|
||||||
@echo "Installing $(BINARY_NAME) to $(INSTALL_DIR)..."
|
@echo "Installing $(BINARY_NAME) to $(INSTALL_DIR)..."
|
||||||
@install -D -m 755 $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_DIR)/$(BINARY_NAME)
|
@install -D -m 755 $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_DIR)/$(BINARY_NAME)
|
||||||
@echo "Installation complete"
|
@echo "Installation complete"
|
||||||
|
|
||||||
install-all: build-all
|
install-all:
|
||||||
@echo "Installing $(BINARY_NAME) to $(INSTALL_DIR)..."
|
@echo "Installing $(BINARY_NAME) to $(INSTALL_DIR)..."
|
||||||
@install -D -m 755 $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_DIR)/$(BINARY_NAME)
|
@install -D -m 755 $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_DIR)/$(BINARY_NAME)
|
||||||
@echo "Installing $(BINARY_NAME_INSTALL) to $(INSTALL_DIR)..."
|
@echo "Installing $(BINARY_NAME_INSTALL) to $(INSTALL_DIR)..."
|
||||||
@install -D -m 755 $(BUILD_DIR)/$(BINARY_NAME_INSTALL) $(INSTALL_DIR)/$(BINARY_NAME_INSTALL)
|
@install -D -m 755 $(BUILD_DIR)/$(BINARY_NAME_INSTALL) $(INSTALL_DIR)/$(BINARY_NAME_INSTALL)
|
||||||
@echo "Installation complete"
|
@echo "Installation complete"
|
||||||
|
|
||||||
install-dankinstall: dankinstall
|
install-dankinstall:
|
||||||
@echo "Installing $(BINARY_NAME_INSTALL) to $(INSTALL_DIR)..."
|
@echo "Installing $(BINARY_NAME_INSTALL) to $(INSTALL_DIR)..."
|
||||||
@install -D -m 755 $(BUILD_DIR)/$(BINARY_NAME_INSTALL) $(INSTALL_DIR)/$(BINARY_NAME_INSTALL)
|
@install -D -m 755 $(BUILD_DIR)/$(BINARY_NAME_INSTALL) $(INSTALL_DIR)/$(BINARY_NAME_INSTALL)
|
||||||
@echo "Installation complete"
|
@echo "Installation complete"
|
||||||
|
|||||||
+42
-2
@@ -10,7 +10,7 @@ Go-based backend for DankMaterialShell providing system integration, IPC, and in
|
|||||||
Command-line interface and daemon for shell management and system control.
|
Command-line interface and daemon for shell management and system control.
|
||||||
|
|
||||||
**dankinstall**
|
**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, and Gentoo. Supports both an interactive TUI and a headless (unattended) mode via CLI flags.
|
||||||
|
|
||||||
## System Integration
|
## System Integration
|
||||||
|
|
||||||
@@ -96,7 +96,7 @@ The on-screen preview displays the selected format. JSON output includes hex, RG
|
|||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
Requires Go 1.24+
|
Requires Go 1.25+
|
||||||
|
|
||||||
**Development build:**
|
**Development build:**
|
||||||
|
|
||||||
@@ -147,10 +147,50 @@ go-wayland-scanner -i internal/proto/xml/wlr-gamma-control-unstable-v1.xml \
|
|||||||
|
|
||||||
## Installation via dankinstall
|
## Installation via dankinstall
|
||||||
|
|
||||||
|
**Interactive (TUI):**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://install.danklinux.com | sh
|
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
|
## Supported Distributions
|
||||||
|
|
||||||
Arch, Fedora, Debian, Ubuntu, openSUSE, Gentoo (and derivatives)
|
Arch, Fedora, Debian, Ubuntu, openSUSE, Gentoo (and derivatives)
|
||||||
|
|||||||
@@ -3,20 +3,152 @@ package main
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/headless"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/tui"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/tui"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
var Version = "dev"
|
var Version = "dev"
|
||||||
|
|
||||||
|
// Flag variables bound via pflag
|
||||||
|
var (
|
||||||
|
compositor string
|
||||||
|
term string
|
||||||
|
includeDeps []string
|
||||||
|
excludeDeps []string
|
||||||
|
replaceConfigs []string
|
||||||
|
replaceConfigsAll bool
|
||||||
|
yes 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 or hyprland (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")
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if os.Getuid() == 0 {
|
if os.Getuid() == 0 {
|
||||||
fmt.Fprintln(os.Stderr, "Error: dankinstall must not be run as root")
|
fmt.Fprintln(os.Stderr, "Error: dankinstall must not be run as root")
|
||||||
os.Exit(1)
|
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",
|
||||||
|
}
|
||||||
|
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 or hyprland)")
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
fileLogger, err := log.NewFileLogger()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Warning: Failed to create log file: %v\n", err)
|
fmt.Printf("Warning: Failed to create log file: %v\n", err)
|
||||||
@@ -38,18 +170,50 @@ func main() {
|
|||||||
|
|
||||||
if fileLogger != nil {
|
if fileLogger != nil {
|
||||||
fileLogger.StartListening(model.GetLogChan())
|
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())
|
p := tea.NewProgram(model, tea.WithAltScreen())
|
||||||
if _, err := p.Run(); err != nil {
|
if _, err := p.Run(); err != nil {
|
||||||
fmt.Printf("Error running program: %v\n", err)
|
|
||||||
if logFilePath != "" {
|
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 != "" {
|
if logFilePath != "" {
|
||||||
fmt.Printf("\nFull logs are available at: %s\n", 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,77 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"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)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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)")
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -236,6 +236,7 @@ func runBrightnessSet(cmd *cobra.Command, args []string) {
|
|||||||
defer ddc.Close()
|
defer ddc.Close()
|
||||||
time.Sleep(100 * time.Millisecond)
|
time.Sleep(100 * time.Millisecond)
|
||||||
if err := ddc.SetBrightnessWithExponent(deviceID, percent, exponential, exponent, nil); err == nil {
|
if err := ddc.SetBrightnessWithExponent(deviceID, percent, exponential, exponent, nil); err == nil {
|
||||||
|
ddc.WaitPending()
|
||||||
fmt.Printf("Set %s to %d%%\n", deviceID, percent)
|
fmt.Printf("Set %s to %d%%\n", deviceID, percent)
|
||||||
return
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,17 +12,21 @@ import (
|
|||||||
_ "image/jpeg"
|
_ "image/jpeg"
|
||||||
_ "image/png"
|
_ "image/png"
|
||||||
"io"
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
bolt "go.etcd.io/bbolt"
|
bolt "go.etcd.io/bbolt"
|
||||||
_ "golang.org/x/image/bmp"
|
_ "golang.org/x/image/bmp"
|
||||||
_ "golang.org/x/image/tiff"
|
_ "golang.org/x/image/tiff"
|
||||||
|
_ "golang.org/x/image/webp"
|
||||||
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||||
@@ -48,6 +52,7 @@ var (
|
|||||||
clipCopyForeground bool
|
clipCopyForeground bool
|
||||||
clipCopyPasteOnce bool
|
clipCopyPasteOnce bool
|
||||||
clipCopyType string
|
clipCopyType string
|
||||||
|
clipCopyDownload bool
|
||||||
clipJSONOutput bool
|
clipJSONOutput bool
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -107,6 +112,7 @@ var clipClearCmd = &cobra.Command{
|
|||||||
}
|
}
|
||||||
|
|
||||||
var clipWatchStore bool
|
var clipWatchStore bool
|
||||||
|
var clipWatchMimes bool
|
||||||
|
|
||||||
var clipSearchCmd = &cobra.Command{
|
var clipSearchCmd = &cobra.Command{
|
||||||
Use: "search [query]",
|
Use: "search [query]",
|
||||||
@@ -184,11 +190,12 @@ func init() {
|
|||||||
clipCopyCmd.Flags().BoolVarP(&clipCopyForeground, "foreground", "f", false, "Stay in foreground instead of forking")
|
clipCopyCmd.Flags().BoolVarP(&clipCopyForeground, "foreground", "f", false, "Stay in foreground instead of forking")
|
||||||
clipCopyCmd.Flags().BoolVarP(&clipCopyPasteOnce, "paste-once", "o", false, "Exit after first paste")
|
clipCopyCmd.Flags().BoolVarP(&clipCopyPasteOnce, "paste-once", "o", false, "Exit after first paste")
|
||||||
clipCopyCmd.Flags().StringVarP(&clipCopyType, "type", "t", "text/plain;charset=utf-8", "MIME type")
|
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")
|
clipWatchCmd.Flags().BoolVar(&clipJSONOutput, "json", false, "Output as JSON")
|
||||||
clipHistoryCmd.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().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(&clipSearchLimit, "limit", "l", 50, "Max results")
|
||||||
clipSearchCmd.Flags().IntVarP(&clipSearchOffset, "offset", "o", 0, "Result offset")
|
clipSearchCmd.Flags().IntVarP(&clipSearchOffset, "offset", "o", 0, "Result offset")
|
||||||
@@ -205,6 +212,7 @@ func init() {
|
|||||||
clipConfigSetCmd.Flags().BoolVar(&clipConfigEnabled, "enable", false, "Enable 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(&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")
|
clipMigrateCmd.Flags().BoolVar(&clipMigrateDelete, "delete", false, "Delete cliphist db after successful migration")
|
||||||
|
|
||||||
@@ -214,15 +222,49 @@ func init() {
|
|||||||
|
|
||||||
func runClipCopy(cmd *cobra.Command, args []string) {
|
func runClipCopy(cmd *cobra.Command, args []string) {
|
||||||
var data []byte
|
var data []byte
|
||||||
|
copyFromStdin := false
|
||||||
|
|
||||||
if len(args) > 0 {
|
switch {
|
||||||
|
case len(args) > 0:
|
||||||
data = []byte(args[0])
|
data = []byte(args[0])
|
||||||
} else {
|
case clipCopyDownload || clipCopyType == "__multi__":
|
||||||
var err error
|
var err error
|
||||||
data, err = io.ReadAll(os.Stdin)
|
data, err = io.ReadAll(os.Stdin)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("read stdin: %v", err)
|
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 {
|
if err := clipboard.CopyOpts(data, clipCopyType, clipCopyForeground, clipCopyPasteOnce); err != nil {
|
||||||
@@ -230,6 +272,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) {
|
func runClipPaste(cmd *cobra.Command, args []string) {
|
||||||
data, _, err := clipboard.Paste()
|
data, _, err := clipboard.Paste()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -264,6 +340,30 @@ func runClipWatch(cmd *cobra.Command, args []string) {
|
|||||||
}); err != nil && err != context.Canceled {
|
}); err != nil && err != context.Canceled {
|
||||||
log.Fatalf("Watch error: %v", err)
|
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:
|
case clipJSONOutput:
|
||||||
if err := clipboard.Watch(ctx, func(data []byte, mimeType string) {
|
if err := clipboard.Watch(ctx, func(data []byte, mimeType string) {
|
||||||
out := map[string]any{
|
out := map[string]any{
|
||||||
@@ -386,16 +486,13 @@ func runClipGet(cmd *cobra.Command, args []string) {
|
|||||||
req := models.Request{
|
req := models.Request{
|
||||||
ID: 1,
|
ID: 1,
|
||||||
Method: "clipboard.copyEntry",
|
Method: "clipboard.copyEntry",
|
||||||
Params: map[string]any{
|
Params: map[string]any{"id": id},
|
||||||
"id": id,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := sendServerRequest(req)
|
resp, err := sendServerRequest(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to copy clipboard entry: %v", err)
|
log.Fatalf("Failed to copy clipboard entry: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if resp.Error != "" {
|
if resp.Error != "" {
|
||||||
log.Fatalf("Error: %s", resp.Error)
|
log.Fatalf("Error: %s", resp.Error)
|
||||||
}
|
}
|
||||||
@@ -672,7 +769,7 @@ func runClipExport(cmd *cobra.Command, args []string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.WriteFile(args[0], out, 0644); err != nil {
|
if err := os.WriteFile(args[0], out, 0o644); err != nil {
|
||||||
log.Fatalf("Failed to write file: %v", err)
|
log.Fatalf("Failed to write file: %v", err)
|
||||||
}
|
}
|
||||||
fmt.Printf("Exported to %s\n", args[0])
|
fmt.Printf("Exported to %s\n", args[0])
|
||||||
@@ -727,7 +824,7 @@ func runClipMigrate(cmd *cobra.Command, args []string) {
|
|||||||
log.Fatalf("Cliphist db not found: %s", dbPath)
|
log.Fatalf("Cliphist db not found: %s", dbPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
db, err := bolt.Open(dbPath, 0644, &bolt.Options{
|
db, err := bolt.Open(dbPath, 0o644, &bolt.Options{
|
||||||
ReadOnly: true,
|
ReadOnly: true,
|
||||||
Timeout: 1 * time.Second,
|
Timeout: 1 * time.Second,
|
||||||
})
|
})
|
||||||
@@ -795,3 +892,113 @@ func detectMimeType(data []byte) string {
|
|||||||
func btoi(v []byte) uint64 {
|
func btoi(v []byte) uint64 {
|
||||||
return binary.BigEndian.Uint64(v)
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ Output format flags (mutually exclusive, default: --hex):
|
|||||||
--cmyk - CMYK values (C% M% Y% K%)
|
--cmyk - CMYK values (C% M% Y% K%)
|
||||||
--json - JSON with all formats
|
--json - JSON with all formats
|
||||||
|
|
||||||
|
Optional:
|
||||||
|
--raw - Removes ANSI escape codes and background colors. Use this when piping to other commands
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
dms color pick # Pick color, output as hex
|
dms color pick # Pick color, output as hex
|
||||||
dms color pick --rgb # Output as RGB
|
dms color pick --rgb # Output as RGB
|
||||||
@@ -53,6 +56,7 @@ func init() {
|
|||||||
colorPickCmd.Flags().Bool("hsv", false, "Output as HSV (H S% V%)")
|
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("cmyk", false, "Output as CMYK (C% M% Y% K%)")
|
||||||
colorPickCmd.Flags().Bool("json", false, "Output all formats as JSON")
|
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().StringVarP(&colorOutputFmt, "output-format", "o", "", "Custom output format template")
|
||||||
colorPickCmd.Flags().BoolVarP(&colorAutocopy, "autocopy", "a", false, "Copy result to clipboard")
|
colorPickCmd.Flags().BoolVarP(&colorAutocopy, "autocopy", "a", false, "Copy result to clipboard")
|
||||||
colorPickCmd.Flags().BoolVarP(&colorLowercase, "lowercase", "l", false, "Output hex in lowercase")
|
colorPickCmd.Flags().BoolVarP(&colorLowercase, "lowercase", "l", false, "Output hex in lowercase")
|
||||||
@@ -113,7 +117,15 @@ func runColorPick(cmd *cobra.Command, args []string) {
|
|||||||
|
|
||||||
if jsonOutput {
|
if jsonOutput {
|
||||||
fmt.Println(output)
|
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)
|
fmt.Printf("\033[48;2;%d;%d;%dm\033[97m %s \033[0m\n", color.R, color.G, color.B, output)
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf("\033[48;2;%d;%d;%dm\033[30m %s \033[0m\n", color.R, color.G, color.B, output)
|
fmt.Printf("\033[48;2;%d;%d;%dm\033[30m %s \033[0m\n", color.R, color.G, color.B, output)
|
||||||
|
|||||||
@@ -26,6 +26,17 @@ var runCmd = &cobra.Command{
|
|||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
daemon, _ := cmd.Flags().GetBool("daemon")
|
daemon, _ := cmd.Flags().GetBool("daemon")
|
||||||
session, _ := cmd.Flags().GetBool("session")
|
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()
|
||||||
if daemon {
|
if daemon {
|
||||||
runShellDaemon(session)
|
runShellDaemon(session)
|
||||||
} else {
|
} else {
|
||||||
@@ -64,10 +75,8 @@ var killCmd = &cobra.Command{
|
|||||||
}
|
}
|
||||||
|
|
||||||
var ipcCmd = &cobra.Command{
|
var ipcCmd = &cobra.Command{
|
||||||
Use: "ipc",
|
Use: "ipc [target] [function] [args...]",
|
||||||
Short: "Send IPC commands to running DMS shell",
|
Short: "Send IPC commands to running DMS shell",
|
||||||
Long: "Send IPC commands to running DMS shell (qs -c dms ipc <args>)",
|
|
||||||
PreRunE: findConfig,
|
|
||||||
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||||
_ = findConfig(cmd, args)
|
_ = findConfig(cmd, args)
|
||||||
return getShellIPCCompletions(args, toComplete), cobra.ShellCompDirectiveNoFileComp
|
return getShellIPCCompletions(args, toComplete), cobra.ShellCompDirectiveNoFileComp
|
||||||
@@ -77,6 +86,13 @@ var ipcCmd = &cobra.Command{
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
ipcCmd.SetHelpFunc(func(cmd *cobra.Command, args []string) {
|
||||||
|
_ = findConfig(cmd, args)
|
||||||
|
printIPCHelp()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
var debugSrvCmd = &cobra.Command{
|
var debugSrvCmd = &cobra.Command{
|
||||||
Use: "debug-srv",
|
Use: "debug-srv",
|
||||||
Short: "Start the debug server",
|
Short: "Start the debug server",
|
||||||
@@ -511,9 +527,17 @@ func getCommonCommands() []*cobra.Command {
|
|||||||
colorCmd,
|
colorCmd,
|
||||||
screenshotCmd,
|
screenshotCmd,
|
||||||
notifyActionCmd,
|
notifyActionCmd,
|
||||||
|
notifyCmd,
|
||||||
|
genericNotifyActionCmd,
|
||||||
matugenCmd,
|
matugenCmd,
|
||||||
clipboardCmd,
|
clipboardCmd,
|
||||||
|
chromaCmd,
|
||||||
doctorCmd,
|
doctorCmd,
|
||||||
configCmd,
|
configCmd,
|
||||||
|
dlCmd,
|
||||||
|
randrCmd,
|
||||||
|
blurCmd,
|
||||||
|
trashCmd,
|
||||||
|
systemCmd,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+271
-67
@@ -11,6 +11,8 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/blur"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/brightness"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/brightness"
|
||||||
@@ -81,12 +83,15 @@ func (ds *DoctorStatus) OKCount() int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
quickshellVersionRegex = regexp.MustCompile(`quickshell (\d+\.\d+\.\d+)`)
|
quickshellVersionRegex = regexp.MustCompile(`(?i)quickshell (\d+\.\d+\.\d+)`)
|
||||||
hyprlandVersionRegex = regexp.MustCompile(`v?(\d+\.\d+\.\d+)`)
|
hyprlandVersionRegex = regexp.MustCompile(`v?(\d+\.\d+\.\d+)`)
|
||||||
niriVersionRegex = regexp.MustCompile(`niri (\d+\.\d+)`)
|
niriVersionRegex = regexp.MustCompile(`niri (\d+\.\d+)`)
|
||||||
swayVersionRegex = regexp.MustCompile(`sway version (\d+\.\d+)`)
|
swayVersionRegex = regexp.MustCompile(`sway version (\d+\.\d+)`)
|
||||||
riverVersionRegex = regexp.MustCompile(`river (\d+\.\d+)`)
|
riverVersionRegex = regexp.MustCompile(`river (\d+\.\d+)`)
|
||||||
wayfireVersionRegex = regexp.MustCompile(`wayfire (\d+\.\d+)`)
|
wayfireVersionRegex = regexp.MustCompile(`wayfire (\d+\.\d+)`)
|
||||||
|
labwcVersionRegex = regexp.MustCompile(`labwc (\d+\.\d+\.\d+)`)
|
||||||
|
mangowcVersionRegex = regexp.MustCompile(`mango (\d+\.\d+\.\d+)`)
|
||||||
|
miracleVersionRegex = regexp.MustCompile(`miracle-wm v?(\d+\.\d+\.\d+)`)
|
||||||
)
|
)
|
||||||
|
|
||||||
var doctorCmd = &cobra.Command{
|
var doctorCmd = &cobra.Command{
|
||||||
@@ -99,11 +104,13 @@ var doctorCmd = &cobra.Command{
|
|||||||
var (
|
var (
|
||||||
doctorVerbose bool
|
doctorVerbose bool
|
||||||
doctorJSON bool
|
doctorJSON bool
|
||||||
|
doctorCopy bool
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
doctorCmd.Flags().BoolVarP(&doctorVerbose, "verbose", "v", false, "Show detailed output including paths and versions")
|
doctorCmd.Flags().BoolVarP(&doctorVerbose, "verbose", "v", false, "Show detailed output including paths and versions")
|
||||||
doctorCmd.Flags().BoolVarP(&doctorJSON, "json", "j", false, "Output results in JSON format")
|
doctorCmd.Flags().BoolVarP(&doctorJSON, "json", "j", false, "Output results in JSON format")
|
||||||
|
doctorCmd.Flags().BoolVarP(&doctorCopy, "copy", "C", false, "Copy results to clipboard in GitHub-friendly format")
|
||||||
}
|
}
|
||||||
|
|
||||||
type category int
|
type category int
|
||||||
@@ -190,7 +197,7 @@ func (r checkResult) toJSON() checkResultJSON {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func runDoctor(cmd *cobra.Command, args []string) {
|
func runDoctor(cmd *cobra.Command, args []string) {
|
||||||
if !doctorJSON {
|
if !doctorJSON && !doctorCopy {
|
||||||
printDoctorHeader()
|
printDoctorHeader()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,9 +215,17 @@ func runDoctor(cmd *cobra.Command, args []string) {
|
|||||||
checkEnvironmentVars(),
|
checkEnvironmentVars(),
|
||||||
)
|
)
|
||||||
|
|
||||||
if doctorJSON {
|
switch {
|
||||||
|
case doctorCopy:
|
||||||
|
text := formatResultsPlain(results)
|
||||||
|
if err := clipboard.CopyOpts([]byte(text), "text/plain;charset=utf-8", false, false); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Failed to copy to clipboard: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Println("Doctor report copied to clipboard")
|
||||||
|
case doctorJSON:
|
||||||
printResultsJSON(results)
|
printResultsJSON(results)
|
||||||
} else {
|
default:
|
||||||
printResults(results)
|
printResults(results)
|
||||||
printSummary(results, qsMissingFeatures)
|
printSummary(results, qsMissingFeatures)
|
||||||
}
|
}
|
||||||
@@ -448,11 +463,14 @@ func checkWindowManagers() []checkResult {
|
|||||||
versionRegex *regexp.Regexp
|
versionRegex *regexp.Regexp
|
||||||
commands []string
|
commands []string
|
||||||
}{
|
}{
|
||||||
{"Hyprland", "hyprctl", "version", hyprlandVersionRegex, []string{"hyprland", "Hyprland"}},
|
{"Hyprland", "Hyprland", "--version", hyprlandVersionRegex, []string{"hyprland", "Hyprland"}},
|
||||||
{"niri", "niri", "--version", niriVersionRegex, []string{"niri"}},
|
{"niri", "niri", "--version", niriVersionRegex, []string{"niri"}},
|
||||||
{"Sway", "sway", "--version", swayVersionRegex, []string{"sway"}},
|
{"Sway", "sway", "--version", swayVersionRegex, []string{"sway"}},
|
||||||
{"River", "river", "-version", riverVersionRegex, []string{"river"}},
|
{"River", "river", "-version", riverVersionRegex, []string{"river"}},
|
||||||
{"Wayfire", "wayfire", "--version", wayfireVersionRegex, []string{"wayfire"}},
|
{"Wayfire", "wayfire", "--version", wayfireVersionRegex, []string{"wayfire"}},
|
||||||
|
{"labwc", "labwc", "--version", labwcVersionRegex, []string{"labwc"}},
|
||||||
|
{"mangowc", "mango", "-v", mangowcVersionRegex, []string{"mango"}},
|
||||||
|
{"Miracle WM", "miracle-wm", "--version", miracleVersionRegex, []string{"miracle-wm"}},
|
||||||
}
|
}
|
||||||
|
|
||||||
var results []checkResult
|
var results []checkResult
|
||||||
@@ -477,7 +495,7 @@ func checkWindowManagers() []checkResult {
|
|||||||
results = append(results, checkResult{
|
results = append(results, checkResult{
|
||||||
catCompositor, c.name, statusOK,
|
catCompositor, c.name, statusOK,
|
||||||
getVersionFromCommand(c.versionCmd, c.versionArg, c.versionRegex), details,
|
getVersionFromCommand(c.versionCmd, c.versionArg, c.versionRegex), details,
|
||||||
doctorDocsURL + "#compositor",
|
doctorDocsURL + "#compositor-checks",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -485,8 +503,8 @@ func checkWindowManagers() []checkResult {
|
|||||||
results = append(results, checkResult{
|
results = append(results, checkResult{
|
||||||
catCompositor, "Compositor", statusError,
|
catCompositor, "Compositor", statusError,
|
||||||
"No supported Wayland compositor found",
|
"No supported Wayland compositor found",
|
||||||
"Install Hyprland, niri, Sway, River, or Wayfire",
|
"Install Hyprland, niri, Sway, River, Wayfire, or miracle-wm",
|
||||||
doctorDocsURL + "#compositor",
|
doctorDocsURL + "#compositor-checks",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -494,12 +512,27 @@ func checkWindowManagers() []checkResult {
|
|||||||
results = append(results, checkResult{catCompositor, "Active", statusInfo, wm, "", doctorDocsURL + "#compositor"})
|
results = append(results, checkResult{catCompositor, "Active", statusInfo, wm, "", doctorDocsURL + "#compositor"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
results = append(results, checkCompositorBlurSupport())
|
||||||
|
|
||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
func getVersionFromCommand(cmd, arg string, regex *regexp.Regexp) string {
|
func checkCompositorBlurSupport() checkResult {
|
||||||
output, err := exec.Command(cmd, arg).Output()
|
supported, err := blur.ProbeSupport()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
return checkResult{catCompositor, "Background Blur", statusInfo, "Unable to verify", err.Error(), doctorDocsURL + "#compositor-checks"}
|
||||||
|
}
|
||||||
|
|
||||||
|
if supported {
|
||||||
|
return checkResult{catCompositor, "Background Blur", statusOK, "Supported", "Compositor supports ext-background-effect-v1", doctorDocsURL + "#compositor-checks"}
|
||||||
|
}
|
||||||
|
|
||||||
|
return checkResult{catCompositor, "Background Blur", statusWarn, "Unsupported", "Compositor does not support ext-background-effect-v1", doctorDocsURL + "#compositor-checks"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getVersionFromCommand(cmd, arg string, regex *regexp.Regexp) string {
|
||||||
|
output, err := exec.Command(cmd, arg).CombinedOutput()
|
||||||
|
if err != nil && len(output) == 0 {
|
||||||
return "installed"
|
return "installed"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -520,6 +553,8 @@ func detectRunningWM() string {
|
|||||||
return "Hyprland"
|
return "Hyprland"
|
||||||
case os.Getenv("NIRI_SOCKET") != "":
|
case os.Getenv("NIRI_SOCKET") != "":
|
||||||
return "niri"
|
return "niri"
|
||||||
|
case os.Getenv("MIRACLESOCK") != "":
|
||||||
|
return "Miracle WM"
|
||||||
case os.Getenv("XDG_CURRENT_DESKTOP") != "":
|
case os.Getenv("XDG_CURRENT_DESKTOP") != "":
|
||||||
return os.Getenv("XDG_CURRENT_DESKTOP")
|
return os.Getenv("XDG_CURRENT_DESKTOP")
|
||||||
}
|
}
|
||||||
@@ -538,6 +573,7 @@ func checkQuickshellFeatures() ([]checkResult, bool) {
|
|||||||
qmlContent := `
|
qmlContent := `
|
||||||
import QtQuick
|
import QtQuick
|
||||||
import Quickshell
|
import Quickshell
|
||||||
|
import Quickshell.Wayland
|
||||||
|
|
||||||
ShellRoot {
|
ShellRoot {
|
||||||
id: root
|
id: root
|
||||||
@@ -546,6 +582,7 @@ ShellRoot {
|
|||||||
property bool idleMonitorAvailable: false
|
property bool idleMonitorAvailable: false
|
||||||
property bool idleInhibitorAvailable: false
|
property bool idleInhibitorAvailable: false
|
||||||
property bool shortcutInhibitorAvailable: false
|
property bool shortcutInhibitorAvailable: false
|
||||||
|
property bool backgroundBlurAvailable: false
|
||||||
|
|
||||||
Timer {
|
Timer {
|
||||||
interval: 50
|
interval: 50
|
||||||
@@ -563,16 +600,18 @@ ShellRoot {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
var testItem = Qt.createQmlObject(
|
var testItem = Qt.createQmlObject(
|
||||||
'import Quickshell.Wayland; import QtQuick; QtObject { ' +
|
'import Quickshell; import Quickshell.Wayland; import QtQuick; QtObject { ' +
|
||||||
'readonly property bool hasIdleMonitor: typeof IdleMonitor !== "undefined"; ' +
|
'readonly property bool hasIdleMonitor: typeof IdleMonitor !== "undefined"; ' +
|
||||||
'readonly property bool hasIdleInhibitor: typeof IdleInhibitor !== "undefined"; ' +
|
'readonly property bool hasIdleInhibitor: typeof IdleInhibitor !== "undefined"; ' +
|
||||||
'readonly property bool hasShortcutInhibitor: typeof ShortcutInhibitor !== "undefined" ' +
|
'readonly property bool hasShortcutInhibitor: typeof ShortcutInhibitor !== "undefined"; ' +
|
||||||
|
'readonly property bool hasBackgroundBlur: typeof BackgroundEffect !== "undefined" ' +
|
||||||
'}',
|
'}',
|
||||||
root
|
root
|
||||||
)
|
)
|
||||||
root.idleMonitorAvailable = testItem.hasIdleMonitor
|
root.idleMonitorAvailable = testItem.hasIdleMonitor
|
||||||
root.idleInhibitorAvailable = testItem.hasIdleInhibitor
|
root.idleInhibitorAvailable = testItem.hasIdleInhibitor
|
||||||
root.shortcutInhibitorAvailable = testItem.hasShortcutInhibitor
|
root.shortcutInhibitorAvailable = testItem.hasShortcutInhibitor
|
||||||
|
root.backgroundBlurAvailable = testItem.hasBackgroundBlur
|
||||||
testItem.destroy()
|
testItem.destroy()
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
||||||
@@ -581,13 +620,15 @@ ShellRoot {
|
|||||||
console.warn(root.idleInhibitorAvailable ? "FEATURE:IdleInhibitor:OK" : "FEATURE:IdleInhibitor:UNAVAILABLE")
|
console.warn(root.idleInhibitorAvailable ? "FEATURE:IdleInhibitor:OK" : "FEATURE:IdleInhibitor:UNAVAILABLE")
|
||||||
console.warn(root.shortcutInhibitorAvailable ? "FEATURE:ShortcutInhibitor:OK" : "FEATURE:ShortcutInhibitor:UNAVAILABLE")
|
console.warn(root.shortcutInhibitorAvailable ? "FEATURE:ShortcutInhibitor:OK" : "FEATURE:ShortcutInhibitor:UNAVAILABLE")
|
||||||
|
|
||||||
|
console.warn(root.backgroundBlurAvailable ? "FEATURE:BackgroundBlur:OK" : "FEATURE:BackgroundBlur:UNAVAILABLE")
|
||||||
|
|
||||||
Quickshell.execDetached(["kill", "-TERM", String(Quickshell.processId)])
|
Quickshell.execDetached(["kill", "-TERM", String(Quickshell.processId)])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
if err := os.WriteFile(testScript, []byte(qmlContent), 0644); err != nil {
|
if err := os.WriteFile(testScript, []byte(qmlContent), 0o644); err != nil {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -601,6 +642,7 @@ ShellRoot {
|
|||||||
{"IdleMonitor", "Idle detection"},
|
{"IdleMonitor", "Idle detection"},
|
||||||
{"IdleInhibitor", "Prevent idle/sleep"},
|
{"IdleInhibitor", "Prevent idle/sleep"},
|
||||||
{"ShortcutInhibitor", "Allow shortcut management (niri)"},
|
{"ShortcutInhibitor", "Allow shortcut management (niri)"},
|
||||||
|
{"BackgroundBlur", "Background blur API support in Quickshell"},
|
||||||
}
|
}
|
||||||
|
|
||||||
var results []checkResult
|
var results []checkResult
|
||||||
@@ -634,19 +676,128 @@ func checkI2CAvailability() checkResult {
|
|||||||
return checkResult{catOptionalFeatures, "I2C/DDC", statusOK, fmt.Sprintf("%d monitor(s) detected", len(devices)), "External monitor brightness control", doctorDocsURL + "#optional-features"}
|
return checkResult{catOptionalFeatures, "I2C/DDC", statusOK, fmt.Sprintf("%d monitor(s) detected", len(devices)), "External monitor brightness control", doctorDocsURL + "#optional-features"}
|
||||||
}
|
}
|
||||||
|
|
||||||
func detectNetworkBackend() string {
|
func checkImageFormatPlugins() []checkResult {
|
||||||
result, err := network.DetectNetworkStack()
|
url := doctorDocsURL + "#optional-features"
|
||||||
if err != nil {
|
|
||||||
return ""
|
pluginDirs := findQtPluginDirs()
|
||||||
|
if len(pluginDirs) == 0 {
|
||||||
|
return []checkResult{
|
||||||
|
{catOptionalFeatures, "qt6-imageformats", statusInfo, "Cannot detect (plugin dir not found)", "WebP, TIFF, JP2 support", url},
|
||||||
|
{catOptionalFeatures, "kimageformats", statusInfo, "Cannot detect (plugin dir not found)", "AVIF, HEIF, JXL support", url},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
switch result.Backend {
|
type pluginCheck struct {
|
||||||
|
name string
|
||||||
|
desc string
|
||||||
|
plugins []struct{ file, format string }
|
||||||
|
}
|
||||||
|
|
||||||
|
checks := []pluginCheck{
|
||||||
|
{
|
||||||
|
name: "qt6-imageformats",
|
||||||
|
desc: "WebP, TIFF, GIF, JP2 support",
|
||||||
|
plugins: []struct{ file, format string }{
|
||||||
|
{"libqwebp.so", "WebP"},
|
||||||
|
{"libqtiff.so", "TIFF"},
|
||||||
|
{"libqgif.so", "GIF"},
|
||||||
|
{"libqjp2.so", "JP2"},
|
||||||
|
{"libqicns.so", "ICNS"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "kimageformats",
|
||||||
|
desc: "AVIF, HEIF, JXL support",
|
||||||
|
plugins: []struct{ file, format string }{
|
||||||
|
{"kimg_avif.so", "AVIF"},
|
||||||
|
{"kimg_heif.so", "HEIF"},
|
||||||
|
{"kimg_jxl.so", "JXL"},
|
||||||
|
{"kimg_exr.so", "EXR"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var results []checkResult
|
||||||
|
for _, c := range checks {
|
||||||
|
var found []string
|
||||||
|
var foundDirs []string
|
||||||
|
for _, pluginDir := range pluginDirs {
|
||||||
|
imageFormatsDir := filepath.Join(pluginDir, "imageformats")
|
||||||
|
for _, p := range c.plugins {
|
||||||
|
if _, err := os.Stat(filepath.Join(imageFormatsDir, p.file)); err == nil {
|
||||||
|
if !slices.Contains(found, p.format) {
|
||||||
|
found = append(found, p.format)
|
||||||
|
}
|
||||||
|
if !slices.Contains(foundDirs, imageFormatsDir) {
|
||||||
|
foundDirs = append(foundDirs, imageFormatsDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var result checkResult
|
||||||
|
switch {
|
||||||
|
case len(found) == 0:
|
||||||
|
result = checkResult{catOptionalFeatures, c.name, statusWarn, "Not installed", c.desc, url}
|
||||||
|
default:
|
||||||
|
details := ""
|
||||||
|
if doctorVerbose {
|
||||||
|
details = fmt.Sprintf("Formats: %s (%s)", strings.Join(found, ", "), strings.Join(foundDirs, ":"))
|
||||||
|
}
|
||||||
|
result = checkResult{catOptionalFeatures, c.name, statusOK, fmt.Sprintf("Installed (%d formats)", len(found)), details, url}
|
||||||
|
}
|
||||||
|
results = append(results, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
func findQtPluginDirs() []string {
|
||||||
|
var dirs []string
|
||||||
|
|
||||||
|
addDir := func(dir string) {
|
||||||
|
if dir != "" {
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "imageformats")); err == nil {
|
||||||
|
dirs = append(dirs, dir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check all paths in QT_PLUGIN_PATH env var (used by NixOS and custom setups)
|
||||||
|
if envPath := os.Getenv("QT_PLUGIN_PATH"); envPath != "" {
|
||||||
|
for dir := range strings.SplitSeq(envPath, ":") {
|
||||||
|
addDir(dir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try qtpaths
|
||||||
|
for _, cmd := range []string{"qtpaths6", "qtpaths"} {
|
||||||
|
if output, err := exec.Command(cmd, "-query", "QT_INSTALL_PLUGINS").Output(); err == nil {
|
||||||
|
addDir(strings.TrimSpace(string(output)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: common distro paths
|
||||||
|
for _, dir := range []string{
|
||||||
|
"/usr/lib/qt6/plugins",
|
||||||
|
"/usr/lib64/qt6/plugins",
|
||||||
|
"/usr/lib/x86_64-linux-gnu/qt6/plugins",
|
||||||
|
"/usr/lib/aarch64-linux-gnu/qt6/plugins",
|
||||||
|
} {
|
||||||
|
addDir(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
return dirs
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectNetworkBackend(stackResult *network.DetectResult) string {
|
||||||
|
switch stackResult.Backend {
|
||||||
case network.BackendNetworkManager:
|
case network.BackendNetworkManager:
|
||||||
return "NetworkManager"
|
return "NetworkManager"
|
||||||
case network.BackendIwd:
|
case network.BackendIwd:
|
||||||
return "iwd"
|
return "iwd"
|
||||||
case network.BackendNetworkd:
|
case network.BackendNetworkd:
|
||||||
if result.HasIwd {
|
if stackResult.HasIwd {
|
||||||
return "iwd + systemd-networkd"
|
return "iwd + systemd-networkd"
|
||||||
}
|
}
|
||||||
return "systemd-networkd"
|
return "systemd-networkd"
|
||||||
@@ -657,75 +808,91 @@ func detectNetworkBackend() string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getOptionalDBusStatus(busName string) (status, string) {
|
||||||
|
if utils.IsDBusServiceAvailable(busName) {
|
||||||
|
return statusOK, "Available"
|
||||||
|
} else {
|
||||||
|
return statusWarn, "Not available"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func checkOptionalDependencies() []checkResult {
|
func checkOptionalDependencies() []checkResult {
|
||||||
var results []checkResult
|
var results []checkResult
|
||||||
|
|
||||||
if utils.IsServiceActive("accounts-daemon", false) {
|
optionalFeaturesURL := doctorDocsURL + "#optional-features"
|
||||||
results = append(results, checkResult{catOptionalFeatures, "accountsservice", statusOK, "Running", "User accounts", doctorDocsURL + "#optional-features"})
|
|
||||||
} else {
|
|
||||||
results = append(results, checkResult{catOptionalFeatures, "accountsservice", statusWarn, "Not running", "User accounts", doctorDocsURL + "#optional-features"})
|
|
||||||
}
|
|
||||||
|
|
||||||
if utils.IsServiceActive("power-profiles-daemon", false) {
|
accountsStatus, accountsMsg := getOptionalDBusStatus("org.freedesktop.Accounts")
|
||||||
results = append(results, checkResult{catOptionalFeatures, "power-profiles-daemon", statusOK, "Running", "Power profile management", doctorDocsURL + "#optional-features"})
|
results = append(results, checkResult{catOptionalFeatures, "accountsservice", accountsStatus, accountsMsg, "User accounts", optionalFeaturesURL})
|
||||||
} else {
|
|
||||||
results = append(results, checkResult{catOptionalFeatures, "power-profiles-daemon", statusInfo, "Not running", "Power profile management", doctorDocsURL + "#optional-features"})
|
ppdStatus, ppdMsg := getOptionalDBusStatus("org.freedesktop.UPower.PowerProfiles")
|
||||||
|
results = append(results, checkResult{catOptionalFeatures, "power-profiles-daemon", ppdStatus, ppdMsg, "Power profile management", optionalFeaturesURL})
|
||||||
|
|
||||||
|
logindStatus, logindMsg := getOptionalDBusStatus("org.freedesktop.login1")
|
||||||
|
results = append(results, checkResult{catOptionalFeatures, "logind", logindStatus, logindMsg, "Session management", optionalFeaturesURL})
|
||||||
|
|
||||||
|
cupsPkHelperBus := "org.opensuse.CupsPkHelper.Mechanism"
|
||||||
|
var cupsPkStatus status
|
||||||
|
var cupsPkMsg string
|
||||||
|
switch {
|
||||||
|
case utils.IsDBusServiceAvailable(cupsPkHelperBus):
|
||||||
|
cupsPkStatus, cupsPkMsg = statusOK, "Running"
|
||||||
|
case utils.IsDBusServiceActivatable(cupsPkHelperBus):
|
||||||
|
cupsPkStatus, cupsPkMsg = statusOK, "Available"
|
||||||
|
default:
|
||||||
|
cupsPkStatus, cupsPkMsg = statusWarn, "Not available (install cups-pk-helper)"
|
||||||
}
|
}
|
||||||
|
results = append(results, checkResult{catOptionalFeatures, "cups-pk-helper", cupsPkStatus, cupsPkMsg, "Printer management", optionalFeaturesURL})
|
||||||
|
|
||||||
results = append(results, checkI2CAvailability())
|
results = append(results, checkI2CAvailability())
|
||||||
|
results = append(results, checkImageFormatPlugins()...)
|
||||||
|
|
||||||
terminals := []string{"ghostty", "kitty", "alacritty", "foot", "wezterm"}
|
terminals := []string{"ghostty", "kitty", "alacritty", "foot", "wezterm"}
|
||||||
if idx := slices.IndexFunc(terminals, utils.CommandExists); idx >= 0 {
|
terminals = slices.DeleteFunc(terminals, func(t string) bool {
|
||||||
results = append(results, checkResult{catOptionalFeatures, "Terminal", statusOK, terminals[idx], "", doctorDocsURL + "#optional-features"})
|
return !utils.CommandExists(t)
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(terminals) > 0 {
|
||||||
|
results = append(results, checkResult{catOptionalFeatures, "Terminal", statusOK, strings.Join(terminals, ", "), "", optionalFeaturesURL})
|
||||||
} else {
|
} else {
|
||||||
results = append(results, checkResult{catOptionalFeatures, "Terminal", statusWarn, "None found", "Install ghostty, kitty, or alacritty", doctorDocsURL + "#optional-features"})
|
results = append(results, checkResult{catOptionalFeatures, "Terminal", statusWarn, "None found", "Install ghostty, kitty, foot or alacritty", optionalFeaturesURL})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
networkResult, err := network.DetectNetworkStack()
|
||||||
|
networkStatus, networkMessage, networkDetails := statusOK, "Not available", "Network management"
|
||||||
|
|
||||||
|
if err == nil && networkResult.Backend != network.BackendNone {
|
||||||
|
networkMessage = detectNetworkBackend(networkResult)
|
||||||
|
if doctorVerbose {
|
||||||
|
networkDetails = networkResult.ChosenReason
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
networkStatus = statusInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
results = append(results, checkResult{catOptionalFeatures, "Network", networkStatus, networkMessage, networkDetails, optionalFeaturesURL})
|
||||||
|
|
||||||
deps := []struct {
|
deps := []struct {
|
||||||
name, cmd, altCmd, desc string
|
name, cmd, desc string
|
||||||
important bool
|
important bool
|
||||||
}{
|
}{
|
||||||
{"matugen", "matugen", "", "Dynamic theming", true},
|
{"matugen", "matugen", "Dynamic theming", true},
|
||||||
{"dgop", "dgop", "", "System monitoring", true},
|
{"dgop", "dgop", "System monitoring", true},
|
||||||
{"cava", "cava", "", "Audio visualizer", true},
|
{"cava", "cava", "Audio visualizer", true},
|
||||||
{"khal", "khal", "", "Calendar events", false},
|
{"khal", "khal", "Calendar events", false},
|
||||||
{"Network", "nmcli", "iwctl", "Network management", false},
|
{"danksearch", "dsearch", "File search", false},
|
||||||
{"danksearch", "dsearch", "", "File search", false},
|
{"fprintd", "fprintd-list", "Fingerprint auth", false},
|
||||||
{"loginctl", "loginctl", "", "Session management", false},
|
|
||||||
{"fprintd", "fprintd-list", "", "Fingerprint auth", false},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, d := range deps {
|
for _, d := range deps {
|
||||||
found, foundCmd := utils.CommandExists(d.cmd), d.cmd
|
found := utils.CommandExists(d.cmd)
|
||||||
if !found && d.altCmd != "" && utils.CommandExists(d.altCmd) {
|
|
||||||
found, foundCmd = true, d.altCmd
|
|
||||||
}
|
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case found:
|
case found:
|
||||||
message := "Installed"
|
results = append(results, checkResult{catOptionalFeatures, d.name, statusOK, "Installed", d.desc, optionalFeaturesURL})
|
||||||
details := d.desc
|
|
||||||
if d.name == "Network" {
|
|
||||||
result, err := network.DetectNetworkStack()
|
|
||||||
if err == nil && result.Backend != network.BackendNone {
|
|
||||||
message = detectNetworkBackend() + " (active)"
|
|
||||||
if doctorVerbose {
|
|
||||||
details = result.ChosenReason
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
switch foundCmd {
|
|
||||||
case "nmcli":
|
|
||||||
message = "NetworkManager (installed)"
|
|
||||||
case "iwctl":
|
|
||||||
message = "iwd (installed)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
results = append(results, checkResult{catOptionalFeatures, d.name, statusOK, message, details, doctorDocsURL + "#optional-features"})
|
|
||||||
case d.important:
|
case d.important:
|
||||||
results = append(results, checkResult{catOptionalFeatures, d.name, statusWarn, "Missing", d.desc, doctorDocsURL + "#optional-features"})
|
results = append(results, checkResult{catOptionalFeatures, d.name, statusWarn, "Missing", d.desc, optionalFeaturesURL})
|
||||||
default:
|
default:
|
||||||
results = append(results, checkResult{catOptionalFeatures, d.name, statusInfo, "Not installed", d.desc, doctorDocsURL + "#optional-features"})
|
results = append(results, checkResult{catOptionalFeatures, d.name, statusInfo, "Not installed", d.desc, optionalFeaturesURL})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -755,7 +922,7 @@ func checkConfigurationFiles() []checkResult {
|
|||||||
|
|
||||||
status := statusOK
|
status := statusOK
|
||||||
message := "Present"
|
message := "Present"
|
||||||
if info.Mode().Perm()&0200 == 0 {
|
if info.Mode().Perm()&0o200 == 0 {
|
||||||
status = statusWarn
|
status = statusWarn
|
||||||
message += " (read-only)"
|
message += " (read-only)"
|
||||||
}
|
}
|
||||||
@@ -893,6 +1060,10 @@ func printResultLine(r checkResult, styles tui.Styles) {
|
|||||||
if doctorVerbose && r.details != "" {
|
if doctorVerbose && r.details != "" {
|
||||||
fmt.Printf(" %s\n", styles.Subtle.Render("└─ "+r.details))
|
fmt.Printf(" %s\n", styles.Subtle.Render("└─ "+r.details))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (r.status == statusError || r.status == statusWarn) && r.url != "" {
|
||||||
|
fmt.Printf(" %s\n", styles.Subtle.Render("→ "+r.url))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func printSummary(results []checkResult, qsMissingFeatures bool) {
|
func printSummary(results []checkResult, qsMissingFeatures bool) {
|
||||||
@@ -928,3 +1099,36 @@ func printSummary(results []checkResult, qsMissingFeatures bool) {
|
|||||||
}
|
}
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func formatResultsPlain(results []checkResult) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("## DMS Doctor Report\n\n")
|
||||||
|
|
||||||
|
currentCategory := category(-1)
|
||||||
|
for _, r := range results {
|
||||||
|
if r.category != currentCategory {
|
||||||
|
if currentCategory != -1 {
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&sb, "**%s**\n", r.category.String())
|
||||||
|
currentCategory = r.category
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(&sb, "- [%s] %s: %s\n", r.status, r.name, r.message)
|
||||||
|
|
||||||
|
if doctorVerbose && r.details != "" {
|
||||||
|
fmt.Fprintf(&sb, " - %s\n", r.details)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var ds DoctorStatus
|
||||||
|
for _, r := range results {
|
||||||
|
ds.Add(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString("\n---\n")
|
||||||
|
fmt.Fprintf(&sb, "**Summary:** %d error(s), %d warning(s), %d ok\n",
|
||||||
|
ds.ErrorCount(), ds.WarningCount(), ds.OKCount())
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
@@ -15,6 +16,7 @@ import (
|
|||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
"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/utils"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/version"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/version"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
@@ -109,16 +111,37 @@ func updateArchLinux() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var packageName string
|
var packageName string
|
||||||
if isArchPackageInstalled("dms-shell-bin") {
|
var isAUR bool
|
||||||
packageName = "dms-shell-bin"
|
if isArchPackageInstalled("dms-shell") {
|
||||||
|
packageName = "dms-shell"
|
||||||
} else if isArchPackageInstalled("dms-shell-git") {
|
} else if isArchPackageInstalled("dms-shell-git") {
|
||||||
packageName = "dms-shell-git"
|
packageName = "dms-shell-git"
|
||||||
|
isAUR = true
|
||||||
|
} else if isArchPackageInstalled("dms-shell-bin") {
|
||||||
|
packageName = "dms-shell-bin"
|
||||||
|
isAUR = true
|
||||||
} else {
|
} 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...")
|
fmt.Println("Info: Falling back to git-based update method...")
|
||||||
return updateOtherDistros()
|
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 helper string
|
||||||
var updateCmd *exec.Cmd
|
var updateCmd *exec.Cmd
|
||||||
|
|
||||||
@@ -454,11 +477,7 @@ func updateDMSBinary() error {
|
|||||||
|
|
||||||
fmt.Printf("Installing to %s...\n", currentPath)
|
fmt.Printf("Installing to %s...\n", currentPath)
|
||||||
|
|
||||||
replaceCmd := exec.Command("sudo", "install", "-m", "0755", decompressedPath, currentPath)
|
if err := privesc.Run(context.Background(), "", "install", "-m", "0755", decompressedPath, currentPath); err != nil {
|
||||||
replaceCmd.Stdin = os.Stdin
|
|
||||||
replaceCmd.Stdout = os.Stdout
|
|
||||||
replaceCmd.Stderr = os.Stderr
|
|
||||||
if err := replaceCmd.Run(); err != nil {
|
|
||||||
return fmt.Errorf("failed to replace binary: %w", err)
|
return fmt.Errorf("failed to replace binary: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1287
-183
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,87 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
sharedpam "github.com/AvengeMedia/DankMaterialShell/core/internal/pam"
|
||||||
|
)
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds/providers"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds/providers"
|
||||||
@@ -63,6 +64,7 @@ func init() {
|
|||||||
keybindsSetCmd.Flags().Bool("allow-when-locked", false, "Allow when screen is locked")
|
keybindsSetCmd.Flags().Bool("allow-when-locked", false, "Allow when screen is locked")
|
||||||
keybindsSetCmd.Flags().Int("cooldown-ms", 0, "Cooldown in milliseconds")
|
keybindsSetCmd.Flags().Int("cooldown-ms", 0, "Cooldown in milliseconds")
|
||||||
keybindsSetCmd.Flags().Bool("no-repeat", false, "Disable key repeat")
|
keybindsSetCmd.Flags().Bool("no-repeat", false, "Disable key repeat")
|
||||||
|
keybindsSetCmd.Flags().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("replace-key", "", "Original key to replace (removes old key)")
|
||||||
keybindsSetCmd.Flags().String("flags", "", "Hyprland bind flags (e.g., 'e' for repeat, 'l' for locked, 'r' for release)")
|
keybindsSetCmd.Flags().String("flags", "", "Hyprland bind flags (e.g., 'e' for repeat, 'l' for locked, 'r' for release)")
|
||||||
|
|
||||||
@@ -81,24 +83,35 @@ func init() {
|
|||||||
func initializeProviders() {
|
func initializeProviders() {
|
||||||
registry := keybinds.GetDefaultRegistry()
|
registry := keybinds.GetDefaultRegistry()
|
||||||
|
|
||||||
hyprlandProvider := providers.NewHyprlandProvider("$HOME/.config/hypr")
|
hyprlandProvider := providers.NewHyprlandProvider("")
|
||||||
if err := registry.Register(hyprlandProvider); err != nil {
|
if err := registry.Register(hyprlandProvider); err != nil {
|
||||||
log.Warnf("Failed to register Hyprland provider: %v", err)
|
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 {
|
if err := registry.Register(mangowcProvider); err != nil {
|
||||||
log.Warnf("Failed to register MangoWC provider: %v", err)
|
log.Warnf("Failed to register MangoWC provider: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
scrollProvider := providers.NewSwayProvider("$HOME/.config/scroll")
|
configDir, _ := os.UserConfigDir()
|
||||||
if err := registry.Register(scrollProvider); err != nil {
|
|
||||||
log.Warnf("Failed to register Scroll provider: %v", err)
|
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")
|
miracleProvider := providers.NewMiracleProvider("")
|
||||||
if err := registry.Register(swayProvider); err != nil {
|
if err := registry.Register(miracleProvider); err != nil {
|
||||||
log.Warnf("Failed to register Sway provider: %v", err)
|
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("")
|
niriProvider := providers.NewNiriProvider("")
|
||||||
@@ -143,6 +156,8 @@ func makeProviderWithPath(name, path string) keybinds.Provider {
|
|||||||
return providers.NewSwayProvider(path)
|
return providers.NewSwayProvider(path)
|
||||||
case "scroll":
|
case "scroll":
|
||||||
return providers.NewSwayProvider(path)
|
return providers.NewSwayProvider(path)
|
||||||
|
case "miracle":
|
||||||
|
return providers.NewMiracleProvider(path)
|
||||||
case "niri":
|
case "niri":
|
||||||
return providers.NewNiriProvider(path)
|
return providers.NewNiriProvider(path)
|
||||||
default:
|
default:
|
||||||
@@ -212,6 +227,9 @@ func runKeybindsSet(cmd *cobra.Command, args []string) {
|
|||||||
if v, _ := cmd.Flags().GetBool("no-repeat"); v {
|
if v, _ := cmd.Flags().GetBool("no-repeat"); v {
|
||||||
options["repeat"] = false
|
options["repeat"] = false
|
||||||
}
|
}
|
||||||
|
if v, _ := cmd.Flags().GetBool("no-inhibiting"); v {
|
||||||
|
options["allow-inhibiting"] = false
|
||||||
|
}
|
||||||
if v, _ := cmd.Flags().GetString("flags"); v != "" {
|
if v, _ := cmd.Flags().GetString("flags"); v != "" {
|
||||||
options["flags"] = v
|
options["flags"] = v
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||||
@@ -55,10 +57,11 @@ func init() {
|
|||||||
cmd.Flags().Bool("sync-mode-with-portal", false, "Sync color scheme with GNOME portal")
|
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().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().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().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")
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildMatugenOptions(cmd *cobra.Command) matugen.Options {
|
func buildMatugenOptions(cmd *cobra.Command) matugen.Options {
|
||||||
@@ -75,6 +78,7 @@ func buildMatugenOptions(cmd *cobra.Command) matugen.Options {
|
|||||||
syncModeWithPortal, _ := cmd.Flags().GetBool("sync-mode-with-portal")
|
syncModeWithPortal, _ := cmd.Flags().GetBool("sync-mode-with-portal")
|
||||||
terminalsAlwaysDark, _ := cmd.Flags().GetBool("terminals-always-dark")
|
terminalsAlwaysDark, _ := cmd.Flags().GetBool("terminals-always-dark")
|
||||||
skipTemplates, _ := cmd.Flags().GetString("skip-templates")
|
skipTemplates, _ := cmd.Flags().GetString("skip-templates")
|
||||||
|
contrast, _ := cmd.Flags().GetFloat64("contrast")
|
||||||
|
|
||||||
return matugen.Options{
|
return matugen.Options{
|
||||||
StateDir: stateDir,
|
StateDir: stateDir,
|
||||||
@@ -85,6 +89,7 @@ func buildMatugenOptions(cmd *cobra.Command) matugen.Options {
|
|||||||
Mode: matugen.ColorMode(mode),
|
Mode: matugen.ColorMode(mode),
|
||||||
IconTheme: iconTheme,
|
IconTheme: iconTheme,
|
||||||
MatugenType: matugenType,
|
MatugenType: matugenType,
|
||||||
|
Contrast: contrast,
|
||||||
RunUserTemplates: runUserTemplates,
|
RunUserTemplates: runUserTemplates,
|
||||||
StockColors: stockColors,
|
StockColors: stockColors,
|
||||||
SyncModeWithPortal: syncModeWithPortal,
|
SyncModeWithPortal: syncModeWithPortal,
|
||||||
@@ -95,7 +100,11 @@ func buildMatugenOptions(cmd *cobra.Command) matugen.Options {
|
|||||||
|
|
||||||
func runMatugenGenerate(cmd *cobra.Command, args []string) {
|
func runMatugenGenerate(cmd *cobra.Command, args []string) {
|
||||||
opts := buildMatugenOptions(cmd)
|
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)
|
log.Fatalf("Theme generation failed: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -122,6 +131,7 @@ func runMatugenQueue(cmd *cobra.Command, args []string) {
|
|||||||
"syncModeWithPortal": opts.SyncModeWithPortal,
|
"syncModeWithPortal": opts.SyncModeWithPortal,
|
||||||
"terminalsAlwaysDark": opts.TerminalsAlwaysDark,
|
"terminalsAlwaysDark": opts.TerminalsAlwaysDark,
|
||||||
"skipTemplates": opts.SkipTemplates,
|
"skipTemplates": opts.SkipTemplates,
|
||||||
|
"contrast": opts.Contrast,
|
||||||
"wait": wait,
|
"wait": wait,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -129,7 +139,11 @@ func runMatugenQueue(cmd *cobra.Command, args []string) {
|
|||||||
if !wait {
|
if !wait {
|
||||||
if err := sendServerRequestFireAndForget(request); err != nil {
|
if err := sendServerRequestFireAndForget(request); err != nil {
|
||||||
log.Info("Server unavailable, running synchronously")
|
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)
|
log.Fatalf("Theme generation failed: %v", err)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@@ -146,11 +160,15 @@ func runMatugenQueue(cmd *cobra.Command, args []string) {
|
|||||||
resp, ok := tryServerRequest(request)
|
resp, ok := tryServerRequest(request)
|
||||||
if !ok {
|
if !ok {
|
||||||
log.Info("Server unavailable, running synchronously")
|
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
|
resultCh <- err
|
||||||
return
|
default:
|
||||||
|
resultCh <- nil
|
||||||
}
|
}
|
||||||
resultCh <- nil
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if resp.Error != "" {
|
if resp.Error != "" {
|
||||||
@@ -162,7 +180,10 @@ func runMatugenQueue(cmd *cobra.Command, args []string) {
|
|||||||
|
|
||||||
select {
|
select {
|
||||||
case err := <-resultCh:
|
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)
|
log.Fatalf("Theme generation failed: %v", err)
|
||||||
}
|
}
|
||||||
fmt.Println("Theme generation completed")
|
fmt.Println("Theme generation completed")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
"strings"
|
||||||
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/dms"
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -20,11 +18,9 @@ var rootCmd = &cobra.Command{
|
|||||||
Use: "dms",
|
Use: "dms",
|
||||||
Short: "dms CLI",
|
Short: "dms CLI",
|
||||||
Long: "dms is the DankMaterialShell management CLI and backend server.",
|
Long: "dms is the DankMaterialShell management CLI and backend server.",
|
||||||
Run: runInteractiveMode,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
// Add the -c flag
|
|
||||||
rootCmd.PersistentFlags().StringVarP(&customConfigPath, "config", "c", "", "Specify a custom path to the DMS config directory")
|
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() {
|
if statErr == nil && !info.IsDir() {
|
||||||
configPath = customConfigPath
|
configPath = customConfigPath
|
||||||
log.Debug("Using config from: %s", configPath)
|
log.Debug("Using config from: %s", configPath)
|
||||||
return nil // <-- Guard statement
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if statErr != nil {
|
if statErr != nil {
|
||||||
@@ -76,18 +72,3 @@ func findConfig(cmd *cobra.Command, args []string) error {
|
|||||||
log.Debug("Using config from: %s", configPath)
|
log.Debug("Using config from: %s", configPath)
|
||||||
return nil
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -13,16 +13,18 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ssOutputName string
|
ssOutputName string
|
||||||
ssIncludeCursor bool
|
ssCursor string
|
||||||
ssFormat string
|
ssFormat string
|
||||||
ssQuality int
|
ssQuality int
|
||||||
ssOutputDir string
|
ssOutputDir string
|
||||||
ssFilename string
|
ssFilename string
|
||||||
ssNoClipboard bool
|
ssNoClipboard bool
|
||||||
ssNoFile bool
|
ssNoFile bool
|
||||||
ssNoNotify bool
|
ssNoNotify bool
|
||||||
ssStdout bool
|
ssNoConfirm bool
|
||||||
|
ssReset bool
|
||||||
|
ssStdout bool
|
||||||
)
|
)
|
||||||
|
|
||||||
var screenshotCmd = &cobra.Command{
|
var screenshotCmd = &cobra.Command{
|
||||||
@@ -50,9 +52,11 @@ Examples:
|
|||||||
dms screenshot output -o DP-1 # Specific output
|
dms screenshot output -o DP-1 # Specific output
|
||||||
dms screenshot window # Focused window (Hyprland)
|
dms screenshot window # Focused window (Hyprland)
|
||||||
dms screenshot last # Last region (pre-selected)
|
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-clipboard # Save file only
|
||||||
dms screenshot --no-file # Clipboard only
|
dms screenshot --no-file # Clipboard only
|
||||||
dms screenshot --cursor # Include cursor
|
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 -f jpg -q 85 # JPEG with quality 85`,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,7 +115,7 @@ var notifyActionCmd = &cobra.Command{
|
|||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
screenshotCmd.PersistentFlags().StringVarP(&ssOutputName, "output", "o", "", "Output name for 'output' mode")
|
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().StringVarP(&ssFormat, "format", "f", "png", "Output format (png, jpg, ppm)")
|
||||||
screenshotCmd.PersistentFlags().IntVarP(&ssQuality, "quality", "q", 90, "JPEG quality (1-100)")
|
screenshotCmd.PersistentFlags().IntVarP(&ssQuality, "quality", "q", 90, "JPEG quality (1-100)")
|
||||||
screenshotCmd.PersistentFlags().StringVarP(&ssOutputDir, "dir", "d", "", "Output directory")
|
screenshotCmd.PersistentFlags().StringVarP(&ssOutputDir, "dir", "d", "", "Output directory")
|
||||||
@@ -119,6 +123,8 @@ func init() {
|
|||||||
screenshotCmd.PersistentFlags().BoolVar(&ssNoClipboard, "no-clipboard", false, "Don't copy to clipboard")
|
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(&ssNoFile, "no-file", false, "Don't save to file")
|
||||||
screenshotCmd.PersistentFlags().BoolVar(&ssNoNotify, "no-notify", false, "Don't show notification")
|
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(&ssStdout, "stdout", false, "Output image to stdout (for piping to swappy, etc.)")
|
||||||
|
|
||||||
screenshotCmd.AddCommand(ssRegionCmd)
|
screenshotCmd.AddCommand(ssRegionCmd)
|
||||||
@@ -136,10 +142,14 @@ func getScreenshotConfig(mode screenshot.Mode) screenshot.Config {
|
|||||||
config := screenshot.DefaultConfig()
|
config := screenshot.DefaultConfig()
|
||||||
config.Mode = mode
|
config.Mode = mode
|
||||||
config.OutputName = ssOutputName
|
config.OutputName = ssOutputName
|
||||||
config.IncludeCursor = ssIncludeCursor
|
if strings.EqualFold(ssCursor, "on") {
|
||||||
|
config.Cursor = screenshot.CursorOn
|
||||||
|
}
|
||||||
config.Clipboard = !ssNoClipboard
|
config.Clipboard = !ssNoClipboard
|
||||||
config.SaveFile = !ssNoFile
|
config.SaveFile = !ssNoFile
|
||||||
config.Notify = !ssNoNotify
|
config.Notify = !ssNoNotify
|
||||||
|
config.NoConfirm = ssNoConfirm
|
||||||
|
config.Reset = ssReset
|
||||||
config.Stdout = ssStdout
|
config.Stdout = ssStdout
|
||||||
|
|
||||||
if ssOutputDir != "" {
|
if ssOutputDir != "" {
|
||||||
|
|||||||
@@ -4,19 +4,24 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/greeter"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
var setupCmd = &cobra.Command{
|
var setupCmd = &cobra.Command{
|
||||||
Use: "setup",
|
Use: "setup",
|
||||||
Short: "Deploy DMS configurations",
|
Short: "Deploy DMS configurations",
|
||||||
Long: "Deploy compositor and terminal configurations with interactive prompts",
|
Long: "Deploy compositor and terminal configurations with interactive prompts",
|
||||||
|
PersistentPreRunE: preRunPrivileged,
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
if err := runSetup(); err != nil {
|
if err := runSetup(); err != nil {
|
||||||
log.Fatalf("Error during setup: %v", err)
|
log.Fatalf("Error during setup: %v", err)
|
||||||
@@ -24,9 +29,248 @@ var setupCmd = &cobra.Command{
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var setupBindsCmd = &cobra.Command{
|
||||||
|
Use: "binds",
|
||||||
|
Short: "Deploy default keybinds config",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
if err := runSetupDmsConfig("binds"); err != nil {
|
||||||
|
log.Fatalf("Error: %v", err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var setupLayoutCmd = &cobra.Command{
|
||||||
|
Use: "layout",
|
||||||
|
Short: "Deploy default layout config",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
if err := runSetupDmsConfig("layout"); err != nil {
|
||||||
|
log.Fatalf("Error: %v", err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var setupColorsCmd = &cobra.Command{
|
||||||
|
Use: "colors",
|
||||||
|
Short: "Deploy default colors config",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
if err := runSetupDmsConfig("colors"); err != nil {
|
||||||
|
log.Fatalf("Error: %v", err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var setupAlttabCmd = &cobra.Command{
|
||||||
|
Use: "alttab",
|
||||||
|
Short: "Deploy default alt-tab config (niri only)",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
if err := runSetupDmsConfig("alttab"); err != nil {
|
||||||
|
log.Fatalf("Error: %v", err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var setupOutputsCmd = &cobra.Command{
|
||||||
|
Use: "outputs",
|
||||||
|
Short: "Deploy default outputs config",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
if err := runSetupDmsConfig("outputs"); err != nil {
|
||||||
|
log.Fatalf("Error: %v", err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var setupCursorCmd = &cobra.Command{
|
||||||
|
Use: "cursor",
|
||||||
|
Short: "Deploy default cursor config",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
if err := runSetupDmsConfig("cursor"); err != nil {
|
||||||
|
log.Fatalf("Error: %v", err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var setupWindowrulesCmd = &cobra.Command{
|
||||||
|
Use: "windowrules",
|
||||||
|
Short: "Deploy default window rules config",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
if err := runSetupDmsConfig("windowrules"); err != nil {
|
||||||
|
log.Fatalf("Error: %v", err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
type dmsConfigSpec struct {
|
||||||
|
niriFile string
|
||||||
|
hyprFile string
|
||||||
|
niriContent func(terminal string) string
|
||||||
|
hyprContent func(terminal string) string
|
||||||
|
}
|
||||||
|
|
||||||
|
var dmsConfigSpecs = map[string]dmsConfigSpec{
|
||||||
|
"binds": {
|
||||||
|
niriFile: "binds.kdl",
|
||||||
|
hyprFile: "binds.conf",
|
||||||
|
niriContent: func(t string) string {
|
||||||
|
return strings.ReplaceAll(config.NiriBindsConfig, "{{TERMINAL_COMMAND}}", t)
|
||||||
|
},
|
||||||
|
hyprContent: func(t string) string {
|
||||||
|
return strings.ReplaceAll(config.HyprBindsConfig, "{{TERMINAL_COMMAND}}", t)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"layout": {
|
||||||
|
niriFile: "layout.kdl",
|
||||||
|
hyprFile: "layout.conf",
|
||||||
|
niriContent: func(_ string) string { return config.NiriLayoutConfig },
|
||||||
|
hyprContent: func(_ string) string { return config.HyprLayoutConfig },
|
||||||
|
},
|
||||||
|
"colors": {
|
||||||
|
niriFile: "colors.kdl",
|
||||||
|
hyprFile: "colors.conf",
|
||||||
|
niriContent: func(_ string) string { return config.NiriColorsConfig },
|
||||||
|
hyprContent: func(_ string) string { return config.HyprColorsConfig },
|
||||||
|
},
|
||||||
|
"alttab": {
|
||||||
|
niriFile: "alttab.kdl",
|
||||||
|
niriContent: func(_ string) string { return config.NiriAlttabConfig },
|
||||||
|
},
|
||||||
|
"outputs": {
|
||||||
|
niriFile: "outputs.kdl",
|
||||||
|
hyprFile: "outputs.conf",
|
||||||
|
niriContent: func(_ string) string { return "" },
|
||||||
|
hyprContent: func(_ string) string { return "" },
|
||||||
|
},
|
||||||
|
"cursor": {
|
||||||
|
niriFile: "cursor.kdl",
|
||||||
|
hyprFile: "cursor.conf",
|
||||||
|
niriContent: func(_ string) string { return "" },
|
||||||
|
hyprContent: func(_ string) string { return "" },
|
||||||
|
},
|
||||||
|
"windowrules": {
|
||||||
|
niriFile: "windowrules.kdl",
|
||||||
|
hyprFile: "windowrules.conf",
|
||||||
|
niriContent: func(_ string) string { return "" },
|
||||||
|
hyprContent: func(_ string) string { return "" },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectTerminal() (string, error) {
|
||||||
|
terminals := []string{"ghostty", "foot", "kitty", "alacritty"}
|
||||||
|
var found []string
|
||||||
|
for _, t := range terminals {
|
||||||
|
if utils.CommandExists(t) {
|
||||||
|
found = append(found, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch len(found) {
|
||||||
|
case 0:
|
||||||
|
return "ghostty", nil
|
||||||
|
case 1:
|
||||||
|
return found[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Multiple terminals detected:")
|
||||||
|
for i, t := range found {
|
||||||
|
fmt.Printf("%d) %s\n", i+1, t)
|
||||||
|
}
|
||||||
|
fmt.Printf("\nChoice (1-%d): ", len(found))
|
||||||
|
|
||||||
|
var response string
|
||||||
|
fmt.Scanln(&response)
|
||||||
|
response = strings.TrimSpace(response)
|
||||||
|
|
||||||
|
choice := 0
|
||||||
|
fmt.Sscanf(response, "%d", &choice)
|
||||||
|
if choice < 1 || choice > len(found) {
|
||||||
|
return "", fmt.Errorf("invalid choice")
|
||||||
|
}
|
||||||
|
return found[choice-1], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectCompositorForSetup() (string, error) {
|
||||||
|
compositors := greeter.DetectCompositors()
|
||||||
|
|
||||||
|
switch len(compositors) {
|
||||||
|
case 0:
|
||||||
|
return "", fmt.Errorf("no supported compositors found (niri or Hyprland required)")
|
||||||
|
case 1:
|
||||||
|
return strings.ToLower(compositors[0]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
selected, err := greeter.PromptCompositorChoice(compositors)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return strings.ToLower(selected), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runSetupDmsConfig(name string) error {
|
||||||
|
spec, ok := dmsConfigSpecs[name]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("unknown config: %s", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
compositor, err := detectCompositorForSetup()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var filename string
|
||||||
|
var contentFn func(string) string
|
||||||
|
switch compositor {
|
||||||
|
case "niri":
|
||||||
|
filename = spec.niriFile
|
||||||
|
contentFn = spec.niriContent
|
||||||
|
case "hyprland":
|
||||||
|
filename = spec.hyprFile
|
||||||
|
contentFn = spec.hyprContent
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported compositor: %s", compositor)
|
||||||
|
}
|
||||||
|
|
||||||
|
if filename == "" {
|
||||||
|
return fmt.Errorf("%s is not supported for %s", name, compositor)
|
||||||
|
}
|
||||||
|
|
||||||
|
var dmsDir string
|
||||||
|
switch compositor {
|
||||||
|
case "niri":
|
||||||
|
dmsDir = filepath.Join(os.Getenv("HOME"), ".config", "niri", "dms")
|
||||||
|
case "hyprland":
|
||||||
|
dmsDir = filepath.Join(os.Getenv("HOME"), ".config", "hypr", "dms")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create dms directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(dmsDir, filename)
|
||||||
|
if info, err := os.Stat(path); err == nil && info.Size() > 0 {
|
||||||
|
return fmt.Errorf("%s already exists and is not empty: %s", name, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
terminal := "ghostty"
|
||||||
|
if contentFn != nil && name == "binds" {
|
||||||
|
terminal, err = detectTerminal()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
content := contentFn(terminal)
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
|
return fmt.Errorf("failed to write %s: %w", filename, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Deployed %s to %s\n", name, path)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func runSetup() error {
|
func runSetup() error {
|
||||||
fmt.Println("=== DMS Configuration Setup ===")
|
fmt.Println("=== DMS Configuration Setup ===")
|
||||||
|
|
||||||
|
ensureInputGroup()
|
||||||
|
|
||||||
wm, wmSelected := promptCompositor()
|
wm, wmSelected := promptCompositor()
|
||||||
terminal, terminalSelected := promptTerminal()
|
terminal, terminalSelected := promptTerminal()
|
||||||
useSystemd := promptSystemd()
|
useSystemd := promptSystemd()
|
||||||
@@ -100,6 +344,37 @@ func runSetup() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
func promptCompositor() (deps.WindowManager, bool) {
|
||||||
fmt.Println("Select compositor:")
|
fmt.Println("Select compositor:")
|
||||||
fmt.Println("1) Niri")
|
fmt.Println("1) Niri")
|
||||||
|
|||||||
@@ -0,0 +1,365 @@
|
|||||||
|
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
|
||||||
|
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().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,
|
||||||
|
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 {
|
||||||
|
return pkgs
|
||||||
|
}
|
||||||
|
out := pkgs[:0]
|
||||||
|
for _, p := range pkgs {
|
||||||
|
if p.Repo == sysupdate.RepoAUR {
|
||||||
|
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,338 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/windowrules"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/windowrules/providers"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var windowrulesCmd = &cobra.Command{
|
||||||
|
Use: "windowrules",
|
||||||
|
Short: "Manage window rules",
|
||||||
|
}
|
||||||
|
|
||||||
|
var windowrulesListCmd = &cobra.Command{
|
||||||
|
Use: "list [compositor]",
|
||||||
|
Short: "List all window rules",
|
||||||
|
Long: "List all window rules from compositor config file. Returns JSON with rules and DMS status.",
|
||||||
|
Args: cobra.MaximumNArgs(1),
|
||||||
|
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||||
|
if len(args) == 0 {
|
||||||
|
return []string{"niri"}, cobra.ShellCompDirectiveNoFileComp
|
||||||
|
}
|
||||||
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
|
},
|
||||||
|
Run: runWindowrulesList,
|
||||||
|
}
|
||||||
|
|
||||||
|
var windowrulesAddCmd = &cobra.Command{
|
||||||
|
Use: "add <compositor> '<json>'",
|
||||||
|
Short: "Add a window rule to DMS file",
|
||||||
|
Long: "Add a new window rule to the DMS-managed rules file.",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||||
|
if len(args) == 0 {
|
||||||
|
// ! disabled hyprland return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp
|
||||||
|
return []string{"niri"}, cobra.ShellCompDirectiveNoFileComp
|
||||||
|
}
|
||||||
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
|
},
|
||||||
|
Run: runWindowrulesAdd,
|
||||||
|
}
|
||||||
|
|
||||||
|
var windowrulesUpdateCmd = &cobra.Command{
|
||||||
|
Use: "update <compositor> <id> '<json>'",
|
||||||
|
Short: "Update a window rule in DMS file",
|
||||||
|
Long: "Update an existing window rule in the DMS-managed rules file.",
|
||||||
|
Args: cobra.ExactArgs(3),
|
||||||
|
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||||
|
if len(args) == 0 {
|
||||||
|
return []string{"niri"}, cobra.ShellCompDirectiveNoFileComp
|
||||||
|
}
|
||||||
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
|
},
|
||||||
|
Run: runWindowrulesUpdate,
|
||||||
|
}
|
||||||
|
|
||||||
|
var windowrulesRemoveCmd = &cobra.Command{
|
||||||
|
Use: "remove <compositor> <id>",
|
||||||
|
Short: "Remove a window rule from DMS file",
|
||||||
|
Long: "Remove a window rule from the DMS-managed rules file.",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||||
|
if len(args) == 0 {
|
||||||
|
return []string{"niri"}, cobra.ShellCompDirectiveNoFileComp
|
||||||
|
}
|
||||||
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
|
},
|
||||||
|
Run: runWindowrulesRemove,
|
||||||
|
}
|
||||||
|
|
||||||
|
var windowrulesReorderCmd = &cobra.Command{
|
||||||
|
Use: "reorder <compositor> '<json-array-of-ids>'",
|
||||||
|
Short: "Reorder window rules in DMS file",
|
||||||
|
Long: "Reorder window rules by providing a JSON array of rule IDs in the desired order.",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||||
|
if len(args) == 0 {
|
||||||
|
return []string{"niri"}, cobra.ShellCompDirectiveNoFileComp
|
||||||
|
}
|
||||||
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
|
},
|
||||||
|
Run: runWindowrulesReorder,
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
configCmd.AddCommand(windowrulesCmd)
|
||||||
|
windowrulesCmd.AddCommand(windowrulesListCmd)
|
||||||
|
windowrulesCmd.AddCommand(windowrulesAddCmd)
|
||||||
|
windowrulesCmd.AddCommand(windowrulesUpdateCmd)
|
||||||
|
windowrulesCmd.AddCommand(windowrulesRemoveCmd)
|
||||||
|
windowrulesCmd.AddCommand(windowrulesReorderCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
type WindowRulesListResult struct {
|
||||||
|
Rules []windowrules.WindowRule `json:"rules"`
|
||||||
|
DMSStatus *windowrules.DMSRulesStatus `json:"dmsStatus,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WindowRuleWriteResult struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
ID string `json:"id,omitempty"`
|
||||||
|
Path string `json:"path,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCompositor(args []string) string {
|
||||||
|
if len(args) > 0 {
|
||||||
|
return strings.ToLower(args[0])
|
||||||
|
}
|
||||||
|
if os.Getenv("NIRI_SOCKET") != "" {
|
||||||
|
return "niri"
|
||||||
|
}
|
||||||
|
// if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") != "" {
|
||||||
|
// return "hyprland"
|
||||||
|
// }
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeRuleError(errMsg string) {
|
||||||
|
result := WindowRuleWriteResult{Success: false, Error: errMsg}
|
||||||
|
output, _ := json.Marshal(result)
|
||||||
|
fmt.Fprintln(os.Stdout, string(output))
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeRuleSuccess(id, path string) {
|
||||||
|
result := WindowRuleWriteResult{Success: true, ID: id, Path: path}
|
||||||
|
output, _ := json.Marshal(result)
|
||||||
|
fmt.Fprintln(os.Stdout, string(output))
|
||||||
|
}
|
||||||
|
|
||||||
|
func runWindowrulesList(cmd *cobra.Command, args []string) {
|
||||||
|
compositor := getCompositor(args)
|
||||||
|
if compositor == "" {
|
||||||
|
log.Fatalf("Could not detect compositor. Please specify: hyprland or niri")
|
||||||
|
}
|
||||||
|
|
||||||
|
var result WindowRulesListResult
|
||||||
|
|
||||||
|
switch compositor {
|
||||||
|
case "niri":
|
||||||
|
configDir, err := utils.ExpandPath("$HOME/.config/niri")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to expand niri config path: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
parseResult, err := providers.ParseNiriWindowRules(configDir)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to parse niri window rules: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
allRules := providers.ConvertNiriRulesToWindowRules(parseResult.Rules)
|
||||||
|
|
||||||
|
provider := providers.NewNiriWritableProvider(configDir)
|
||||||
|
dmsRulesPath := provider.GetOverridePath()
|
||||||
|
dmsRules, _ := provider.LoadDMSRules()
|
||||||
|
|
||||||
|
dmsRuleMap := make(map[int]windowrules.WindowRule)
|
||||||
|
for i, dr := range dmsRules {
|
||||||
|
dmsRuleMap[i] = dr
|
||||||
|
}
|
||||||
|
|
||||||
|
dmsIdx := 0
|
||||||
|
for i, r := range allRules {
|
||||||
|
if r.Source == dmsRulesPath {
|
||||||
|
if dmr, ok := dmsRuleMap[dmsIdx]; ok {
|
||||||
|
allRules[i].ID = dmr.ID
|
||||||
|
allRules[i].Name = dmr.Name
|
||||||
|
}
|
||||||
|
dmsIdx++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Rules = allRules
|
||||||
|
result.DMSStatus = parseResult.DMSStatus
|
||||||
|
|
||||||
|
case "hyprland":
|
||||||
|
log.Fatalf("Hyprland support is currently disabled.") // ! disabled hyprland
|
||||||
|
configDir, err := utils.ExpandPath("$HOME/.config/hypr")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to expand hyprland config path: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
parseResult, err := providers.ParseHyprlandWindowRules(configDir)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to parse hyprland window rules: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
allRules := providers.ConvertHyprlandRulesToWindowRules(parseResult.Rules)
|
||||||
|
|
||||||
|
provider := providers.NewHyprlandWritableProvider(configDir)
|
||||||
|
dmsRulesPath := provider.GetOverridePath()
|
||||||
|
dmsRules, _ := provider.LoadDMSRules()
|
||||||
|
|
||||||
|
dmsRuleMap := make(map[int]windowrules.WindowRule)
|
||||||
|
for i, dr := range dmsRules {
|
||||||
|
dmsRuleMap[i] = dr
|
||||||
|
}
|
||||||
|
|
||||||
|
dmsIdx := 0
|
||||||
|
for i, r := range allRules {
|
||||||
|
if r.Source == dmsRulesPath {
|
||||||
|
if dmr, ok := dmsRuleMap[dmsIdx]; ok {
|
||||||
|
allRules[i].ID = dmr.ID
|
||||||
|
allRules[i].Name = dmr.Name
|
||||||
|
}
|
||||||
|
dmsIdx++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Rules = allRules
|
||||||
|
result.DMSStatus = parseResult.DMSStatus
|
||||||
|
|
||||||
|
default:
|
||||||
|
log.Fatalf("Unknown compositor: %s", compositor)
|
||||||
|
}
|
||||||
|
|
||||||
|
output, _ := json.Marshal(result)
|
||||||
|
fmt.Fprintln(os.Stdout, string(output))
|
||||||
|
}
|
||||||
|
|
||||||
|
func runWindowrulesAdd(cmd *cobra.Command, args []string) {
|
||||||
|
compositor := strings.ToLower(args[0])
|
||||||
|
ruleJSON := args[1]
|
||||||
|
|
||||||
|
var rule windowrules.WindowRule
|
||||||
|
if err := json.Unmarshal([]byte(ruleJSON), &rule); err != nil {
|
||||||
|
writeRuleError(fmt.Sprintf("Invalid JSON: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
if rule.ID == "" {
|
||||||
|
rule.ID = generateRuleID()
|
||||||
|
}
|
||||||
|
rule.Enabled = true
|
||||||
|
|
||||||
|
provider := getWindowRulesProvider(compositor)
|
||||||
|
if provider == nil {
|
||||||
|
writeRuleError(fmt.Sprintf("Unknown compositor: %s", compositor))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := provider.SetRule(rule); err != nil {
|
||||||
|
writeRuleError(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
writeRuleSuccess(rule.ID, provider.GetOverridePath())
|
||||||
|
}
|
||||||
|
|
||||||
|
func runWindowrulesUpdate(cmd *cobra.Command, args []string) {
|
||||||
|
compositor := strings.ToLower(args[0])
|
||||||
|
ruleID := args[1]
|
||||||
|
ruleJSON := args[2]
|
||||||
|
|
||||||
|
var rule windowrules.WindowRule
|
||||||
|
if err := json.Unmarshal([]byte(ruleJSON), &rule); err != nil {
|
||||||
|
writeRuleError(fmt.Sprintf("Invalid JSON: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
rule.ID = ruleID
|
||||||
|
|
||||||
|
provider := getWindowRulesProvider(compositor)
|
||||||
|
if provider == nil {
|
||||||
|
writeRuleError(fmt.Sprintf("Unknown compositor: %s", compositor))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := provider.SetRule(rule); err != nil {
|
||||||
|
writeRuleError(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
writeRuleSuccess(rule.ID, provider.GetOverridePath())
|
||||||
|
}
|
||||||
|
|
||||||
|
func runWindowrulesRemove(cmd *cobra.Command, args []string) {
|
||||||
|
compositor := strings.ToLower(args[0])
|
||||||
|
ruleID := args[1]
|
||||||
|
|
||||||
|
provider := getWindowRulesProvider(compositor)
|
||||||
|
if provider == nil {
|
||||||
|
writeRuleError(fmt.Sprintf("Unknown compositor: %s", compositor))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := provider.RemoveRule(ruleID); err != nil {
|
||||||
|
writeRuleError(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
writeRuleSuccess(ruleID, provider.GetOverridePath())
|
||||||
|
}
|
||||||
|
|
||||||
|
func runWindowrulesReorder(cmd *cobra.Command, args []string) {
|
||||||
|
compositor := strings.ToLower(args[0])
|
||||||
|
idsJSON := args[1]
|
||||||
|
|
||||||
|
var ids []string
|
||||||
|
if err := json.Unmarshal([]byte(idsJSON), &ids); err != nil {
|
||||||
|
writeRuleError(fmt.Sprintf("Invalid JSON array: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
provider := getWindowRulesProvider(compositor)
|
||||||
|
if provider == nil {
|
||||||
|
writeRuleError(fmt.Sprintf("Unknown compositor: %s", compositor))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := provider.ReorderRules(ids); err != nil {
|
||||||
|
writeRuleError(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
writeRuleSuccess("", provider.GetOverridePath())
|
||||||
|
}
|
||||||
|
|
||||||
|
func getWindowRulesProvider(compositor string) windowrules.WritableProvider {
|
||||||
|
switch compositor {
|
||||||
|
case "niri":
|
||||||
|
configDir, err := utils.ExpandPath("$HOME/.config/niri")
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return providers.NewNiriWritableProvider(configDir)
|
||||||
|
case "hyprland":
|
||||||
|
configDir, err := utils.ExpandPath("$HOME/.config/hypr")
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return providers.NewHyprlandWritableProvider(configDir)
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateRuleID() string {
|
||||||
|
return fmt.Sprintf("wr_%d", time.Now().UnixNano())
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
+10
-9
@@ -5,6 +5,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
"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().BoolP("daemon", "d", false, "Run in daemon mode")
|
||||||
runCmd.Flags().Bool("daemon-child", false, "Internal flag for daemon child process")
|
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().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")
|
runCmd.Flags().MarkHidden("daemon-child")
|
||||||
|
|
||||||
// Add subcommands to greeter
|
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd)
|
||||||
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd)
|
authCmd.AddCommand(authSyncCmd)
|
||||||
|
setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
|
||||||
// Add subcommands to update
|
|
||||||
updateCmd.AddCommand(updateCheckCmd)
|
updateCmd.AddCommand(updateCheckCmd)
|
||||||
|
|
||||||
// Add subcommands to plugins
|
|
||||||
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)
|
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)
|
||||||
|
|
||||||
// Add common commands to root
|
|
||||||
rootCmd.AddCommand(getCommonCommands()...)
|
rootCmd.AddCommand(getCommonCommands()...)
|
||||||
|
|
||||||
|
rootCmd.AddCommand(authCmd)
|
||||||
rootCmd.AddCommand(updateCmd)
|
rootCmd.AddCommand(updateCmd)
|
||||||
|
|
||||||
rootCmd.SetHelpTemplate(getHelpTemplate())
|
rootCmd.SetHelpTemplate(getHelpTemplate())
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
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.")
|
log.Fatal("This program should not be run as root. Exiting.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,33 +5,34 @@ package main
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
var Version = "dev"
|
var Version = "dev"
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
// Add flags
|
|
||||||
runCmd.Flags().BoolP("daemon", "d", false, "Run in daemon mode")
|
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("daemon-child", false, "Internal flag for daemon child process")
|
||||||
runCmd.Flags().Bool("session", false, "Session managed (like as a systemd unit)")
|
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")
|
runCmd.Flags().MarkHidden("daemon-child")
|
||||||
|
|
||||||
// Add subcommands to greeter
|
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd)
|
||||||
greeterCmd.AddCommand(greeterSyncCmd, greeterEnableCmd, greeterStatusCmd)
|
authCmd.AddCommand(authSyncCmd)
|
||||||
|
setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
|
||||||
// Add subcommands to plugins
|
|
||||||
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)
|
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)
|
||||||
|
|
||||||
// Add common commands to root
|
|
||||||
rootCmd.AddCommand(getCommonCommands()...)
|
rootCmd.AddCommand(getCommonCommands()...)
|
||||||
|
rootCmd.AddCommand(authCmd)
|
||||||
|
|
||||||
rootCmd.SetHelpTemplate(getHelpTemplate())
|
rootCmd.SetHelpTemplate(getHelpTemplate())
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// Block root
|
clipboard.MaybeServeAndExit()
|
||||||
if os.Geteuid() == 0 {
|
|
||||||
|
if os.Geteuid() == 0 && !isReadOnlyCommand(os.Args) {
|
||||||
log.Fatal("This program should not be run as root. Exiting.")
|
log.Fatal("This program should not be run as root. Exiting.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_output_management"
|
||||||
|
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||||
|
)
|
||||||
|
|
||||||
|
type randrOutput struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Scale float64 `json:"scale"`
|
||||||
|
Width int32 `json:"width"`
|
||||||
|
Height int32 `json:"height"`
|
||||||
|
Refresh int32 `json:"refresh"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type randrHead struct {
|
||||||
|
name string
|
||||||
|
enabled bool
|
||||||
|
scale float64
|
||||||
|
currentModeID uint32
|
||||||
|
modeIDs []uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
type randrMode struct {
|
||||||
|
width int32
|
||||||
|
height int32
|
||||||
|
refresh int32
|
||||||
|
}
|
||||||
|
|
||||||
|
type randrClient struct {
|
||||||
|
display *wlclient.Display
|
||||||
|
ctx *wlclient.Context
|
||||||
|
manager *wlr_output_management.ZwlrOutputManagerV1
|
||||||
|
heads map[uint32]*randrHead
|
||||||
|
modes map[uint32]*randrMode
|
||||||
|
done bool
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func queryRandr() ([]randrOutput, error) {
|
||||||
|
display, err := wlclient.Connect("")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to connect to Wayland: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c := &randrClient{
|
||||||
|
display: display,
|
||||||
|
ctx: display.Context(),
|
||||||
|
heads: make(map[uint32]*randrHead),
|
||||||
|
modes: make(map[uint32]*randrMode),
|
||||||
|
}
|
||||||
|
defer c.ctx.Close()
|
||||||
|
|
||||||
|
registry, err := display.GetRegistry()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get registry: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) {
|
||||||
|
if e.Interface == wlr_output_management.ZwlrOutputManagerV1InterfaceName {
|
||||||
|
mgr := wlr_output_management.NewZwlrOutputManagerV1(c.ctx)
|
||||||
|
version := min(e.Version, 4)
|
||||||
|
|
||||||
|
mgr.SetHeadHandler(func(e wlr_output_management.ZwlrOutputManagerV1HeadEvent) {
|
||||||
|
c.handleHead(e)
|
||||||
|
})
|
||||||
|
|
||||||
|
mgr.SetDoneHandler(func(e wlr_output_management.ZwlrOutputManagerV1DoneEvent) {
|
||||||
|
c.done = true
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := registry.Bind(e.Name, e.Interface, version, mgr); err == nil {
|
||||||
|
c.manager = mgr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// First roundtrip: discover globals and bind manager
|
||||||
|
syncCallback, err := display.Sync()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to sync display: %w", err)
|
||||||
|
}
|
||||||
|
syncCallback.SetDoneHandler(func(e wlclient.CallbackDoneEvent) {
|
||||||
|
if c.manager == nil {
|
||||||
|
c.err = fmt.Errorf("zwlr_output_manager_v1 protocol not supported by compositor")
|
||||||
|
c.done = true
|
||||||
|
}
|
||||||
|
// Otherwise wait for manager's DoneHandler
|
||||||
|
})
|
||||||
|
|
||||||
|
for !c.done {
|
||||||
|
if err := c.ctx.Dispatch(); err != nil {
|
||||||
|
return nil, fmt.Errorf("dispatch error: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.err != nil {
|
||||||
|
return nil, c.err
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.buildOutputs(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *randrClient) handleHead(e wlr_output_management.ZwlrOutputManagerV1HeadEvent) {
|
||||||
|
handle := e.Head
|
||||||
|
headID := handle.ID()
|
||||||
|
|
||||||
|
head := &randrHead{
|
||||||
|
modeIDs: make([]uint32, 0),
|
||||||
|
}
|
||||||
|
c.heads[headID] = head
|
||||||
|
|
||||||
|
handle.SetNameHandler(func(e wlr_output_management.ZwlrOutputHeadV1NameEvent) {
|
||||||
|
head.name = e.Name
|
||||||
|
})
|
||||||
|
|
||||||
|
handle.SetEnabledHandler(func(e wlr_output_management.ZwlrOutputHeadV1EnabledEvent) {
|
||||||
|
head.enabled = e.Enabled != 0
|
||||||
|
})
|
||||||
|
|
||||||
|
handle.SetScaleHandler(func(e wlr_output_management.ZwlrOutputHeadV1ScaleEvent) {
|
||||||
|
head.scale = e.Scale
|
||||||
|
})
|
||||||
|
|
||||||
|
handle.SetCurrentModeHandler(func(e wlr_output_management.ZwlrOutputHeadV1CurrentModeEvent) {
|
||||||
|
head.currentModeID = e.Mode.ID()
|
||||||
|
})
|
||||||
|
|
||||||
|
handle.SetModeHandler(func(e wlr_output_management.ZwlrOutputHeadV1ModeEvent) {
|
||||||
|
modeHandle := e.Mode
|
||||||
|
modeID := modeHandle.ID()
|
||||||
|
|
||||||
|
head.modeIDs = append(head.modeIDs, modeID)
|
||||||
|
|
||||||
|
mode := &randrMode{}
|
||||||
|
c.modes[modeID] = mode
|
||||||
|
|
||||||
|
modeHandle.SetSizeHandler(func(e wlr_output_management.ZwlrOutputModeV1SizeEvent) {
|
||||||
|
mode.width = e.Width
|
||||||
|
mode.height = e.Height
|
||||||
|
})
|
||||||
|
|
||||||
|
modeHandle.SetRefreshHandler(func(e wlr_output_management.ZwlrOutputModeV1RefreshEvent) {
|
||||||
|
mode.refresh = e.Refresh
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *randrClient) buildOutputs() []randrOutput {
|
||||||
|
outputs := make([]randrOutput, 0, len(c.heads))
|
||||||
|
|
||||||
|
for _, head := range c.heads {
|
||||||
|
out := randrOutput{
|
||||||
|
Name: head.name,
|
||||||
|
Scale: head.scale,
|
||||||
|
Enabled: head.enabled,
|
||||||
|
}
|
||||||
|
|
||||||
|
if mode, ok := c.modes[head.currentModeID]; ok {
|
||||||
|
out.Width = mode.width
|
||||||
|
out.Height = mode.height
|
||||||
|
out.Refresh = mode.refresh
|
||||||
|
}
|
||||||
|
|
||||||
|
outputs = append(outputs, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
return outputs
|
||||||
|
}
|
||||||
+114
-8
@@ -80,6 +80,16 @@ func getRuntimeDir() string {
|
|||||||
return os.TempDir()
|
return os.TempDir()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func appendLogEnv(env []string) []string {
|
||||||
|
if v := os.Getenv("DMS_LOG_LEVEL"); v != "" {
|
||||||
|
env = append(env, "DMS_LOG_LEVEL="+v)
|
||||||
|
}
|
||||||
|
if v := os.Getenv("DMS_LOG_FILE"); v != "" {
|
||||||
|
env = append(env, "DMS_LOG_FILE="+v)
|
||||||
|
}
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
func hasSystemdRun() bool {
|
func hasSystemdRun() bool {
|
||||||
_, err := exec.LookPath("systemd-run")
|
_, err := exec.LookPath("systemd-run")
|
||||||
return err == nil
|
return err == nil
|
||||||
@@ -210,9 +220,11 @@ func runShellInteractive(session bool) {
|
|||||||
cmd.Env = append(cmd.Env, "QT_QPA_PLATFORMTHEME_QT6=gtk3")
|
cmd.Env = append(cmd.Env, "QT_QPA_PLATFORMTHEME_QT6=gtk3")
|
||||||
}
|
}
|
||||||
if os.Getenv("QT_QPA_PLATFORM") == "" {
|
if os.Getenv("QT_QPA_PLATFORM") == "" {
|
||||||
cmd.Env = append(cmd.Env, "QT_QPA_PLATFORM=wayland")
|
cmd.Env = append(cmd.Env, "QT_QPA_PLATFORM=wayland;xcb")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cmd.Env = appendLogEnv(cmd.Env)
|
||||||
|
|
||||||
cmd.Stdin = os.Stdin
|
cmd.Stdin = os.Stdin
|
||||||
cmd.Stdout = os.Stdout
|
cmd.Stdout = os.Stdout
|
||||||
cmd.Stderr = os.Stderr
|
cmd.Stderr = os.Stderr
|
||||||
@@ -432,6 +444,9 @@ func runShellDaemon(session bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ! TODO - remove when QS 0.3 is up and we can use the pragma
|
||||||
|
cmd.Env = append(cmd.Env, "QS_APP_ID=com.danklinux.dms")
|
||||||
|
|
||||||
if isSessionManaged && hasSystemdRun() {
|
if isSessionManaged && hasSystemdRun() {
|
||||||
cmd.Env = append(cmd.Env, "DMS_DEFAULT_LAUNCH_PREFIX=systemd-run --user --scope")
|
cmd.Env = append(cmd.Env, "DMS_DEFAULT_LAUNCH_PREFIX=systemd-run --user --scope")
|
||||||
}
|
}
|
||||||
@@ -450,9 +465,11 @@ func runShellDaemon(session bool) {
|
|||||||
cmd.Env = append(cmd.Env, "QT_QPA_PLATFORMTHEME_QT6=gtk3")
|
cmd.Env = append(cmd.Env, "QT_QPA_PLATFORMTHEME_QT6=gtk3")
|
||||||
}
|
}
|
||||||
if os.Getenv("QT_QPA_PLATFORM") == "" {
|
if os.Getenv("QT_QPA_PLATFORM") == "" {
|
||||||
cmd.Env = append(cmd.Env, "QT_QPA_PLATFORM=wayland")
|
cmd.Env = append(cmd.Env, "QT_QPA_PLATFORM=wayland;xcb")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cmd.Env = appendLogEnv(cmd.Env)
|
||||||
|
|
||||||
devNull, err := os.OpenFile("/dev/null", os.O_RDWR, 0)
|
devNull, err := os.OpenFile("/dev/null", os.O_RDWR, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Error opening /dev/null: %v", err)
|
log.Fatalf("Error opening /dev/null: %v", err)
|
||||||
@@ -616,11 +633,47 @@ func getShellIPCCompletions(args []string, _ string) []string {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getFirstDMSPID() (int, bool) {
|
||||||
|
dir := getRuntimeDir()
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !strings.HasPrefix(entry.Name(), "danklinux-") || !strings.HasSuffix(entry.Name(), ".pid") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(filepath.Join(dir, entry.Name()))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
proc, err := os.FindProcess(pid)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if proc.Signal(syscall.Signal(0)) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
return pid, true
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
func runShellIPCCommand(args []string) {
|
func runShellIPCCommand(args []string) {
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
log.Error("IPC command requires arguments")
|
printIPCHelp()
|
||||||
log.Info("Usage: dms ipc <command> [args...]")
|
return
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if args[0] != "call" {
|
if args[0] != "call" {
|
||||||
@@ -628,10 +681,21 @@ func runShellIPCCommand(args []string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cmdArgs := []string{"ipc"}
|
cmdArgs := []string{"ipc"}
|
||||||
if qsHasAnyDisplay() {
|
|
||||||
cmdArgs = append(cmdArgs, "--any-display")
|
switch pid, ok := getFirstDMSPID(); {
|
||||||
|
case ok:
|
||||||
|
cmdArgs = append(cmdArgs, "--pid", strconv.Itoa(pid))
|
||||||
|
default:
|
||||||
|
if err := findConfig(nil, nil); err != nil {
|
||||||
|
log.Fatalf("Error finding config: %v", err)
|
||||||
|
}
|
||||||
|
// ! TODO - remove check when QS 0.3 is released
|
||||||
|
if qsHasAnyDisplay() {
|
||||||
|
cmdArgs = append(cmdArgs, "--any-display")
|
||||||
|
}
|
||||||
|
cmdArgs = append(cmdArgs, "-p", configPath)
|
||||||
}
|
}
|
||||||
cmdArgs = append(cmdArgs, "-p", configPath)
|
|
||||||
cmdArgs = append(cmdArgs, args...)
|
cmdArgs = append(cmdArgs, args...)
|
||||||
cmd := exec.Command("qs", cmdArgs...)
|
cmd := exec.Command("qs", cmdArgs...)
|
||||||
cmd.Stdin = os.Stdin
|
cmd.Stdin = os.Stdin
|
||||||
@@ -642,3 +706,45 @@ func runShellIPCCommand(args []string) {
|
|||||||
log.Fatalf("Error running IPC command: %v", err)
|
log.Fatalf("Error running IPC command: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func printIPCHelp() {
|
||||||
|
fmt.Println("Usage: dms ipc <target> <function> [args...]")
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
cmdArgs := []string{"ipc"}
|
||||||
|
if qsHasAnyDisplay() {
|
||||||
|
cmdArgs = append(cmdArgs, "--any-display")
|
||||||
|
}
|
||||||
|
cmdArgs = append(cmdArgs, "-p", configPath, "show")
|
||||||
|
cmd := exec.Command("qs", cmdArgs...)
|
||||||
|
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("Could not retrieve available IPC targets (is DMS running?)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
targets := parseTargetsFromIPCShowOutput(string(output))
|
||||||
|
if len(targets) == 0 {
|
||||||
|
fmt.Println("No IPC targets available")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Targets:")
|
||||||
|
|
||||||
|
targetNames := make([]string, 0, len(targets))
|
||||||
|
for name := range targets {
|
||||||
|
targetNames = append(targetNames, name)
|
||||||
|
}
|
||||||
|
slices.Sort(targetNames)
|
||||||
|
|
||||||
|
for _, targetName := range targetNames {
|
||||||
|
funcs := targets[targetName]
|
||||||
|
funcNames := make([]string, 0, len(funcs))
|
||||||
|
for fn := range funcs {
|
||||||
|
funcNames = append(funcNames, fn)
|
||||||
|
}
|
||||||
|
slices.Sort(funcNames)
|
||||||
|
fmt.Printf(" %-16s %s\n", targetName, strings.Join(funcNames, ", "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+13
-5
@@ -7,12 +7,20 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
func findCommandPath(cmd string) (string, error) {
|
// isReadOnlyCommand returns true if the CLI args indicate a command that is
|
||||||
path, err := exec.LookPath(cmd)
|
// safe to run as root (e.g. shell completion, help).
|
||||||
if err != nil {
|
func isReadOnlyCommand(args []string) bool {
|
||||||
return "", fmt.Errorf("command '%s' not found in PATH", cmd)
|
for _, arg := range args[1:] {
|
||||||
|
if strings.HasPrefix(arg, "-") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch arg {
|
||||||
|
case "completion", "help", "__complete", "system":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
return path, nil
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func isArchPackageInstalled(packageName string) bool {
|
func isArchPackageInstalled(packageName string) bool {
|
||||||
|
|||||||
+63
-32
@@ -1,72 +1,103 @@
|
|||||||
module github.com/AvengeMedia/DankMaterialShell/core
|
module github.com/AvengeMedia/DankMaterialShell/core
|
||||||
|
|
||||||
go 1.24.6
|
go 1.26.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/Wifx/gonetworkmanager/v2 v2.2.0
|
github.com/Wifx/gonetworkmanager/v2 v2.2.0
|
||||||
github.com/charmbracelet/bubbles v0.21.0
|
github.com/alecthomas/chroma/v2 v2.24.1
|
||||||
|
github.com/charmbracelet/bubbles v1.0.0
|
||||||
github.com/charmbracelet/bubbletea v1.3.10
|
github.com/charmbracelet/bubbletea v1.3.10
|
||||||
github.com/charmbracelet/lipgloss v1.1.0
|
github.com/charmbracelet/lipgloss v1.1.0
|
||||||
github.com/charmbracelet/log v0.4.2
|
github.com/charmbracelet/log v1.0.0
|
||||||
github.com/fsnotify/fsnotify v1.9.0
|
github.com/fsnotify/fsnotify v1.10.1
|
||||||
github.com/godbus/dbus/v5 v5.2.2
|
github.com/godbus/dbus/v5 v5.2.2
|
||||||
github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83
|
github.com/holoplot/go-evdev v0.0.0-20260504100651-66d1748fe847
|
||||||
github.com/pilebones/go-udev v0.9.1
|
github.com/pilebones/go-udev v0.9.1
|
||||||
github.com/sblinch/kdl-go v0.0.0-20251203232544-981d4ecc17c3
|
github.com/sblinch/kdl-go v0.0.0-20260121213736-8b7053306ca6
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
|
github.com/yeqown/go-qrcode/v2 v2.2.5
|
||||||
|
github.com/yeqown/go-qrcode/writer/standard v1.3.0
|
||||||
|
github.com/yuin/goldmark v1.8.2
|
||||||
|
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
|
||||||
go.etcd.io/bbolt v1.4.3
|
go.etcd.io/bbolt v1.4.3
|
||||||
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93
|
go4.org/mem v0.0.0-20240501181205-ae6ca9944745
|
||||||
golang.org/x/image v0.34.0
|
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f
|
||||||
|
golang.org/x/image v0.39.0
|
||||||
|
tailscale.com v1.96.5
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
filippo.io/edwards25519 v1.2.0 // indirect
|
||||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||||
github.com/ProtonMail/go-crypto v1.3.0 // indirect
|
github.com/ProtonMail/go-crypto v1.4.1 // indirect
|
||||||
github.com/clipperhouse/displaywidth v0.6.2 // indirect
|
github.com/akutz/memconn v0.1.0 // indirect
|
||||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||||
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
|
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||||
github.com/cloudflare/circl v1.6.2 // indirect
|
github.com/cloudflare/circl v1.6.3 // indirect
|
||||||
|
github.com/coder/websocket v1.8.14 // indirect
|
||||||
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
|
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
|
||||||
|
github.com/dblohm7/wingoes v0.0.0-20250822163801-6d8e6105c62d // indirect
|
||||||
|
github.com/dlclark/regexp2 v1.12.0 // indirect
|
||||||
github.com/emirpasic/gods v1.18.1 // indirect
|
github.com/emirpasic/gods v1.18.1 // indirect
|
||||||
|
github.com/fogleman/gg v1.3.0 // indirect
|
||||||
|
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
|
||||||
github.com/go-git/gcfg/v2 v2.0.2 // indirect
|
github.com/go-git/gcfg/v2 v2.0.2 // indirect
|
||||||
github.com/go-git/go-billy/v6 v6.0.0-20251217170237-e9738f50a3cd // indirect
|
github.com/go-git/go-billy/v6 v6.0.0-20260504142752-cb8e9d337266 // indirect
|
||||||
|
github.com/go-json-experiment/json v0.0.0-20260430182902-b6187a392ed4 // indirect
|
||||||
github.com/go-logfmt/logfmt v0.6.1 // indirect
|
github.com/go-logfmt/logfmt v0.6.1 // indirect
|
||||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||||
github.com/kevinburke/ssh_config v1.4.0 // indirect
|
github.com/google/go-cmp v0.7.0 // indirect
|
||||||
|
github.com/hdevalence/ed25519consensus v0.2.0 // indirect
|
||||||
|
github.com/jsimonetti/rtnetlink v1.4.2 // indirect
|
||||||
|
github.com/kevinburke/ssh_config v1.6.0 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
github.com/pjbgf/sha1cd v0.5.0 // indirect
|
github.com/mdlayher/netlink v1.11.1 // indirect
|
||||||
|
github.com/mdlayher/socket v0.6.0 // indirect
|
||||||
|
github.com/mitchellh/go-ps v1.0.0 // indirect
|
||||||
|
github.com/pjbgf/sha1cd v0.6.0 // indirect
|
||||||
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/sergi/go-diff v1.4.0 // indirect
|
github.com/sergi/go-diff v1.4.0 // indirect
|
||||||
github.com/stretchr/objx v0.5.3 // indirect
|
github.com/stretchr/objx v0.5.3 // indirect
|
||||||
golang.org/x/crypto v0.46.0 // indirect
|
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect
|
||||||
golang.org/x/net v0.48.0 // indirect
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
|
github.com/yeqown/reedsolomon v1.0.0 // indirect
|
||||||
|
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
|
||||||
|
golang.org/x/crypto v0.50.0 // indirect
|
||||||
|
golang.org/x/net v0.53.0 // indirect
|
||||||
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
|
golang.zx2c4.com/wireguard/windows v1.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/atotto/clipboard v0.1.4 // indirect
|
github.com/atotto/clipboard v0.1.4 // indirect
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
||||||
github.com/charmbracelet/harmonica v0.2.0 // indirect
|
github.com/charmbracelet/harmonica v0.2.0 // indirect
|
||||||
github.com/charmbracelet/x/ansi v0.11.3 // indirect
|
github.com/charmbracelet/x/ansi v0.11.7 // indirect
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.14 // indirect
|
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
github.com/go-git/go-git/v6 v6.0.0-20251231065035-29ae690a9f19
|
github.com/go-git/go-git/v6 v6.0.0-alpha.2
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0
|
github.com/lucasb-eyer/go-colorful v1.4.0
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.22
|
||||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
github.com/mattn/go-runewidth v0.0.23 // indirect
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
github.com/muesli/termenv v0.16.0 // indirect
|
github.com/muesli/termenv v0.16.0
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/spf13/afero v1.15.0
|
github.com/spf13/afero v1.15.0
|
||||||
github.com/spf13/pflag v1.0.10 // indirect
|
github.com/spf13/pflag v1.0.10 // indirect
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
golang.org/x/sys v0.39.0
|
golang.org/x/sys v0.43.0
|
||||||
golang.org/x/text v0.32.0
|
golang.org/x/text v0.36.0
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// v0.0.1 tag is missing a LICENSE file; master has it.
|
||||||
|
// See: https://github.com/mattn/go-localereader/issues/2
|
||||||
|
replace github.com/mattn/go-localereader v0.0.1 => github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75
|
||||||
|
|||||||
+144
-95
@@ -1,9 +1,21 @@
|
|||||||
|
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||||
|
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
|
github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM=
|
||||||
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
|
github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
|
||||||
github.com/Wifx/gonetworkmanager/v2 v2.2.0 h1:kPstgsQtY8CmDOOFZd81ytM9Gi3f6ImzPCKF7nNhQ2U=
|
github.com/Wifx/gonetworkmanager/v2 v2.2.0 h1:kPstgsQtY8CmDOOFZd81ytM9Gi3f6ImzPCKF7nNhQ2U=
|
||||||
github.com/Wifx/gonetworkmanager/v2 v2.2.0/go.mod h1:fMDb//SHsKWxyDUAwXvCqurV3npbIyyaQWenGpZ/uXg=
|
github.com/Wifx/gonetworkmanager/v2 v2.2.0/go.mod h1:fMDb//SHsKWxyDUAwXvCqurV3npbIyyaQWenGpZ/uXg=
|
||||||
|
github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A=
|
||||||
|
github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw=
|
||||||
|
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||||
|
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||||
|
github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs=
|
||||||
|
github.com/alecthomas/chroma/v2 v2.24.1 h1:m5ffpfZbIb++k8AqFEKy9uVgY12xIQtBsQlc6DfZJQM=
|
||||||
|
github.com/alecthomas/chroma/v2 v2.24.1/go.mod h1:l+ohZ9xRXIbGe7cIW+YZgOGbvuVLjMps/FYN/CwuabI=
|
||||||
|
github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
|
||||||
|
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
|
||||||
|
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
||||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
||||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
|
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
|
||||||
@@ -12,87 +24,99 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z
|
|||||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||||
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
|
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
|
||||||
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
|
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
|
||||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||||
github.com/charmbracelet/colorprofile v0.3.3 h1:DjJzJtLP6/NZ8p7Cgjno0CKGr7wwRJGxWUwh2IyhfAI=
|
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
||||||
github.com/charmbracelet/colorprofile v0.3.3/go.mod h1:nB1FugsAbzq284eJcjfah2nhdSLppN2NqvfotkfRYP4=
|
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
||||||
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
|
||||||
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
|
||||||
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
|
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
|
||||||
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
|
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
|
||||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||||
github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig=
|
github.com/charmbracelet/log v1.0.0 h1:HVVVMmfOorfj3BA9i8X8UL69Hoz9lI0PYwXfJvOdRc4=
|
||||||
github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw=
|
github.com/charmbracelet/log v1.0.0/go.mod h1:uYgY3SmLpwJWxmlrPwXvzVYujxis1vAKRV/0VQB7yWA=
|
||||||
github.com/charmbracelet/x/ansi v0.11.2 h1:XAG3FSjiVtFvgEgGrNBkCNNYrsucAt8c6bfxHyROLLs=
|
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
|
||||||
github.com/charmbracelet/x/ansi v0.11.2/go.mod h1:9tY2bzX5SiJCU0iWyskjBeI2BRQfvPqI+J760Mjf+Rg=
|
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
|
||||||
github.com/charmbracelet/x/ansi v0.11.3 h1:6DcVaqWI82BBVM/atTyq6yBoRLZFBsnoDoX9GCu2YOI=
|
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||||
github.com/charmbracelet/x/ansi v0.11.3/go.mod h1:yI7Zslym9tCJcedxz5+WBq+eUGMJT0bM06Fqy1/Y4dI=
|
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.14 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaLOXuc+/Kj4=
|
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA=
|
|
||||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||||
github.com/clipperhouse/displaywidth v0.6.0 h1:k32vueaksef9WIKCNcoqRNyKbyvkvkysNYnAWz2fN4s=
|
github.com/cilium/ebpf v0.16.0 h1:+BiEnHL6Z7lXnlGUsXQPPAE7+kenAd4ES8MQ5min0Ok=
|
||||||
github.com/clipperhouse/displaywidth v0.6.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
|
github.com/cilium/ebpf v0.16.0/go.mod h1:L7u2Blt2jMM/vLAVgjxluxtBKlz3/GWjB0dMOEngfwE=
|
||||||
github.com/clipperhouse/displaywidth v0.6.2 h1:ZDpTkFfpHOKte4RG5O/BOyf3ysnvFswpyYrV7z2uAKo=
|
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||||
github.com/clipperhouse/displaywidth v0.6.2/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
|
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||||
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
|
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
||||||
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
||||||
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
|
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
|
||||||
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
|
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||||
github.com/cloudflare/circl v1.6.2 h1:hL7VBpHHKzrV5WTfHCaBsgx/HGbBYlgrwvNXEVDYYsQ=
|
|
||||||
github.com/cloudflare/circl v1.6.2/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
|
github.com/creachadair/taskgroup v0.13.2 h1:3KyqakBuFsm3KkXi/9XIb0QcA8tEzLHLgaoidf0MdVc=
|
||||||
|
github.com/creachadair/taskgroup v0.13.2/go.mod h1:i3V1Zx7H8RjwljUEeUWYT30Lmb9poewSb2XI1yTwD0g=
|
||||||
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
|
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
|
||||||
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
|
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dblohm7/wingoes v0.0.0-20250822163801-6d8e6105c62d h1:QRKpU+9ZBDs62LyBfwhZkJdB5DJX2Sm3p4kUh7l1aA0=
|
||||||
|
github.com/dblohm7/wingoes v0.0.0-20250822163801-6d8e6105c62d/go.mod h1:SUxUaAK/0UG5lYyZR1L1nC4AaYYvSSYTWQSH3FPcxKU=
|
||||||
|
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
|
||||||
|
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
|
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
|
||||||
|
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
||||||
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
|
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
|
||||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
|
||||||
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
|
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||||
|
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||||
|
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
|
||||||
|
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||||
|
github.com/gaissmai/bart v0.26.1 h1:+w4rnLGNlA2GDVn382Tfe3jOsK5vOr5n4KmigJ9lbTo=
|
||||||
|
github.com/gaissmai/bart v0.26.1/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c=
|
||||||
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
||||||
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
||||||
github.com/go-git/gcfg/v2 v2.0.2 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo=
|
github.com/go-git/gcfg/v2 v2.0.2 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo=
|
||||||
github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs=
|
github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs=
|
||||||
github.com/go-git/go-billy/v6 v6.0.0-20251126203821-7f9c95185ee0 h1:eY5aB2GXiVdgTueBcqsBt53WuJTRZAuCdIS/86Pcq5c=
|
github.com/go-git/go-billy/v6 v6.0.0-20260504142752-cb8e9d337266 h1:wH21vHuv323v9x78JNFNJ6P7HEAsdwr9yq2k9/o4zEE=
|
||||||
github.com/go-git/go-billy/v6 v6.0.0-20251126203821-7f9c95185ee0/go.mod h1:0NjwVNrwtVFZBReAp5OoGklGJIgJFEbVyHneAr4lc8k=
|
github.com/go-git/go-billy/v6 v6.0.0-20260504142752-cb8e9d337266/go.mod h1:CdBVp7CXl9l3sOyNEog46cP1Pvx/hjCe9AD0mtaIUYU=
|
||||||
github.com/go-git/go-billy/v6 v6.0.0-20251217170237-e9738f50a3cd h1:Gd/f9cGi/3h1JOPaa6er+CkKUGyGX2DBJdFbDKVO+R0=
|
github.com/go-git/go-git-fixtures/v6 v6.0.0-20260405195209-b16dd39735e0 h1:XoTsdvaghuVfIr7HpNTmFDLu2nz3I2iGqyn6Uk6MkJc=
|
||||||
github.com/go-git/go-billy/v6 v6.0.0-20251217170237-e9738f50a3cd/go.mod h1:d3XQcsHu1idnquxt48kAv+h+1MUiYKLH/e7LAzjP+pI=
|
github.com/go-git/go-git-fixtures/v6 v6.0.0-20260405195209-b16dd39735e0/go.mod h1:1Lr7/vYEYyl6Ir9Ku0tKrCIRreM5zovv0Jdx2MPSM4s=
|
||||||
github.com/go-git/go-git-fixtures/v5 v5.1.1 h1:OH8i1ojV9bWfr0ZfasfpgtUXQHQyVS8HXik/V1C099w=
|
github.com/go-git/go-git/v6 v6.0.0-alpha.2 h1:T3loNtDuAixNzXtlQxZhnYiYpaQ3CA4vn9RssAniEeI=
|
||||||
github.com/go-git/go-git-fixtures/v5 v5.1.1/go.mod h1:Altk43lx3b1ks+dVoAG2300o5WWUnktvfY3VI6bcaXU=
|
github.com/go-git/go-git/v6 v6.0.0-alpha.2/go.mod h1:oCD3i19CTz7gBpeb11ZZqL91WzqbMq9avn5KpUYy/Ak=
|
||||||
github.com/go-git/go-git-fixtures/v5 v5.1.2-0.20251229094738-4b14af179146 h1:xYfxAopYyL44ot6dMBIb1Z1njFM0ZBQ99HdIB99KxLs=
|
github.com/go-json-experiment/json v0.0.0-20260430182902-b6187a392ed4 h1:2WmHkJINIjgXXYDGik8d3oJvFA3DAwPy00csDJ3vo+o=
|
||||||
github.com/go-git/go-git/v6 v6.0.0-20251128074608-48f817f57805 h1:jxQ3BzYeErNRvlI/4+0mpwqMzvB4g97U+ksfgvrUEbY=
|
github.com/go-json-experiment/json v0.0.0-20260430182902-b6187a392ed4/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
|
||||||
github.com/go-git/go-git/v6 v6.0.0-20251128074608-48f817f57805/go.mod h1:dIwT3uWK1ooHInyVnK2JS5VfQ3peVGYaw2QPqX7uFvs=
|
|
||||||
github.com/go-git/go-git/v6 v6.0.0-20251231065035-29ae690a9f19 h1:0lz2eJScP8v5YZQsrEw+ggWC5jNySjg4bIZo5BIh6iI=
|
|
||||||
github.com/go-git/go-git/v6 v6.0.0-20251231065035-29ae690a9f19/go.mod h1:L+Evfcs7EdTqxwv854354cb6+++7TFL3hJn3Wy4g+3w=
|
|
||||||
github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE=
|
github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE=
|
||||||
github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk=
|
github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk=
|
||||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
github.com/godbus/dbus/v5 v5.2.0 h1:3WexO+U+yg9T70v9FdHr9kCxYlazaAXUhx2VMkbfax8=
|
|
||||||
github.com/godbus/dbus/v5 v5.2.0/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
|
||||||
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||||
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||||
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
|
||||||
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
||||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
||||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83 h1:B+A58zGFuDrvEZpPN+yS6swJA0nzqgZvDzgl/OPyefU=
|
github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU=
|
||||||
github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83/go.mod h1:iHAf8OIncO2gcQ8XOjS7CMJ2aPbX2Bs0wl5pZyanEqk=
|
github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo=
|
||||||
|
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||||
|
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||||
|
github.com/holoplot/go-evdev v0.0.0-20260504100651-66d1748fe847 h1:1rQ5UQXFm02DXEtsIpotfA32WJ9KceS6t8w5K8QtFqc=
|
||||||
|
github.com/holoplot/go-evdev v0.0.0-20260504100651-66d1748fe847/go.mod h1:iHAf8OIncO2gcQ8XOjS7CMJ2aPbX2Bs0wl5pZyanEqk=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PWkxoFkQ=
|
github.com/jsimonetti/rtnetlink v1.4.2 h1:Df9w9TZ3npHTyDn0Ev9e1uzmN2odmXd0QX+J5GTEn90=
|
||||||
github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
|
github.com/jsimonetti/rtnetlink v1.4.2/go.mod h1:92s6LJdE+1iOrw+F2/RO7LYI2Qd8pPpFNNUYW06gcoM=
|
||||||
|
github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY=
|
||||||
|
github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
@@ -102,14 +126,20 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
|||||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75 h1:P8UmIzZMYDR+NGImiFvErt6VWfIRPuGM+vyjiEdkmIw=
|
||||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
|
||||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||||
|
github.com/mdlayher/netlink v1.11.1 h1:T136gDS6Gkt+hLncaBwKdW5GpEC8Z0ykqimOebVoal0=
|
||||||
|
github.com/mdlayher/netlink v1.11.1/go.mod h1:ao4LjamyK4Uq9L8+fQzqFYpAncbeCdwbvd9Edv/pYnc=
|
||||||
|
github.com/mdlayher/socket v0.6.0 h1:ScZPaAGyO1icQnbFrhPM8mnXyMu9qukC1K4ZoM2IQKU=
|
||||||
|
github.com/mdlayher/socket v0.6.0/go.mod h1:q7vozUAnxSqnjHc12Fik5yUKIzfZ8ITCfMkhOtE9z18=
|
||||||
|
github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc=
|
||||||
|
github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg=
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||||
@@ -118,25 +148,24 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc
|
|||||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||||
github.com/pilebones/go-udev v0.9.1 h1:uN72M1C1fgzhsVmBGEM8w9RD1JY4iVsPZpr+Z6rb3O8=
|
github.com/pilebones/go-udev v0.9.1 h1:uN72M1C1fgzhsVmBGEM8w9RD1JY4iVsPZpr+Z6rb3O8=
|
||||||
github.com/pilebones/go-udev v0.9.1/go.mod h1:Bgcl07crebF3JSeS4+nuaRvhWFdCeFoBhXXeAp93XNo=
|
github.com/pilebones/go-udev v0.9.1/go.mod h1:Bgcl07crebF3JSeS4+nuaRvhWFdCeFoBhXXeAp93XNo=
|
||||||
github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
|
github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU=
|
||||||
github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
|
github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/sblinch/kdl-go v0.0.0-20250930225324-bf4099d4614a h1:8ZZwZWIQKC0YVMyaCkbrdeI8faTjD1QBrRAAWc1TjMI=
|
github.com/sblinch/kdl-go v0.0.0-20260121213736-8b7053306ca6 h1:JsjzqC6ymELkN4XlTjZPSahSAem21GySugLbKz6uF5E=
|
||||||
github.com/sblinch/kdl-go v0.0.0-20250930225324-bf4099d4614a/go.mod h1:b3oNGuAKOQzhsCKmuLc/urEOPzgHj6fB8vl8bwTBh28=
|
github.com/sblinch/kdl-go v0.0.0-20260121213736-8b7053306ca6/go.mod h1:b3oNGuAKOQzhsCKmuLc/urEOPzgHj6fB8vl8bwTBh28=
|
||||||
github.com/sblinch/kdl-go v0.0.0-20251203232544-981d4ecc17c3 h1:msKaIZrrNpvofLPDzNBW3152PJBsnPZsoNNosOCS+C0=
|
|
||||||
github.com/sblinch/kdl-go v0.0.0-20251203232544-981d4ecc17c3/go.mod h1:b3oNGuAKOQzhsCKmuLc/urEOPzgHj6fB8vl8bwTBh28=
|
|
||||||
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
|
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
|
||||||
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||||
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
|
|
||||||
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
|
|
||||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
@@ -146,45 +175,65 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
|
|||||||
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
|
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
|
||||||
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
|
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
|
||||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4=
|
||||||
|
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg=
|
||||||
|
github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da h1:jVRUZPRs9sqyKlYHHzHjAqKN+6e/Vog6NpHYeNPJqOw=
|
||||||
|
github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4=
|
||||||
|
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||||
|
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
|
github.com/yeqown/go-qrcode/v2 v2.2.5 h1:HCOe2bSjkhZyYoyyNaXNzh4DJZll6inVJQQw+8228Zk=
|
||||||
|
github.com/yeqown/go-qrcode/v2 v2.2.5/go.mod h1:uHpt9CM0V1HeXLz+Wg5MN50/sI/fQhfkZlOM+cOTHxw=
|
||||||
|
github.com/yeqown/go-qrcode/writer/standard v1.3.0 h1:chdyhEfRtUPgQtuPeaWVGQ/TQx4rE1PqeoW3U+53t34=
|
||||||
|
github.com/yeqown/go-qrcode/writer/standard v1.3.0/go.mod h1:O4MbzsotGCvy8upYPCR91j81dr5XLT7heuljcNXW+oQ=
|
||||||
|
github.com/yeqown/reedsolomon v1.0.0 h1:x1h/Ej/uJnNu8jaX7GLHBWmZKCAWjEJTetkqaabr4B0=
|
||||||
|
github.com/yeqown/reedsolomon v1.0.0/go.mod h1:P76zpcn2TCuL0ul1Fso373qHRc69LKwAw/Iy6g1WiiM=
|
||||||
|
github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||||
|
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||||
|
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
|
||||||
|
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
|
||||||
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
|
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
|
||||||
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
|
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek=
|
||||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g=
|
||||||
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
|
||||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
|
||||||
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 h1:DHNhtq3sNNzrvduZZIiFyXWOL9IWaDPHqTnLJp+rCBY=
|
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||||
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
|
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||||
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0=
|
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
||||||
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
|
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
||||||
golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8=
|
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
|
||||||
golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU=
|
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
|
||||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
||||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
||||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||||
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||||
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
|
||||||
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
|
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
|
||||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
golang.zx2c4.com/wireguard/windows v1.0.1 h1:eOxiDVbywPC+ZQqvdCK7x+ZwWXKbYv50TtH8ysFIbw8=
|
||||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
golang.zx2c4.com/wireguard/windows v1.0.1/go.mod h1:+fbT3FFdX4zzYDLwJh5+HPEcNN/3HyNdzhNSVsQM+zs=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
|
||||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
tailscale.com v1.96.5 h1:gNkfA/KSZAl6jCH9cj8urq00HRWItDDTtGsyATI89jA=
|
||||||
|
tailscale.com v1.96.5/go.mod h1:/3lnZBYb2UEwnN0MNu2SDXUtT06AGd5k0s+OWx3WmcY=
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package blur
|
||||||
|
|
||||||
|
import (
|
||||||
|
wlhelpers "github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/client"
|
||||||
|
client "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||||
|
)
|
||||||
|
|
||||||
|
const extBackgroundEffectInterface = "ext_background_effect_manager_v1"
|
||||||
|
|
||||||
|
func ProbeSupport() (bool, error) {
|
||||||
|
display, err := client.Connect("")
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer display.Context().Close()
|
||||||
|
|
||||||
|
registry, err := display.GetRegistry()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
found := false
|
||||||
|
registry.SetGlobalHandler(func(e client.RegistryGlobalEvent) {
|
||||||
|
switch e.Interface {
|
||||||
|
case extBackgroundEffectInterface:
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := wlhelpers.Roundtrip(display, display.Context()); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return found, nil
|
||||||
|
}
|
||||||
@@ -5,55 +5,196 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/ext_data_control"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/ext_data_control"
|
||||||
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const envServe = "_DMS_CLIPBOARD_SERVE"
|
||||||
|
const envMime = "_DMS_CLIPBOARD_MIME"
|
||||||
|
const envPasteOnce = "_DMS_CLIPBOARD_PASTE_ONCE"
|
||||||
|
const envCacheFile = "_DMS_CLIPBOARD_CACHE"
|
||||||
|
|
||||||
|
// MaybeServeAndExit intercepts before cobra when re-exec'd as a clipboard
|
||||||
|
// child. Reads source data into memory, deletes any cache file, then serves.
|
||||||
|
func MaybeServeAndExit() {
|
||||||
|
if os.Getenv(envServe) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mimeType := os.Getenv(envMime)
|
||||||
|
pasteOnce := os.Getenv(envPasteOnce) == "1"
|
||||||
|
cachePath := os.Getenv(envCacheFile)
|
||||||
|
|
||||||
|
var data []byte
|
||||||
|
var err error
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case cachePath != "":
|
||||||
|
data, err = os.ReadFile(cachePath)
|
||||||
|
os.Remove(cachePath)
|
||||||
|
default:
|
||||||
|
data, err = io.ReadAll(os.Stdin)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "clipboard: read source: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := serveClipboard(data, mimeType, pasteOnce); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "clipboard: serve: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
func Copy(data []byte, mimeType string) error {
|
func Copy(data []byte, mimeType string) error {
|
||||||
return CopyOpts(data, mimeType, false, false)
|
return copyForkCached(data, mimeType, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
func CopyOpts(data []byte, mimeType string, foreground, pasteOnce bool) error {
|
func CopyOpts(data []byte, mimeType string, foreground, pasteOnce bool) error {
|
||||||
if !foreground {
|
if foreground {
|
||||||
return copyFork(data, mimeType, pasteOnce)
|
return serveClipboard(data, mimeType, pasteOnce)
|
||||||
}
|
}
|
||||||
return copyServe(data, mimeType, pasteOnce)
|
return copyForkCached(data, mimeType, pasteOnce)
|
||||||
}
|
}
|
||||||
|
|
||||||
func copyFork(data []byte, mimeType string, pasteOnce bool) error {
|
func CopyReader(data io.Reader, mimeType string, foreground, pasteOnce bool) error {
|
||||||
args := []string{os.Args[0], "cl", "copy", "--foreground"}
|
if foreground {
|
||||||
if pasteOnce {
|
buf, err := io.ReadAll(data)
|
||||||
args = append(args, "--paste-once")
|
if err != nil {
|
||||||
|
return fmt.Errorf("read source: %w", err)
|
||||||
|
}
|
||||||
|
return serveClipboard(buf, mimeType, pasteOnce)
|
||||||
}
|
}
|
||||||
args = append(args, "--type", mimeType)
|
return copyFork(data, mimeType, pasteOnce)
|
||||||
|
}
|
||||||
|
|
||||||
cmd := exec.Command(args[0], args[1:]...)
|
func newForkCmd(mimeType string, pasteOnce bool, extra ...string) *exec.Cmd {
|
||||||
cmd.Stdin = nil
|
cmd := exec.Command(os.Args[0])
|
||||||
cmd.Stdout = nil
|
|
||||||
cmd.Stderr = nil
|
cmd.Stderr = nil
|
||||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
||||||
|
cmd.Env = append(os.Environ(),
|
||||||
stdin, err := cmd.StdinPipe()
|
envServe+"=1",
|
||||||
if err != nil {
|
envMime+"="+mimeType,
|
||||||
return fmt.Errorf("stdin pipe: %w", err)
|
)
|
||||||
|
if pasteOnce {
|
||||||
|
cmd.Env = append(cmd.Env, envPasteOnce+"=1")
|
||||||
}
|
}
|
||||||
|
cmd.Env = append(cmd.Env, extra...)
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitReady(cmd *exec.Cmd) error {
|
||||||
|
stdout, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("stdout pipe: %w", err)
|
||||||
|
}
|
||||||
if err := cmd.Start(); err != nil {
|
if err := cmd.Start(); err != nil {
|
||||||
return fmt.Errorf("start: %w", err)
|
return fmt.Errorf("start: %w", err)
|
||||||
}
|
}
|
||||||
|
var buf [1]byte
|
||||||
if _, err := stdin.Write(data); err != nil {
|
if _, err := stdout.Read(buf[:]); err != nil {
|
||||||
stdin.Close()
|
return fmt.Errorf("waiting for clipboard ready: %w", err)
|
||||||
return fmt.Errorf("write stdin: %w", err)
|
|
||||||
}
|
}
|
||||||
stdin.Close()
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func copyServe(data []byte, mimeType string, pasteOnce bool) error {
|
func copyForkCached(data []byte, mimeType string, pasteOnce bool) error {
|
||||||
|
cacheFile, err := createClipboardCacheFile()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create cache file: %w", err)
|
||||||
|
}
|
||||||
|
cachePath := cacheFile.Name()
|
||||||
|
|
||||||
|
if _, err := cacheFile.Write(data); err != nil {
|
||||||
|
cacheFile.Close()
|
||||||
|
os.Remove(cachePath)
|
||||||
|
return fmt.Errorf("write cache file: %w", err)
|
||||||
|
}
|
||||||
|
if err := cacheFile.Close(); err != nil {
|
||||||
|
os.Remove(cachePath)
|
||||||
|
return fmt.Errorf("close cache file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := newForkCmd(mimeType, pasteOnce, envCacheFile+"="+cachePath)
|
||||||
|
cmd.Stdin = nil
|
||||||
|
if err := waitReady(cmd); err != nil {
|
||||||
|
os.Remove(cachePath)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyFork(data io.Reader, mimeType string, pasteOnce bool) error {
|
||||||
|
cmd := newForkCmd(mimeType, pasteOnce)
|
||||||
|
|
||||||
|
switch src := data.(type) {
|
||||||
|
case *os.File:
|
||||||
|
cmd.Stdin = src
|
||||||
|
return waitReady(cmd)
|
||||||
|
|
||||||
|
default:
|
||||||
|
stdin, err := cmd.StdinPipe()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("stdin pipe: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stdout, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("stdout pipe: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return fmt.Errorf("start: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(stdin, data); err != nil {
|
||||||
|
stdin.Close()
|
||||||
|
return fmt.Errorf("write stdin: %w", err)
|
||||||
|
}
|
||||||
|
if err := stdin.Close(); err != nil {
|
||||||
|
return fmt.Errorf("close stdin: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf [1]byte
|
||||||
|
if _, err := stdout.Read(buf[:]); err != nil {
|
||||||
|
return fmt.Errorf("waiting for clipboard ready: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func signalReady() {
|
||||||
|
if os.Getenv(envServe) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
os.Stdout.Write([]byte{1})
|
||||||
|
}
|
||||||
|
|
||||||
|
func createClipboardCacheFile() (*os.File, error) {
|
||||||
|
preferredDirs := []string{}
|
||||||
|
|
||||||
|
if cacheDir, err := os.UserCacheDir(); err == nil {
|
||||||
|
preferredDirs = append(preferredDirs, filepath.Join(cacheDir, "dms", "clipboard"))
|
||||||
|
}
|
||||||
|
preferredDirs = append(preferredDirs, "/var/tmp/dms/clipboard")
|
||||||
|
|
||||||
|
for _, dir := range preferredDirs {
|
||||||
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cachedData, err := os.CreateTemp(dir, "dms-clipboard-*")
|
||||||
|
if err == nil {
|
||||||
|
return cachedData, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return os.CreateTemp("", "dms-clipboard-*")
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveClipboard(data []byte, mimeType string, pasteOnce bool) error {
|
||||||
display, err := wlclient.Connect("")
|
display, err := wlclient.Connect("")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("wayland connect: %w", err)
|
return fmt.Errorf("wayland connect: %w", err)
|
||||||
@@ -95,12 +236,10 @@ func copyServe(data []byte, mimeType string, pasteOnce bool) error {
|
|||||||
if bindErr != nil {
|
if bindErr != nil {
|
||||||
return fmt.Errorf("registry bind: %w", bindErr)
|
return fmt.Errorf("registry bind: %w", bindErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
if dataControlMgr == nil {
|
if dataControlMgr == nil {
|
||||||
return fmt.Errorf("compositor does not support ext_data_control_manager_v1")
|
return fmt.Errorf("compositor does not support ext_data_control_manager_v1")
|
||||||
}
|
}
|
||||||
defer dataControlMgr.Destroy()
|
defer dataControlMgr.Destroy()
|
||||||
|
|
||||||
if seat == nil {
|
if seat == nil {
|
||||||
return fmt.Errorf("no seat available")
|
return fmt.Errorf("no seat available")
|
||||||
}
|
}
|
||||||
@@ -141,10 +280,10 @@ func copyServe(data []byte, mimeType string, pasteOnce bool) error {
|
|||||||
pasted := make(chan struct{}, 1)
|
pasted := make(chan struct{}, 1)
|
||||||
|
|
||||||
source.SetSendHandler(func(e ext_data_control.ExtDataControlSourceV1SendEvent) {
|
source.SetSendHandler(func(e ext_data_control.ExtDataControlSourceV1SendEvent) {
|
||||||
defer syscall.Close(e.Fd)
|
_ = syscall.SetNonblock(e.Fd, false)
|
||||||
file := os.NewFile(uintptr(e.Fd), "pipe")
|
file := os.NewFile(uintptr(e.Fd), "pipe")
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
file.Write(data)
|
_, _ = file.Write(data)
|
||||||
select {
|
select {
|
||||||
case pasted <- struct{}{}:
|
case pasted <- struct{}{}:
|
||||||
default:
|
default:
|
||||||
@@ -160,6 +299,7 @@ func copyServe(data []byte, mimeType string, pasteOnce bool) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
display.Roundtrip()
|
display.Roundtrip()
|
||||||
|
signalReady()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
@@ -330,3 +470,161 @@ func selectPreferredMimeType(mimes []string) string {
|
|||||||
func IsImageMimeType(mime string) bool {
|
func IsImageMimeType(mime string) bool {
|
||||||
return len(mime) > 6 && mime[:6] == "image/"
|
return len(mime) > 6 && mime[:6] == "image/"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Offer struct {
|
||||||
|
MimeType string
|
||||||
|
Data []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func CopyMulti(offers []Offer, foreground, pasteOnce bool) error {
|
||||||
|
if !foreground {
|
||||||
|
return copyMultiFork(offers, pasteOnce)
|
||||||
|
}
|
||||||
|
return copyMultiServe(offers, pasteOnce)
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyMultiFork(offers []Offer, pasteOnce bool) error {
|
||||||
|
args := []string{os.Args[0], "cl", "copy", "--foreground", "--type", "__multi__"}
|
||||||
|
if pasteOnce {
|
||||||
|
args = append(args, "--paste-once")
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(args[0], args[1:]...)
|
||||||
|
cmd.Stdin = nil
|
||||||
|
cmd.Stdout = nil
|
||||||
|
cmd.Stderr = nil
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
||||||
|
|
||||||
|
stdin, err := cmd.StdinPipe()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("stdin pipe: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return fmt.Errorf("start: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, offer := range offers {
|
||||||
|
fmt.Fprintf(stdin, "%s\x00%d\x00", offer.MimeType, len(offer.Data))
|
||||||
|
if _, err := stdin.Write(offer.Data); err != nil {
|
||||||
|
stdin.Close()
|
||||||
|
return fmt.Errorf("write offer data: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stdin.Close()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyMultiServe(offers []Offer, pasteOnce bool) error {
|
||||||
|
display, err := wlclient.Connect("")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("wayland connect: %w", err)
|
||||||
|
}
|
||||||
|
defer display.Destroy()
|
||||||
|
|
||||||
|
ctx := display.Context()
|
||||||
|
registry, err := display.GetRegistry()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get registry: %w", err)
|
||||||
|
}
|
||||||
|
defer registry.Destroy()
|
||||||
|
|
||||||
|
var dataControlMgr *ext_data_control.ExtDataControlManagerV1
|
||||||
|
var seat *wlclient.Seat
|
||||||
|
var bindErr error
|
||||||
|
|
||||||
|
registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) {
|
||||||
|
switch e.Interface {
|
||||||
|
case "ext_data_control_manager_v1":
|
||||||
|
dataControlMgr = ext_data_control.NewExtDataControlManagerV1(ctx)
|
||||||
|
if err := registry.Bind(e.Name, e.Interface, e.Version, dataControlMgr); err != nil {
|
||||||
|
bindErr = err
|
||||||
|
}
|
||||||
|
case "wl_seat":
|
||||||
|
if seat != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seat = wlclient.NewSeat(ctx)
|
||||||
|
if err := registry.Bind(e.Name, e.Interface, e.Version, seat); err != nil {
|
||||||
|
bindErr = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
display.Roundtrip()
|
||||||
|
display.Roundtrip()
|
||||||
|
|
||||||
|
if bindErr != nil {
|
||||||
|
return fmt.Errorf("registry bind: %w", bindErr)
|
||||||
|
}
|
||||||
|
if dataControlMgr == nil {
|
||||||
|
return fmt.Errorf("compositor does not support ext_data_control_manager_v1")
|
||||||
|
}
|
||||||
|
defer dataControlMgr.Destroy()
|
||||||
|
if seat == nil {
|
||||||
|
return fmt.Errorf("no seat available")
|
||||||
|
}
|
||||||
|
|
||||||
|
device, err := dataControlMgr.GetDataDevice(seat)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get data device: %w", err)
|
||||||
|
}
|
||||||
|
defer device.Destroy()
|
||||||
|
|
||||||
|
source, err := dataControlMgr.CreateDataSource()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create data source: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
offerMap := make(map[string][]byte)
|
||||||
|
for _, offer := range offers {
|
||||||
|
if err := source.Offer(offer.MimeType); err != nil {
|
||||||
|
return fmt.Errorf("offer %s: %w", offer.MimeType, err)
|
||||||
|
}
|
||||||
|
offerMap[offer.MimeType] = offer.Data
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelled := make(chan struct{})
|
||||||
|
pasted := make(chan struct{}, 1)
|
||||||
|
|
||||||
|
source.SetSendHandler(func(e ext_data_control.ExtDataControlSourceV1SendEvent) {
|
||||||
|
_ = syscall.SetNonblock(e.Fd, false)
|
||||||
|
file := os.NewFile(uintptr(e.Fd), "pipe")
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
if data, ok := offerMap[e.MimeType]; ok {
|
||||||
|
_, _ = file.Write(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case pasted <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
source.SetCancelledHandler(func(e ext_data_control.ExtDataControlSourceV1CancelledEvent) {
|
||||||
|
close(cancelled)
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := device.SetSelection(source); err != nil {
|
||||||
|
return fmt.Errorf("set selection: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
display.Roundtrip()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-cancelled:
|
||||||
|
return nil
|
||||||
|
case <-pasted:
|
||||||
|
if pasteOnce {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if err := ctx.Dispatch(); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ func StoreWithConfig(data []byte, mimeType string, cfg StoreConfig) error {
|
|||||||
return fmt.Errorf("get db path: %w", err)
|
return fmt.Errorf("get db path: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
db, err := bolt.Open(dbPath, 0644, &bolt.Options{Timeout: 1 * time.Second})
|
db, err := bolt.Open(dbPath, 0o644, &bolt.Options{Timeout: 1 * time.Second})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("open db: %w", err)
|
return fmt.Errorf("open db: %w", err)
|
||||||
}
|
}
|
||||||
@@ -132,7 +132,7 @@ func GetDBPath() (string, error) {
|
|||||||
oldPath := filepath.Join(oldDir, "db")
|
oldPath := filepath.Join(oldDir, "db")
|
||||||
|
|
||||||
if _, err := os.Stat(oldPath); err == nil {
|
if _, err := os.Stat(oldPath); err == nil {
|
||||||
if err := os.MkdirAll(newDir, 0700); err != nil {
|
if err := os.MkdirAll(newDir, 0o700); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if err := os.Rename(oldPath, newPath); err != nil {
|
if err := os.Rename(oldPath, newPath); err != nil {
|
||||||
@@ -142,7 +142,7 @@ func GetDBPath() (string, error) {
|
|||||||
return newPath, nil
|
return newPath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.MkdirAll(newDir, 0700); err != nil {
|
if err := os.MkdirAll(newDir, 0o700); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return newPath, nil
|
return newPath, nil
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package clipboard
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
@@ -12,8 +13,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type ClipboardChange struct {
|
type ClipboardChange struct {
|
||||||
Data []byte
|
Data []byte
|
||||||
MimeType string
|
MimeType string
|
||||||
|
MimeTypes []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Watch(ctx context.Context, callback func(data []byte, mimeType string)) error {
|
func Watch(ctx context.Context, callback func(data []byte, mimeType string)) error {
|
||||||
@@ -130,13 +132,154 @@ func Watch(ctx context.Context, callback func(data []byte, mimeType string)) err
|
|||||||
if err := wlCtx.SetReadDeadline(time.Now().Add(100 * time.Millisecond)); err != nil {
|
if err := wlCtx.SetReadDeadline(time.Now().Add(100 * time.Millisecond)); err != nil {
|
||||||
return fmt.Errorf("set read deadline: %w", err)
|
return fmt.Errorf("set read deadline: %w", err)
|
||||||
}
|
}
|
||||||
if err := wlCtx.Dispatch(); err != nil && err != os.ErrDeadlineExceeded {
|
if err := wlCtx.Dispatch(); err != nil {
|
||||||
|
if isTimeoutError(err) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
return fmt.Errorf("dispatch: %w", err)
|
return fmt.Errorf("dispatch: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func WatchAll(ctx context.Context, callback func(data []byte, mimeType string, allMimeTypes []string)) error {
|
||||||
|
display, err := wlclient.Connect("")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("wayland connect: %w", err)
|
||||||
|
}
|
||||||
|
defer display.Destroy()
|
||||||
|
|
||||||
|
wlCtx := display.Context()
|
||||||
|
registry, err := display.GetRegistry()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get registry: %w", err)
|
||||||
|
}
|
||||||
|
defer registry.Destroy()
|
||||||
|
|
||||||
|
var dataControlMgr *ext_data_control.ExtDataControlManagerV1
|
||||||
|
var seat *wlclient.Seat
|
||||||
|
var bindErr error
|
||||||
|
|
||||||
|
registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) {
|
||||||
|
switch e.Interface {
|
||||||
|
case "ext_data_control_manager_v1":
|
||||||
|
dataControlMgr = ext_data_control.NewExtDataControlManagerV1(wlCtx)
|
||||||
|
if err := registry.Bind(e.Name, e.Interface, e.Version, dataControlMgr); err != nil {
|
||||||
|
bindErr = err
|
||||||
|
}
|
||||||
|
case "wl_seat":
|
||||||
|
if seat != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seat = wlclient.NewSeat(wlCtx)
|
||||||
|
if err := registry.Bind(e.Name, e.Interface, e.Version, seat); err != nil {
|
||||||
|
bindErr = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
display.Roundtrip()
|
||||||
|
display.Roundtrip()
|
||||||
|
|
||||||
|
if bindErr != nil {
|
||||||
|
return fmt.Errorf("registry bind: %w", bindErr)
|
||||||
|
}
|
||||||
|
if dataControlMgr == nil {
|
||||||
|
return fmt.Errorf("compositor does not support ext_data_control_manager_v1")
|
||||||
|
}
|
||||||
|
defer dataControlMgr.Destroy()
|
||||||
|
if seat == nil {
|
||||||
|
return fmt.Errorf("no seat available")
|
||||||
|
}
|
||||||
|
|
||||||
|
device, err := dataControlMgr.GetDataDevice(seat)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get data device: %w", err)
|
||||||
|
}
|
||||||
|
defer device.Destroy()
|
||||||
|
|
||||||
|
offerMimeTypes := make(map[*ext_data_control.ExtDataControlOfferV1][]string)
|
||||||
|
|
||||||
|
device.SetDataOfferHandler(func(e ext_data_control.ExtDataControlDeviceV1DataOfferEvent) {
|
||||||
|
if e.Id == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
offerMimeTypes[e.Id] = nil
|
||||||
|
e.Id.SetOfferHandler(func(me ext_data_control.ExtDataControlOfferV1OfferEvent) {
|
||||||
|
offerMimeTypes[e.Id] = append(offerMimeTypes[e.Id], me.MimeType)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
device.SetSelectionHandler(func(e ext_data_control.ExtDataControlDeviceV1SelectionEvent) {
|
||||||
|
if e.Id == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mimes := offerMimeTypes[e.Id]
|
||||||
|
selectedMime := selectPreferredMimeType(mimes)
|
||||||
|
if selectedMime == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mimesCopy := make([]string, len(mimes))
|
||||||
|
copy(mimesCopy, mimes)
|
||||||
|
|
||||||
|
r, w, err := os.Pipe()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := e.Id.Receive(selectedMime, int(w.Fd())); err != nil {
|
||||||
|
w.Close()
|
||||||
|
r.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Close()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer r.Close()
|
||||||
|
data, err := io.ReadAll(r)
|
||||||
|
if err != nil || len(data) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
callback(data, selectedMime, mimesCopy)
|
||||||
|
}()
|
||||||
|
})
|
||||||
|
|
||||||
|
display.Roundtrip()
|
||||||
|
display.Roundtrip()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
if err := wlCtx.SetReadDeadline(time.Now().Add(100 * time.Millisecond)); err != nil {
|
||||||
|
return fmt.Errorf("set read deadline: %w", err)
|
||||||
|
}
|
||||||
|
if err := wlCtx.Dispatch(); err != nil {
|
||||||
|
if isTimeoutError(err) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return fmt.Errorf("dispatch: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isTimeoutError(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if errors.Is(err, os.ErrDeadlineExceeded) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if netErr, ok := err.(interface{ Timeout() bool }); ok && netErr.Timeout() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func WatchChan(ctx context.Context) (<-chan ClipboardChange, <-chan error) {
|
func WatchChan(ctx context.Context) (<-chan ClipboardChange, <-chan error) {
|
||||||
ch := make(chan ClipboardChange, 16)
|
ch := make(chan ClipboardChange, 16)
|
||||||
errCh := make(chan error, 1)
|
errCh := make(chan error, 1)
|
||||||
|
|||||||
@@ -39,11 +39,10 @@ type LayerSurface struct {
|
|||||||
wlSurface *client.Surface
|
wlSurface *client.Surface
|
||||||
layerSurf *wlr_layer_shell.ZwlrLayerSurfaceV1
|
layerSurf *wlr_layer_shell.ZwlrLayerSurfaceV1
|
||||||
viewport *wp_viewporter.WpViewport
|
viewport *wp_viewporter.WpViewport
|
||||||
wlPool *client.ShmPool
|
wlPools [2]*client.ShmPool
|
||||||
wlBuffer *client.Buffer
|
wlBuffers [2]*client.Buffer
|
||||||
bufferBusy bool
|
slotBusy [2]bool
|
||||||
oldPool *client.ShmPool
|
needsRedraw bool
|
||||||
oldBuffer *client.Buffer
|
|
||||||
scopyBuffer *client.Buffer
|
scopyBuffer *client.Buffer
|
||||||
configured bool
|
configured bool
|
||||||
hidden bool
|
hidden bool
|
||||||
@@ -136,6 +135,7 @@ func (p *Picker) Run() (*Color, error) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
p.flushRedraws()
|
||||||
p.checkDone()
|
p.checkDone()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,6 +164,15 @@ func (p *Picker) checkDone() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *Picker) flushRedraws() {
|
||||||
|
for _, ls := range p.surfaces {
|
||||||
|
if !ls.needsRedraw {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p.redrawSurface(ls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (p *Picker) connect() error {
|
func (p *Picker) connect() error {
|
||||||
display, err := client.Connect("")
|
display, err := client.Connect("")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -507,47 +516,45 @@ func (p *Picker) captureForSurface(ls *LayerSurface) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *Picker) redrawSurface(ls *LayerSurface) {
|
func (p *Picker) redrawSurface(ls *LayerSurface) {
|
||||||
|
slot := ls.state.FrontIndex()
|
||||||
|
if ls.slotBusy[slot] {
|
||||||
|
ls.needsRedraw = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var renderBuf *ShmBuffer
|
var renderBuf *ShmBuffer
|
||||||
if ls.hidden {
|
switch {
|
||||||
|
case ls.hidden:
|
||||||
renderBuf = ls.state.RedrawScreenOnly()
|
renderBuf = ls.state.RedrawScreenOnly()
|
||||||
} else {
|
default:
|
||||||
renderBuf = ls.state.Redraw()
|
renderBuf = ls.state.Redraw()
|
||||||
}
|
}
|
||||||
if renderBuf == nil {
|
if renderBuf == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if ls.oldBuffer != nil {
|
ls.needsRedraw = false
|
||||||
ls.oldBuffer.Destroy()
|
|
||||||
ls.oldBuffer = nil
|
if ls.wlPools[slot] == nil {
|
||||||
}
|
pool, err := p.shm.CreatePool(renderBuf.Fd(), int32(renderBuf.Size()))
|
||||||
if ls.oldPool != nil {
|
if err != nil {
|
||||||
ls.oldPool.Destroy()
|
return
|
||||||
ls.oldPool = nil
|
}
|
||||||
|
ls.wlPools[slot] = pool
|
||||||
|
|
||||||
|
wlBuffer, err := pool.CreateBuffer(0, int32(renderBuf.Width), int32(renderBuf.Height), int32(renderBuf.Stride), uint32(ls.state.ScreenFormat()))
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ls.wlBuffers[slot] = wlBuffer
|
||||||
|
|
||||||
|
s := slot
|
||||||
|
wlBuffer.SetReleaseHandler(func(e client.BufferReleaseEvent) {
|
||||||
|
ls.slotBusy[s] = false
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
ls.oldPool = ls.wlPool
|
ls.slotBusy[slot] = true
|
||||||
ls.oldBuffer = ls.wlBuffer
|
|
||||||
ls.wlPool = nil
|
|
||||||
ls.wlBuffer = nil
|
|
||||||
|
|
||||||
pool, err := p.shm.CreatePool(renderBuf.Fd(), int32(renderBuf.Size()))
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ls.wlPool = pool
|
|
||||||
|
|
||||||
wlBuffer, err := pool.CreateBuffer(0, int32(renderBuf.Width), int32(renderBuf.Height), int32(renderBuf.Stride), uint32(ls.state.ScreenFormat()))
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ls.wlBuffer = wlBuffer
|
|
||||||
|
|
||||||
lsRef := ls
|
|
||||||
wlBuffer.SetReleaseHandler(func(e client.BufferReleaseEvent) {
|
|
||||||
lsRef.bufferBusy = false
|
|
||||||
})
|
|
||||||
ls.bufferBusy = true
|
|
||||||
|
|
||||||
logicalW, logicalH := ls.state.LogicalSize()
|
logicalW, logicalH := ls.state.LogicalSize()
|
||||||
if logicalW == 0 || logicalH == 0 {
|
if logicalW == 0 || logicalH == 0 {
|
||||||
@@ -566,7 +573,7 @@ func (p *Picker) redrawSurface(ls *LayerSurface) {
|
|||||||
}
|
}
|
||||||
_ = ls.wlSurface.SetBufferScale(bufferScale)
|
_ = ls.wlSurface.SetBufferScale(bufferScale)
|
||||||
}
|
}
|
||||||
_ = ls.wlSurface.Attach(wlBuffer, 0, 0)
|
_ = ls.wlSurface.Attach(ls.wlBuffers[slot], 0, 0)
|
||||||
_ = ls.wlSurface.Damage(0, 0, int32(logicalW), int32(logicalH))
|
_ = ls.wlSurface.Damage(0, 0, int32(logicalW), int32(logicalH))
|
||||||
_ = ls.wlSurface.Commit()
|
_ = ls.wlSurface.Commit()
|
||||||
|
|
||||||
@@ -634,7 +641,7 @@ func (p *Picker) setupPointerHandlers() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
p.activeSurface.state.OnPointerMotion(e.SurfaceX, e.SurfaceY)
|
p.activeSurface.state.OnPointerMotion(e.SurfaceX, e.SurfaceY)
|
||||||
p.redrawSurface(p.activeSurface)
|
p.activeSurface.needsRedraw = true
|
||||||
})
|
})
|
||||||
|
|
||||||
p.pointer.SetLeaveHandler(func(e client.PointerLeaveEvent) {
|
p.pointer.SetLeaveHandler(func(e client.PointerLeaveEvent) {
|
||||||
@@ -655,7 +662,7 @@ func (p *Picker) setupPointerHandlers() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
p.activeSurface.state.OnPointerMotion(e.SurfaceX, e.SurfaceY)
|
p.activeSurface.state.OnPointerMotion(e.SurfaceX, e.SurfaceY)
|
||||||
p.redrawSurface(p.activeSurface)
|
p.activeSurface.needsRedraw = true
|
||||||
})
|
})
|
||||||
|
|
||||||
p.pointer.SetButtonHandler(func(e client.PointerButtonEvent) {
|
p.pointer.SetButtonHandler(func(e client.PointerButtonEvent) {
|
||||||
@@ -679,17 +686,13 @@ func (p *Picker) cleanup() {
|
|||||||
if ls.scopyBuffer != nil {
|
if ls.scopyBuffer != nil {
|
||||||
ls.scopyBuffer.Destroy()
|
ls.scopyBuffer.Destroy()
|
||||||
}
|
}
|
||||||
if ls.oldBuffer != nil {
|
for i := range ls.wlBuffers {
|
||||||
ls.oldBuffer.Destroy()
|
if ls.wlBuffers[i] != nil {
|
||||||
}
|
ls.wlBuffers[i].Destroy()
|
||||||
if ls.oldPool != nil {
|
}
|
||||||
ls.oldPool.Destroy()
|
if ls.wlPools[i] != nil {
|
||||||
}
|
ls.wlPools[i].Destroy()
|
||||||
if ls.wlBuffer != nil {
|
}
|
||||||
ls.wlBuffer.Destroy()
|
|
||||||
}
|
|
||||||
if ls.wlPool != nil {
|
|
||||||
ls.wlPool.Destroy()
|
|
||||||
}
|
}
|
||||||
if ls.viewport != nil {
|
if ls.viewport != nil {
|
||||||
ls.viewport.Destroy()
|
ls.viewport.Destroy()
|
||||||
|
|||||||
@@ -274,6 +274,12 @@ func (s *SurfaceState) FrontRenderBuffer() *ShmBuffer {
|
|||||||
return s.renderBufs[s.front]
|
return s.renderBufs[s.front]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *SurfaceState) FrontIndex() int {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.front
|
||||||
|
}
|
||||||
|
|
||||||
func (s *SurfaceState) SwapBuffers() {
|
func (s *SurfaceState) SwapBuffers() {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
s.front ^= 1
|
s.front ^= 1
|
||||||
|
|||||||
@@ -62,12 +62,31 @@ func (cd *ConfigDeployer) DeployConfigurationsSelectiveWithReinstalls(ctx contex
|
|||||||
func (cd *ConfigDeployer) deployConfigurationsInternal(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal, installedDeps []deps.Dependency, replaceConfigs map[string]bool, reinstallItems map[string]bool, useSystemd bool) ([]DeploymentResult, error) {
|
func (cd *ConfigDeployer) deployConfigurationsInternal(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal, installedDeps []deps.Dependency, replaceConfigs map[string]bool, reinstallItems map[string]bool, useSystemd bool) ([]DeploymentResult, error) {
|
||||||
var results []DeploymentResult
|
var results []DeploymentResult
|
||||||
|
|
||||||
|
// Primary config file paths used to detect fresh installs.
|
||||||
|
configPrimaryPaths := map[string]string{
|
||||||
|
"Niri": filepath.Join(os.Getenv("HOME"), ".config", "niri", "config.kdl"),
|
||||||
|
"Hyprland": filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.conf"),
|
||||||
|
"Ghostty": filepath.Join(os.Getenv("HOME"), ".config", "ghostty", "config"),
|
||||||
|
"Kitty": filepath.Join(os.Getenv("HOME"), ".config", "kitty", "kitty.conf"),
|
||||||
|
"Alacritty": filepath.Join(os.Getenv("HOME"), ".config", "alacritty", "alacritty.toml"),
|
||||||
|
}
|
||||||
|
|
||||||
shouldReplaceConfig := func(configType string) bool {
|
shouldReplaceConfig := func(configType string) bool {
|
||||||
if replaceConfigs == nil {
|
if replaceConfigs == nil {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
replace, exists := replaceConfigs[configType]
|
replace, exists := replaceConfigs[configType]
|
||||||
return !exists || replace
|
if !exists || replace {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Config is explicitly set to "don't replace" — but still deploy
|
||||||
|
// if the config file doesn't exist yet (fresh install scenario).
|
||||||
|
if primaryPath, ok := configPrimaryPaths[configType]; ok {
|
||||||
|
if _, err := os.Stat(primaryPath); os.IsNotExist(err) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
switch wm {
|
switch wm {
|
||||||
@@ -126,13 +145,13 @@ func (cd *ConfigDeployer) deployNiriConfig(terminal deps.Terminal, useSystemd bo
|
|||||||
}
|
}
|
||||||
|
|
||||||
configDir := filepath.Dir(result.Path)
|
configDir := filepath.Dir(result.Path)
|
||||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||||
result.Error = fmt.Errorf("failed to create config directory: %w", err)
|
result.Error = fmt.Errorf("failed to create config directory: %w", err)
|
||||||
return result, result.Error
|
return result, result.Error
|
||||||
}
|
}
|
||||||
|
|
||||||
dmsDir := filepath.Join(configDir, "dms")
|
dmsDir := filepath.Join(configDir, "dms")
|
||||||
if err := os.MkdirAll(dmsDir, 0755); err != nil {
|
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
|
||||||
result.Error = fmt.Errorf("failed to create dms directory: %w", err)
|
result.Error = fmt.Errorf("failed to create dms directory: %w", err)
|
||||||
return result, result.Error
|
return result, result.Error
|
||||||
}
|
}
|
||||||
@@ -150,7 +169,7 @@ func (cd *ConfigDeployer) deployNiriConfig(terminal deps.Terminal, useSystemd bo
|
|||||||
|
|
||||||
timestamp := time.Now().Format("2006-01-02_15-04-05")
|
timestamp := time.Now().Format("2006-01-02_15-04-05")
|
||||||
result.BackupPath = result.Path + ".backup." + timestamp
|
result.BackupPath = result.Path + ".backup." + timestamp
|
||||||
if err := os.WriteFile(result.BackupPath, existingData, 0644); err != nil {
|
if err := os.WriteFile(result.BackupPath, existingData, 0o644); err != nil {
|
||||||
result.Error = fmt.Errorf("failed to create backup: %w", err)
|
result.Error = fmt.Errorf("failed to create backup: %w", err)
|
||||||
return result, result.Error
|
return result, result.Error
|
||||||
}
|
}
|
||||||
@@ -185,7 +204,7 @@ func (cd *ConfigDeployer) deployNiriConfig(terminal deps.Terminal, useSystemd bo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.WriteFile(result.Path, []byte(newConfig), 0644); err != nil {
|
if err := os.WriteFile(result.Path, []byte(newConfig), 0o644); err != nil {
|
||||||
result.Error = fmt.Errorf("failed to write config: %w", err)
|
result.Error = fmt.Errorf("failed to write config: %w", err)
|
||||||
return result, result.Error
|
return result, result.Error
|
||||||
}
|
}
|
||||||
@@ -211,16 +230,17 @@ func (cd *ConfigDeployer) deployNiriDmsConfigs(dmsDir, terminalCommand string) e
|
|||||||
{"binds.kdl", strings.ReplaceAll(NiriBindsConfig, "{{TERMINAL_COMMAND}}", terminalCommand)},
|
{"binds.kdl", strings.ReplaceAll(NiriBindsConfig, "{{TERMINAL_COMMAND}}", terminalCommand)},
|
||||||
{"outputs.kdl", ""},
|
{"outputs.kdl", ""},
|
||||||
{"cursor.kdl", ""},
|
{"cursor.kdl", ""},
|
||||||
|
{"windowrules.kdl", ""},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, cfg := range configs {
|
for _, cfg := range configs {
|
||||||
path := filepath.Join(dmsDir, cfg.name)
|
path := filepath.Join(dmsDir, cfg.name)
|
||||||
// Skip if file already exists to preserve user modifications
|
// Skip if file already exists and is not empty to preserve user modifications
|
||||||
if _, err := os.Stat(path); err == nil {
|
if info, err := os.Stat(path); err == nil && info.Size() > 0 {
|
||||||
cd.log(fmt.Sprintf("Skipping %s (already exists)", cfg.name))
|
cd.log(fmt.Sprintf("Skipping %s (already exists)", cfg.name))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(path, []byte(cfg.content), 0644); err != nil {
|
if err := os.WriteFile(path, []byte(cfg.content), 0o644); err != nil {
|
||||||
return fmt.Errorf("failed to write %s: %w", cfg.name, err)
|
return fmt.Errorf("failed to write %s: %w", cfg.name, err)
|
||||||
}
|
}
|
||||||
cd.log(fmt.Sprintf("Deployed %s", cfg.name))
|
cd.log(fmt.Sprintf("Deployed %s", cfg.name))
|
||||||
@@ -238,7 +258,7 @@ func (cd *ConfigDeployer) deployGhosttyConfig() ([]DeploymentResult, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
configDir := filepath.Dir(mainResult.Path)
|
configDir := filepath.Dir(mainResult.Path)
|
||||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||||
mainResult.Error = fmt.Errorf("failed to create config directory: %w", err)
|
mainResult.Error = fmt.Errorf("failed to create config directory: %w", err)
|
||||||
return []DeploymentResult{mainResult}, mainResult.Error
|
return []DeploymentResult{mainResult}, mainResult.Error
|
||||||
}
|
}
|
||||||
@@ -254,14 +274,14 @@ func (cd *ConfigDeployer) deployGhosttyConfig() ([]DeploymentResult, error) {
|
|||||||
|
|
||||||
timestamp := time.Now().Format("2006-01-02_15-04-05")
|
timestamp := time.Now().Format("2006-01-02_15-04-05")
|
||||||
mainResult.BackupPath = mainResult.Path + ".backup." + timestamp
|
mainResult.BackupPath = mainResult.Path + ".backup." + timestamp
|
||||||
if err := os.WriteFile(mainResult.BackupPath, existingData, 0644); err != nil {
|
if err := os.WriteFile(mainResult.BackupPath, existingData, 0o644); err != nil {
|
||||||
mainResult.Error = fmt.Errorf("failed to create backup: %w", err)
|
mainResult.Error = fmt.Errorf("failed to create backup: %w", err)
|
||||||
return []DeploymentResult{mainResult}, mainResult.Error
|
return []DeploymentResult{mainResult}, mainResult.Error
|
||||||
}
|
}
|
||||||
cd.log(fmt.Sprintf("Backed up existing config to %s", mainResult.BackupPath))
|
cd.log(fmt.Sprintf("Backed up existing config to %s", mainResult.BackupPath))
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.WriteFile(mainResult.Path, []byte(GhosttyConfig), 0644); err != nil {
|
if err := os.WriteFile(mainResult.Path, []byte(GhosttyConfig), 0o644); err != nil {
|
||||||
mainResult.Error = fmt.Errorf("failed to write config: %w", err)
|
mainResult.Error = fmt.Errorf("failed to write config: %w", err)
|
||||||
return []DeploymentResult{mainResult}, mainResult.Error
|
return []DeploymentResult{mainResult}, mainResult.Error
|
||||||
}
|
}
|
||||||
@@ -276,12 +296,12 @@ func (cd *ConfigDeployer) deployGhosttyConfig() ([]DeploymentResult, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
themesDir := filepath.Dir(colorResult.Path)
|
themesDir := filepath.Dir(colorResult.Path)
|
||||||
if err := os.MkdirAll(themesDir, 0755); err != nil {
|
if err := os.MkdirAll(themesDir, 0o755); err != nil {
|
||||||
mainResult.Error = fmt.Errorf("failed to create themes directory: %w", err)
|
mainResult.Error = fmt.Errorf("failed to create themes directory: %w", err)
|
||||||
return []DeploymentResult{mainResult}, mainResult.Error
|
return []DeploymentResult{mainResult}, mainResult.Error
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.WriteFile(colorResult.Path, []byte(GhosttyColorConfig), 0644); err != nil {
|
if err := os.WriteFile(colorResult.Path, []byte(GhosttyColorConfig), 0o644); err != nil {
|
||||||
colorResult.Error = fmt.Errorf("failed to write color config: %w", err)
|
colorResult.Error = fmt.Errorf("failed to write color config: %w", err)
|
||||||
return results, colorResult.Error
|
return results, colorResult.Error
|
||||||
}
|
}
|
||||||
@@ -302,7 +322,7 @@ func (cd *ConfigDeployer) deployKittyConfig() ([]DeploymentResult, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
configDir := filepath.Dir(mainResult.Path)
|
configDir := filepath.Dir(mainResult.Path)
|
||||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||||
mainResult.Error = fmt.Errorf("failed to create config directory: %w", err)
|
mainResult.Error = fmt.Errorf("failed to create config directory: %w", err)
|
||||||
return []DeploymentResult{mainResult}, mainResult.Error
|
return []DeploymentResult{mainResult}, mainResult.Error
|
||||||
}
|
}
|
||||||
@@ -318,14 +338,14 @@ func (cd *ConfigDeployer) deployKittyConfig() ([]DeploymentResult, error) {
|
|||||||
|
|
||||||
timestamp := time.Now().Format("2006-01-02_15-04-05")
|
timestamp := time.Now().Format("2006-01-02_15-04-05")
|
||||||
mainResult.BackupPath = mainResult.Path + ".backup." + timestamp
|
mainResult.BackupPath = mainResult.Path + ".backup." + timestamp
|
||||||
if err := os.WriteFile(mainResult.BackupPath, existingData, 0644); err != nil {
|
if err := os.WriteFile(mainResult.BackupPath, existingData, 0o644); err != nil {
|
||||||
mainResult.Error = fmt.Errorf("failed to create backup: %w", err)
|
mainResult.Error = fmt.Errorf("failed to create backup: %w", err)
|
||||||
return []DeploymentResult{mainResult}, mainResult.Error
|
return []DeploymentResult{mainResult}, mainResult.Error
|
||||||
}
|
}
|
||||||
cd.log(fmt.Sprintf("Backed up existing config to %s", mainResult.BackupPath))
|
cd.log(fmt.Sprintf("Backed up existing config to %s", mainResult.BackupPath))
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.WriteFile(mainResult.Path, []byte(KittyConfig), 0644); err != nil {
|
if err := os.WriteFile(mainResult.Path, []byte(KittyConfig), 0o644); err != nil {
|
||||||
mainResult.Error = fmt.Errorf("failed to write config: %w", err)
|
mainResult.Error = fmt.Errorf("failed to write config: %w", err)
|
||||||
return []DeploymentResult{mainResult}, mainResult.Error
|
return []DeploymentResult{mainResult}, mainResult.Error
|
||||||
}
|
}
|
||||||
@@ -339,7 +359,7 @@ func (cd *ConfigDeployer) deployKittyConfig() ([]DeploymentResult, error) {
|
|||||||
Path: filepath.Join(os.Getenv("HOME"), ".config", "kitty", "dank-theme.conf"),
|
Path: filepath.Join(os.Getenv("HOME"), ".config", "kitty", "dank-theme.conf"),
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.WriteFile(themeResult.Path, []byte(KittyThemeConfig), 0644); err != nil {
|
if err := os.WriteFile(themeResult.Path, []byte(KittyThemeConfig), 0o644); err != nil {
|
||||||
themeResult.Error = fmt.Errorf("failed to write theme config: %w", err)
|
themeResult.Error = fmt.Errorf("failed to write theme config: %w", err)
|
||||||
return results, themeResult.Error
|
return results, themeResult.Error
|
||||||
}
|
}
|
||||||
@@ -353,7 +373,7 @@ func (cd *ConfigDeployer) deployKittyConfig() ([]DeploymentResult, error) {
|
|||||||
Path: filepath.Join(os.Getenv("HOME"), ".config", "kitty", "dank-tabs.conf"),
|
Path: filepath.Join(os.Getenv("HOME"), ".config", "kitty", "dank-tabs.conf"),
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.WriteFile(tabsResult.Path, []byte(KittyTabsConfig), 0644); err != nil {
|
if err := os.WriteFile(tabsResult.Path, []byte(KittyTabsConfig), 0o644); err != nil {
|
||||||
tabsResult.Error = fmt.Errorf("failed to write tabs config: %w", err)
|
tabsResult.Error = fmt.Errorf("failed to write tabs config: %w", err)
|
||||||
return results, tabsResult.Error
|
return results, tabsResult.Error
|
||||||
}
|
}
|
||||||
@@ -374,7 +394,7 @@ func (cd *ConfigDeployer) deployAlacrittyConfig() ([]DeploymentResult, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
configDir := filepath.Dir(mainResult.Path)
|
configDir := filepath.Dir(mainResult.Path)
|
||||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||||
mainResult.Error = fmt.Errorf("failed to create config directory: %w", err)
|
mainResult.Error = fmt.Errorf("failed to create config directory: %w", err)
|
||||||
return []DeploymentResult{mainResult}, mainResult.Error
|
return []DeploymentResult{mainResult}, mainResult.Error
|
||||||
}
|
}
|
||||||
@@ -390,14 +410,14 @@ func (cd *ConfigDeployer) deployAlacrittyConfig() ([]DeploymentResult, error) {
|
|||||||
|
|
||||||
timestamp := time.Now().Format("2006-01-02_15-04-05")
|
timestamp := time.Now().Format("2006-01-02_15-04-05")
|
||||||
mainResult.BackupPath = mainResult.Path + ".backup." + timestamp
|
mainResult.BackupPath = mainResult.Path + ".backup." + timestamp
|
||||||
if err := os.WriteFile(mainResult.BackupPath, existingData, 0644); err != nil {
|
if err := os.WriteFile(mainResult.BackupPath, existingData, 0o644); err != nil {
|
||||||
mainResult.Error = fmt.Errorf("failed to create backup: %w", err)
|
mainResult.Error = fmt.Errorf("failed to create backup: %w", err)
|
||||||
return []DeploymentResult{mainResult}, mainResult.Error
|
return []DeploymentResult{mainResult}, mainResult.Error
|
||||||
}
|
}
|
||||||
cd.log(fmt.Sprintf("Backed up existing config to %s", mainResult.BackupPath))
|
cd.log(fmt.Sprintf("Backed up existing config to %s", mainResult.BackupPath))
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.WriteFile(mainResult.Path, []byte(AlacrittyConfig), 0644); err != nil {
|
if err := os.WriteFile(mainResult.Path, []byte(AlacrittyConfig), 0o644); err != nil {
|
||||||
mainResult.Error = fmt.Errorf("failed to write config: %w", err)
|
mainResult.Error = fmt.Errorf("failed to write config: %w", err)
|
||||||
return []DeploymentResult{mainResult}, mainResult.Error
|
return []DeploymentResult{mainResult}, mainResult.Error
|
||||||
}
|
}
|
||||||
@@ -411,7 +431,7 @@ func (cd *ConfigDeployer) deployAlacrittyConfig() ([]DeploymentResult, error) {
|
|||||||
Path: filepath.Join(os.Getenv("HOME"), ".config", "alacritty", "dank-theme.toml"),
|
Path: filepath.Join(os.Getenv("HOME"), ".config", "alacritty", "dank-theme.toml"),
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.WriteFile(themeResult.Path, []byte(AlacrittyThemeConfig), 0644); err != nil {
|
if err := os.WriteFile(themeResult.Path, []byte(AlacrittyThemeConfig), 0o644); err != nil {
|
||||||
themeResult.Error = fmt.Errorf("failed to write theme config: %w", err)
|
themeResult.Error = fmt.Errorf("failed to write theme config: %w", err)
|
||||||
return results, themeResult.Error
|
return results, themeResult.Error
|
||||||
}
|
}
|
||||||
@@ -438,7 +458,7 @@ func (cd *ConfigDeployer) mergeNiriOutputSections(newConfig, existingConfig, dms
|
|||||||
outputsContent.WriteString(output)
|
outputsContent.WriteString(output)
|
||||||
outputsContent.WriteString("\n\n")
|
outputsContent.WriteString("\n\n")
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(outputsPath, []byte(outputsContent.String()), 0644); err != nil {
|
if err := os.WriteFile(outputsPath, []byte(outputsContent.String()), 0o644); err != nil {
|
||||||
cd.log(fmt.Sprintf("Warning: Failed to migrate outputs to %s: %v", outputsPath, err))
|
cd.log(fmt.Sprintf("Warning: Failed to migrate outputs to %s: %v", outputsPath, err))
|
||||||
} else {
|
} else {
|
||||||
cd.log("Migrated output sections to dms/outputs.kdl")
|
cd.log("Migrated output sections to dms/outputs.kdl")
|
||||||
@@ -479,13 +499,13 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem
|
|||||||
}
|
}
|
||||||
|
|
||||||
configDir := filepath.Dir(result.Path)
|
configDir := filepath.Dir(result.Path)
|
||||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||||
result.Error = fmt.Errorf("failed to create config directory: %w", err)
|
result.Error = fmt.Errorf("failed to create config directory: %w", err)
|
||||||
return result, result.Error
|
return result, result.Error
|
||||||
}
|
}
|
||||||
|
|
||||||
dmsDir := filepath.Join(configDir, "dms")
|
dmsDir := filepath.Join(configDir, "dms")
|
||||||
if err := os.MkdirAll(dmsDir, 0755); err != nil {
|
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
|
||||||
result.Error = fmt.Errorf("failed to create dms directory: %w", err)
|
result.Error = fmt.Errorf("failed to create dms directory: %w", err)
|
||||||
return result, result.Error
|
return result, result.Error
|
||||||
}
|
}
|
||||||
@@ -503,7 +523,7 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem
|
|||||||
|
|
||||||
timestamp := time.Now().Format("2006-01-02_15-04-05")
|
timestamp := time.Now().Format("2006-01-02_15-04-05")
|
||||||
result.BackupPath = result.Path + ".backup." + timestamp
|
result.BackupPath = result.Path + ".backup." + timestamp
|
||||||
if err := os.WriteFile(result.BackupPath, existingData, 0644); err != nil {
|
if err := os.WriteFile(result.BackupPath, existingData, 0o644); err != nil {
|
||||||
result.Error = fmt.Errorf("failed to create backup: %w", err)
|
result.Error = fmt.Errorf("failed to create backup: %w", err)
|
||||||
return result, result.Error
|
return result, result.Error
|
||||||
}
|
}
|
||||||
@@ -538,7 +558,7 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.WriteFile(result.Path, []byte(newConfig), 0644); err != nil {
|
if err := os.WriteFile(result.Path, []byte(newConfig), 0o644); err != nil {
|
||||||
result.Error = fmt.Errorf("failed to write config: %w", err)
|
result.Error = fmt.Errorf("failed to write config: %w", err)
|
||||||
return result, result.Error
|
return result, result.Error
|
||||||
}
|
}
|
||||||
@@ -563,15 +583,17 @@ func (cd *ConfigDeployer) deployHyprlandDmsConfigs(dmsDir string, terminalComman
|
|||||||
{"binds.conf", strings.ReplaceAll(HyprBindsConfig, "{{TERMINAL_COMMAND}}", terminalCommand)},
|
{"binds.conf", strings.ReplaceAll(HyprBindsConfig, "{{TERMINAL_COMMAND}}", terminalCommand)},
|
||||||
{"outputs.conf", ""},
|
{"outputs.conf", ""},
|
||||||
{"cursor.conf", ""},
|
{"cursor.conf", ""},
|
||||||
|
{"windowrules.conf", ""},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, cfg := range configs {
|
for _, cfg := range configs {
|
||||||
path := filepath.Join(dmsDir, cfg.name)
|
path := filepath.Join(dmsDir, cfg.name)
|
||||||
if _, err := os.Stat(path); err == nil {
|
// Skip if file already exists and is not empty to preserve user modifications
|
||||||
|
if info, err := os.Stat(path); err == nil && info.Size() > 0 {
|
||||||
cd.log(fmt.Sprintf("Skipping %s (already exists)", cfg.name))
|
cd.log(fmt.Sprintf("Skipping %s (already exists)", cfg.name))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(path, []byte(cfg.content), 0644); err != nil {
|
if err := os.WriteFile(path, []byte(cfg.content), 0o644); err != nil {
|
||||||
return fmt.Errorf("failed to write %s: %w", cfg.name, err)
|
return fmt.Errorf("failed to write %s: %w", cfg.name, err)
|
||||||
}
|
}
|
||||||
cd.log(fmt.Sprintf("Deployed %s", cfg.name))
|
cd.log(fmt.Sprintf("Deployed %s", cfg.name))
|
||||||
@@ -595,7 +617,7 @@ func (cd *ConfigDeployer) mergeHyprlandMonitorSections(newConfig, existingConfig
|
|||||||
outputsContent.WriteString(monitor)
|
outputsContent.WriteString(monitor)
|
||||||
outputsContent.WriteString("\n")
|
outputsContent.WriteString("\n")
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(outputsPath, []byte(outputsContent.String()), 0644); err != nil {
|
if err := os.WriteFile(outputsPath, []byte(outputsContent.String()), 0o644); err != nil {
|
||||||
cd.log(fmt.Sprintf("Warning: Failed to migrate monitors to %s: %v", outputsPath, err))
|
cd.log(fmt.Sprintf("Warning: Failed to migrate monitors to %s: %v", outputsPath, err))
|
||||||
} else {
|
} else {
|
||||||
cd.log("Migrated monitor sections to dms/outputs.conf")
|
cd.log("Migrated monitor sections to dms/outputs.conf")
|
||||||
@@ -641,7 +663,7 @@ func (cd *ConfigDeployer) transformHyprlandConfigForNonSystemd(config, terminalC
|
|||||||
if strings.HasPrefix(trimmed, "exec-once = systemctl --user start") {
|
if strings.HasPrefix(trimmed, "exec-once = systemctl --user start") {
|
||||||
startupSectionFound = true
|
startupSectionFound = true
|
||||||
result = append(result, "exec-once = dms run")
|
result = append(result, "exec-once = dms run")
|
||||||
result = append(result, "env = QT_QPA_PLATFORM,wayland")
|
result = append(result, "env = QT_QPA_PLATFORM,wayland;xcb")
|
||||||
result = append(result, "env = ELECTRON_OZONE_PLATFORM_HINT,auto")
|
result = append(result, "env = ELECTRON_OZONE_PLATFORM_HINT,auto")
|
||||||
result = append(result, "env = QT_QPA_PLATFORMTHEME,gtk3")
|
result = append(result, "env = QT_QPA_PLATFORMTHEME,gtk3")
|
||||||
result = append(result, "env = QT_QPA_PLATFORMTHEME_QT6,gtk3")
|
result = append(result, "env = QT_QPA_PLATFORMTHEME_QT6,gtk3")
|
||||||
@@ -656,7 +678,7 @@ func (cd *ConfigDeployer) transformHyprlandConfigForNonSystemd(config, terminalC
|
|||||||
if strings.Contains(line, "STARTUP APPS") {
|
if strings.Contains(line, "STARTUP APPS") {
|
||||||
insertLines := []string{
|
insertLines := []string{
|
||||||
"exec-once = dms run",
|
"exec-once = dms run",
|
||||||
"env = QT_QPA_PLATFORM,wayland",
|
"env = QT_QPA_PLATFORM,wayland;xcb",
|
||||||
"env = ELECTRON_OZONE_PLATFORM_HINT,auto",
|
"env = ELECTRON_OZONE_PLATFORM_HINT,auto",
|
||||||
"env = QT_QPA_PLATFORMTHEME,gtk3",
|
"env = QT_QPA_PLATFORMTHEME,gtk3",
|
||||||
"env = QT_QPA_PLATFORMTHEME_QT6,gtk3",
|
"env = QT_QPA_PLATFORMTHEME_QT6,gtk3",
|
||||||
@@ -674,7 +696,7 @@ func (cd *ConfigDeployer) transformHyprlandConfigForNonSystemd(config, terminalC
|
|||||||
func (cd *ConfigDeployer) transformNiriConfigForNonSystemd(config, terminalCommand string) string {
|
func (cd *ConfigDeployer) transformNiriConfigForNonSystemd(config, terminalCommand string) string {
|
||||||
envVars := fmt.Sprintf(`environment {
|
envVars := fmt.Sprintf(`environment {
|
||||||
XDG_CURRENT_DESKTOP "niri"
|
XDG_CURRENT_DESKTOP "niri"
|
||||||
QT_QPA_PLATFORM "wayland"
|
QT_QPA_PLATFORM "wayland;xcb"
|
||||||
ELECTRON_OZONE_PLATFORM_HINT "auto"
|
ELECTRON_OZONE_PLATFORM_HINT "auto"
|
||||||
QT_QPA_PLATFORMTHEME "gtk3"
|
QT_QPA_PLATFORMTHEME "gtk3"
|
||||||
QT_QPA_PLATFORMTHEME_QT6 "gtk3"
|
QT_QPA_PLATFORMTHEME_QT6 "gtk3"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -220,9 +221,9 @@ func TestConfigDeploymentFlow(t *testing.T) {
|
|||||||
t.Run("deploy ghostty config with existing file", func(t *testing.T) {
|
t.Run("deploy ghostty config with existing file", func(t *testing.T) {
|
||||||
existingContent := "# Old config\nfont-size = 14\n"
|
existingContent := "# Old config\nfont-size = 14\n"
|
||||||
ghosttyPath := getGhosttyPath()
|
ghosttyPath := getGhosttyPath()
|
||||||
err := os.MkdirAll(filepath.Dir(ghosttyPath), 0755)
|
err := os.MkdirAll(filepath.Dir(ghosttyPath), 0o755)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
err = os.WriteFile(ghosttyPath, []byte(existingContent), 0644)
|
err = os.WriteFile(ghosttyPath, []byte(existingContent), 0o644)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
results, err := cd.deployGhosttyConfig()
|
results, err := cd.deployGhosttyConfig()
|
||||||
@@ -422,9 +423,9 @@ general {
|
|||||||
}
|
}
|
||||||
`
|
`
|
||||||
hyprPath := filepath.Join(tempDir, ".config", "hypr", "hyprland.conf")
|
hyprPath := filepath.Join(tempDir, ".config", "hypr", "hyprland.conf")
|
||||||
err := os.MkdirAll(filepath.Dir(hyprPath), 0755)
|
err := os.MkdirAll(filepath.Dir(hyprPath), 0o755)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
err = os.WriteFile(hyprPath, []byte(existingContent), 0644)
|
err = os.WriteFile(hyprPath, []byte(existingContent), 0o644)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
result, err := cd.deployHyprlandConfig(deps.TerminalKitty, true)
|
result, err := cd.deployHyprlandConfig(deps.TerminalKitty, true)
|
||||||
@@ -600,9 +601,9 @@ func TestAlacrittyConfigDeployment(t *testing.T) {
|
|||||||
t.Run("deploy alacritty config with existing file", func(t *testing.T) {
|
t.Run("deploy alacritty config with existing file", func(t *testing.T) {
|
||||||
existingContent := "# Old alacritty config\n[window]\nopacity = 0.9\n"
|
existingContent := "# Old alacritty config\n[window]\nopacity = 0.9\n"
|
||||||
alacrittyPath := filepath.Join(tempDir, ".config", "alacritty", "alacritty.toml")
|
alacrittyPath := filepath.Join(tempDir, ".config", "alacritty", "alacritty.toml")
|
||||||
err := os.MkdirAll(filepath.Dir(alacrittyPath), 0755)
|
err := os.MkdirAll(filepath.Dir(alacrittyPath), 0o755)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
err = os.WriteFile(alacrittyPath, []byte(existingContent), 0644)
|
err = os.WriteFile(alacrittyPath, []byte(existingContent), 0o644)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
results, err := cd.deployAlacrittyConfig()
|
results, err := cd.deployAlacrittyConfig()
|
||||||
@@ -624,3 +625,168 @@ func TestAlacrittyConfigDeployment(t *testing.T) {
|
|||||||
assert.Contains(t, string(newContent), "decorations = \"None\"")
|
assert.Contains(t, string(newContent), "decorations = \"None\"")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestShouldReplaceConfigDeployIfMissing(t *testing.T) {
|
||||||
|
allFalse := map[string]bool{
|
||||||
|
"Niri": false,
|
||||||
|
"Hyprland": false,
|
||||||
|
"Ghostty": false,
|
||||||
|
"Kitty": false,
|
||||||
|
"Alacritty": false,
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("replaceConfigs nil deploys config", func(t *testing.T) {
|
||||||
|
tempDir, err := os.MkdirTemp("", "dankinstall-replace-nil-test")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
|
originalHome := os.Getenv("HOME")
|
||||||
|
os.Setenv("HOME", tempDir)
|
||||||
|
defer os.Setenv("HOME", originalHome)
|
||||||
|
|
||||||
|
logChan := make(chan string, 100)
|
||||||
|
cd := NewConfigDeployer(logChan)
|
||||||
|
|
||||||
|
results, err := cd.DeployConfigurationsSelectiveWithReinstalls(
|
||||||
|
context.Background(),
|
||||||
|
deps.WindowManagerNiri,
|
||||||
|
deps.TerminalGhostty,
|
||||||
|
nil, // installedDeps
|
||||||
|
nil, // replaceConfigs
|
||||||
|
nil, // reinstallItems
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// With replaceConfigs=nil, all configs should be deployed
|
||||||
|
hasDeployed := false
|
||||||
|
for _, r := range results {
|
||||||
|
if r.Deployed {
|
||||||
|
hasDeployed = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.True(t, hasDeployed, "expected at least one config to be deployed when replaceConfigs is nil")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("replaceConfigs all false and config missing deploys config", func(t *testing.T) {
|
||||||
|
tempDir, err := os.MkdirTemp("", "dankinstall-replace-missing-test")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
|
originalHome := os.Getenv("HOME")
|
||||||
|
os.Setenv("HOME", tempDir)
|
||||||
|
defer os.Setenv("HOME", originalHome)
|
||||||
|
|
||||||
|
logChan := make(chan string, 100)
|
||||||
|
cd := NewConfigDeployer(logChan)
|
||||||
|
|
||||||
|
results, err := cd.DeployConfigurationsSelectiveWithReinstalls(
|
||||||
|
context.Background(),
|
||||||
|
deps.WindowManagerNiri,
|
||||||
|
deps.TerminalGhostty,
|
||||||
|
nil, // installedDeps
|
||||||
|
allFalse, // replaceConfigs — all false
|
||||||
|
nil, // reinstallItems
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Config files don't exist on disk, so they should still be deployed
|
||||||
|
hasDeployed := false
|
||||||
|
for _, r := range results {
|
||||||
|
if r.Deployed {
|
||||||
|
hasDeployed = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.True(t, hasDeployed, "expected configs to be deployed when files are missing, even with replaceConfigs all false")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("replaceConfigs false and config exists skips config", func(t *testing.T) {
|
||||||
|
tempDir, err := os.MkdirTemp("", "dankinstall-replace-exists-test")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
|
originalHome := os.Getenv("HOME")
|
||||||
|
os.Setenv("HOME", tempDir)
|
||||||
|
defer os.Setenv("HOME", originalHome)
|
||||||
|
|
||||||
|
// Create the Ghostty primary config file so shouldReplaceConfig returns false
|
||||||
|
ghosttyPath := filepath.Join(tempDir, ".config", "ghostty", "config")
|
||||||
|
err = os.MkdirAll(filepath.Dir(ghosttyPath), 0o755)
|
||||||
|
require.NoError(t, err)
|
||||||
|
err = os.WriteFile(ghosttyPath, []byte("# existing ghostty config\n"), 0o644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Also create the Niri primary config file
|
||||||
|
niriPath := filepath.Join(tempDir, ".config", "niri", "config.kdl")
|
||||||
|
err = os.MkdirAll(filepath.Dir(niriPath), 0o755)
|
||||||
|
require.NoError(t, err)
|
||||||
|
err = os.WriteFile(niriPath, []byte("// existing niri config\n"), 0o644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
logChan := make(chan string, 100)
|
||||||
|
cd := NewConfigDeployer(logChan)
|
||||||
|
|
||||||
|
results, err := cd.DeployConfigurationsSelectiveWithReinstalls(
|
||||||
|
context.Background(),
|
||||||
|
deps.WindowManagerNiri,
|
||||||
|
deps.TerminalGhostty,
|
||||||
|
nil, // installedDeps
|
||||||
|
allFalse, // replaceConfigs — all false
|
||||||
|
nil, // reinstallItems
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Both Niri and Ghostty config files exist, so with all false they should be skipped
|
||||||
|
for _, r := range results {
|
||||||
|
assert.Fail(t, "expected no configs to be deployed", "got deployed config: %s", r.ConfigType)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("replaceConfigs true and config exists deploys config", func(t *testing.T) {
|
||||||
|
tempDir, err := os.MkdirTemp("", "dankinstall-replace-true-test")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
|
originalHome := os.Getenv("HOME")
|
||||||
|
os.Setenv("HOME", tempDir)
|
||||||
|
defer os.Setenv("HOME", originalHome)
|
||||||
|
|
||||||
|
// Create the Ghostty primary config file
|
||||||
|
ghosttyPath := filepath.Join(tempDir, ".config", "ghostty", "config")
|
||||||
|
err = os.MkdirAll(filepath.Dir(ghosttyPath), 0o755)
|
||||||
|
require.NoError(t, err)
|
||||||
|
err = os.WriteFile(ghosttyPath, []byte("# existing ghostty config\n"), 0o644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
logChan := make(chan string, 100)
|
||||||
|
cd := NewConfigDeployer(logChan)
|
||||||
|
|
||||||
|
replaceConfigs := map[string]bool{
|
||||||
|
"Niri": false,
|
||||||
|
"Hyprland": false,
|
||||||
|
"Ghostty": true, // explicitly true
|
||||||
|
"Kitty": false,
|
||||||
|
"Alacritty": false,
|
||||||
|
}
|
||||||
|
|
||||||
|
results, err := cd.DeployConfigurationsSelectiveWithReinstalls(
|
||||||
|
context.Background(),
|
||||||
|
deps.WindowManagerNiri,
|
||||||
|
deps.TerminalGhostty,
|
||||||
|
nil, // installedDeps
|
||||||
|
replaceConfigs, // Ghostty=true, rest=false
|
||||||
|
nil, // reinstallItems
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Ghostty should be deployed because replaceConfigs["Ghostty"]=true
|
||||||
|
foundGhostty := false
|
||||||
|
for _, r := range results {
|
||||||
|
if r.ConfigType == "Ghostty" && r.Deployed {
|
||||||
|
foundGhostty = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.True(t, foundGhostty, "expected Ghostty config to be deployed when replaceConfigs is true")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ bindl = , XF86AudioPause, exec, dms ipc call mpris playPause
|
|||||||
bindl = , XF86AudioPlay, exec, dms ipc call mpris playPause
|
bindl = , XF86AudioPlay, exec, dms ipc call mpris playPause
|
||||||
bindl = , XF86AudioPrev, exec, dms ipc call mpris previous
|
bindl = , XF86AudioPrev, exec, dms ipc call mpris previous
|
||||||
bindl = , XF86AudioNext, exec, dms ipc call mpris next
|
bindl = , XF86AudioNext, exec, dms ipc call mpris next
|
||||||
|
bindel = CTRL, XF86AudioRaiseVolume, exec, dms ipc call mpris increment 3
|
||||||
|
bindel = CTRL, XF86AudioLowerVolume, exec, dms ipc call mpris decrement 3
|
||||||
|
|
||||||
# === Brightness Controls ===
|
# === Brightness Controls ===
|
||||||
bindel = , XF86MonBrightnessUp, exec, dms ipc call brightness increment 5 ""
|
bindel = , XF86MonBrightnessUp, exec, dms ipc call brightness increment 5 ""
|
||||||
@@ -38,6 +40,7 @@ bind = SUPER, F, fullscreen, 1
|
|||||||
bind = SUPER SHIFT, F, fullscreen, 0
|
bind = SUPER SHIFT, F, fullscreen, 0
|
||||||
bind = SUPER SHIFT, T, togglefloating
|
bind = SUPER SHIFT, T, togglefloating
|
||||||
bind = SUPER, W, togglegroup
|
bind = SUPER, W, togglegroup
|
||||||
|
bind = SUPER SHIFT, W, exec, dms ipc call window-rules toggle
|
||||||
|
|
||||||
# === Focus Navigation ===
|
# === Focus Navigation ===
|
||||||
bind = SUPER, left, movefocus, l
|
bind = SUPER, left, movefocus, l
|
||||||
@@ -91,6 +94,9 @@ bind = SUPER CTRL, up, movetoworkspace, e-1
|
|||||||
bind = SUPER CTRL, U, movetoworkspace, e+1
|
bind = SUPER CTRL, U, movetoworkspace, e+1
|
||||||
bind = SUPER CTRL, I, movetoworkspace, e-1
|
bind = SUPER CTRL, I, movetoworkspace, e-1
|
||||||
|
|
||||||
|
# === Workspace Management ===
|
||||||
|
bind = CTRL SHIFT, R, exec, dms ipc call workspace-rename open
|
||||||
|
|
||||||
# === Move Workspaces ===
|
# === Move Workspaces ===
|
||||||
bind = SUPER SHIFT, Page_Down, movetoworkspace, e+1
|
bind = SUPER SHIFT, Page_Down, movetoworkspace, e+1
|
||||||
bind = SUPER SHIFT, Page_Up, movetoworkspace, e-1
|
bind = SUPER SHIFT, Page_Up, movetoworkspace, e-1
|
||||||
@@ -131,7 +137,7 @@ bind = SUPER, bracketright, layoutmsg, preselect r
|
|||||||
|
|
||||||
# === Sizing & Layout ===
|
# === Sizing & Layout ===
|
||||||
bind = SUPER, R, layoutmsg, togglesplit
|
bind = SUPER, R, layoutmsg, togglesplit
|
||||||
bind = SUPER CTRL, F, resizeactive, exact 100%
|
bind = SUPER CTRL, F, resizeactive, exact 100% 100%
|
||||||
|
|
||||||
# === Move/resize windows with mainMod + LMB/RMB and dragging ===
|
# === Move/resize windows with mainMod + LMB/RMB and dragging ===
|
||||||
bindmd = SUPER, mouse:272, Move window, movewindow
|
bindmd = SUPER, mouse:272, Move window, movewindow
|
||||||
|
|||||||
@@ -81,7 +81,6 @@ master {
|
|||||||
misc {
|
misc {
|
||||||
disable_hyprland_logo = true
|
disable_hyprland_logo = true
|
||||||
disable_splash_rendering = true
|
disable_splash_rendering = true
|
||||||
vrr = 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# ==================
|
# ==================
|
||||||
@@ -95,21 +94,21 @@ windowrule = tile on, match:class ^(gnome-control-center)$
|
|||||||
windowrule = tile on, match:class ^(pavucontrol)$
|
windowrule = tile on, match:class ^(pavucontrol)$
|
||||||
windowrule = tile on, match:class ^(nm-connection-editor)$
|
windowrule = tile on, match:class ^(nm-connection-editor)$
|
||||||
|
|
||||||
|
windowrule = float on, match:class ^(org\.gnome\.Calculator)$
|
||||||
windowrule = float on, match:class ^(gnome-calculator)$
|
windowrule = float on, match:class ^(gnome-calculator)$
|
||||||
windowrule = float on, match:class ^(galculator)$
|
windowrule = float on, match:class ^(galculator)$
|
||||||
windowrule = float on, match:class ^(blueman-manager)$
|
windowrule = float on, match:class ^(blueman-manager)$
|
||||||
windowrule = float on, match:class ^(org\.gnome\.Nautilus)$
|
windowrule = float on, match:class ^(org\.gnome\.Nautilus)$
|
||||||
windowrule = float on, match:class ^(steam)$
|
|
||||||
windowrule = float on, match:class ^(xdg-desktop-portal)$
|
windowrule = float on, match:class ^(xdg-desktop-portal)$
|
||||||
|
|
||||||
|
windowrule = no_initial_focus on, match:class ^(steam)$, match:title ^(notificationtoasts)
|
||||||
|
windowrule = pin on, match:class ^(steam)$, match:title ^(notificationtoasts)
|
||||||
|
|
||||||
windowrule = float on, match:class ^(firefox)$, match:title ^(Picture-in-Picture)$
|
windowrule = float on, match:class ^(firefox)$, match:title ^(Picture-in-Picture)$
|
||||||
windowrule = float on, match:class ^(zoom)$
|
windowrule = float on, match:class ^(zoom)$
|
||||||
|
|
||||||
# DMS windows floating by default
|
|
||||||
# ! Hyprland doesn't size these windows correctly so disabling by default here
|
|
||||||
# windowrule = float on, match:class ^(org.quickshell)$
|
|
||||||
|
|
||||||
layerrule = no_anim on, match:namespace ^(quickshell)$
|
layerrule = no_anim on, match:namespace ^(quickshell)$
|
||||||
|
layerrule = no_anim on, match:namespace ^dms:.*
|
||||||
|
|
||||||
source = ./dms/colors.conf
|
source = ./dms/colors.conf
|
||||||
source = ./dms/outputs.conf
|
source = ./dms/outputs.conf
|
||||||
|
|||||||
@@ -60,6 +60,12 @@ binds {
|
|||||||
XF86AudioNext allow-when-locked=true {
|
XF86AudioNext allow-when-locked=true {
|
||||||
spawn "dms" "ipc" "call" "mpris" "next";
|
spawn "dms" "ipc" "call" "mpris" "next";
|
||||||
}
|
}
|
||||||
|
Ctrl+XF86AudioRaiseVolume allow-when-locked=true {
|
||||||
|
spawn "dms" "ipc" "call" "mpris" "increment" "3";
|
||||||
|
}
|
||||||
|
Ctrl+XF86AudioLowerVolume allow-when-locked=true {
|
||||||
|
spawn "dms" "ipc" "call" "mpris" "decrement" "3";
|
||||||
|
}
|
||||||
|
|
||||||
// === Brightness Controls ===
|
// === Brightness Controls ===
|
||||||
XF86MonBrightnessUp allow-when-locked=true {
|
XF86MonBrightnessUp allow-when-locked=true {
|
||||||
@@ -76,6 +82,7 @@ binds {
|
|||||||
Mod+Shift+T { toggle-window-floating; }
|
Mod+Shift+T { toggle-window-floating; }
|
||||||
Mod+Shift+V { switch-focus-between-floating-and-tiling; }
|
Mod+Shift+V { switch-focus-between-floating-and-tiling; }
|
||||||
Mod+W { toggle-column-tabbed-display; }
|
Mod+W { toggle-column-tabbed-display; }
|
||||||
|
Mod+Shift+W hotkey-overlay-title="Create window rule" { spawn "dms" "ipc" "call" "window-rules" "toggle"; }
|
||||||
|
|
||||||
// === Focus Navigation ===
|
// === Focus Navigation ===
|
||||||
Mod+Left { focus-column-left; }
|
Mod+Left { focus-column-left; }
|
||||||
@@ -133,6 +140,11 @@ binds {
|
|||||||
Mod+Ctrl+U { move-column-to-workspace-down; }
|
Mod+Ctrl+U { move-column-to-workspace-down; }
|
||||||
Mod+Ctrl+I { move-column-to-workspace-up; }
|
Mod+Ctrl+I { move-column-to-workspace-up; }
|
||||||
|
|
||||||
|
// === Workspace Management ===
|
||||||
|
Ctrl+Shift+R hotkey-overlay-title="Rename Workspace" {
|
||||||
|
spawn "dms" "ipc" "call" "workspace-rename" "open";
|
||||||
|
}
|
||||||
|
|
||||||
// === Move Workspaces ===
|
// === Move Workspaces ===
|
||||||
Mod+Shift+Page_Down { move-workspace-down; }
|
Mod+Shift+Page_Down { move-workspace-down; }
|
||||||
Mod+Shift+Page_Up { move-workspace-up; }
|
Mod+Shift+Page_Up { move-workspace-up; }
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
hotkey-overlay {
|
||||||
|
skip-at-startup
|
||||||
|
}
|
||||||
|
|
||||||
|
environment {
|
||||||
|
DMS_RUN_GREETER "1"
|
||||||
|
}
|
||||||
|
|
||||||
|
gestures {
|
||||||
|
hot-corners {
|
||||||
|
off
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
layout {
|
||||||
|
background-color "#000000"
|
||||||
|
}
|
||||||
@@ -224,14 +224,19 @@ window-rule {
|
|||||||
open-floating false
|
open-floating false
|
||||||
}
|
}
|
||||||
window-rule {
|
window-rule {
|
||||||
|
match app-id=r#"^org\.gnome\.Calculator$"#
|
||||||
match app-id=r#"^gnome-calculator$"#
|
match app-id=r#"^gnome-calculator$"#
|
||||||
match app-id=r#"^galculator$"#
|
match app-id=r#"^galculator$"#
|
||||||
match app-id=r#"^blueman-manager$"#
|
match app-id=r#"^blueman-manager$"#
|
||||||
match app-id=r#"^org\.gnome\.Nautilus$"#
|
match app-id=r#"^org\.gnome\.Nautilus$"#
|
||||||
match app-id=r#"^steam$"#
|
|
||||||
match app-id=r#"^xdg-desktop-portal$"#
|
match app-id=r#"^xdg-desktop-portal$"#
|
||||||
open-floating true
|
open-floating true
|
||||||
}
|
}
|
||||||
|
window-rule {
|
||||||
|
match app-id=r#"^steam$"# title=r#"^notificationtoasts_\d+_desktop$"#
|
||||||
|
default-floating-position x=10 y=10 relative-to="bottom-right"
|
||||||
|
open-focused false
|
||||||
|
}
|
||||||
window-rule {
|
window-rule {
|
||||||
match app-id=r#"^org\.wezfurlong\.wezterm$"#
|
match app-id=r#"^org\.wezfurlong\.wezterm$"#
|
||||||
match app-id="Alacritty"
|
match app-id="Alacritty"
|
||||||
@@ -245,11 +250,6 @@ window-rule {
|
|||||||
match app-id="zoom"
|
match app-id="zoom"
|
||||||
open-floating true
|
open-floating true
|
||||||
}
|
}
|
||||||
// Open dms windows as floating by default
|
|
||||||
window-rule {
|
|
||||||
match app-id=r#"org.quickshell$"#
|
|
||||||
open-floating true
|
|
||||||
}
|
|
||||||
debug {
|
debug {
|
||||||
honor-xdg-activation-with-invalid-serial
|
honor-xdg-activation-with-invalid-serial
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,3 +16,6 @@ var NiriAlttabConfig string
|
|||||||
|
|
||||||
//go:embed embedded/niri-binds.kdl
|
//go:embed embedded/niri-binds.kdl
|
||||||
var NiriBindsConfig string
|
var NiriBindsConfig string
|
||||||
|
|
||||||
|
//go:embed embedded/niri-greeter.kdl
|
||||||
|
var NiriGreeterConfig string
|
||||||
|
|||||||
+202
-127
@@ -199,31 +199,6 @@ func labToHex(L, a, b float64) string {
|
|||||||
return fmt.Sprintf("#%02x%02x%02x", r, g, b2)
|
return fmt.Sprintf("#%02x%02x%02x", r, g, b2)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Adjust brightness while keeping the same hue
|
|
||||||
func retoneToL(hex string, Ltarget float64) string {
|
|
||||||
rgb := HexToRGB(hex)
|
|
||||||
col := colorful.Color{R: rgb.R, G: rgb.G, B: rgb.B}
|
|
||||||
L, a, b := col.Lab()
|
|
||||||
L100 := L * 100.0
|
|
||||||
|
|
||||||
scale := 1.0
|
|
||||||
if L100 != 0 {
|
|
||||||
scale = Ltarget / L100
|
|
||||||
}
|
|
||||||
|
|
||||||
a2, b2 := a*scale, b*scale
|
|
||||||
|
|
||||||
// Don't let it get too saturated
|
|
||||||
maxChroma := 0.4
|
|
||||||
if math.Hypot(a2, b2) > maxChroma {
|
|
||||||
k := maxChroma / math.Hypot(a2, b2)
|
|
||||||
a2 *= k
|
|
||||||
b2 *= k
|
|
||||||
}
|
|
||||||
|
|
||||||
return labToHex(Ltarget, a2, b2)
|
|
||||||
}
|
|
||||||
|
|
||||||
func DeltaPhiStar(hexFg, hexBg string, negativePolarity bool) float64 {
|
func DeltaPhiStar(hexFg, hexBg string, negativePolarity bool) float64 {
|
||||||
Lf := getLstar(hexFg)
|
Lf := getLstar(hexFg)
|
||||||
Lb := getLstar(hexBg)
|
Lb := getLstar(hexBg)
|
||||||
@@ -356,6 +331,59 @@ func EnsureContrastDPSLstar(hexColor, hexBg string, minLc float64, isLightMode b
|
|||||||
return hexColor
|
return hexColor
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bidirectional contrast - tries both lighter and darker, picks closest to original
|
||||||
|
func EnsureContrastDPSBidirectional(hexColor, hexBg string, minLc float64, isLightMode bool) string {
|
||||||
|
current := DeltaPhiStarContrast(hexColor, hexBg, isLightMode)
|
||||||
|
if current >= minLc {
|
||||||
|
return hexColor
|
||||||
|
}
|
||||||
|
|
||||||
|
fg := HexToRGB(hexColor)
|
||||||
|
cf := colorful.Color{R: fg.R, G: fg.G, B: fg.B}
|
||||||
|
origL, af, bf := cf.Lab()
|
||||||
|
|
||||||
|
var darkerResult, lighterResult string
|
||||||
|
darkerL, lighterL := origL, origL
|
||||||
|
darkerFound, lighterFound := false, false
|
||||||
|
|
||||||
|
step := 0.5
|
||||||
|
for i := range 120 {
|
||||||
|
if !darkerFound {
|
||||||
|
darkerL = math.Max(0, origL-float64(i)*step)
|
||||||
|
cand := labToHex(darkerL, af, bf)
|
||||||
|
if DeltaPhiStarContrast(cand, hexBg, isLightMode) >= minLc {
|
||||||
|
darkerResult = cand
|
||||||
|
darkerFound = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !lighterFound {
|
||||||
|
lighterL = math.Min(100, origL+float64(i)*step)
|
||||||
|
cand := labToHex(lighterL, af, bf)
|
||||||
|
if DeltaPhiStarContrast(cand, hexBg, isLightMode) >= minLc {
|
||||||
|
lighterResult = cand
|
||||||
|
lighterFound = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if darkerFound && lighterFound {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if darkerFound && lighterFound {
|
||||||
|
if math.Abs(darkerL-origL) <= math.Abs(lighterL-origL) {
|
||||||
|
return darkerResult
|
||||||
|
}
|
||||||
|
return lighterResult
|
||||||
|
}
|
||||||
|
if darkerFound {
|
||||||
|
return darkerResult
|
||||||
|
}
|
||||||
|
if lighterFound {
|
||||||
|
return lighterResult
|
||||||
|
}
|
||||||
|
return hexColor
|
||||||
|
}
|
||||||
|
|
||||||
type PaletteOptions struct {
|
type PaletteOptions struct {
|
||||||
IsLight bool
|
IsLight bool
|
||||||
Background string
|
Background string
|
||||||
@@ -369,6 +397,29 @@ func ensureContrastAuto(hexColor, hexBg string, target float64, opts PaletteOpti
|
|||||||
return EnsureContrast(hexColor, hexBg, target, opts.IsLight)
|
return EnsureContrast(hexColor, hexBg, target, opts.IsLight)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ensureContrastBidirectional(hexColor, hexBg string, target float64, opts PaletteOptions) string {
|
||||||
|
if opts.UseDPS {
|
||||||
|
return EnsureContrastDPSBidirectional(hexColor, hexBg, target, opts.IsLight)
|
||||||
|
}
|
||||||
|
return EnsureContrast(hexColor, hexBg, target, opts.IsLight)
|
||||||
|
}
|
||||||
|
|
||||||
|
func blendHue(base, target, factor float64) float64 {
|
||||||
|
diff := target - base
|
||||||
|
if diff > 0.5 {
|
||||||
|
diff -= 1.0
|
||||||
|
} else if diff < -0.5 {
|
||||||
|
diff += 1.0
|
||||||
|
}
|
||||||
|
result := base + diff*factor
|
||||||
|
if result < 0 {
|
||||||
|
result += 1.0
|
||||||
|
} else if result >= 1.0 {
|
||||||
|
result -= 1.0
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
func DeriveContainer(primary string, isLight bool) string {
|
func DeriveContainer(primary string, isLight bool) string {
|
||||||
rgb := HexToRGB(primary)
|
rgb := HexToRGB(primary)
|
||||||
hsv := RGBToHSV(rgb)
|
hsv := RGBToHSV(rgb)
|
||||||
@@ -389,6 +440,9 @@ func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
|
|||||||
rgb := HexToRGB(baseColor)
|
rgb := HexToRGB(baseColor)
|
||||||
hsv := RGBToHSV(rgb)
|
hsv := RGBToHSV(rgb)
|
||||||
|
|
||||||
|
pr := HexToRGB(primaryColor)
|
||||||
|
ph := RGBToHSV(pr)
|
||||||
|
|
||||||
var palette Palette
|
var palette Palette
|
||||||
|
|
||||||
var normalTextTarget, secondaryTarget float64
|
var normalTextTarget, secondaryTarget float64
|
||||||
@@ -410,115 +464,136 @@ func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
|
|||||||
}
|
}
|
||||||
palette.Color0 = NewColorInfo(bgColor)
|
palette.Color0 = NewColorInfo(bgColor)
|
||||||
|
|
||||||
hueShift := (hsv.H - 0.6) * 0.12
|
baseSat := math.Max(ph.S, 0.5)
|
||||||
satBoost := 1.15
|
baseVal := math.Max(ph.V, 0.5)
|
||||||
|
|
||||||
redH := math.Mod(0.0+hueShift+1.0, 1.0)
|
redH := blendHue(0.0, ph.H, 0.12)
|
||||||
var redColor string
|
greenH := blendHue(0.33, ph.H, 0.10)
|
||||||
if opts.IsLight {
|
yellowH := blendHue(0.14, ph.H, 0.04)
|
||||||
redColor = RGBToHex(HSVToRGB(HSV{H: redH, S: math.Min(0.80*satBoost, 1.0), V: 0.55}))
|
|
||||||
palette.Color1 = NewColorInfo(ensureContrastAuto(redColor, bgColor, normalTextTarget, opts))
|
|
||||||
} else {
|
|
||||||
redColor = RGBToHex(HSVToRGB(HSV{H: redH, S: math.Min(0.65*satBoost, 1.0), V: 0.80}))
|
|
||||||
palette.Color1 = NewColorInfo(ensureContrastAuto(redColor, bgColor, normalTextTarget, opts))
|
|
||||||
}
|
|
||||||
|
|
||||||
greenH := math.Mod(0.33+hueShift+1.0, 1.0)
|
accentTarget := secondaryTarget * 0.7
|
||||||
var greenColor string
|
|
||||||
if opts.IsLight {
|
|
||||||
greenColor = RGBToHex(HSVToRGB(HSV{H: greenH, S: math.Min(math.Max(hsv.S*0.9, 0.80)*satBoost, 1.0), V: 0.45}))
|
|
||||||
palette.Color2 = NewColorInfo(ensureContrastAuto(greenColor, bgColor, normalTextTarget, opts))
|
|
||||||
} else {
|
|
||||||
greenColor = RGBToHex(HSVToRGB(HSV{H: greenH, S: math.Min(0.42*satBoost, 1.0), V: 0.84}))
|
|
||||||
palette.Color2 = NewColorInfo(ensureContrastAuto(greenColor, bgColor, normalTextTarget, opts))
|
|
||||||
}
|
|
||||||
|
|
||||||
yellowH := math.Mod(0.15+hueShift+1.0, 1.0)
|
|
||||||
var yellowColor string
|
|
||||||
if opts.IsLight {
|
|
||||||
yellowColor = RGBToHex(HSVToRGB(HSV{H: yellowH, S: math.Min(0.75*satBoost, 1.0), V: 0.50}))
|
|
||||||
palette.Color3 = NewColorInfo(ensureContrastAuto(yellowColor, bgColor, normalTextTarget, opts))
|
|
||||||
} else {
|
|
||||||
yellowColor = RGBToHex(HSVToRGB(HSV{H: yellowH, S: math.Min(0.38*satBoost, 1.0), V: 0.86}))
|
|
||||||
palette.Color3 = NewColorInfo(ensureContrastAuto(yellowColor, bgColor, normalTextTarget, opts))
|
|
||||||
}
|
|
||||||
|
|
||||||
var blueColor string
|
|
||||||
if opts.IsLight {
|
|
||||||
blueColor = RGBToHex(HSVToRGB(HSV{H: hsv.H, S: math.Max(hsv.S*0.9, 0.7), V: hsv.V * 1.1}))
|
|
||||||
palette.Color4 = NewColorInfo(ensureContrastAuto(blueColor, bgColor, normalTextTarget, opts))
|
|
||||||
} else {
|
|
||||||
blueColor = RGBToHex(HSVToRGB(HSV{H: hsv.H, S: math.Max(hsv.S*0.8, 0.6), V: math.Min(hsv.V*1.6, 1.0)}))
|
|
||||||
palette.Color4 = NewColorInfo(ensureContrastAuto(blueColor, bgColor, normalTextTarget, opts))
|
|
||||||
}
|
|
||||||
|
|
||||||
magH := hsv.H - 0.03
|
|
||||||
if magH < 0 {
|
|
||||||
magH += 1.0
|
|
||||||
}
|
|
||||||
var magColor string
|
|
||||||
hr := HexToRGB(primaryColor)
|
|
||||||
hh := RGBToHSV(hr)
|
|
||||||
if opts.IsLight {
|
|
||||||
magColor = RGBToHex(HSVToRGB(HSV{H: hh.H, S: math.Max(hh.S*0.9, 0.7), V: hh.V * 0.85}))
|
|
||||||
palette.Color5 = NewColorInfo(ensureContrastAuto(magColor, bgColor, normalTextTarget, opts))
|
|
||||||
} else {
|
|
||||||
magColor = RGBToHex(HSVToRGB(HSV{H: hh.H, S: hh.S * 0.8, V: hh.V * 0.75}))
|
|
||||||
palette.Color5 = NewColorInfo(ensureContrastAuto(magColor, bgColor, normalTextTarget, opts))
|
|
||||||
}
|
|
||||||
|
|
||||||
cyanH := hsv.H + 0.08
|
|
||||||
if cyanH > 1.0 {
|
|
||||||
cyanH -= 1.0
|
|
||||||
}
|
|
||||||
palette.Color6 = NewColorInfo(ensureContrastAuto(primaryColor, bgColor, normalTextTarget, opts))
|
|
||||||
|
|
||||||
if opts.IsLight {
|
if opts.IsLight {
|
||||||
palette.Color7 = NewColorInfo("#1a1a1a")
|
redS := math.Min(baseSat*1.2, 1.0)
|
||||||
palette.Color8 = NewColorInfo("#2e2e2e")
|
redV := baseVal * 0.95
|
||||||
} else {
|
palette.Color1 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: redH, S: redS, V: redV})), bgColor, normalTextTarget, opts))
|
||||||
palette.Color7 = NewColorInfo("#abb2bf")
|
|
||||||
palette.Color8 = NewColorInfo("#5c6370")
|
|
||||||
}
|
|
||||||
|
|
||||||
if opts.IsLight {
|
greenS := math.Min(baseSat*1.3, 1.0)
|
||||||
brightRed := RGBToHex(HSVToRGB(HSV{H: redH, S: math.Min(0.70*satBoost, 1.0), V: 0.65}))
|
greenV := baseVal * 0.75
|
||||||
palette.Color9 = NewColorInfo(ensureContrastAuto(brightRed, bgColor, secondaryTarget, opts))
|
palette.Color2 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: greenH, S: greenS, V: greenV})), bgColor, normalTextTarget, opts))
|
||||||
brightGreen := RGBToHex(HSVToRGB(HSV{H: greenH, S: math.Min(math.Max(hsv.S*0.85, 0.75)*satBoost, 1.0), V: 0.55}))
|
|
||||||
palette.Color10 = NewColorInfo(ensureContrastAuto(brightGreen, bgColor, secondaryTarget, opts))
|
|
||||||
brightYellow := RGBToHex(HSVToRGB(HSV{H: yellowH, S: math.Min(0.68*satBoost, 1.0), V: 0.60}))
|
|
||||||
palette.Color11 = NewColorInfo(ensureContrastAuto(brightYellow, bgColor, secondaryTarget, opts))
|
|
||||||
hr := HexToRGB(primaryColor)
|
|
||||||
hh := RGBToHSV(hr)
|
|
||||||
brightBlue := RGBToHex(HSVToRGB(HSV{H: hh.H, S: math.Min(hh.S*1.1, 1.0), V: math.Min(hh.V*1.2, 1.0)}))
|
|
||||||
palette.Color12 = NewColorInfo(ensureContrastAuto(brightBlue, bgColor, secondaryTarget, opts))
|
|
||||||
brightMag := RGBToHex(HSVToRGB(HSV{H: magH, S: math.Max(hsv.S*0.9, 0.75), V: math.Min(hsv.V*1.25, 1.0)}))
|
|
||||||
palette.Color13 = NewColorInfo(ensureContrastAuto(brightMag, bgColor, secondaryTarget, opts))
|
|
||||||
brightCyan := RGBToHex(HSVToRGB(HSV{H: cyanH, S: math.Max(hsv.S*0.75, 0.65), V: math.Min(hsv.V*1.25, 1.0)}))
|
|
||||||
palette.Color14 = NewColorInfo(ensureContrastAuto(brightCyan, bgColor, secondaryTarget, opts))
|
|
||||||
} else {
|
|
||||||
brightRed := RGBToHex(HSVToRGB(HSV{H: redH, S: math.Min(0.50*satBoost, 1.0), V: 0.88}))
|
|
||||||
palette.Color9 = NewColorInfo(ensureContrastAuto(brightRed, bgColor, secondaryTarget, opts))
|
|
||||||
brightGreen := RGBToHex(HSVToRGB(HSV{H: greenH, S: math.Min(0.35*satBoost, 1.0), V: 0.88}))
|
|
||||||
palette.Color10 = NewColorInfo(ensureContrastAuto(brightGreen, bgColor, secondaryTarget, opts))
|
|
||||||
brightYellow := RGBToHex(HSVToRGB(HSV{H: yellowH, S: math.Min(0.30*satBoost, 1.0), V: 0.91}))
|
|
||||||
palette.Color11 = NewColorInfo(ensureContrastAuto(brightYellow, bgColor, secondaryTarget, opts))
|
|
||||||
brightBlue := retoneToL(primaryColor, 85.0)
|
|
||||||
palette.Color12 = NewColorInfo(brightBlue)
|
|
||||||
brightMag := RGBToHex(HSVToRGB(HSV{H: magH, S: math.Max(hsv.S*0.7, 0.6), V: math.Min(hsv.V*1.3, 0.9)}))
|
|
||||||
palette.Color13 = NewColorInfo(ensureContrastAuto(brightMag, bgColor, secondaryTarget, opts))
|
|
||||||
brightCyanH := hsv.H + 0.02
|
|
||||||
if brightCyanH > 1.0 {
|
|
||||||
brightCyanH -= 1.0
|
|
||||||
}
|
|
||||||
brightCyan := RGBToHex(HSVToRGB(HSV{H: brightCyanH, S: math.Max(hsv.S*0.6, 0.5), V: math.Min(hsv.V*1.2, 0.85)}))
|
|
||||||
palette.Color14 = NewColorInfo(ensureContrastAuto(brightCyan, bgColor, secondaryTarget, opts))
|
|
||||||
}
|
|
||||||
|
|
||||||
if opts.IsLight {
|
yellowS := math.Min(baseSat*1.5, 1.0)
|
||||||
palette.Color15 = NewColorInfo("#1a1a1a")
|
yellowV := math.Min(baseVal*1.2, 1.0)
|
||||||
|
palette.Color3 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: yellowH, S: yellowS, V: yellowV})), bgColor, accentTarget, opts))
|
||||||
|
|
||||||
|
blueS := math.Min(ph.S*1.05, 1.0)
|
||||||
|
blueV := math.Min(ph.V*1.05, 1.0)
|
||||||
|
palette.Color4 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: ph.H, S: blueS, V: blueV})), bgColor, normalTextTarget, opts))
|
||||||
|
|
||||||
|
// Color5 matches primary_container exactly (light container in light mode)
|
||||||
|
container5 := DeriveContainer(primaryColor, true)
|
||||||
|
palette.Color5 = NewColorInfo(container5)
|
||||||
|
|
||||||
|
palette.Color6 = NewColorInfo(primaryColor)
|
||||||
|
|
||||||
|
gray7S := baseSat * 0.08
|
||||||
|
gray7V := baseVal * 0.28
|
||||||
|
palette.Color7 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: hsv.H, S: gray7S, V: gray7V})), bgColor, normalTextTarget, opts))
|
||||||
|
|
||||||
|
gray8S := baseSat * 0.05
|
||||||
|
gray8V := baseVal * 0.85
|
||||||
|
dimTarget := secondaryTarget * 0.5
|
||||||
|
palette.Color8 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: hsv.H, S: gray8S, V: gray8V})), bgColor, dimTarget, opts))
|
||||||
|
|
||||||
|
brightRedS := math.Min(baseSat*1.0, 1.0)
|
||||||
|
brightRedV := math.Min(baseVal*1.2, 1.0)
|
||||||
|
palette.Color9 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: redH, S: brightRedS, V: brightRedV})), bgColor, accentTarget, opts))
|
||||||
|
|
||||||
|
brightGreenS := math.Min(baseSat*1.1, 1.0)
|
||||||
|
brightGreenV := math.Min(baseVal*1.1, 1.0)
|
||||||
|
palette.Color10 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: greenH, S: brightGreenS, V: brightGreenV})), bgColor, accentTarget, opts))
|
||||||
|
|
||||||
|
brightYellowS := math.Min(baseSat*1.4, 1.0)
|
||||||
|
brightYellowV := math.Min(baseVal*1.3, 1.0)
|
||||||
|
palette.Color11 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: yellowH, S: brightYellowS, V: brightYellowV})), bgColor, accentTarget, opts))
|
||||||
|
|
||||||
|
brightBlueS := math.Min(ph.S*1.1, 1.0)
|
||||||
|
brightBlueV := math.Min(ph.V*1.15, 1.0)
|
||||||
|
palette.Color12 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: ph.H, S: brightBlueS, V: brightBlueV})), bgColor, accentTarget, opts))
|
||||||
|
|
||||||
|
lightContainer := DeriveContainer(primaryColor, true)
|
||||||
|
palette.Color13 = NewColorInfo(lightContainer)
|
||||||
|
|
||||||
|
brightCyanS := ph.S * 0.5
|
||||||
|
brightCyanV := math.Min(ph.V*1.3, 1.0)
|
||||||
|
palette.Color14 = NewColorInfo(RGBToHex(HSVToRGB(HSV{H: ph.H, S: brightCyanS, V: brightCyanV})))
|
||||||
|
|
||||||
|
white15S := baseSat * 0.04
|
||||||
|
white15V := math.Min(baseVal*1.5, 1.0)
|
||||||
|
palette.Color15 = NewColorInfo(RGBToHex(HSVToRGB(HSV{H: hsv.H, S: white15S, V: white15V})))
|
||||||
} else {
|
} else {
|
||||||
palette.Color15 = NewColorInfo("#ffffff")
|
redS := math.Min(baseSat*1.1, 1.0)
|
||||||
|
redV := math.Min(baseVal*1.15, 1.0)
|
||||||
|
palette.Color1 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: redH, S: redS, V: redV})), bgColor, normalTextTarget, opts))
|
||||||
|
|
||||||
|
greenS := math.Min(baseSat*1.0, 1.0)
|
||||||
|
greenV := math.Min(baseVal*1.0, 1.0)
|
||||||
|
palette.Color2 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: greenH, S: greenS, V: greenV})), bgColor, normalTextTarget, opts))
|
||||||
|
|
||||||
|
yellowS := math.Min(baseSat*1.1, 1.0)
|
||||||
|
yellowV := math.Min(baseVal*1.25, 1.0)
|
||||||
|
palette.Color3 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: yellowH, S: yellowS, V: yellowV})), bgColor, normalTextTarget, opts))
|
||||||
|
|
||||||
|
// Slightly more saturated variant of primary
|
||||||
|
blueS := math.Min(ph.S*1.2, 1.0)
|
||||||
|
blueV := ph.V * 0.95
|
||||||
|
palette.Color4 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: ph.H, S: blueS, V: blueV})), bgColor, normalTextTarget, opts))
|
||||||
|
|
||||||
|
// Color5 matches primary_container exactly (dark container in dark mode)
|
||||||
|
darkContainer := DeriveContainer(primaryColor, false)
|
||||||
|
palette.Color5 = NewColorInfo(darkContainer)
|
||||||
|
|
||||||
|
palette.Color6 = NewColorInfo(primaryColor)
|
||||||
|
|
||||||
|
gray7S := baseSat * 0.12
|
||||||
|
gray7V := math.Min(baseVal*1.05, 1.0)
|
||||||
|
palette.Color7 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: hsv.H, S: gray7S, V: gray7V})), bgColor, normalTextTarget, opts))
|
||||||
|
|
||||||
|
gray8S := baseSat * 0.15
|
||||||
|
gray8V := baseVal * 0.65
|
||||||
|
palette.Color8 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: hsv.H, S: gray8S, V: gray8V})), bgColor, secondaryTarget, opts))
|
||||||
|
|
||||||
|
brightRedS := math.Min(baseSat*0.75, 1.0)
|
||||||
|
brightRedV := math.Min(baseVal*1.35, 1.0)
|
||||||
|
palette.Color9 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: redH, S: brightRedS, V: brightRedV})), bgColor, accentTarget, opts))
|
||||||
|
|
||||||
|
brightGreenS := math.Min(baseSat*0.7, 1.0)
|
||||||
|
brightGreenV := math.Min(baseVal*1.2, 1.0)
|
||||||
|
palette.Color10 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: greenH, S: brightGreenS, V: brightGreenV})), bgColor, accentTarget, opts))
|
||||||
|
|
||||||
|
brightYellowS := math.Min(baseSat*0.7, 1.0)
|
||||||
|
brightYellowV := math.Min(baseVal*1.5, 1.0)
|
||||||
|
palette.Color11 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: yellowH, S: brightYellowS, V: brightYellowV})), bgColor, accentTarget, opts))
|
||||||
|
|
||||||
|
// Create a gradient of primary variants: Color12 -> Color13 -> Color14 -> Color15 (near white)
|
||||||
|
// Color12: Start of the lighter gradient - slightly desaturated
|
||||||
|
brightBlueS := ph.S * 0.85
|
||||||
|
brightBlueV := math.Min(ph.V*1.1, 1.0)
|
||||||
|
palette.Color12 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: ph.H, S: brightBlueS, V: brightBlueV})), bgColor, accentTarget, opts))
|
||||||
|
|
||||||
|
// Medium-high saturation pastel primary
|
||||||
|
color13S := ph.S * 0.7
|
||||||
|
color13V := math.Min(ph.V*1.3, 1.0)
|
||||||
|
palette.Color13 = NewColorInfo(RGBToHex(HSVToRGB(HSV{H: ph.H, S: color13S, V: color13V})))
|
||||||
|
|
||||||
|
// Lower saturation, lighter variant
|
||||||
|
color14S := ph.S * 0.45
|
||||||
|
color14V := math.Min(ph.V*1.4, 1.0)
|
||||||
|
palette.Color14 = NewColorInfo(RGBToHex(HSVToRGB(HSV{H: ph.H, S: color14S, V: color14V})))
|
||||||
|
|
||||||
|
white15S := baseSat * 0.05
|
||||||
|
white15V := math.Min(baseVal*1.45, 1.0)
|
||||||
|
palette.Color15 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: hsv.H, S: white15S, V: white15V})), bgColor, normalTextTarget, opts))
|
||||||
}
|
}
|
||||||
|
|
||||||
return palette
|
return palette
|
||||||
|
|||||||
@@ -366,10 +366,19 @@ func TestGeneratePalette(t *testing.T) {
|
|||||||
t.Errorf("Light mode background = %s, expected #f8f8f8", result.Color0.Hex)
|
t.Errorf("Light mode background = %s, expected #f8f8f8", result.Color0.Hex)
|
||||||
}
|
}
|
||||||
|
|
||||||
if tt.opts.IsLight && result.Color15.Hex != "#1a1a1a" {
|
// Color15 is now derived from primary, so just verify it's a valid color
|
||||||
t.Errorf("Light mode foreground = %s, expected #1a1a1a", result.Color15.Hex)
|
// and has appropriate luminance for the mode (now theme-tinted, not pure white/black)
|
||||||
} else if !tt.opts.IsLight && result.Color15.Hex != "#ffffff" {
|
color15Lum := Luminance(result.Color15.Hex)
|
||||||
t.Errorf("Dark mode foreground = %s, expected #ffffff", result.Color15.Hex)
|
if tt.opts.IsLight {
|
||||||
|
// Light mode: Color15 should still be relatively light
|
||||||
|
if color15Lum < 0.5 {
|
||||||
|
t.Errorf("Light mode Color15 = %s (lum %.2f) is too dark", result.Color15.Hex, color15Lum)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Dark mode: Color15 should be light (but may have theme tint, so lower threshold)
|
||||||
|
if color15Lum < 0.5 {
|
||||||
|
t.Errorf("Dark mode Color15 = %s (lum %.2f) is too dark", result.Color15.Hex, color15Lum)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -579,6 +588,10 @@ func TestGeneratePaletteWithDPS(t *testing.T) {
|
|||||||
|
|
||||||
bgColor := result.Color0.Hex
|
bgColor := result.Color0.Hex
|
||||||
for i := 1; i < 8; i++ {
|
for i := 1; i < 8; i++ {
|
||||||
|
// Skip Color5 (container) and Color6 (exact primary) - intentionally not contrast-adjusted
|
||||||
|
if i == 5 || i == 6 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
lc := DeltaPhiStarContrast(colors[i].Hex, bgColor, tt.opts.IsLight)
|
lc := DeltaPhiStarContrast(colors[i].Hex, bgColor, tt.opts.IsLight)
|
||||||
minLc := 30.0
|
minLc := 30.0
|
||||||
if lc < minLc && lc > 0 {
|
if lc < minLc && lc > 0 {
|
||||||
|
|||||||
+180
-105
@@ -11,6 +11,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -26,6 +27,9 @@ func init() {
|
|||||||
Register("cachyos", "#08A283", FamilyArch, func(config DistroConfig, logChan chan<- string) Distribution {
|
Register("cachyos", "#08A283", FamilyArch, func(config DistroConfig, logChan chan<- string) Distribution {
|
||||||
return NewArchDistribution(config, logChan)
|
return NewArchDistribution(config, logChan)
|
||||||
})
|
})
|
||||||
|
Register("catos", "#1793D1", FamilyArch, func(config DistroConfig, logChan chan<- string) Distribution {
|
||||||
|
return NewArchDistribution(config, logChan)
|
||||||
|
})
|
||||||
Register("endeavouros", "#7F3FBF", FamilyArch, func(config DistroConfig, logChan chan<- string) Distribution {
|
Register("endeavouros", "#7F3FBF", FamilyArch, func(config DistroConfig, logChan chan<- string) Distribution {
|
||||||
return NewArchDistribution(config, logChan)
|
return NewArchDistribution(config, logChan)
|
||||||
})
|
})
|
||||||
@@ -41,6 +45,9 @@ func init() {
|
|||||||
Register("artix", "#1793D1", FamilyArch, func(config DistroConfig, logChan chan<- string) Distribution {
|
Register("artix", "#1793D1", FamilyArch, func(config DistroConfig, logChan chan<- string) Distribution {
|
||||||
return NewArchDistribution(config, logChan)
|
return NewArchDistribution(config, logChan)
|
||||||
})
|
})
|
||||||
|
Register("XeroLinux", "#888fe2", FamilyArch, func(config DistroConfig, logChan chan<- string) Distribution {
|
||||||
|
return NewArchDistribution(config, logChan)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
type ArchDistribution struct {
|
type ArchDistribution struct {
|
||||||
@@ -91,6 +98,7 @@ func (a *ArchDistribution) DetectDependenciesWithTerminal(ctx context.Context, w
|
|||||||
dependencies = append(dependencies, a.detectGit())
|
dependencies = append(dependencies, a.detectGit())
|
||||||
dependencies = append(dependencies, a.detectWindowManager(wm))
|
dependencies = append(dependencies, a.detectWindowManager(wm))
|
||||||
dependencies = append(dependencies, a.detectQuickshell())
|
dependencies = append(dependencies, a.detectQuickshell())
|
||||||
|
dependencies = append(dependencies, a.detectDMSGreeter())
|
||||||
dependencies = append(dependencies, a.detectXDGPortal())
|
dependencies = append(dependencies, a.detectXDGPortal())
|
||||||
dependencies = append(dependencies, a.detectAccountsService())
|
dependencies = append(dependencies, a.detectAccountsService())
|
||||||
|
|
||||||
@@ -118,12 +126,52 @@ func (a *ArchDistribution) detectAccountsService() deps.Dependency {
|
|||||||
return a.detectPackage("accountsservice", "D-Bus interface for user account query and manipulation", a.packageInstalled("accountsservice"))
|
return a.detectPackage("accountsservice", "D-Bus interface for user account query and manipulation", a.packageInstalled("accountsservice"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *ArchDistribution) detectDMSGreeter() deps.Dependency {
|
||||||
|
return a.detectOptionalPackage("dms-greeter", "DankMaterialShell greetd greeter", a.packageInstalled("greetd-dms-greeter-git"))
|
||||||
|
}
|
||||||
|
|
||||||
func (a *ArchDistribution) packageInstalled(pkg string) bool {
|
func (a *ArchDistribution) packageInstalled(pkg string) bool {
|
||||||
cmd := exec.Command("pacman", "-Q", pkg)
|
cmd := exec.Command("pacman", "-Q", pkg)
|
||||||
err := cmd.Run()
|
err := cmd.Run()
|
||||||
return err == nil
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseSRCINFODeps reads a .SRCINFO file and returns runtime dep and makedep package
|
||||||
|
func parseSRCINFODeps(srcinfoPath string) (deps []string, makedeps []string, err error) {
|
||||||
|
data, err := os.ReadFile(srcinfoPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
var pkg string
|
||||||
|
var target *[]string
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(line, "makedepends = "):
|
||||||
|
pkg = strings.TrimPrefix(line, "makedepends = ")
|
||||||
|
target = &makedeps
|
||||||
|
case strings.HasPrefix(line, "depends = "):
|
||||||
|
pkg = strings.TrimPrefix(line, "depends = ")
|
||||||
|
target = &deps
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Strip version constraint (>=, <=, >, <, =) and colon-descriptions
|
||||||
|
if idx := strings.IndexAny(pkg, "><:="); idx >= 0 {
|
||||||
|
pkg = pkg[:idx]
|
||||||
|
}
|
||||||
|
pkg = strings.TrimSpace(pkg)
|
||||||
|
if pkg != "" {
|
||||||
|
*target = append(*target, pkg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return deps, makedeps, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ArchDistribution) isInSystemRepo(pkg string) bool {
|
||||||
|
return exec.Command("pacman", "-Si", pkg).Run() == nil
|
||||||
|
}
|
||||||
|
|
||||||
func (a *ArchDistribution) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping {
|
func (a *ArchDistribution) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping {
|
||||||
return a.GetPackageMappingWithVariants(wm, make(map[string]deps.PackageVariant))
|
return a.GetPackageMappingWithVariants(wm, make(map[string]deps.PackageVariant))
|
||||||
}
|
}
|
||||||
@@ -133,6 +181,7 @@ func (a *ArchDistribution) GetPackageMappingWithVariants(wm deps.WindowManager,
|
|||||||
"dms (DankMaterialShell)": a.getDMSMapping(variants["dms (DankMaterialShell)"]),
|
"dms (DankMaterialShell)": a.getDMSMapping(variants["dms (DankMaterialShell)"]),
|
||||||
"git": {Name: "git", Repository: RepoTypeSystem},
|
"git": {Name: "git", Repository: RepoTypeSystem},
|
||||||
"quickshell": a.getQuickshellMapping(variants["quickshell"]),
|
"quickshell": a.getQuickshellMapping(variants["quickshell"]),
|
||||||
|
"dms-greeter": {Name: "greetd-dms-greeter-git", Repository: RepoTypeAUR},
|
||||||
"matugen": a.getMatugenMapping(variants["matugen"]),
|
"matugen": a.getMatugenMapping(variants["matugen"]),
|
||||||
"dgop": {Name: "dgop", Repository: RepoTypeSystem},
|
"dgop": {Name: "dgop", Repository: RepoTypeSystem},
|
||||||
"ghostty": {Name: "ghostty", Repository: RepoTypeSystem},
|
"ghostty": {Name: "ghostty", Repository: RepoTypeSystem},
|
||||||
@@ -159,8 +208,7 @@ func (a *ArchDistribution) getQuickshellMapping(variant deps.PackageVariant) Pac
|
|||||||
if forceQuickshellGit || variant == deps.VariantGit {
|
if forceQuickshellGit || variant == deps.VariantGit {
|
||||||
return PackageMapping{Name: "quickshell-git", Repository: RepoTypeAUR}
|
return PackageMapping{Name: "quickshell-git", Repository: RepoTypeAUR}
|
||||||
}
|
}
|
||||||
// ! TODO - for now we're only forcing quickshell-git on ARCH, as other distros use DL repos which pin a newer quickshell
|
return PackageMapping{Name: "quickshell", Repository: RepoTypeSystem}
|
||||||
return PackageMapping{Name: "quickshell-git", Repository: RepoTypeAUR}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *ArchDistribution) getHyprlandMapping(_ deps.PackageVariant) PackageMapping {
|
func (a *ArchDistribution) getHyprlandMapping(_ deps.PackageVariant) PackageMapping {
|
||||||
@@ -194,11 +242,7 @@ func (a *ArchDistribution) getDMSMapping(variant deps.PackageVariant) PackageMap
|
|||||||
return PackageMapping{Name: "dms-shell-git", Repository: RepoTypeAUR}
|
return PackageMapping{Name: "dms-shell-git", Repository: RepoTypeAUR}
|
||||||
}
|
}
|
||||||
|
|
||||||
if a.packageInstalled("dms-shell-bin") {
|
return PackageMapping{Name: "dms-shell", Repository: RepoTypeSystem}
|
||||||
return PackageMapping{Name: "dms-shell-bin", Repository: RepoTypeAUR}
|
|
||||||
}
|
|
||||||
|
|
||||||
return PackageMapping{Name: "dms-shell-bin", Repository: RepoTypeAUR}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *ArchDistribution) detectXwaylandSatellite() deps.Dependency {
|
func (a *ArchDistribution) detectXwaylandSatellite() deps.Dependency {
|
||||||
@@ -248,7 +292,7 @@ func (a *ArchDistribution) InstallPrerequisites(ctx context.Context, sudoPasswor
|
|||||||
LogOutput: "Installing base-devel development tools",
|
LogOutput: "Installing base-devel development tools",
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := ExecSudoCommand(ctx, sudoPassword, "pacman -S --needed --noconfirm base-devel")
|
cmd := privesc.ExecCommand(ctx, sudoPassword, "pacman -S --needed --noconfirm base-devel")
|
||||||
if err := a.runWithProgress(cmd, progressChan, PhasePrerequisites, 0.08, 0.10); err != nil {
|
if err := a.runWithProgress(cmd, progressChan, PhasePrerequisites, 0.08, 0.10); err != nil {
|
||||||
return fmt.Errorf("failed to install base-devel: %w", err)
|
return fmt.Errorf("failed to install base-devel: %w", err)
|
||||||
}
|
}
|
||||||
@@ -280,6 +324,19 @@ func (a *ArchDistribution) InstallPackages(ctx context.Context, dependencies []d
|
|||||||
|
|
||||||
systemPkgs, aurPkgs, manualPkgs, variantMap := a.categorizePackages(dependencies, wm, reinstallFlags, disabledFlags)
|
systemPkgs, aurPkgs, manualPkgs, variantMap := a.categorizePackages(dependencies, wm, reinstallFlags, disabledFlags)
|
||||||
|
|
||||||
|
if slices.Contains(aurPkgs, "quickshell-git") && slices.Contains(systemPkgs, "dms-shell") {
|
||||||
|
if err := a.preinstallQuickshellGit(ctx, sudoPassword, progressChan); err != nil {
|
||||||
|
return fmt.Errorf("failed to preinstall quickshell-git: %w", err)
|
||||||
|
}
|
||||||
|
aurPkgs = slices.DeleteFunc(aurPkgs, func(p string) bool { return p == "quickshell-git" })
|
||||||
|
}
|
||||||
|
|
||||||
|
if slices.Contains(systemPkgs, "quickshell") && a.packageInstalled("quickshell-git") {
|
||||||
|
if err := a.removeQuickshellGit(ctx, sudoPassword, progressChan); err != nil {
|
||||||
|
return fmt.Errorf("failed to remove quickshell-git: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Phase 3: System Packages
|
// Phase 3: System Packages
|
||||||
if len(systemPkgs) > 0 {
|
if len(systemPkgs) > 0 {
|
||||||
progressChan <- InstallProgressMsg{
|
progressChan <- InstallProgressMsg{
|
||||||
@@ -397,6 +454,51 @@ func (a *ArchDistribution) categorizePackages(dependencies []deps.Dependency, wm
|
|||||||
return systemPkgs, aurPkgs, manualPkgs, variantMap
|
return systemPkgs, aurPkgs, manualPkgs, variantMap
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *ArchDistribution) removeQuickshellGit(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
||||||
|
progressChan <- InstallProgressMsg{
|
||||||
|
Phase: PhaseSystemPackages,
|
||||||
|
Progress: 0.33,
|
||||||
|
Step: "Removing quickshell-git...",
|
||||||
|
IsComplete: false,
|
||||||
|
NeedsSudo: true,
|
||||||
|
CommandInfo: "sudo pacman -Rdd --noconfirm quickshell-git",
|
||||||
|
LogOutput: "Removing quickshell-git so stable quickshell can be installed",
|
||||||
|
}
|
||||||
|
cmd := privesc.ExecCommand(ctx, sudoPassword, "pacman -Rdd --noconfirm quickshell-git")
|
||||||
|
return a.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.33, 0.35)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ArchDistribution) preinstallQuickshellGit(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
||||||
|
if a.packageInstalled("quickshell-git") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if a.packageInstalled("quickshell") {
|
||||||
|
progressChan <- InstallProgressMsg{
|
||||||
|
Phase: PhaseAURPackages,
|
||||||
|
Progress: 0.15,
|
||||||
|
Step: "Removing stable quickshell...",
|
||||||
|
IsComplete: false,
|
||||||
|
NeedsSudo: true,
|
||||||
|
CommandInfo: "sudo pacman -Rdd --noconfirm quickshell",
|
||||||
|
LogOutput: "Removing stable quickshell so quickshell-git can be installed",
|
||||||
|
}
|
||||||
|
cmd := privesc.ExecCommand(ctx, sudoPassword, "pacman -Rdd --noconfirm quickshell")
|
||||||
|
if err := a.runWithProgress(cmd, progressChan, PhaseAURPackages, 0.15, 0.18); err != nil {
|
||||||
|
return fmt.Errorf("failed to remove stable quickshell: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
progressChan <- InstallProgressMsg{
|
||||||
|
Phase: PhaseAURPackages,
|
||||||
|
Progress: 0.18,
|
||||||
|
Step: "Building quickshell-git before system packages...",
|
||||||
|
IsComplete: false,
|
||||||
|
CommandInfo: "Installing quickshell-git ahead of dms-shell to avoid conflict",
|
||||||
|
}
|
||||||
|
return a.installSingleAURPackage(ctx, "quickshell-git", sudoPassword, progressChan, 0.18, 0.32)
|
||||||
|
}
|
||||||
|
|
||||||
func (a *ArchDistribution) installSystemPackages(ctx context.Context, packages []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
func (a *ArchDistribution) installSystemPackages(ctx context.Context, packages []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
||||||
if len(packages) == 0 {
|
if len(packages) == 0 {
|
||||||
return nil
|
return nil
|
||||||
@@ -405,6 +507,9 @@ func (a *ArchDistribution) installSystemPackages(ctx context.Context, packages [
|
|||||||
a.log(fmt.Sprintf("Installing system packages: %s", strings.Join(packages, ", ")))
|
a.log(fmt.Sprintf("Installing system packages: %s", strings.Join(packages, ", ")))
|
||||||
|
|
||||||
args := []string{"pacman", "-S", "--needed", "--noconfirm"}
|
args := []string{"pacman", "-S", "--needed", "--noconfirm"}
|
||||||
|
if slices.Contains(packages, "dms-shell") {
|
||||||
|
args = append(args, "--assume-installed", "dms-shell-compositor=1")
|
||||||
|
}
|
||||||
args = append(args, packages...)
|
args = append(args, packages...)
|
||||||
|
|
||||||
progressChan <- InstallProgressMsg{
|
progressChan <- InstallProgressMsg{
|
||||||
@@ -416,7 +521,7 @@ func (a *ArchDistribution) installSystemPackages(ctx context.Context, packages [
|
|||||||
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
|
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
|
cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
|
||||||
return a.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.40, 0.60)
|
return a.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.40, 0.60)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -428,29 +533,10 @@ func (a *ArchDistribution) installAURPackages(ctx context.Context, packages []st
|
|||||||
a.log(fmt.Sprintf("Installing AUR packages manually: %s", strings.Join(packages, ", ")))
|
a.log(fmt.Sprintf("Installing AUR packages manually: %s", strings.Join(packages, ", ")))
|
||||||
|
|
||||||
hasNiri := false
|
hasNiri := false
|
||||||
hasQuickshell := false
|
|
||||||
for _, pkg := range packages {
|
for _, pkg := range packages {
|
||||||
if pkg == "niri-git" {
|
if pkg == "niri-git" {
|
||||||
hasNiri = true
|
hasNiri = true
|
||||||
}
|
}
|
||||||
if pkg == "quickshell" || pkg == "quickshell-git" {
|
|
||||||
hasQuickshell = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If quickshell is in the list, always reinstall google-breakpad first
|
|
||||||
if hasQuickshell {
|
|
||||||
progressChan <- InstallProgressMsg{
|
|
||||||
Phase: PhaseAURPackages,
|
|
||||||
Progress: 0.63,
|
|
||||||
Step: "Reinstalling google-breakpad for quickshell...",
|
|
||||||
IsComplete: false,
|
|
||||||
CommandInfo: "Reinstalling prerequisite AUR package for quickshell",
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := a.installSingleAURPackage(ctx, "google-breakpad", sudoPassword, progressChan, 0.63, 0.65); err != nil {
|
|
||||||
return fmt.Errorf("failed to reinstall google-breakpad prerequisite for quickshell: %w", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If niri is in the list, install makepkg-git-lfs-proto first if not already installed
|
// If niri is in the list, install makepkg-git-lfs-proto first if not already installed
|
||||||
@@ -511,7 +597,7 @@ func (a *ArchDistribution) reorderAURPackages(packages []string) []string {
|
|||||||
var dmsShell []string
|
var dmsShell []string
|
||||||
|
|
||||||
for _, pkg := range packages {
|
for _, pkg := range packages {
|
||||||
if pkg == "dms-shell-git" || pkg == "dms-shell-bin" {
|
if pkg == "dms-shell-git" {
|
||||||
dmsShell = append(dmsShell, pkg)
|
dmsShell = append(dmsShell, pkg)
|
||||||
} else {
|
} else {
|
||||||
isDep := false
|
isDep := false
|
||||||
@@ -531,6 +617,16 @@ func (a *ArchDistribution) reorderAURPackages(packages []string) []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *ArchDistribution) installSingleAURPackage(ctx context.Context, pkg, sudoPassword string, progressChan chan<- InstallProgressMsg, startProgress, endProgress float64) error {
|
func (a *ArchDistribution) installSingleAURPackage(ctx context.Context, pkg, sudoPassword string, progressChan chan<- InstallProgressMsg, startProgress, endProgress float64) error {
|
||||||
|
return a.installSingleAURPackageInternal(ctx, pkg, sudoPassword, progressChan, startProgress, endProgress, make(map[string]bool))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ArchDistribution) installSingleAURPackageInternal(ctx context.Context, pkg, sudoPassword string, progressChan chan<- InstallProgressMsg, startProgress, endProgress float64, visited map[string]bool) error {
|
||||||
|
if visited[pkg] {
|
||||||
|
a.log(fmt.Sprintf("Skipping %s (already being installed, cycle detected)", pkg))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
visited[pkg] = true
|
||||||
|
|
||||||
homeDir, err := os.UserHomeDir()
|
homeDir, err := os.UserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get user home directory: %w", err)
|
return fmt.Errorf("failed to get user home directory: %w", err)
|
||||||
@@ -582,7 +678,7 @@ func (a *ArchDistribution) installSingleAURPackage(ctx context.Context, pkg, sud
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if pkg == "dms-shell-git" || pkg == "dms-shell-bin" {
|
if pkg == "dms-shell-git" {
|
||||||
srcinfoPath := filepath.Join(packageDir, ".SRCINFO")
|
srcinfoPath := filepath.Join(packageDir, ".SRCINFO")
|
||||||
depsToRemove := []string{
|
depsToRemove := []string{
|
||||||
"depends = quickshell",
|
"depends = quickshell",
|
||||||
@@ -604,54 +700,66 @@ func (a *ArchDistribution) installSingleAURPackage(ctx context.Context, pkg, sud
|
|||||||
return fmt.Errorf("failed to remove optdepends from .SRCINFO for %s: %w", pkg, err)
|
return fmt.Errorf("failed to remove optdepends from .SRCINFO for %s: %w", pkg, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip dependency installation for dms-shell-git and dms-shell-bin
|
srcinfoPath = filepath.Join(packageDir, ".SRCINFO")
|
||||||
// since we manually manage those dependencies
|
{
|
||||||
if pkg != "dms-shell-git" && pkg != "dms-shell-bin" {
|
|
||||||
// Pre-install dependencies from .SRCINFO
|
|
||||||
progressChan <- InstallProgressMsg{
|
progressChan <- InstallProgressMsg{
|
||||||
Phase: PhaseAURPackages,
|
Phase: PhaseAURPackages,
|
||||||
Progress: startProgress + 0.3*(endProgress-startProgress),
|
Progress: startProgress + 0.3*(endProgress-startProgress),
|
||||||
Step: fmt.Sprintf("Installing dependencies for %s...", pkg),
|
Step: fmt.Sprintf("Resolving dependencies for %s...", pkg),
|
||||||
IsComplete: false,
|
IsComplete: false,
|
||||||
CommandInfo: "Installing package dependencies and makedepends",
|
CommandInfo: "Classifying dependencies as system or AUR",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Install dependencies and makedepends explicitly
|
runtimeDeps, makeDeps, err := parseSRCINFODeps(srcinfoPath)
|
||||||
srcinfoPath = filepath.Join(packageDir, ".SRCINFO")
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to parse .SRCINFO for %s: %w", pkg, err)
|
||||||
depsCmd := exec.CommandContext(ctx, "bash", "-c",
|
|
||||||
fmt.Sprintf(`
|
|
||||||
deps=$(grep "depends = " "%s" | grep -v "makedepends" | sed 's/.*depends = //' | tr '\n' ' ' | sed 's/[[:space:]]*$//')
|
|
||||||
if [[ "%s" == *"quickshell"* ]]; then
|
|
||||||
deps=$(echo "$deps" | sed 's/google-breakpad//g' | sed 's/ / /g' | sed 's/^ *//g' | sed 's/ *$//g')
|
|
||||||
fi
|
|
||||||
if [ ! -z "$deps" ] && [ "$deps" != " " ]; then
|
|
||||||
echo '%s' | sudo -S pacman -S --needed --noconfirm $deps
|
|
||||||
fi
|
|
||||||
`, srcinfoPath, pkg, sudoPassword))
|
|
||||||
|
|
||||||
if err := a.runWithProgress(depsCmd, progressChan, PhaseAURPackages, startProgress+0.3*(endProgress-startProgress), startProgress+0.35*(endProgress-startProgress)); err != nil {
|
|
||||||
return fmt.Errorf("FAILED to install runtime dependencies for %s: %w", pkg, err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
makedepsCmd := exec.CommandContext(ctx, "bash", "-c",
|
seen := make(map[string]bool)
|
||||||
fmt.Sprintf(`
|
var systemPkgs []string
|
||||||
makedeps=$(grep -E "^[[:space:]]*makedepends = " "%s" | sed 's/^[[:space:]]*makedepends = //' | tr '\n' ' ')
|
var aurPkgs []string
|
||||||
if [ ! -z "$makedeps" ]; then
|
|
||||||
echo '%s' | sudo -S pacman -S --needed --noconfirm $makedeps
|
|
||||||
fi
|
|
||||||
`, srcinfoPath, sudoPassword))
|
|
||||||
|
|
||||||
if err := a.runWithProgress(makedepsCmd, progressChan, PhaseAURPackages, startProgress+0.35*(endProgress-startProgress), startProgress+0.4*(endProgress-startProgress)); err != nil {
|
for _, dep := range append(runtimeDeps, makeDeps...) {
|
||||||
return fmt.Errorf("FAILED to install make dependencies for %s: %w", pkg, err)
|
if seen[dep] || a.packageInstalled(dep) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[dep] = true
|
||||||
|
if a.isInSystemRepo(dep) {
|
||||||
|
systemPkgs = append(systemPkgs, dep)
|
||||||
|
} else {
|
||||||
|
aurPkgs = append(aurPkgs, dep)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
progressChan <- InstallProgressMsg{
|
if len(systemPkgs) > 0 {
|
||||||
Phase: PhaseAURPackages,
|
progressChan <- InstallProgressMsg{
|
||||||
Progress: startProgress + 0.35*(endProgress-startProgress),
|
Phase: PhaseAURPackages,
|
||||||
Step: fmt.Sprintf("Skipping dependency installation for %s (manually managed)...", pkg),
|
Progress: startProgress + 0.32*(endProgress-startProgress),
|
||||||
IsComplete: false,
|
Step: fmt.Sprintf("Installing %d system dependencies for %s...", len(systemPkgs), pkg),
|
||||||
LogOutput: fmt.Sprintf("Dependencies for %s are installed separately", pkg),
|
IsComplete: false,
|
||||||
|
CommandInfo: fmt.Sprintf("sudo pacman -S --needed --noconfirm %s", strings.Join(systemPkgs, " ")),
|
||||||
|
}
|
||||||
|
if err := a.installSystemPackages(ctx, systemPkgs, sudoPassword, progressChan); err != nil {
|
||||||
|
return fmt.Errorf("failed to install system dependencies for %s: %w", pkg, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, aurDep := range aurPkgs {
|
||||||
|
a.log(fmt.Sprintf("Dependency %s is AUR-only, building from source...", aurDep))
|
||||||
|
progressChan <- InstallProgressMsg{
|
||||||
|
Phase: PhaseAURPackages,
|
||||||
|
Progress: startProgress + 0.35*(endProgress-startProgress),
|
||||||
|
Step: fmt.Sprintf("Installing AUR dependency %s for %s...", aurDep, pkg),
|
||||||
|
IsComplete: false,
|
||||||
|
CommandInfo: fmt.Sprintf("Building AUR dependency: %s", aurDep),
|
||||||
|
}
|
||||||
|
if err := a.installSingleAURPackageInternal(ctx, aurDep, sudoPassword, progressChan,
|
||||||
|
startProgress+0.35*(endProgress-startProgress),
|
||||||
|
startProgress+0.39*(endProgress-startProgress),
|
||||||
|
visited,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("failed to install AUR dependency %s for %s: %w", aurDep, pkg, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -665,7 +773,7 @@ func (a *ArchDistribution) installSingleAURPackage(ctx context.Context, pkg, sud
|
|||||||
|
|
||||||
buildCmd := exec.CommandContext(ctx, "makepkg", "--noconfirm")
|
buildCmd := exec.CommandContext(ctx, "makepkg", "--noconfirm")
|
||||||
buildCmd.Dir = packageDir
|
buildCmd.Dir = packageDir
|
||||||
buildCmd.Env = append(os.Environ(), "PKGEXT=.pkg.tar") // Disable compression for speed
|
buildCmd.Env = append(os.Environ(), "PKGEXT=.pkg.tar")
|
||||||
|
|
||||||
if err := a.runWithProgress(buildCmd, progressChan, PhaseAURPackages, startProgress+0.4*(endProgress-startProgress), startProgress+0.7*(endProgress-startProgress)); err != nil {
|
if err := a.runWithProgress(buildCmd, progressChan, PhaseAURPackages, startProgress+0.4*(endProgress-startProgress), startProgress+0.7*(endProgress-startProgress)); err != nil {
|
||||||
return fmt.Errorf("failed to build %s: %w", pkg, err)
|
return fmt.Errorf("failed to build %s: %w", pkg, err)
|
||||||
@@ -680,42 +788,9 @@ func (a *ArchDistribution) installSingleAURPackage(ctx context.Context, pkg, sud
|
|||||||
CommandInfo: "sudo pacman -U built-package",
|
CommandInfo: "sudo pacman -U built-package",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find .pkg.tar* files - for split packages, install the base and any installed compositor variants
|
|
||||||
var files []string
|
var files []string
|
||||||
if pkg == "dms-shell-git" || pkg == "dms-shell-bin" {
|
matches, _ := filepath.Glob(filepath.Join(packageDir, "*.pkg.tar*"))
|
||||||
// For DMS split packages, install base package
|
files = matches
|
||||||
pattern := filepath.Join(packageDir, fmt.Sprintf("%s-%s*.pkg.tar*", pkg, "*"))
|
|
||||||
matches, err := filepath.Glob(pattern)
|
|
||||||
if err == nil {
|
|
||||||
for _, match := range matches {
|
|
||||||
basename := filepath.Base(match)
|
|
||||||
// Always include base package
|
|
||||||
if !strings.Contains(basename, "hyprland") && !strings.Contains(basename, "niri") {
|
|
||||||
files = append(files, match)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also update compositor-specific packages if they're installed
|
|
||||||
if strings.HasSuffix(pkg, "-git") {
|
|
||||||
if a.packageInstalled("dms-shell-hyprland-git") {
|
|
||||||
hyprlandPattern := filepath.Join(packageDir, "dms-shell-hyprland-git-*.pkg.tar*")
|
|
||||||
if hyprlandMatches, err := filepath.Glob(hyprlandPattern); err == nil && len(hyprlandMatches) > 0 {
|
|
||||||
files = append(files, hyprlandMatches[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if a.packageInstalled("dms-shell-niri-git") {
|
|
||||||
niriPattern := filepath.Join(packageDir, "dms-shell-niri-git-*.pkg.tar*")
|
|
||||||
if niriMatches, err := filepath.Glob(niriPattern); err == nil && len(niriMatches) > 0 {
|
|
||||||
files = append(files, niriMatches[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// For other packages, install all built packages
|
|
||||||
matches, _ := filepath.Glob(filepath.Join(packageDir, "*.pkg.tar*"))
|
|
||||||
files = matches
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(files) == 0 {
|
if len(files) == 0 {
|
||||||
return fmt.Errorf("no package files found after building %s", pkg)
|
return fmt.Errorf("no package files found after building %s", pkg)
|
||||||
@@ -724,7 +799,7 @@ func (a *ArchDistribution) installSingleAURPackage(ctx context.Context, pkg, sud
|
|||||||
installArgs := []string{"pacman", "-U", "--noconfirm"}
|
installArgs := []string{"pacman", "-U", "--noconfirm"}
|
||||||
installArgs = append(installArgs, files...)
|
installArgs = append(installArgs, files...)
|
||||||
|
|
||||||
installCmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(installArgs, " "))
|
installCmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(installArgs, " "))
|
||||||
|
|
||||||
fileNames := make([]string, len(files))
|
fileNames := make([]string, len(files))
|
||||||
for i, f := range files {
|
for i, f := range files {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/version"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -55,27 +56,6 @@ func (b *BaseDistribution) logError(message string, err error) {
|
|||||||
b.log(errorMsg)
|
b.log(errorMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// escapeSingleQuotes escapes single quotes in a string for safe use in bash single-quoted strings.
|
|
||||||
// It replaces each ' with '\” which closes the quote, adds an escaped quote, and reopens the quote.
|
|
||||||
// This prevents shell injection and syntax errors when passwords contain single quotes or apostrophes.
|
|
||||||
func escapeSingleQuotes(s string) string {
|
|
||||||
return strings.ReplaceAll(s, "'", "'\\''")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MakeSudoCommand creates a command string that safely passes password to sudo.
|
|
||||||
// This helper escapes special characters in the password to prevent shell injection
|
|
||||||
// and syntax errors when passwords contain single quotes, apostrophes, or other special chars.
|
|
||||||
func MakeSudoCommand(sudoPassword string, command string) string {
|
|
||||||
return fmt.Sprintf("echo '%s' | sudo -S %s", escapeSingleQuotes(sudoPassword), command)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExecSudoCommand creates an exec.Cmd that runs a command with sudo using the provided password.
|
|
||||||
// The password is properly escaped to prevent shell injection and syntax errors.
|
|
||||||
func ExecSudoCommand(ctx context.Context, sudoPassword string, command string) *exec.Cmd {
|
|
||||||
cmdStr := MakeSudoCommand(sudoPassword, command)
|
|
||||||
return exec.CommandContext(ctx, "bash", "-c", cmdStr)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *BaseDistribution) detectCommand(name, description string) deps.Dependency {
|
func (b *BaseDistribution) detectCommand(name, description string) deps.Dependency {
|
||||||
status := deps.StatusMissing
|
status := deps.StatusMissing
|
||||||
if b.commandExists(name) {
|
if b.commandExists(name) {
|
||||||
@@ -102,6 +82,19 @@ func (b *BaseDistribution) detectPackage(name, description string, installed boo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (b *BaseDistribution) detectOptionalPackage(name, description string, installed bool) deps.Dependency {
|
||||||
|
status := deps.StatusMissing
|
||||||
|
if installed {
|
||||||
|
status = deps.StatusInstalled
|
||||||
|
}
|
||||||
|
return deps.Dependency{
|
||||||
|
Name: name,
|
||||||
|
Status: status,
|
||||||
|
Description: description,
|
||||||
|
Required: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (b *BaseDistribution) detectGit() deps.Dependency {
|
func (b *BaseDistribution) detectGit() deps.Dependency {
|
||||||
return b.detectCommand("git", "Version control system")
|
return b.detectCommand("git", "Version control system")
|
||||||
}
|
}
|
||||||
@@ -239,7 +232,7 @@ func (b *BaseDistribution) detectQuickshell() deps.Dependency {
|
|||||||
}
|
}
|
||||||
|
|
||||||
versionStr := string(output)
|
versionStr := string(output)
|
||||||
versionRegex := regexp.MustCompile(`quickshell (\d+\.\d+\.\d+)`)
|
versionRegex := regexp.MustCompile(`(?i)quickshell (\d+\.\d+\.\d+)`)
|
||||||
matches := versionRegex.FindStringSubmatch(versionStr)
|
matches := versionRegex.FindStringSubmatch(versionStr)
|
||||||
|
|
||||||
if len(matches) < 2 {
|
if len(matches) < 2 {
|
||||||
@@ -534,7 +527,7 @@ func (b *BaseDistribution) WriteEnvironmentConfig(terminal deps.Terminal) error
|
|||||||
}
|
}
|
||||||
|
|
||||||
envDir := filepath.Join(homeDir, ".config", "environment.d")
|
envDir := filepath.Join(homeDir, ".config", "environment.d")
|
||||||
if err := os.MkdirAll(envDir, 0755); err != nil {
|
if err := os.MkdirAll(envDir, 0o755); err != nil {
|
||||||
return fmt.Errorf("failed to create environment.d directory: %w", err)
|
return fmt.Errorf("failed to create environment.d directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -555,7 +548,7 @@ TERMINAL=%s
|
|||||||
`, terminalCmd)
|
`, terminalCmd)
|
||||||
|
|
||||||
envFile := filepath.Join(envDir, "90-dms.conf")
|
envFile := filepath.Join(envDir, "90-dms.conf")
|
||||||
if err := os.WriteFile(envFile, []byte(content), 0644); err != nil {
|
if err := os.WriteFile(envFile, []byte(content), 0o644); err != nil {
|
||||||
return fmt.Errorf("failed to write environment config: %w", err)
|
return fmt.Errorf("failed to write environment config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -594,7 +587,7 @@ func (b *BaseDistribution) WriteHyprlandSessionTarget() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
targetDir := filepath.Join(homeDir, ".config", "systemd", "user")
|
targetDir := filepath.Join(homeDir, ".config", "systemd", "user")
|
||||||
if err := os.MkdirAll(targetDir, 0755); err != nil {
|
if err := os.MkdirAll(targetDir, 0o755); err != nil {
|
||||||
return fmt.Errorf("failed to create systemd user directory: %w", err)
|
return fmt.Errorf("failed to create systemd user directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -605,7 +598,7 @@ Requires=graphical-session.target
|
|||||||
After=graphical-session.target
|
After=graphical-session.target
|
||||||
`
|
`
|
||||||
|
|
||||||
if err := os.WriteFile(targetPath, []byte(content), 0644); err != nil {
|
if err := os.WriteFile(targetPath, []byte(content), 0o644); err != nil {
|
||||||
return fmt.Errorf("failed to write hyprland-session.target: %w", err)
|
return fmt.Errorf("failed to write hyprland-session.target: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -697,7 +690,7 @@ func (b *BaseDistribution) installDMSBinary(ctx context.Context, sudoPassword st
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Install to /usr/local/bin
|
// Install to /usr/local/bin
|
||||||
installCmd := ExecSudoCommand(ctx, sudoPassword,
|
installCmd := privesc.ExecCommand(ctx, sudoPassword,
|
||||||
fmt.Sprintf("cp %s /usr/local/bin/dms", binaryPath))
|
fmt.Sprintf("cp %s /usr/local/bin/dms", binaryPath))
|
||||||
if err := installCmd.Run(); err != nil {
|
if err := installCmd.Run(); err != nil {
|
||||||
return fmt.Errorf("failed to install DMS binary: %w", err)
|
return fmt.Errorf("failed to install DMS binary: %w", err)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user