commit 10b88a67bc9b1798350ecd4b60d8c8cc3f68c408 Author: Karim Date: Thu Jun 18 20:18:30 2026 +0200 Initial commit — TANINUX (camel): management app + distro packaging - app: GTK System Settings (tsettings) + Software Hub (thub) + TUI - distro/: camel.toml manifest + MANIFEST.md (Arch + [tanin] repo model) - packaging/: taninux, tanin-desktop (niri metapackage), tanin-greet, tanin-libadwaita, tanin-setup - docs/, data/, LICENSE (GPL-3.0-or-later) Co-Authored-By: Claude Opus 4.8 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f9d4bb6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +dist/ +build/ +.ruff_cache/ +.mypy_cache/ +.pytest_cache/ +uv.lock diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f6cdd22 --- /dev/null +++ b/LICENSE @@ -0,0 +1,232 @@ +GNU GENERAL PUBLIC LICENSE +Version 3, 29 June 2007 + +Copyright © 2007 Free Software Foundation, Inc. + +Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. + +Preamble + +The GNU General Public License is a free, copyleft license for software and other kinds of works. + +The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. + +When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. + +To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. + +For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. + +Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. + +For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. + +Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. + +Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. + +The precise terms and conditions for copying, distribution and modification follow. + +TERMS AND CONDITIONS + +0. Definitions. + +“This License” refers to version 3 of the GNU General Public License. + +“Copyright” also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. + +“The Program” refers to any copyrightable work licensed under this License. Each licensee is addressed as “you”. “Licensees” and “recipients” may be individuals or organizations. + +To “modify” a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a “modified version” of the earlier work or a work “based on” the earlier work. + +A “covered work” means either the unmodified Program or a work based on the Program. + +To “propagate” a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. + +To “convey” a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. + +An interactive user interface displays “Appropriate Legal Notices” to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. + +1. Source Code. +The “source code” for a work means the preferred form of the work for making modifications to it. “Object code” means any non-source form of a work. + +A “Standard Interface” means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. + +The “System Libraries” of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A “Major Component”, in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. + +The “Corresponding Source” for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. + +The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. + +The Corresponding Source for a work in source code form is that same work. + +2. Basic Permissions. +All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. + +You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. + +Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. + +3. Protecting Users' Legal Rights From Anti-Circumvention Law. +No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. + +When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. + +4. Conveying Verbatim Copies. +You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. + +5. Conveying Modified Source Versions. +You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to “keep intact all notices”. + + c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. + +A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an “aggregate” if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. + +6. Conveying Non-Source Forms. +You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: + + a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. + + d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. + +A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. + +A “User Product” is either (1) a “consumer product”, which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, “normally used” refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. + +“Installation Information” for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. + +If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). + +The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. + +Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. + +7. Additional Terms. +“Additional permissions” are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. + +When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. + +Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or authors of the material; or + + e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. + +All other non-permissive additional terms are considered “further restrictions” within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. + +Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. + +8. Termination. +You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). + +However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. + +Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. + +Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. + +9. Acceptance Not Required for Having Copies. +You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. + +10. Automatic Licensing of Downstream Recipients. +Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. + +An “entity transaction” is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. + +You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. + +11. Patents. +A “contributor” is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's “contributor version”. + +A contributor's “essential patent claims” are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, “control” includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. + +In the following three paragraphs, a “patent license” is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To “grant” such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. + +If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. “Knowingly relying” means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. + +A patent license is “discriminatory” if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. + +12. No Surrender of Others' Freedom. +If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. + +13. Use with the GNU Affero General Public License. +Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. + +14. Revised Versions of this License. +The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. + +If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. + +Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. + +15. Disclaimer of Warranty. +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + +16. Limitation of Liability. +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +17. Interpretation of Sections 15 and 16. +If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. + +END OF TERMS AND CONDITIONS + +How to Apply These Terms to Your New Programs + +If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. + +To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + +If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an “about box”. + +You should also get your employer (if you work as a programmer) or school, if any, to sign a “copyright disclaimer” for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . + +The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . diff --git a/data/applications/ch.gabrielevarano.Taninux.Settings.desktop b/data/applications/ch.gabrielevarano.Taninux.Settings.desktop new file mode 100644 index 0000000..23f47cf --- /dev/null +++ b/data/applications/ch.gabrielevarano.Taninux.Settings.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Type=Application +Name=System Settings +Comment=System, hardware and personalization settings +Exec=tsettings +Icon=preferences-system +Terminal=false +Categories=Settings;GTK; +Keywords=settings;system;kernel;display;audio;network;bluetooth;power; +StartupNotify=true diff --git a/data/applications/ch.gabrielevarano.Taninux.SoftwareHub.desktop b/data/applications/ch.gabrielevarano.Taninux.SoftwareHub.desktop new file mode 100644 index 0000000..f824c67 --- /dev/null +++ b/data/applications/ch.gabrielevarano.Taninux.SoftwareHub.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Type=Application +Name=Software Hub +Comment=Search, install and update packages (pacman, AUR, Flatpak) +Exec=thub +Icon=system-software-install +Terminal=false +Categories=System;PackageManager;GTK; +Keywords=software;packages;install;pacman;aur;flatpak;update; +StartupNotify=true diff --git a/distro/MANIFEST.md b/distro/MANIFEST.md new file mode 100644 index 0000000..4bc8cc2 --- /dev/null +++ b/distro/MANIFEST.md @@ -0,0 +1,111 @@ +# camel — Distribution Manifest + +> Codename **camel**. The project/OS name is still provisional (**taninux** · +> **tanin** · or a later marketing name). Everything stable keys off the +> codename, not the name. Machine-readable source of truth: [`camel.toml`](camel.toml). +> Last reconciled 2026-06-17. + +camel is **not a fork**. It's stock Arch Linux plus one extra binary repo +(`[tanin]`) holding our own packages — the EndeavourOS/CachyOS model. A +Calamares installer lays down a ready-to-use **niri + eww** desktop managed by +the **TANINUX** apps. + +## Identity (Fuji palette) +Dark, monochrome, libadwaita, rounded, soft short shadows. + +| Token | Value | Used by | +|------------|----------------------|--------------------------------| +| accent | `#a39ec4` (violet) | greeter, eww (`accent.sh`), GTK | +| bg / fg | `#0f0f0f` / `#f0f0f0`| theme | +| UI font | Inter (Medium) | apps, bar | +| icon font | Iosevka Nerd Font | `nf-md-*` glyphs | +| icon theme | Papirus | dock app icons | + +## The stack (what it boots into) + +| Layer | Choice | Package(s) | +|------------------|-------------------------------------|----------------------------------| +| Display manager | **greetd** + custom GTK4 greeter (via `cage`) | `greetd`, `cage`, `tanin-greet` | +| Compositor | **niri** (Wayland, niri-only) | `niri` | +| Bar/dock/panels | **eww** + Spotlight | `eww`, `tanin-eww` | +| Notifications | **tiramisu** (headless) + eww UI | `tiramisu` | +| Terminal | foot | `foot` | +| Wallpaper | waypaper | `waypaper` | +| Apps | tsettings · thub · taninux (TUI) | `taninux` | +| GTK4 theme | **Fuji** recolour, light + dark | `tanin-libadwaita` | +| Portals | gtk (dark mode) + gnome (screencast)| `xdg-desktop-portal-{gtk,gnome}` | + +> **Compositor note:** earlier docs (`docs/distribution.md`, the `tanin-desktop` +> PKGBUILD) still say *Hyprland*. That is stale — the desktop is **niri-only**. +> Reconcile both against this manifest. + +## Package model + +``` +official Arch repos (core/extra/multilib) → everything standard +[tanin] (own binary repo) → our packages + rebuilt AUR deps +AUR → OPT-IN only (paru = optdepend) +``` + +**Our packages** (`[tanin]`): + +| Package | Role | Status | +|-----------------|---------------------------------------------------|-------------------| +| `taninux` | tsettings + thub + TUI, helper, polkit, .desktop | ready | +| `tanin-greet` | custom GTK4 greeter for greetd → `/etc/greetd/` | ready (preview) | +| `tanin-libadwaita` | Fuji libadwaita recolour, light + dark (pills) | ready (preview) | +| `tanin-eww` | eww config + scripts → `/usr/share/tanin/skel` | **todo** | +| `tanin-setup` | seed `~/.config` + enable session target | ready | +| `tanin-desktop` | metapackage pulling the whole environment (niri) | ready (needs tanin-eww) | + +**Rebuilt AUR deps** — the desktop's hard requirements that aren't in the +official repos. We rebuild them into `[tanin]` so `tanin-desktop` can hard-depend +on them **without the AUR** (binary repos must never need it): `eww`, `tiramisu`, +`waypaper`, `regreet`, `cliphist`. ⚠️ Verify each before publishing — any that +turn out to live in `extra` should be dropped from this list. + +The full Arch package set (compositor, portals, audio, connectivity, fonts, …) +is enumerated under `[packages.arch]` in `camel.toml` and is the seed for the +archiso `packages.x86_64`. + +## Why these are first-class (not "just config") + +Discovered from the live stack ([eww `launch.sh`](../../eww/launch.sh) + scripts): +the bar genuinely shells out to `eww · niri · jq · wpctl · cliphist · playerctl · +bluetoothctl · nmcli · wl-clipboard · checkupdates · qalc · grim · flatpak · +tiramisu`. These map to the package set above; the AUR ones (eww/tiramisu/ +waypaper/cliphist) are exactly why `[tanin]` must carry rebuilt AUR packages. + +## Delivery + +1. **Foundation** — `taninux` builds (`makepkg -si`). ✅ +2. **Meta + config** — finish `tanin-eww`; update `tanin-desktop` to niri; keep + `tanin-setup`. Dotfiles ship to `/usr/share/tanin/skel`, never to `/home` at + package time; `tanin-setup` seeds them on first run. +3. **`[tanin]` repo** — `repo-add -s` to build+sign the DB. Host as a + Gitea/Forgejo Arch registry **or** a static dir behind `taninux.kgva.ch`. +4. **ISO** — archiso profile with `[tanin]` pre-configured; Calamares `packages` + module installs `tanin-desktop`; greetd+regreet enabled; session target wired. + +### Session / dark-mode fix +libadwaita apps read light/dark via `org.freedesktop.portal.Settings`, which +needs `graphical-session.target` active. `tanin-setup` installs +`tanin-session.target`; the shipped niri autostart must run: +``` +dbus-update-activation-environment --systemd --all +systemctl --user start tanin-session.target +``` + +## Open questions (decide before a release tag) +- **Name:** keep TANINUX, or split `app = tanin` / `OS = `? +- **Repo hosting:** Gitea Arch registry vs static dir behind `taninux.kgva.ch`. +- **Signing:** move `[tanin]` to `SigLevel = Required` + distribute the key. +- **regreet theme:** author its GTK CSS to the Fuji accent. +- **AUR check:** confirm which rebuilt-AUR entries are already in `extra`. +- **Blur/shadow:** niri `layer-rule` blur+shadow for eww layers (template is + commented in the niri config). + +## Related +- App repo & packaging: `~/projects/taninux` (this repo) +- Desktop shell: `~/projects/eww` — read its `HANDOVER.md` first +- Older prose draft (Hyprland-era): [`../docs/distribution.md`](../docs/distribution.md) diff --git a/distro/camel.toml b/distro/camel.toml new file mode 100644 index 0000000..3ad55c5 --- /dev/null +++ b/distro/camel.toml @@ -0,0 +1,203 @@ +# camel.toml — distribution manifest (single source of truth) +# ============================================================================= +# Codename "camel" is the STABLE internal handle. The project/OS name is still +# provisional — it may stay "taninux", shrink to "tanin", or the shipped OS may +# get a different marketing name later. Everything that must not break when the +# name changes keys off `codename`, not `name`. +# +# This file is the canonical spec. The PKGBUILDs under ../packaging, the archiso +# profile, and docs/distribution.md should be derived from / reconciled against +# it. Last reconciled: 2026-06-17 (compositor switched Hyprland -> niri). +# ============================================================================= + +[distribution] +codename = "camel" +name = "taninux" # PROVISIONAL (taninux | tanin | TBD) +display_name = "TANINUX" # PROVISIONAL +tagline = "A curated Arch desktop — niri + eww, managed by TANINUX." +base = "arch" # not a fork; Arch + own [tanin] repo +arch = ["x86_64"] +channel = "rolling" +license = "GPL-3.0-or-later" +homepage = "https://taninux.kgva.ch" +maintainer = "Karim " + +# Visual identity — the "Fuji" palette. Mirrored into eww (accent.sh) and the +# greeter/GTK theme. Single source for accent so DM, bar and apps agree. +[identity] +theme = "dark, monochrome, libadwaita, soft short shadows, rounded" +accent = "#a39ec4" # Fuji violet +bg = "#0f0f0f" +fg = "#f0f0f0" +ui_font = "Inter" # Medium +icon_font = "Iosevka Nerd Font" # Material Design Icons (nf-md-*) +icon_theme = "Papirus" # dock app icons + +# ----------------------------------------------------------------------------- +# Package sources. Standard software comes from the official Arch repos; only +# our own packages (and AUR deps we are forced to rebuild) live in [tanin]. +# ----------------------------------------------------------------------------- +[sources.arch] +repos = ["core", "extra", "multilib"] + +[sources.tanin] +repo_name = "tanin" +server = "https://taninux.kgva.ch/$arch" +code_host = "https://git.openbureau.ch/karim/taninux" # PKGBUILD source clones +siglevel = "Optional TrustAll" # TODO: tighten to "Required" + ship the key +db_build = "repo-add -s tanin.db.tar.zst *.pkg.tar.zst" + +[sources.aur] +# Policy: AUR is OPT-IN. Nothing in the desktop may HARD-depend on the AUR +# (binary repos must not need it). paru is an optdepend only. Any AUR package +# the desktop genuinely needs is rebuilt into [tanin] (see packages.rebuilt_aur). +policy = "opt-in" +helper = "paru" # optdepend, never a hard depend + +# ----------------------------------------------------------------------------- +# The desktop stack — what the OS actually boots into. +# ----------------------------------------------------------------------------- +[stack] +display_manager = "greetd" # greeter = tanin-greet (own GTK4 greeter) +greeter = "tanin-greet" # custom; mirrors the eww topbar +compositor = "niri" # Wayland, niri-only (Hyprland/Sway removed) +shell_bar = "tanin-eww" # eww bar + dock + panels + spotlight +launcher = "Spotlight (eww)" +notifications = "tiramisu (headless) + eww UI" # replaces mako +terminal = "foot" +wallpaper = "waypaper" +session_target = "tanin-session.target" # pulls graphical-session.target for portals +apps = ["tsettings", "thub", "taninux (TUI)"] +gtk_theme = "tanin-libadwaita" # Fuji recolour, light + dark +portals = ["xdg-desktop-portal-gtk", "xdg-desktop-portal-gnome"] +# gtk portal -> org.freedesktop.portal.Settings (dark mode for libadwaita apps); +# gnome portal -> screencast (niri's recommended screencast backend). + +# ============================================================================= +# OWN PACKAGES — built from our repo, served from [tanin]. +# ============================================================================= +[[packages.own]] +name = "taninux" +desc = "GTK System Settings (tsettings) + Software Hub (thub) + TUI" +provides = ["tsettings", "thub", "taninux"] +pkgbuild = "packaging/taninux/PKGBUILD" # exists +status = "ready" + +[[packages.own]] +name = "tanin-eww" +desc = "eww bar/dock/panels/spotlight config + scripts -> /usr/share/tanin/skel" +upstream = "~/projects/eww" +pkgbuild = "packaging/tanin-eww/PKGBUILD" # TODO: create +status = "todo" + +[[packages.own]] +name = "tanin-greet" +desc = "custom GTK4 greeter for greetd — topbar + wallpaper + corner session" +pkgbuild = "packaging/tanin-greet/PKGBUILD" # exists +status = "ready (previewed)" +note = "replaces ReGreet — its fixed layout couldn't do topbar/corner-session" + +[[packages.own]] +name = "tanin-libadwaita" +desc = "Fuji recolour for GTK4/libadwaita + GTK3 — light + dark, pill controls" +pkgbuild = "packaging/tanin-libadwaita/PKGBUILD" # exists +status = "ready (live-verified GTK4; GTK3 partial)" +note = "STATIC defaults + CLI (tanin-libadwaita {dark|light|auto|watch}) for greeter/first-boot. LIVE theming is driven by the taninux app: gui/style.py write_global_theme() regenerates ~/.config/gtk-{4.0,3.0}/gtk(.css)|libadwaita.css from the Appearance settings (dynamic accent + light/dark, surfaces #0f0f0f/#f0f0f0, pills). GTK4/libadwaita = full exact Fuji; GTK3/adw-gtk3 = light/dark + pills only (GTK3 @define-color is parse-time, so adw-gtk3's accent/surfaces can't be overridden — exact-Fuji GTK3 would need a standalone GTK3 theme)." + +[[packages.own]] +name = "tanin-setup" +desc = "first-run: seed ~/.config from /usr/share/tanin/skel + enable session target" +pkgbuild = "packaging/tanin-setup/PKGBUILD" # exists +status = "ready" + +[[packages.own]] +name = "tanin-desktop" +desc = "metapackage — depends-pulls the whole environment (own + Arch)" +pkgbuild = "packaging/tanin-desktop/PKGBUILD" # exists +type = "meta" +status = "ready (niri stack; depends tanin-eww which is still todo)" + +# ============================================================================= +# REBUILT AUR DEPS — hard requirements of the desktop that are NOT in the +# official repos. Rebuilt into [tanin] so tanin-desktop can hard-depend on them +# without pulling the AUR. Verify each repo membership before publishing +# (some of these may already be in `extra`; if so, drop them from here). +# ============================================================================= +[[packages.rebuilt_aur]] +name = "eww" +reason = "desktop bar/dock/panels — not in official repos" +verify = "extra? AUR has `eww` and `eww-git`" + +[[packages.rebuilt_aur]] +name = "tiramisu" +reason = "headless notification daemon feeding the eww UI" +verify = "AUR" + +[[packages.rebuilt_aur]] +name = "waypaper" +reason = "wallpaper setter (niri autostart: waypaper --restore)" +verify = "AUR" + +[[packages.rebuilt_aur]] +name = "cliphist" +reason = "clipboard history for Spotlight" +verify = "extra? else AUR" + +# ============================================================================= +# ARCH PACKAGE SET — hard depends of tanin-desktop, pulled from official repos. +# Grouped by role. This is the buildable seed for archiso packages.x86_64. +# ============================================================================= +[packages.arch] +compositor = ["niri"] +greeter = ["greetd", "cage"] # cage hosts the GTK greeter; regreet is in rebuilt_aur until confirmed in extra +portals = ["xdg-desktop-portal", "xdg-desktop-portal-gtk", "xdg-desktop-portal-gnome"] +toolkit = ["gtk4", "libadwaita", "gtk3", "python", "python-gobject"] +terminal = ["foot"] +cli = ["jq", "wl-clipboard", "xdg-utils", "libqalculate"] # libqalculate -> qalc +connectivity = ["networkmanager", "bluez", "bluez-utils"] +audio = ["pipewire", "pipewire-pulse", "wireplumber"] # wpctl, pactl +hardware = ["brightnessctl"] +media = ["playerctl"] +screenshot = ["grim", "slurp"] +packages = ["flatpak", "pacman-contrib"] # checkupdates, paccache +security = ["polkit"] +fonts = ["inter-font", "ttf-iosevka-nerd"] # verify nerd-font pkg name +icons = ["papirus-icon-theme", "adwaita-icon-theme"] + +[packages.optional] +# optdepends — installed on demand, never required to boot. +"paru" = "AUR support in the Software Hub (opt-in)" +"wlsunset" = "night light" +"cups" = "printer management" +"timeshift" = "system snapshots/backups" +"waybar" = "fallback bar if eww is missing (launch.sh falls back to it)" + +# ============================================================================= +# DELIVERY / BUILD — how camel becomes an installable ISO. +# ============================================================================= +[delivery] +model = "own binary repo [tanin] + official Arch repos (EndeavourOS/CachyOS style)" +iso_tool = "archiso" +installer = "calamares" +calamares_target = "tanin-desktop" # the netinstall/packages module installs just this +dotfiles = "tanin-setup seeds ~/.config from /usr/share/tanin/skel (never writes /home at pkg time)" + +[delivery.hosts] +code = "git.openbureau.ch/karim/taninux" # source for makepkg +project = "taninux.kgva.ch" # homepage + [tanin] pacman Server + +# pacman.conf snippet shipped to users: +# [tanin] +# SigLevel = Optional TrustAll +# Server = https://taninux.kgva.ch/$arch + +# ============================================================================= +# OPEN QUESTIONS — decide before tagging a release. +# ============================================================================= +[open_questions] +name = "Final project/OS name: keep TANINUX, or split app=tanin / OS=?" +repo_hosting = "Gitea/Forgejo Arch registry vs static dir behind taninux.kgva.ch" +signing = "Move [tanin] SigLevel to Required + distribute the packager key" +regreet_theme = "Author the regreet GTK config/CSS to the Fuji palette (accent #a39ec4)" +aur_repo_check = "Confirm which rebuilt_aur entries are actually in extra (drop those)" +blur_shadow = "niri layer-rule blur/shadow for eww layers (template commented in niri config)" diff --git a/docs/distribution.md b/docs/distribution.md new file mode 100644 index 0000000..c237e45 --- /dev/null +++ b/docs/distribution.md @@ -0,0 +1,78 @@ +# TANINUX — Verteilung als „Distro" + +Ziel: ein Calamares-Installer, der eine fertige TANINUX-Umgebung aufsetzt. +Modell: **eigenes Binär-Repo `[tanin]` nur für eigene Pakete**, alles Standard +aus den **offiziellen Arch-Repos** (wie CachyOS/EndeavourOS). + +## Pakete + +| Paket | Repo | Inhalt | +|------------------|-------------|-----------------------------------------------------| +| `taninux` | `[tanin]` | App: tsettings + thub + TUI, Helper, polkit, .desktop | +| `tanin-eww` | `[tanin]` | eww Panel/Dock-Config + Scripts (→ /usr/share) | +| `tanin-setup` | `[tanin]` | seedet `~/.config` aus /usr/share + Portal/Session-Fix | +| `tanin-desktop` | `[tanin]` | **Metapackage**: depends = eigene + Arch-Pakete | + +`tanin-desktop` zieht per `depends` die Arch-Pakete aus den normalen Repos und +die eigenen aus `[tanin]`. AUR bleibt Opt-in → `paru` ist `optdepends`, kein +hartes `depends` (Metapackage baut also nichts aus dem AUR). + +## Dotfile-Auslieferung (wichtig) + +AUR/Binär-Pakete dürfen **nicht** nach `/home` schreiben. Daher: +1. Default-Configs ins Paket nach `/usr/share/tanin/` (oder `/etc/skel/`). +2. `tanin-setup` kopiert sie beim ersten Start nach `~/.config` (idempotent, + überschreibt nichts ohne `--force`) und richtet das Session-Portal ein + (siehe „Dark Mode / Portal" unten). + +## Phasen + +1. **Fundament** — `packaging/taninux/PKGBUILD` (fertig), LICENSE wählen, + `cd packaging/taninux && makepkg -si` lokal testen. +2. **Meta + Config** — `tanin-desktop` (Skelett da), `tanin-eww`, `tanin-setup`. +3. **Eigenes Repo `[tanin]`**. Hosts: + - **Code** → `git.openbureau.ch/karim/taninux` (PKGBUILD-`source`, woher `makepkg` klont) + - **Projekt + Repo** → `taninux.kgva.ch` (PKGBUILD-`url`/Homepage **und** pacman-`Server`) + + DB bauen + signieren: + ```sh + repo-add -s tanin.db.tar.zst *.pkg.tar.zst # -s signiert mit Packager-Key + ``` + Hosten — zwei Optionen: + - **Gitea/Forgejo Arch-Registry** (falls git.openbureau.ch das kann): Pakete + pushen, `Server = https://git.openbureau.ch/api/packages/karim/arch/`. + `taninux.kgva.ch` als Reverse-Proxy/Vanity davor. + - **Statisches Verzeichnis** hinter `taninux.kgva.ch`: `*.pkg.tar.zst` + `tanin.db`. + + `pacman.conf`-Snippet für User: + ```ini + [tanin] + SigLevel = Optional TrustAll # später: Required + Key importieren + Server = https://taninux.kgva.ch/$arch + ``` +4. **Calamares / ISO** — `archiso`-Profil mit `[tanin]` vorkonfiguriert + + Calamares `packages`-Modul, das `tanin-desktop` installiert. + +## Dark Mode / Portal (Session-Fix) + +libadwaita-Apps (Nautilus etc.) lesen Hell/Dunkel über +`org.freedesktop.portal.Settings`. Damit das Portal startet, muss die Session +`graphical-session.target` aktivieren. `tanin-setup` legt dafür +`~/.config/systemd/user/-session.target` an und ergänzt die Compositor- +Autostarts: +```sh +exec-once = dbus-update-activation-environment --systemd --all +exec-once = systemctl --user start -session.target +``` + +## Hosts + +- **Code:** `git.openbureau.ch/karim/taninux` (+ Repo für die eww-Config, + Absprache mit der eww-Instanz). +- **Projekt/Repo:** `taninux.kgva.ch` (Homepage + `[tanin]`-pacman-Server). + +## Offene Voraussetzungen + +- **LICENSE**-Datei ablegen (Vorschlag GPL-3.0-or-later): + `curl -L https://www.gnu.org/licenses/gpl-3.0.txt -o LICENSE`. +- `taninux.kgva.ch` ans Repo-Hosting hängen (Gitea-Arch-Registry oder statisch). diff --git a/docs/eww-accent-integration.md b/docs/eww-accent-integration.md new file mode 100644 index 0000000..5575eab --- /dev/null +++ b/docs/eww-accent-integration.md @@ -0,0 +1,122 @@ +# TANINUX → eww: Akzent-Integration (Handoff) + +**Für die Instanz, die an `~/eww` arbeitet.** Diese Datei ist der Vertrag, wie +die eww-Bar ihren Akzent von der TANINUX-Appearance-App bekommt. + +--- + +## TL;DR (Kurzanleitung) + +1. **Lies** `~/.local/share/taninux/gui.json`. +2. **Nimm** das Feld `accent_hex` (fertiger Hex-String, z. B. `#8aac8b`). +3. **Setz** damit deine eww-Akzentvariablen (`$accent`, `$accent-dim`, die + `$accent-08/14/22`-Washes) und mach `eww reload`. +4. **Reagiere auf Änderungen**: watch die Datei (inotify) — TANINUX schreibt sie + bei jedem Akzent-Wechsel neu. + +Das war's. Der Rest hier ist Detail/Begründung. + +--- + +## Quelle der Wahrheit + +**Datei:** `~/.local/share/taninux/gui.json` +(exakt: `$XDG_DATA_HOME/taninux/gui.json`, Fallback `~/.local/share/...`) + +**Schema:** +```json +{ + "version": 1, + "color_scheme": "dark", + "accent": "take", + "accent_hex": "#8aac8b", + "accent_label": "Take · green" +} +``` + +| Feld | Bedeutung | +|----------------|------------------------------------------------------------------| +| `accent_hex` | **Das relevante Feld.** Fertiger Hex des gewählten Fuji-Akzents. | +| `accent` | interner Key (`ume`/`shinkai`/`take`/`chikyu`/`beni`). | +| `accent_label` | menschlicher Name, nur fürs Anzeigen. | +| `color_scheme` | `system` / `light` / `dark` — **NICHT für deinen Akzent nötig**, siehe unten. | + +Die Datei existiert erst, nachdem in TANINUX einmal ein Setting geändert wurde. +Wenn sie fehlt: Default-Akzent `ume` / `#8f8aac` annehmen. + +--- + +## Was du in eww damit machst + +Deine `~/eww/eww.scss` hat oben den Variablenblock. Akzent-relevant sind: + +```scss +$accent: #a39ec4; // bright +$accent-dim: #8f8aac; // muted ← entspricht direkt accent_hex +$accent-08: rgba(163, 158, 196, 0.08); +$accent-14: rgba(163, 158, 196, 0.14); +$accent-22: rgba(163, 158, 196, 0.22); +$acc-l: $accent-dim; +``` + +`accent_hex` ist der **gedämpfte** Fuji-Ton (= dein bisheriges `$accent-dim`). +Mapping-Vorschlag: + +- `$accent-dim` = `accent_hex` (1:1) +- `$accent` = `accent_hex` ~18 % Richtung Weiß aufgehellt (bright) +- `$accent-08/14/22` = `rgba(R, G, B, 0.08/0.14/0.22)` aus den RGB von `accent_hex` + (oder vom bright — wie es bei dir besser aussieht) + +In SCSS kannst du das selbst ableiten, ohne die bright-Werte vorzuberechnen: +```scss +$accent-dim: #8aac8b; // = accent_hex aus gui.json +$accent: lighten($accent-dim, 8%); +$accent-08: rgba($accent-dim, 0.08); +$accent-14: rgba($accent-dim, 0.14); +$accent-22: rgba($accent-dim, 0.22); +``` +Du musst also nur **eine Zeile** (`$accent-dim`) aus `gui.json` regenerieren. + +### Die 5 Fuji-Akzente (falls du sie vorab kennen willst) + +| key | label | accent_hex (dim) | bright (Referenz) | +|-----------|------------------|------------------|-------------------| +| `ume` | Ume · violet | `#8f8aac` | `#a39ec4` | +| `shinkai` | Shinkai · blue | `#8a98ac` | `#9fabbb` | +| `take` | Take · green | `#8aac8b` | `#9fbba0` | +| `chikyu` | Chikyū · yellow | `#aca98a` | `#bbb89f` | +| `beni` | Beni · red | `#ac8a8c` | `#bb9fa1` | + +(Quelle der Hexes: `taninux/core/theme.py` — identisch zur TUI. `ume`-bright +entspricht deinem aktuellen `$accent`.) + +--- + +## Hell/Dunkel — NICHT dein Akzent-Job + +Das globale Hell/Dunkel läuft über `gsettings org.gnome.desktop.interface +color-scheme` (steht auf `prefer-dark`). TANINUX **steuert das nicht über +gui.json** und fasst es bewusst nicht an. Dein eww-Hell/Dunkel (`.light`-Klasse + +`-l`-Variablen) bleibt **vollständig deine Sache** — TANINUX mischt sich da nicht +ein. `color_scheme` in der JSON ist nur informativ; ignorier es für den Akzent. + +--- + +## Reload-Trigger — bitte abstimmen + +TANINUX schreibt `gui.json` bei jeder Änderung, ruft aktuell aber **kein +`eww reload`** auf. Zwei Optionen, einigt euch auf eine: + +- **(A) eww-Seite watcht** `gui.json` (z. B. `inotifywait` / ein eww-`deflisten` + / kleines Script) und macht selbst `eww reload`. → empfohlen, hält eww autark. +- **(B) TANINUX triggert** nach dem Schreiben `eww reload`. Sag Bescheid, dann + baue ich das in den Appearance-`persist()` ein (best-effort, nur wenn eww läuft). + +--- + +## Grenzen (wichtig) + +- TANINUX fasst `~/eww/**` **nicht** an. Die scss-Verdrahtung machst du. +- TANINUX besitzt zusätzlich `~/.config/gtk-4.0/libadwaita.css` (globaler + GTK4/libadwaita-Akzent via `@define-color`) — das ist für **GTK-Apps**, nicht + für eww. Nicht damit verwechseln; eww liest `gui.json`. diff --git a/docs/eww-collaboration.md b/docs/eww-collaboration.md new file mode 100644 index 0000000..312f4e1 --- /dev/null +++ b/docs/eww-collaboration.md @@ -0,0 +1,145 @@ +# eww ↔ TANINUX — Arbeits- & Koordinationsanleitung (für die TANINUX-Instanz) + +**Lies das, bevor du irgendetwas anfasst, das die Bar/das Dock/eww betrifft.** +Es ist die verbindliche Spielregel zwischen dir (TANINUX-Instanz) und der +eww-Instanz, die `~/eww` besitzt. Es gibt sonst Chaos — wir hatten es schon. + +--- + +## 0. Die EINE Regel + +> **Fass `~/eww/**` nicht direkt an. Steuere Bar/Dock ausschließlich über +> Config-Dateien. Lass keinen Porter / Sync / Format-on-save über `~/eww` +> laufen.** + +Warum so hart? Während gemeinsamer Arbeit hat *etwas auf deiner Seite* +(ein Sway-Porter o.ä.) `eww.yuck`, `eww.scss`, `launch.sh`, `dock.sh` **live +umgeschrieben — mitten zwischen Lesen und Schreiben der eww-Instanz.** Folge: +jeder Fix (Box-in-Box, Dock-Autohide, Flackern) wurde Sekunden später wieder +überschrieben. **Zwei Editoren auf derselben Datei = der einzige Fall, der +garantiert kaputtgeht.** Settings laufen über Config-Dateien → du brauchst eww +nie hand zu editieren. + +--- + +## 1. Wenn du eww trotzdem ändern MUSST (Feature/Layout, kein Setting) + +Es ist EIN Projekt — Code-Arbeit an der Bar ist kein Tabu. Aber: + +1. **Niemals automatisiert** (kein Porter, kein Watcher, der `~/eww/**` schreibt). +2. **Einer nach dem anderen.** Sag der eww-Instanz Bescheid bzw. mach es selbst, + aber nicht *während* die eww-Instanz dieselbe Datei bearbeitet. +3. **eww ist bereits compositor-agnostisch** (siehe §4). **Portiere es nicht auf + Sway-only zurück** — das bricht es auf Hyprland und macht genau die Bugs. + +--- + +## 2. Die Integrations-Schnittstelle (so steuerst du eww — ohne es anzufassen) + +Drei Config-Dateien + ein Launcher. Detail-Verträge: +`eww-accent-integration.md`, `eww-panel-dock-integration.md`, `eww-integration.md`. + +| Thema | Du schreibst | eww zieht nach via | +|------------------|------------------------------------------------|-----------------------------------------------------| +| **Akzentfarbe** | `~/.local/share/taninux/gui.json` → `accent_hex` (`#rrggbb`) | `scripts/accent.sh watch` → `$accent-dim` in scss, reload | +| **Hell/Dunkel** | `gsettings …interface color-scheme` (NICHT die JSON) | `scripts/colorscheme.sh` (defpoll) → `.light`-Klasse | +| **Panel & Dock** | `~/.local/share/taninux/panel.json` | `scripts/panelcfg.sh watch` → `eww update` (live) | +| **Dock-Pins** | `~/.config/eww/dock-pins` (Zeilen `exec\|class\|icon`) | Poll alle 2 s, automatisch | +| **Settings öffnen** | *(Ziel ist deine App)* | Control-Center-Zahnrad → `scripts/settings.sh` → `taninux-gtk` | + +**`panel.json`-Schema** (v1, alles live, kein Reload): +```json +{ + "version": 1, + "dock": { "autohide": true, "icon_size": 42, "hide_delay": 1.2 }, + "bar": { "clock_format": "%H:%M %A, %d.%m.%Y", + "modules": { "music": true, "sys": true, "updates": true, + "net": true, "bt": true, "vol": true } } +} +``` +- `dock.icon_size`: int 24–64 · `dock.hide_delay`: 0.2–5.0 s · `dock.autohide`: bool +- `bar.clock_format`: strftime · `bar.modules`: bool je Modul (`music sys updates net bt vol`) +- Anwenden: Datei schreiben → Watcher zieht in ≤2 s nach. Für „sofort": + `~/eww/scripts/panelcfg.sh apply`. +- **Noch nicht im Schema:** `dock.position`, feste Bar/Dock-Größen (= Reload/ + Geometrie-Neubau). Wenn du das in der GUI willst → bei der eww-Instanz anfragen, + nicht selbst in eww bauen. + +**Grenze:** `gui.json`/`panel.json`/`dock-pins` gehören dir (schreib sie frei). +`~/.config/gtk-4.0/libadwaita.css` ist *dein* GTK-App-Akzent — **nicht** für eww. +`~/eww/**` gehört der eww-Instanz. + +--- + +## 3. Wie ich das in TANINUX einordnen würde (Vorschlag) + +Eine Seite **„Panel & Dock"** in *Personalization*, zwei Gruppen: +- **Top bar** — `bar.modules` (Toggles), `bar.clock_format`. +- **Dock** — `dock.autohide`, `dock.icon_size` (Slider 24–64), + `dock.hide_delay`, gepinnte Apps (liest/schreibt `~/.config/eww/dock-pins`). + +`core/panel.py` = Read/Write `panel.json` (+ `dock-pins`), Apply = Datei +schreiben **und optional** `~/eww/scripts/panelcfg.sh apply` feuern. Das ist +exakt die Akzent-Kette, nur mit mehr Keys. Du musst die eww-Variablennamen +**nicht** kennen. + +--- + +## 4. Was in eww schon gebaut & getestet ist (Kontext, nicht ändern) + +- **Compositor-Abstraktion** `~/eww/scripts/wm.sh`: erkennt Hyprland/Sway (`$WM`) + und kapselt alle WM-Befehle (`wm_clients/wm_focus/wm_goto_ws/wm_cursor_y/ + wm_outputs/wm_lock/wm_exit/wm_blur_layer`). **dock.sh, power.sh, ws.sh, + workspaces.sh, launch.sh nutzen das.** → eww läuft auf beiden Compositoren. +- **Auto-hide ist compositor-aware:** Hyprland = **Cursor-Polling** + (`dock.sh watch`, kein GTK-Hover); Sway = **Hover-Trigger** (`dock-trigger`, + weil Sway kein cursorpos-IPC hat). `launch.sh` startet automatisch das Richtige. +- Akzent (gui.json), Hell/Dunkel (gsettings → Shibui-Weiß), Now-Playing im + Control Center, Updates-Panel, Power-Dropdown, Settings-Zahnrad, + Multi-Instanz-Rechtsklick, ESC/Klick-daneben-Dismiss — alles vorhanden. + +--- + +## 5. NICHT wieder einbauen — die Bugs, die ständig zurückkamen + +Wenn du (oder ein Porter) eww doch anfasst, **reintroduziere diese nicht:** + +1. **Dock „Box-in-Box".** Ursache: ein **Drop-Shadow auf `.dock`**. Auf der + halbtransparenten, geblurrten Dock-Ebene blüht er in die Margin und rendert + als faler äußerer Kasten (bei opaken Panels passiert das nicht). + → `.dock` darf **nur** `box-shadow: inset 0 1px 0 …` haben, **keinen Drop-Shadow**. + Zusätzlich: `window/.background/decoration { background: transparent; + box-shadow: none }` und `eventbox { background: transparent }` müssen bleiben. +2. **Dock flackert / bleibt offen auf Hyprland.** Ursache: GTK-Hover-Autohide + (`eventbox onhover/onhoverlost`). Kindbuttons feuern Enter/Leave (NotifyInferior) + → Dauer-Toggle. → Auf Hyprland **Cursor-Polling** (`dock.sh watch`), NICHT Hover. + Genau deshalb ist die Sway-Hover-Variante **nicht** für Hyprland. +3. **Hell/Dunkel reagiert nicht.** Ursache: `gsettings monitor`-deflisten stirbt + in eww's Spawn-Env. → **defpoll** nutzen. Und: nach so einem Wechsel **vollen + eww-Neustart** (`launch.sh`), `eww reload` reicht nicht. +4. **Nerd-Font-Glyphen unsichtbar.** `button :text "glyph"` rendert nicht → + Glyphe als `label`-Kind im Button. +5. **Hyprland-Dispatch ist Lua.** `hyprctl dispatch "hl.dsp.focus({…})"`, nicht die + klassische Textsyntax. (In `wm.sh` schon gekapselt.) + +--- + +## 6. Offene Sway-Punkte (wenn du DE-light auf swayfx fertigstellst) + +- `swaylock` + `swayidle` installieren (für `power.sh` Lock). +- SwayFX-Dock-Blur zuverlässig: `layer_effects "gtk-layer-shell" blur enable` in + die Sway-Config (statt nur `wm_blur_layer`-Laufzeitversuch). +- eww-Keybinds (ESC-Dismiss, SUPER+A/N Panels) von `hyprland.lua` in die + Sway-Config übernehmen. +- Dann unter echtem Sway gegentesten — die `$WM=sway`-Zweige sind da, aber bisher + nur auf Hyprland verifiziert. + +--- + +## TL;DR + +1. eww nie automatisiert/parallel editieren. **Kein Porter über `~/eww`.** +2. Bar/Dock nur über `gui.json` + `panel.json` + `dock-pins` steuern. +3. eww ist schon dual (Hyprland+Sway) — **nicht** auf Sway-only zurückbauen. +4. Wenn du eww-Code wirklich brauchst: koordiniert, einer nach dem anderen, + und die §5-Bugs nicht wieder reinbauen. diff --git a/docs/eww-integration.md b/docs/eww-integration.md new file mode 100644 index 0000000..6642ca8 --- /dev/null +++ b/docs/eww-integration.md @@ -0,0 +1,176 @@ +# eww ↔ TANINUX — Integrations-Vertrag (Handover) + +**Zweck:** Ab jetzt werden die **eww-Bar** (`~/eww`, symlinked nach `~/.config/eww`) +und die **TANINUX-Settings-App** *zusammen gedacht*. Dieses Dokument ist der +Vertrag: was über welche Datei fließt, wer was besitzt, und was man beim Ändern +der einen Seite auf der anderen beachten muss. + +Gegenstück: `docs/eww-accent-integration.md` (der ursprüngliche Accent-Handoff +TANINUX → eww). Dieses Dokument hier ist die **vollständige, gelebte** Sicht. + +--- + +## TL;DR — die Schnittstellen + +| # | Thema | Quelle der Wahrheit (TANINUX schreibt) | eww liest / reagiert via | +|---|----------------|-----------------------------------------------------|--------------------------------------------------| +| 1 | **Akzentfarbe**| `~/.local/share/taninux/gui.json` → `accent_hex` | `~/eww/scripts/accent.sh watch` → setzt `$accent-dim` in `eww.scss`, `eww reload` | +| 2 | **Hell/Dunkel**| `gsettings org.gnome.desktop.interface color-scheme`| `~/eww/scripts/colorscheme.sh get` (defpoll 2 s) → `.light`-Klasse an den Panels | +| 3 | **Compositor** | *(unabhängig erkannt — keine Datei)* | `~/eww/scripts/wm.sh` erkennt `hyprland`/`sway` selbst | +| 4 | **Settings öffnen** | *(App ist das Ziel)* | eww Control-Center-Zahnrad → `~/eww/scripts/settings.sh` → `taninux-gtk` | + +Kurz: **Akzent läuft über die JSON, Hell/Dunkel über gsettings, Compositor +erkennt jede Seite selbst.** Diese drei Achsen müssen konsistent bleiben. + +--- + +## 1. Akzentfarbe — `gui.json` → eww + +- **TANINUX schreibt** bei jeder Akzent-Änderung `~/.local/share/taninux/gui.json`, + Feld `accent_hex` (fertiger Hex, z. B. `#8f8aac`). Schema siehe + `eww-accent-integration.md`. +- **eww** hat einen Watcher `scripts/accent.sh watch` (gestartet aus + `~/eww/launch.sh`), der `gui.json` alle 2 s auf mtime-Änderung pollt und bei + Änderung: + - `accent_hex` liest (Fallback `#8f8aac`, wenn Datei/Feld fehlt/ungültig), + - damit **nur die eine Zeile** `$accent-dim:` in `~/eww/eww.scss` neu schreibt, + - `eww reload` auslöst. +- In der SCSS leiten sich **alle** Akzent-Werte aus `$accent-dim` ab: + ```scss + $accent-dim: #8f8aac; // = accent_hex (von accent.sh verwaltet) + $accent: lighten($accent-dim, 8%); + $accent-08: rgba($accent, 0.08); + $accent-14: rgba($accent, 0.14); + $accent-22: rgba($accent, 0.22); + ``` +- **Verifiziert:** Akzent in der App auf z. B. `take`/grün stellen → Bar/Panels/ + Dock tinten innerhalb ~2 s nach. + +**Wenn TANINUX hier etwas ändert:** Solange `accent_hex` ein `#rrggbb` bleibt, +muss eww **nichts** angepasst werden. Neue Akzent-Keys (über die 5 Fuji-Töne +hinaus) brauchen nur einen gültigen Hex — eww ist farbagnostisch. + +--- + +## 2. Hell/Dunkel — gsettings (NICHT die JSON) + +**Klare Abgrenzung:** Hell/Dunkel läuft **global über +`gsettings org.gnome.desktop.interface color-scheme`** (`prefer-dark` / +`prefer-light` / `default`). Das `color_scheme`-Feld in `gui.json` ist für eww +**nur informativ und wird ignoriert.** + +- **TANINUX** setzt beim Umschalten die gsettings-Keys (`gui/desktop.py`, + `gsettings set …`). Das ist der globale Standard und greift für alle GTK-Apps. +- **eww** pollt `scripts/colorscheme.sh get` (defpoll, 2 s) → `light`/`dark` und + hängt eine `.light`-Klasse an alle Panels/Dock (Bar bleibt immer dunkel). + Hell = „Shibui"-Weiß-Palette in `eww.scss` (`$bg-l`, `$fg-l`, …). +- **Wichtig (Lesson learned):** Ein `gsettings monitor`-Deflisten **stirbt** in + eww's Spawn-Umgebung — deshalb **defpoll**, nicht monitor. Und: ein + `deflisten→defpoll`-Wechsel braucht einen **vollen eww-Neustart** + (`launch.sh`), ein `eww reload` reicht nicht. + +**Vertrag:** Damit eww dem App-Umschalter folgt, **muss TANINUX die gsettings +tatsächlich setzen** (nicht nur `gui.json.color_scheme`). Tut es das nicht, +bleibt eww auf dem alten Modus — das ist dann ein TANINUX-Bug, kein eww-Bug. +(Beobachtet: einmal stand `gui.json=light`, aber `gsettings=prefer-dark` → +eww blieb korrekterweise dunkel.) + +--- + +## 3. Compositor-Erkennung — beide Seiten, unabhängig + +Beide Apps müssen auf **Hyprland UND Sway/SwayFX** laufen. Es gibt **keine +geteilte Datei** dafür — jede Seite erkennt selbst: + +- **TANINUX:** eigene Compositor-Erkennung (von dir gebaut). +- **eww:** `~/eww/scripts/wm.sh` — eine sourcebare Abstraktion. Erkennt über + `$HYPRLAND_INSTANCE_SIGNATURE` / `$SWAYSOCK` / `pgrep` und setzt `$WM`. + Bietet einheitliche Funktionen, die alle eww-Scripts nutzen: + + | Funktion | Hyprland | Sway / SwayFX | + |-----------------------|---------------------------------------|---------------------------------------| + | `wm_clients` | `hyprctl clients -j` | `swaymsg -t get_tree` | + | `wm_focus ` | `hl.dsp.focus({window="address:…"})` | `[con_id=…] focus` | + | `wm_goto_ws` / `cycle`| `hl.dsp.focus({workspace=…})` | `workspace number …` | + | `wm_cursor_y` | `hyprctl cursorpos` | *(kein Cursor-IPC → Menü zentriert)* | + | `wm_outputs` | `hyprctl monitors` | `swaymsg -t get_outputs` | + | `wm_lock` / `wm_exit` | `hyprlock` / `hl.dsp.exit()` | `swaylock -f` / `swaymsg exit` | + | `wm_blur_layer` | `hyprctl keyword layerrule blur,…` | SwayFX `layer_effects … blur enable` | + + Genutzt von: `dock.sh`, `power.sh`, `ws.sh`, `workspaces.sh`, `launch.sh`. + + **Hyprland-Eigenheit:** Dispatch läuft auf diesem Setup über die **Lua-API** + (`hyprctl dispatch "hl.dsp.…()"`), nicht über die klassische Textsyntax. + `hl.dsp.focus` springt automatisch auf den Workspace des Fensters. + +**Konsistenz-Regel:** Wenn TANINUX seine Compositor-Erkennung ändert (neue +Compositor, andere Detektion), sollte `wm.sh` dieselbe Logik spiegeln, damit +beide Apps nie auseinanderlaufen. Idealerweise teilt man sich später *eine* +Detektionsquelle — aktuell bewusst getrennt (keine harte Kopplung). + +--- + +## 4. eww startet die Settings-App + +Das **Zahnrad oben rechts im Control Center** öffnet die TANINUX-GUI: +`~/eww/scripts/settings.sh` → `cd ~/projects/taninux && setsid -f env +PYTHONPATH=src /usr/bin/python -m taninux.gui`. + +- **System-Python**, nicht das venv — das venv hat **kein `gi`/PyGObject** + (Systempaket). Entry point laut `pyproject.toml`: `taninux-gtk = + taninux.gui.app:main`. +- Single-Instance über die GTK-App-ID `ch.gabrielevarano.Taninux` → zweiter + Klick fokussiert nur. + +**Wenn TANINUX die Startweise ändert** (z. B. richtig installiertes +`taninux-gtk` mit `gi` im venv, oder ein `.desktop`/Binary auf dem PATH): +`scripts/settings.sh` entsprechend anpassen — am besten auf `taninux-gtk`, +sobald es ohne `PYTHONPATH=src`-Hack läuft. + +--- + +## Datei- & Besitz-Karte + +| Pfad | Besitzer | Rolle | +|----------------------------------------------|-----------|-------| +| `~/.local/share/taninux/gui.json` | TANINUX | Akzent (`accent_hex`) — eww liest | +| `gsettings …interface color-scheme` | TANINUX | Hell/Dunkel global — eww liest | +| `~/.config/gtk-4.0/libadwaita.css` | TANINUX | **GTK-App-Akzent** — *nicht* für eww, nicht anfassen | +| `~/eww/**` (yuck, scss, scripts) | **eww** | Bar/Panels/Dock — TANINUX fasst das **nie** an | +| `~/eww/scripts/wm.sh` | eww | Compositor-Abstraktion | +| `~/eww/scripts/accent.sh` | eww | gui.json → `$accent-dim` | +| `~/eww/scripts/colorscheme.sh` | eww | gsettings → light/dark | +| `~/eww/scripts/settings.sh` | eww | startet TANINUX-GUI | + +**Goldene Regel:** TANINUX schreibt **nie** in `~/eww/**`; eww schreibt **nie** +in TANINUX-Dateien (außer es liest `gui.json` und startet die GUI). Die +SCSS-Verdrahtung gehört eww, die Settings-Logik gehört TANINUX. + +--- + +## Checkliste beim Ändern + +**TANINUX-Seite ändert Akzent-Format?** → nur prüfen, dass `accent_hex` ein +`#rrggbb` bleibt. Sonst nichts. + +**TANINUX-Seite ändert Hell/Dunkel-Mechanik?** → muss weiterhin `gsettings +color-scheme` setzen. eww hängt daran. + +**Neuer Compositor unterstützt?** → `wm.sh` um einen `$WM`-Zweig erweitern +(dieselben Funktionen implementieren). Sonst läuft die Bar dort nicht. + +**Settings-Startweg ändert sich?** → `~/eww/scripts/settings.sh` anpassen. + +--- + +## Status (Stand dieser Übergabe) + +- ✅ Akzent live aus `gui.json` (getestet: ume/violet ↔ take/green). +- ✅ Hell/Dunkel live aus gsettings (Shibui-Hell-Palette, Bar bleibt dunkel). +- ✅ Compositor-Abstraktion `wm.sh` — auf **Hyprland verifiziert**; Sway-Zweige + implementiert (final unter Sway noch gegenzutesten). +- ✅ Settings-Zahnrad startet `taninux-gtk` (System-Python). +- ⚠️ Für Sway fehlen noch **`swaylock` + `swayidle`** (Pakete installieren). +- ⚠️ SwayFX-Blur fürs Dock: `wm_blur_layer` versucht `layer_effects` live; + zuverlässiger ist die Regel in der SwayFX-Config (`layer_effects + "gtk-layer-shell" blur enable`). diff --git a/docs/eww-panel-dock-integration.md b/docs/eww-panel-dock-integration.md new file mode 100644 index 0000000..6ff7279 --- /dev/null +++ b/docs/eww-panel-dock-integration.md @@ -0,0 +1,134 @@ +# TANINUX → eww: Panel & Dock — Settings-Vertrag (Handover) + +**Für die TANINUX-Instanz, die die „Panel & Dock"-Seite baut.** Analog zur +Akzent-Kette: **TANINUX schreibt EINE Config-Datei, eww konsumiert sie.** Die +eww-Seite (Variablen, `:style`/`:visible`, Watcher) ist **bereits gebaut** — du +brauchst nur die Datei zu schreiben. + +Gegenstücke: `eww-accent-integration.md` (Akzent), `eww-integration.md` (Gesamtvertrag). + +--- + +## 1. Wo die Werte liegen + +**Datei:** `~/.local/share/taninux/panel.json` +(`$XDG_DATA_HOME/taninux/panel.json`, Fallback `~/.local/share/...`) — JSON, +**eine** Datei, neben `gui.json`. Existiert mit Defaults; eww seedet sie zur Not. + +```json +{ + "version": 1, + "dock": { + "autohide": true, + "icon_size": 42, + "hide_delay": 1.2 + }, + "bar": { + "clock_format": "%H:%M %A, %d.%m.%Y", + "modules": { + "music": true, "sys": true, "updates": true, + "net": true, "bt": true, "vol": true + } + } +} +``` + +--- + +## 2. Die Knöpfe (Keys, Typ, erlaubte Werte) + +| Key | Typ | Werte / Range | Wirkung in eww | Live? | +|----------------------|----------------|--------------------------|-------------------------------------------------|-------| +| `dock.autohide` | bool | `true` / `false` | `false` = Dock immer sichtbar; `true` = Hover-Trigger | live | +| `dock.icon_size` | int | **24 – 64** (px) | Icon-Größe im Dock (`:style min-width/height`) | live | +| `dock.hide_delay` | number | **0.2 – 5.0** (Sekunden) | Nachhall, bis das Dock nach Verlassen schließt | next-hide | +| `bar.clock_format` | string | strftime (z.B. `%H:%M`) | Format der Uhr in der Bar-Mitte | ≤10 s | +| `bar.modules` | object | Keys: `music sys updates net bt vol` | Welche Rechts-Module die Bar zeigt | live | +| `updates.include_aur`| bool | `true`/`false` (Default false) | AUR im Update-Scope (Zähler+Liste). Opt-in, braucht paru | poll | + +- **`updates.include_aur`** (Top-Level-Sektion `updates`): TANINUX-Hub macht AUR + zum Opt-in (Default Repo+Flatpak). `scripts/updates.sh` soll diesen Key lesen + und nur dann `paru -Qua` mitzählen/-listen. JSON-Schema der Liste bleibt + `[{name, old, new}]` (gern + `src`). Beispiel-Lesen: + `inc=$(jq -r '.updates.include_aur // false' "$PANEL_JSON")`. +- **`bar.modules`** ist ein Objekt `{ "": true/false }` (nicht Liste) — + fehlt ein Key, gilt er als sichtbar. Module: `music` (Now-Playing-Mini), + `sys` (CPU/RAM), `updates` (Update-Zähler), `net`, `bt`, `vol`. +- Clamping macht eww defensiv (icon_size 24–64, ungültiges → Default). Trotzdem + bitte in der GUI validieren (Spin 24–64, Slider 0.2–5.0). + +**Noch nicht im v1 (bewusst):** `dock.position` (left/bottom/right) und feste +Größen der Bar/Dock-Fenster. Die brauchen `eww reload` + Geometrie-Neubau +(Orientierungswechsel bei bottom). Sag Bescheid, wenn du das willst — dann +erweitere ich die eww-Seite und dieses Doc; bis dahin nicht in der GUI anbieten. + +--- + +## 3. Wie man anwendet + +Du schreibst `panel.json` — **mehr nicht**. eww zieht nach, auf zwei Wegen: + +1. **Automatisch:** ein Watcher (`scripts/panelcfg.sh watch`, läuft aus + `launch.sh`) pollt die Datei alle 2 s und wendet Änderungen an. +2. **Sofort (optional, empfohlen für „Apply"):** nach dem Schreiben einmal + ``` + ~/eww/scripts/panelcfg.sh apply + ``` + feuern — dann greift es ohne die 2-s-Latenz. (Best-effort, schadet nie.) + +**Kein `eww reload` nötig** für die v1-Knöpfe. Intern macht `panelcfg.sh`: +`eww update cfg-icon-size=… cfg-modules=…`, schaltet Autohide imperativ, und +`clock_format`/`hide_delay` werden on-demand gelesen. (Du musst die +eww-Variablennamen **nicht** kennen — nur `panel.json` schreiben.) + +--- + +## 4. Die Grenze — wer schreibt was + +- **`panel.json` gehört dir (TANINUX).** Schreib es frei. eww liest es nur. +- **Gepinnte Dock-Apps** liegen NICHT in `panel.json`, sondern in + `~/.config/eww/dock-pins` — eine simple Zeilen-Datei, Format **`exec|class|icon`**: + ``` + librewolf|librewolf|librewolf + kitty|kitty|kitty + code|Code|code-oss + ``` + - `exec` = Startbefehl, `class` = Fensterklasse (app_id/WM_CLASS), `icon` = + Icon-Name (Papirus). eww spiegelt Änderungen automatisch (Poll alle 2 s). + - **Die Zeilen-REIHENFOLGE = die Dock-Reihenfolge.** → **Drag & Drop bitte hier + bauen:** Die „Dock"-Gruppe zeigt die gepinnten Apps als ziehbare GTK-Liste; + beim Loslassen schreibst du `dock-pins` in der neuen Reihenfolge neu. eww + sortiert das Dock in ≤2 s nach. Echtes DnD im eww-Dock selbst geht nicht + (eww/GTK3 hat keine Drag-Primitiven) — deshalb gehört das in deine GTK-Seite. + (eww bietet zusätzlich „Move up/Move down" im Dock-Rechtsklick als + Schnell-Option — die schreibt dieselbe Datei.) + - **Du darfst diese Datei direkt lesen UND schreiben** (z.B. die Pin-Liste in + der GUI verwalten). eww mutiert sie auch selbst (Dock-Rechtsklick → + Pin/Unpin). Zwei-Wege ist ok, weil das Format trivial und idempotent ist — + schreib die ganze Datei neu, eww liest sie beim nächsten Poll. + - Falls du lieber Befehle feuerst statt zu schreiben: + `~/eww/scripts/dock.sh pin ` / `unpin `. +- **`~/eww/**` (yuck/scss/andere scripts)** ist eww-intern. Für *Settings* + brauchst du das nie anzufassen — alles läuft über `panel.json` + `dock-pins`. + +### Darfst du eww trotzdem direkt anfassen? +Ja — für *Code/Feature*-Arbeit an der Bar selbst (neue Widgets, Layout) ist es +EIN Projekt, kein Tabu. **Einzige harte Regel: nie zwei Editoren gleichzeitig +auf derselben Datei** (genau das hat beim Hyprland→Sway-Umbau Chaos gemacht). +Für *Settings* aber gilt: über `panel.json` gehen, nicht eww hand-editieren — +sonst ist jede GUI-Änderung ein yuck-Edit (brittle + Race-Gefahr). + +--- + +## Status (gebaut & getestet) + +- ✅ `panel.json` (Default vorhanden) + `scripts/panelcfg.sh` (apply/watch/get). +- ✅ `dock.icon_size` live (24–64, getestet 42↔56), `bar.modules` live, + `dock.autohide` imperativ, `bar.clock_format` via `scripts/clock.sh`, + `dock.hide_delay` aus der JSON gelesen. +- ✅ Watcher in `launch.sh` eingetragen. +- ⏳ `dock.position`/Fenstergrößen = Reload-Thema, auf Anfrage. + +Damit kannst du `core/panel.py` (Read/Write `panel.json` + optional `dock-pins`, +Apply = Datei schreiben + `panelcfg.sh apply`) und die GTK-Seite „Panel & Dock" +(Gruppen *Top bar* / *Dock*) direkt bauen. diff --git a/packaging/tanin-desktop/PKGBUILD b/packaging/tanin-desktop/PKGBUILD new file mode 100644 index 0000000..06debef --- /dev/null +++ b/packaging/tanin-desktop/PKGBUILD @@ -0,0 +1,69 @@ +# Maintainer: Karim +# +# Metapackage: pulls the complete TANINUX (camel) desktop — niri + eww, managed +# by the TANINUX apps. Own packages come from the [tanin] repo, everything else +# from the official Arch repos. AUR-only runtime deps (eww, tiramisu, waypaper, +# cliphist) are rebuilt into [tanin] so this never hard-depends on the AUR. +# Calamares just installs this package at the end. +pkgname=tanin-desktop +pkgver=0.2.0 +pkgrel=1 +pkgdesc="TANINUX desktop — metapackage pulling the full niri/eww environment" +arch=('any') +url="https://taninux.kgva.ch" +license=('GPL-3.0-or-later') + +depends=( + # --- own packages ([tanin] repo) --- + 'taninux' # System Settings + Software Hub + TUI + 'tanin-greet' # greetd greeter (GTK4, Fuji topbar) + 'tanin-libadwaita' # Fuji recolour for GTK4/libadwaita + GTK3 + 'tanin-eww' # eww bar/dock/panels + niri config -> /usr/share/tanin/skel + 'tanin-setup' # seeds ~/.config + session portal on first run + + # --- compositor + login --- + 'niri' # Wayland compositor (niri-only) + 'greetd' # display manager (greeter = tanin-greet) + 'cage' # kiosk compositor hosting the greeter + + # --- session portals --- + 'xdg-desktop-portal' + 'xdg-desktop-portal-gtk' # Settings/Appearance portal (dark mode!) + 'xdg-desktop-portal-gnome' # screencast backend recommended for niri + + # --- shell / eww stack (eww, tiramisu, waypaper, cliphist via [tanin]) --- + 'eww' # bar/dock/panels/spotlight + 'tiramisu' # headless notification daemon -> eww UI + 'waypaper' # wallpaper (niri autostart: waypaper --restore) + 'cliphist' # clipboard history for Spotlight + + # --- terminal + CLI used by the bar/scripts --- + 'foot' 'jq' 'wl-clipboard' 'xdg-utils' 'libqalculate' # libqalculate -> qalc + 'grim' 'slurp' # screenshots + + # --- connectivity / audio / hardware --- + 'networkmanager' 'bluez' 'bluez-utils' + 'pipewire' 'pipewire-pulse' 'wireplumber' # wpctl / pactl + 'brightnessctl' 'playerctl' + + # --- packages / updates --- + 'flatpak' 'pacman-contrib' # checkupdates / paccache + + # --- theming / fonts --- + 'adw-gtk3' # GTK3 base theme tanin-libadwaita recolours + 'adwaita-icon-theme' 'papirus-icon-theme' + 'inter-font' 'ttf-iosevka-nerd' # UI font + nf-md-* glyphs (verify pkg name) +) + +optdepends=( + 'paru: AUR support in the Software Hub (opt-in)' + 'wlsunset: night light' + 'cups: printer management' + 'timeshift: system snapshots/backups' + 'waybar: fallback bar if eww is missing (launch.sh falls back to it)' +) + +# Metapackage — no files of its own. +package() { + : +} diff --git a/packaging/tanin-greet/PKGBUILD b/packaging/tanin-greet/PKGBUILD new file mode 100644 index 0000000..0fc6b5e --- /dev/null +++ b/packaging/tanin-greet/PKGBUILD @@ -0,0 +1,25 @@ +# Maintainer: Karim +# +# tanin-greet — TANINUX custom GTK4 greeter for greetd. Black top bar like the +# eww topbar, wallpaper background, user+password centered, session in a corner. +# After install: systemctl enable greetd (disable any other DM first) +pkgname=tanin-greet +pkgver=0.1.0 +pkgrel=1 +pkgdesc="TANINUX login — custom GTK4 greeter for greetd (mirrors the topbar)" +arch=('any') +url="https://taninux.kgva.ch" +license=('GPL-3.0-or-later') +depends=('greetd' 'cage' 'gtk4' 'python' 'python-gobject' + 'adwaita-icon-theme' 'inter-font') +optdepends=('tanin-backgrounds: default login wallpaper at /usr/share/backgrounds/tanin/login.jpg') +backup=('etc/greetd/config.toml' 'etc/greetd/tanin-greet.css') + +package() { + install -Dm755 "$startdir/tanin-greet" "$pkgdir/usr/bin/tanin-greet" + install -Dm644 "$startdir/tanin-greet.css" "$pkgdir/etc/greetd/tanin-greet.css" + install -Dm644 "$startdir/config.toml" "$pkgdir/etc/greetd/config.toml" + # The greeter reads its CSS from /etc/greetd by default; no wallpaper is + # shipped here (provide one at /usr/share/backgrounds/tanin/login.jpg, e.g. + # via a tanin-backgrounds package — the greeter falls back to a dark scrim). +} diff --git a/packaging/tanin-greet/README.md b/packaging/tanin-greet/README.md new file mode 100644 index 0000000..1f3d855 --- /dev/null +++ b/packaging/tanin-greet/README.md @@ -0,0 +1,47 @@ +# tanin-greet + +TANINUX's custom **GTK4 greeter** for greetd. Built because ReGreet's layout is +fixed — this gives full control to match the design: + +- a **black top bar** like the eww topbar (`TANINUX` · clock · power) +- a **wallpaper** background under a dark scrim +- **user + password** centered as pills (Fuji violet focus ring, Enter submits) +- the **session selector** tucked tiny in the **bottom-right corner** + +It speaks the greetd IPC directly (create_session → auth → start_session) and is +hosted by `cage`. + +## Files +- `tanin-greet` → `/usr/bin/tanin-greet` — the GTK4 app (PyGObject) +- `tanin-greet.css` → `/etc/greetd/tanin-greet.css` — the look (mirrors `eww.scss`) +- `config.toml` → `/etc/greetd/config.toml` — greetd; `cage -s -- tanin-greet` +- `dev/` → preview helper + greetd mock (not packaged) + +## Preview (no logout, no real auth) +```sh +./dev/preview.sh # uses your current wallpaper path +./dev/preview.sh /path/to/wall.jpg +# screenshot lands at /tmp/tanin-greet.png ; override output with GREET_OUTPUT=HDMI-A-1 +``` +The greeter renders even without greetd; `dev/mockgreetd.py` provides a fake +socket so the password prompt and login flow work for a full dry-run. + +## Env overrides (used by the preview) +- `TANIN_GREET_CSS` stylesheet path (default `/etc/greetd/tanin-greet.css`) +- `TANIN_GREET_BG` wallpaper path (default `/usr/share/backgrounds/tanin/login.jpg`) +- `TANIN_GREET_SESSIONS` sessions dir (default `/usr/share/wayland-sessions`) +- `TANIN_GREET_BRAND` top-bar text (default `TANINUX`) + +## Install (on the distro) +```sh +makepkg -si +sudo systemctl enable greetd # disable any other DM first +``` +Drop a login wallpaper at `/usr/share/backgrounds/tanin/login.jpg` (or ship a +`tanin-backgrounds` package). Without one, the greeter shows a dark scrim. + +## Notes +- Sessions are read from `/usr/share/wayland-sessions/*.desktop`; Niri is the + default selection. Users are read from `/etc/passwd` (uid 1000–65533, real shell). +- Icons (`avatar-default-symbolic`, `system-shutdown-symbolic`) come from + `adwaita-icon-theme`. diff --git a/packaging/tanin-greet/config.toml b/packaging/tanin-greet/config.toml new file mode 100644 index 0000000..6b01619 --- /dev/null +++ b/packaging/tanin-greet/config.toml @@ -0,0 +1,8 @@ +# greetd config for tanin-greet (TANINUX custom GTK4 greeter). +# The greeter is a GTK app, so it's hosted by `cage` (one-window kiosk compositor). +[terminal] +vt = 1 + +[default_session] +command = "cage -s -- tanin-greet" +user = "greeter" diff --git a/packaging/tanin-greet/dev/mockgreetd.py b/packaging/tanin-greet/dev/mockgreetd.py new file mode 100755 index 0000000..9f2fcec --- /dev/null +++ b/packaging/tanin-greet/dev/mockgreetd.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +# Minimal greetd IPC mock — just enough for tanin-greet to render and dry-run a +# login WITHOUT real auth. greetd framing: native-endian u32 length + JSON. +# Usage: python3 mockgreetd.py /tmp/mock-greetd.sock +import json, os, socket, struct, sys + +SOCK = sys.argv[1] if len(sys.argv) > 1 else "/tmp/mock-greetd.sock" +try: + os.unlink(SOCK) +except FileNotFoundError: + pass + +srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) +srv.bind(SOCK) +srv.listen(8) +print(f"mock greetd listening on {SOCK}", flush=True) + + +def recv(conn): + hdr = conn.recv(4) + if len(hdr) < 4: + return None + n = struct.unpack("=I", hdr)[0] + buf = b"" + while len(buf) < n: + chunk = conn.recv(n - len(buf)) + if not chunk: + break + buf += chunk + return json.loads(buf) + + +def send(conn, obj): + data = json.dumps(obj).encode() + conn.sendall(struct.pack("=I", len(data)) + data) + + +while True: + conn, _ = srv.accept() + try: + while True: + req = recv(conn) + if req is None: + break + if req.get("type") == "create_session": + send(conn, {"type": "auth_message", + "auth_message_type": "secret", + "auth_message": "Password:"}) + else: # post_auth_message_response / start_session / cancel_session + send(conn, {"type": "success"}) + except Exception as e: # noqa: BLE001 + print("conn error:", e, flush=True) + finally: + conn.close() diff --git a/packaging/tanin-greet/dev/preview.sh b/packaging/tanin-greet/dev/preview.sh new file mode 100755 index 0000000..baab4ca --- /dev/null +++ b/packaging/tanin-greet/dev/preview.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Preview tanin-greet from the repo in a nested cage window (no logout, no real +# auth) and screenshot it. Uses the mock greetd socket so the UI fully renders. +# ./dev/preview.sh [wallpaper.jpg] +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SOCK=/tmp/mock-greetd.sock +SHOT=/tmp/tanin-greet.png + +export GREETD_SOCK="$SOCK" +export TANIN_GREET_CSS="$HERE/tanin-greet.css" +export TANIN_GREET_BG="${1:-$HOME/Downloads/karen-z-OW5F_DMfb0E-unsplash.jpg}" + +python3 "$HERE/dev/mockgreetd.py" "$SOCK" >/tmp/mockgreetd.log 2>&1 & +MOCK=$! +trap 'kill "$MOCK" 2>/dev/null; pkill -x cage 2>/dev/null' EXIT +sleep 0.4 + +timeout 8 cage -s -- python3 "$HERE/tanin-greet" >/tmp/tanin-greet.log 2>&1 & +sleep 4.5 +grim -o "${GREET_OUTPUT:-DP-3}" "$SHOT" && echo "screenshot: $SHOT" +pkill -x cage 2>/dev/null || true diff --git a/packaging/tanin-greet/install-local.sh b/packaging/tanin-greet/install-local.sh new file mode 100644 index 0000000..6cdacea --- /dev/null +++ b/packaging/tanin-greet/install-local.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Install tanin-greet as the greetd greeter on THIS machine. Run with sudo: +# sudo bash packaging/tanin-greet/install-local.sh +# Reversible: the current greetd config is backed up first. +set -euo pipefail +SRC="$(cd "$(dirname "$0")" && pwd)" +ts="$(date +%Y%m%d-%H%M%S)" + +# 1) greeter binary + stylesheet +install -Dm755 "$SRC/tanin-greet" /usr/bin/tanin-greet +install -Dm644 "$SRC/tanin-greet.css" /etc/greetd/tanin-greet.css + +# 2) optional login wallpaper (greeter falls back to a dark scrim if absent) +if [ -f "${TANIN_LOGIN_BG:-}" ]; then + install -Dm644 "$TANIN_LOGIN_BG" /usr/share/backgrounds/tanin/login.jpg +fi + +# 3) point greetd at tanin-greet (back up the working config first) +[ -f /etc/greetd/config.toml ] && cp -a /etc/greetd/config.toml "/etc/greetd/config.toml.bak-$ts" +install -Dm644 "$SRC/config.toml" /etc/greetd/config.toml + +cat < cage -s -- tanin-greet + backup: /etc/greetd/config.toml.bak-$ts + +greetd is already enabled — it shows on the NEXT login. To see it now: log out +(or 'sudo systemctl restart greetd' from a TTY — Ctrl+Alt+F2). +If anything is wrong, switch to a TTY and revert: + sudo cp /etc/greetd/config.toml.bak-$ts /etc/greetd/config.toml +EOF diff --git a/packaging/tanin-greet/tanin-greet b/packaging/tanin-greet/tanin-greet new file mode 100755 index 0000000..1eda1ca --- /dev/null +++ b/packaging/tanin-greet/tanin-greet @@ -0,0 +1,328 @@ +#!/usr/bin/env python3 +"""tanin-greet — TANINUX login greeter (GTK4 / PyGObject). + +Custom layout: a black top bar like the eww topbar (brand · clock · power), a +wallpaper background, user + password centered as pills, and a small session +selector tucked in the bottom-right corner. Talks to greetd over GREETD_SOCK. + +Run by greetd via cage: cage -s -- tanin-greet +Preview without greetd: just run it — the UI renders; only login needs the +socket (set GREETD_SOCK via a mock for a full dry-run). + +Env overrides (handy for previewing from the repo): + TANIN_GREET_CSS path to the stylesheet (default /etc/greetd/tanin-greet.css) + TANIN_GREET_BG wallpaper image path (default /usr/share/backgrounds/tanin/login.jpg) + TANIN_GREET_SESSIONS wayland-sessions dir (default /usr/share/wayland-sessions) +""" +import gi, os, sys, json, socket, struct, shlex, glob, pwd + +gi.require_version("Gtk", "4.0") +from gi.repository import Gtk, GLib, Gdk, Gio # noqa: E402 + +CSS_PATH = os.environ.get("TANIN_GREET_CSS", "/etc/greetd/tanin-greet.css") +WALLPAPER = os.environ.get("TANIN_GREET_BG", "/usr/share/backgrounds/tanin/login.jpg") +SESSION_DIR = os.environ.get("TANIN_GREET_SESSIONS", "/usr/share/wayland-sessions") +BRAND = os.environ.get("TANIN_GREET_BRAND", "TANINUX") + + +# --------------------------------------------------------------------------- # +# greetd IPC client — native-endian u32 length prefix + JSON payload. +# --------------------------------------------------------------------------- # +class Greetd: + def __init__(self): + path = os.environ.get("GREETD_SOCK") + self.sock = None + if path: + try: + self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.sock.connect(path) + except OSError: + # No reachable greetd (e.g. preview without a daemon). Render the + # UI anyway; login() will report the missing socket on submit. + self.sock = None + + def _recvn(self, n): + buf = b"" + while len(buf) < n: + chunk = self.sock.recv(n - len(buf)) + if not chunk: + raise RuntimeError("greetd closed the connection") + buf += chunk + return buf + + def _send(self, obj): + data = json.dumps(obj).encode() + self.sock.sendall(struct.pack("=I", len(data)) + data) + + def _recv(self): + n = struct.unpack("=I", self._recvn(4))[0] + return json.loads(self._recvn(n)) + + def login(self, username, password, cmd_argv): + """Authenticate and start the session. Raises on failure.""" + if self.sock is None: + raise RuntimeError("GREETD_SOCK not set (preview mode — cannot log in)") + self._send({"type": "create_session", "username": username}) + while True: + r = self._recv() + t = r.get("type") + if t == "auth_message": + amt = r.get("auth_message_type") + if amt in ("secret", "visible"): + self._send({"type": "post_auth_message_response", "response": password}) + else: # info / error — acknowledge with no response + self._send({"type": "post_auth_message_response"}) + elif t == "success": + break + elif t == "error": + self._send({"type": "cancel_session"}) + raise RuntimeError(r.get("description", "authentication failed")) + else: + raise RuntimeError(f"unexpected greetd reply: {r}") + # authenticated → launch the chosen session + self._send({"type": "start_session", "cmd": cmd_argv, "env": []}) + r = self._recv() + if r.get("type") != "success": + self._send({"type": "cancel_session"}) + raise RuntimeError(r.get("description", "failed to start session")) + + +# --------------------------------------------------------------------------- # +# System enumeration +# --------------------------------------------------------------------------- # +def list_users(): + users = [] + for p in pwd.getpwall(): + if 1000 <= p.pw_uid < 65534 and p.pw_shell not in ("/usr/bin/nologin", "/sbin/nologin", "/bin/false"): + users.append(p.pw_name) + return users or [os.environ.get("USER", "user")] + + +def list_sessions(): + sessions = [] # (name, exec_argv) + for f in sorted(glob.glob(os.path.join(SESSION_DIR, "*.desktop"))): + name = exec_ = None + try: + for line in open(f, encoding="utf-8"): + if line.startswith("Name=") and name is None: + name = line[5:].strip() + elif line.startswith("Exec=") and exec_ is None: + exec_ = line[5:].strip() + except OSError: + continue + if name and exec_: + sessions.append((name, shlex.split(exec_))) + return sessions or [("Niri", ["niri-session"])] + + +# --------------------------------------------------------------------------- # +# UI +# --------------------------------------------------------------------------- # +class Greeter(Gtk.Application): + def __init__(self): + super().__init__(application_id="ch.gabrielevarano.TaninGreet") + self.greetd = Greetd() + self.users = list_users() + self.sessions = list_sessions() + + def do_activate(self): + self._load_css() + Gtk.Settings.get_default().set_property("gtk-application-prefer-dark-theme", True) + + win = Gtk.ApplicationWindow(application=self) + win.set_decorated(False) + win.fullscreen() + win.add_css_class("greeter") + + overlay = Gtk.Overlay() + win.set_child(overlay) + + # background image (falls back to the scrim colour if missing) + if os.path.exists(WALLPAPER): + bg = Gtk.Picture.new_for_filename(WALLPAPER) + bg.set_content_fit(Gtk.ContentFit.COVER) + overlay.set_child(bg) + else: + overlay.set_child(Gtk.Box()) + + # dark scrim so text stays readable over any wallpaper + scrim = Gtk.Box() + scrim.add_css_class("scrim") + scrim.set_hexpand(True) + scrim.set_vexpand(True) + overlay.add_overlay(scrim) + + # main column: top bar + centered login + col = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + col.append(self._topbar()) + center = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + center.set_vexpand(True) + center.set_valign(Gtk.Align.CENTER) + center.set_halign(Gtk.Align.CENTER) + center.append(self._login_card()) + col.append(center) + col.append(self._footer()) # TANINUX wordmark, bottom center + overlay.add_overlay(col) + + win.present() + self.password.grab_focus() + + # debug: auto-open the session dropdown so previews can screenshot the + # popover without a click (TANIN_GREET_POPUP=1). No effect in production. + if os.environ.get("TANIN_GREET_POPUP"): + def _open(): + btn = self.session_dd.get_first_child() + try: + btn.set_active(True) + except Exception: # noqa: BLE001 + pass + return False + GLib.timeout_add(900, _open) + + # ---- pieces ----------------------------------------------------------- # + def _topbar(self): + bar = Gtk.CenterBox() + bar.add_css_class("topbar") + + # top-left corner: session dropdown + bar.set_start_widget(self._session_dd()) + + # center: clock — time · weekday, date + self.clock = Gtk.Label() + self.clock.add_css_class("clock") + bar.set_center_widget(self.clock) + self._tick() + GLib.timeout_add_seconds(1, self._tick) + + # top-right corner: power + bar.set_end_widget(self._power_button()) + return bar + + def _session_dd(self): + self.session_dd = Gtk.DropDown.new_from_strings([s[0] for s in self.sessions]) + self.session_dd.add_css_class("session-dd") + for i, (name, _) in enumerate(self.sessions): + if name.lower().startswith("niri"): + self.session_dd.set_selected(i) + break + return self.session_dd + + def _footer(self): + box = Gtk.Box() + box.set_halign(Gtk.Align.CENTER) + box.set_valign(Gtk.Align.END) + box.set_margin_bottom(18) + lbl = Gtk.Label(label=BRAND) + lbl.add_css_class("brand") + box.append(lbl) + return box + + def _power_button(self): + btn = Gtk.MenuButton() + btn.add_css_class("power") + btn.set_icon_name("system-shutdown-symbolic") + pop = Gtk.Popover() + pop.add_css_class("menu") + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) + for label, argv in (("Reboot", ["systemctl", "reboot"]), + ("Power Off", ["systemctl", "poweroff"])): + b = Gtk.Button(label=label) + b.add_css_class("menuitem") + b.connect("clicked", lambda _w, a=argv: self._run(a)) + box.append(b) + pop.set_child(box) + btn.set_popover(pop) + return btn + + def _login_card(self): + card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=14) + card.add_css_class("logincard") # NOT "card" — libadwaita's .card adds a bg+shadow + card.set_halign(Gtk.Align.CENTER) + + avatar = Gtk.Image.new_from_icon_name("avatar-default-symbolic") + avatar.set_pixel_size(64) + avatar.add_css_class("avatar") + avatar.set_halign(Gtk.Align.CENTER) + card.append(avatar) + + # user row — just the NAME as text, with a small dropdown chevron on the + # right edge (aligned with where the password pill ends). Not a pill. + name_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + name_row.add_css_class("namerow") + name_row.set_halign(Gtk.Align.CENTER) + + self.name_label = Gtk.Label(label=self.users[0]) + self.name_label.add_css_class("username") + self.name_label.set_xalign(0.5) # centered + self.name_label.set_hexpand(True) + name_row.append(self.name_label) + + self.user_btn = Gtk.MenuButton() + self.user_btn.add_css_class("userswitch") + self.user_btn.set_icon_name("pan-down-symbolic") + self.user_btn.set_visible(len(self.users) > 1) # no point if there's one user + upop = Gtk.Popover() + upop.add_css_class("menu") + ubox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) + for u in self.users: + b = Gtk.Button(label=u) + b.add_css_class("menuitem") + b.connect("clicked", lambda _w, name=u: (self.name_label.set_text(name), + self.user_btn.popdown())) + ubox.append(b) + upop.set_child(ubox) + self.user_btn.set_popover(upop) + name_row.append(self.user_btn) + card.append(name_row) + + # password pill + self.password = Gtk.PasswordEntry() + self.password.set_show_peek_icon(True) + self.password.set_property("placeholder-text", "Password") + self.password.add_css_class("pill") + self.password.connect("activate", self._on_submit) + card.append(self.password) + + self.error = Gtk.Label() + self.error.add_css_class("error") + self.error.set_visible(False) + card.append(self.error) + return card + + # ---- behaviour -------------------------------------------------------- # + def _tick(self): + now = GLib.DateTime.new_now_local() + self.clock.set_text(now.format("%H:%M · %A, %d %B %Y")) + return True + + def _on_submit(self, _entry): + user = self.name_label.get_text() + pw = self.password.get_text() + cmd = self.sessions[self.session_dd.get_selected()][1] + try: + self.greetd.login(user, pw, cmd) + self.quit() # greetd takes over from here + except Exception as e: # noqa: BLE001 + self.password.set_text("") + self.error.set_text(str(e)) + self.error.set_visible(True) + + def _run(self, argv): + try: + Gio.Subprocess.new(argv, Gio.SubprocessFlags.NONE) + except GLib.Error as e: + self.error.set_text(str(e)) + self.error.set_visible(True) + + def _load_css(self): + if not os.path.exists(CSS_PATH): + return + provider = Gtk.CssProvider() + provider.load_from_path(CSS_PATH) + Gtk.StyleContext.add_provider_for_display( + Gdk.Display.get_default(), provider, + Gtk.STYLE_PROVIDER_PRIORITY_USER) + + +if __name__ == "__main__": + sys.exit(Greeter().run(None)) diff --git a/packaging/tanin-greet/tanin-greet.css b/packaging/tanin-greet/tanin-greet.css new file mode 100644 index 0000000..bfb093d --- /dev/null +++ b/packaging/tanin-greet/tanin-greet.css @@ -0,0 +1,176 @@ +/* ========================================================================= + * tanin-greet — stylesheet. Mirrors the eww topbar (eww.scss): Fuji palette, + * monochrome, pill shapes, Inter Medium. Accent is the brand Fuji violet. + * ========================================================================= */ +@define-color bg #0f0f0f; +@define-color bg_alt #191919; +@define-color bg_hi #242424; +@define-color fg #f0f0f0; +@define-color yuki #f0f0f0; /* snow white — pill fill */ +@define-color muted #8a8a8a; +@define-color accent #a39ec4; + +/* No font-family override — inherit the system UI font. */ +.greeter { + font-weight: 500; + color: @fg; +} +* { text-shadow: none; -gtk-icon-shadow: none; outline: none; } + +/* dark scrim over the wallpaper — keeps text readable, keeps it moody */ +.scrim { background-color: alpha(black, 0.55); } + +/* ---- top bar: the eww bar look (opaque #0f0f0f strip) ------------------- * + * left = session dropdown · center = clock (time/day/date) · right = power. */ +.topbar { + background-color: @bg; + min-height: 34px; + padding: 0 12px; +} +.topbar .clock { + color: @fg; + font-size: 13px; +} + +/* session dropdown, top-left corner — quiet on the black bar, no box */ +.session-dd, +.session-dd > button { + background: transparent; + background-image: none; + border: none; + box-shadow: none; + min-height: 0; + padding: 2px 6px; + color: @muted; + font-size: 12px; +} +.session-dd > button:hover { color: @fg; } + +/* power button — NO background at all, just the glyph */ +.topbar .power, +.topbar .power > button { + background: transparent; + background-image: none; + border: none; + box-shadow: none; + min-height: 0; + min-width: 0; + padding: 2px 6px; + color: @muted; +} +.topbar .power:hover, +.topbar .power > button:hover { color: @accent; background: transparent; } + +/* TANINUX wordmark — bottom center, understated */ +.brand { + color: @fg; + font-weight: 700; + font-size: 12px; + letter-spacing: 3px; + opacity: 0.8; +} + +/* ---- centered login card (flat, no heavy frame — minimal) --------------- * + * NB: class is .logincard, never .card — libadwaita's built-in .card recipe + * paints an elevated background + shadow (the "solid box, too big" bug). */ +.logincard { padding: 8px; background: transparent; box-shadow: none; } + +.avatar { + color: @fg; + background-color: alpha(@accent, 0.14); + border-radius: 999px; + padding: 16px; + margin-bottom: 6px; +} + +/* username row — plain name + chevron on the right, same width as the pill so + * the chevron sits where the password pill ends. No background (just the name). */ +.namerow { min-width: 300px; } +.username { + color: @yuki; + font-size: 17px; + font-weight: 600; +} +.userswitch { + background: transparent; + border: none; + box-shadow: none; + min-height: 0; + min-width: 0; + padding: 2px 4px; + color: @muted; +} +.userswitch:hover { color: @fg; } + +/* password pill — snow white (yuki), dark text, accent focus ring. + * border-radius 999px like the bar's workspaces/clock. */ +.pill { + min-width: 300px; + min-height: 0; + background-color: alpha(@bg_alt, 0.45); /* greyish, slightly transparent */ + color: @fg; + border: 1.5px solid alpha(@fg, 0.22); /* grey border (unfocused) */ + border-radius: 999px; + padding: 11px 18px; + box-shadow: none; +} +entry.pill { caret-color: @yuki; } +entry.pill text { color: @fg; } +entry.pill text > placeholder { color: alpha(@fg, 0.45); } +entry.pill image { color: alpha(@fg, 0.6); } /* peek-eye icon */ +/* clicked / focused → white border */ +.pill:focus-within { + border-color: @yuki; + background-color: alpha(@bg_alt, 0.55); + box-shadow: none; +} + +/* popup lists (session dropdown, user switcher). + * IMPORTANT: only style `popover > contents` — the outer `popover` node also + * covers the reserved shadow/margin area, so a solid bg there paints a huge + * solid box. Keep the outer node fully transparent. */ +popover { + background: transparent; + background-image: none; + border: none; + box-shadow: none; + padding: 0; +} +popover > contents { + background-color: @bg_alt; + color: @fg; + border: 1px solid alpha(@fg, 0.06); + border-radius: 12px; + box-shadow: 0 8px 24px alpha(black, 0.45); + padding: 6px; + min-width: 0; +} +/* kill the inner list's OWN themed background (the lighter nested box) so the + * whole popover reads as one uniform surface. */ +popover listview, +popover scrolledwindow, +popover scrolledwindow > viewport, +popover .view { + background: transparent; + background-color: transparent; + color: @fg; +} +popover listview > row, +popover .menuitem { border-radius: 8px; padding: 5px 10px; } +popover listview > row:selected, +popover listview > row:hover, +popover row:selected, +popover .menuitem:hover { background-color: alpha(@accent, 0.14); color: @accent; } + +.error { color: #e0a3a3; font-size: 12px; margin-top: 4px; } + +/* power popover buttons */ +.menuitem { + background: transparent; + border: none; + box-shadow: none; + border-radius: 8px; + padding: 6px 14px; + color: @fg; +} +.menuitem:hover { background-color: alpha(@accent, 0.14); color: @accent; } diff --git a/packaging/tanin-libadwaita/PKGBUILD b/packaging/tanin-libadwaita/PKGBUILD new file mode 100644 index 0000000..b26285e --- /dev/null +++ b/packaging/tanin-libadwaita/PKGBUILD @@ -0,0 +1,24 @@ +# Maintainer: Karim +# +# tanin-libadwaita — Fuji recolour for GTK4/libadwaita AND GTK3 (light + dark). +# Recolours libadwaita apps (tsettings/thub, Nautilus, …) via named Adwaita +# colors, and GTK3 apps (pavucontrol, nm-connection-editor, …) via adw-gtk3, +# with pill-shaped controls. One switch covers both toolkits: +# tanin-libadwaita {dark|light|auto|watch} +pkgname=tanin-libadwaita +pkgver=0.1.0 +pkgrel=1 +pkgdesc="TANINUX Fuji recolour for GTK4/libadwaita + GTK3 — light + dark, pills" +arch=('any') +url="https://taninux.kgva.ch" +license=('GPL-3.0-or-later') +depends=('glib2' 'adw-gtk3') # gsettings + GTK3 base theme for the recolour +optdepends=('tanin-setup: seed the variant + start the color-scheme watcher on login') + +package() { + install -Dm644 "$startdir/gtk-4.0/dark.css" "$pkgdir/usr/share/tanin/libadwaita/gtk-4.0/dark.css" + install -Dm644 "$startdir/gtk-4.0/light.css" "$pkgdir/usr/share/tanin/libadwaita/gtk-4.0/light.css" + install -Dm644 "$startdir/gtk-3.0/dark.css" "$pkgdir/usr/share/tanin/libadwaita/gtk-3.0/dark.css" + install -Dm644 "$startdir/gtk-3.0/light.css" "$pkgdir/usr/share/tanin/libadwaita/gtk-3.0/light.css" + install -Dm755 "$startdir/tanin-libadwaita" "$pkgdir/usr/bin/tanin-libadwaita" +} diff --git a/packaging/tanin-libadwaita/README.md b/packaging/tanin-libadwaita/README.md new file mode 100644 index 0000000..9ef0db3 --- /dev/null +++ b/packaging/tanin-libadwaita/README.md @@ -0,0 +1,41 @@ +# tanin-libadwaita + +Fuji recolour for **libadwaita / GTK4** — a **light** and a **dark** variant so +the whole OS is covered. Recolours every libadwaita app (tsettings, thub, +Nautilus, GNOME apps, …) by overriding Adwaita's *named colors*, and makes +buttons/entries **pill-shaped** to match the bar and greeter. + +## Files +- `tanin-dark.css` → `/usr/share/tanin/libadwaita/tanin-dark.css` +- `tanin-light.css` → `/usr/share/tanin/libadwaita/tanin-light.css` +- `tanin-libadwaita` → `/usr/bin/tanin-libadwaita` — switch script + +## Use +```sh +tanin-libadwaita dark # or: light +``` +Symlinks the variant to `~/.config/gtk-4.0/gtk.css` and sets +`color-scheme` + `accent-color`. A pre-existing real `gtk.css` is backed up to +`gtk.css.pre-tanin` once. + +## Why named-color overrides (not a full theme) +libadwaita ignores `gtk-theme-name`; the supported way to restyle it is the +user `gtk.css`, where overriding `@define-color accent_color` / `window_bg_color` +/ `card_bg_color` / … recolours all libadwaita apps at once. There are no CSS +media queries, so light/dark is two files the switch script flips between. + +## Preview from the repo (isolated, no change to your config) +```sh +mkdir -p /tmp/t/gtk-4.0 && cp tanin-dark.css /tmp/t/gtk-4.0/gtk.css +XDG_CONFIG_HOME=/tmp/t cage -s -- gnome-text-editor # or any libadwaita app +``` + +## Palettes +- **Dark:** bg `#0f0f0f`, fg `#f0f0f0`, accent `#a39ec4` (Fuji violet). +- **Light:** bg `#f3f1ec`, fg `#2a2926`, accent `#6f67a8` (deeper violet for contrast). + +## Notes +- App content that uses its own style scheme (e.g. gnome-text-editor's + GtkSourceView syntax theme) is NOT controlled here — that's app-internal. +- GTK3 apps (incl. eww) aren't covered by this; they use `adw-gtk3` / their own + CSS. A matching GTK3 `Tanin` theme could follow if wanted. diff --git a/packaging/tanin-libadwaita/gtk-3.0/dark.css b/packaging/tanin-libadwaita/gtk-3.0/dark.css new file mode 100644 index 0000000..8724301 --- /dev/null +++ b/packaging/tanin-libadwaita/gtk-3.0/dark.css @@ -0,0 +1,66 @@ +/* ========================================================================= + * tanin-libadwaita — Fuji DARK recolour for GTK3 (rides on adw-gtk3-dark). + * Covers the gaps the libadwaita/GTK4 recolour can't reach: GTK3 apps + * (pavucontrol, nm-connection-editor, …). Same palette as the GTK4 dark. + * ========================================================================= */ + +/* libadwaita-style names (adw-gtk3 honours these) */ +@define-color accent_color #a39ec4; +@define-color accent_bg_color #a39ec4; +@define-color accent_fg_color #0f0f0f; +@define-color window_bg_color #0f0f0f; +@define-color window_fg_color #f0f0f0; +@define-color view_bg_color #141414; +@define-color view_fg_color #f0f0f0; +@define-color headerbar_bg_color #0f0f0f; +@define-color headerbar_fg_color #f0f0f0; +@define-color card_bg_color #191919; +@define-color card_fg_color #f0f0f0; +@define-color popover_bg_color #191919; +@define-color popover_fg_color #f0f0f0; +@define-color dialog_bg_color #141414; +@define-color dialog_fg_color #f0f0f0; +@define-color sidebar_bg_color #131313; + +/* legacy GTK3 / Adwaita names */ +@define-color theme_bg_color #0f0f0f; +@define-color theme_fg_color #f0f0f0; +@define-color theme_base_color #141414; +@define-color theme_text_color #f0f0f0; +@define-color theme_selected_bg_color #a39ec4; +@define-color theme_selected_fg_color #0f0f0f; +@define-color insensitive_bg_color #161616; +@define-color insensitive_fg_color #6a6a6a; +@define-color insensitive_base_color #121212; +@define-color borders rgba(240, 240, 240, 0.10); + +@define-color destructive_color #e0a3a3; +@define-color success_color #9ec4a3; +@define-color warning_color #c4bd9e; +@define-color error_color #e0a3a3; + +/* pill-shaped controls */ +button, +entry, +combobox button, +spinbutton, +notebook > header > tabs > tab { border-radius: 999px; } + +/* Force the Fuji accent. adw-gtk3 otherwise follows the GNOME accent-color + * gsetting (would stay blue), so we override the accented widgets explicitly. */ +scale highlight, +scale trough progress, +progressbar progress, +levelbar block.filled { background-color: @accent_color; } +checkbutton check:checked, +radiobutton radio:checked, +check:checked, radio:checked { background-color: @accent_color; border-color: @accent_color; } +switch:checked { background-color: @accent_color; } +notebook > header tab:checked { box-shadow: inset 0 -3px @accent_color; } +notebook > header tab:checked label { color: @accent_color; } +row:selected, list row:selected, +treeview.view:selected, .view:selected, +menuitem:hover, menu menuitem:hover, +*:selected { background-color: @accent_color; color: @accent_fg_color; } +button.suggested-action { background-color: @accent_color; color: @accent_fg_color; } +entry:focus, entry:focus-within { border-color: @accent_color; } diff --git a/packaging/tanin-libadwaita/gtk-3.0/light.css b/packaging/tanin-libadwaita/gtk-3.0/light.css new file mode 100644 index 0000000..fd900fa --- /dev/null +++ b/packaging/tanin-libadwaita/gtk-3.0/light.css @@ -0,0 +1,65 @@ +/* ========================================================================= + * tanin-libadwaita — Fuji LIGHT recolour for GTK3 (rides on adw-gtk3). + * Same warm Fuji light palette as the GTK4 light variant. + * ========================================================================= */ + +/* libadwaita-style names (adw-gtk3 honours these) */ +@define-color accent_color #6f67a8; +@define-color accent_bg_color #6f67a8; +@define-color accent_fg_color #ffffff; +@define-color window_bg_color #f3f1ec; +@define-color window_fg_color #2a2926; +@define-color view_bg_color #faf8f4; +@define-color view_fg_color #2a2926; +@define-color headerbar_bg_color #ece8e1; +@define-color headerbar_fg_color #2a2926; +@define-color card_bg_color #ffffff; +@define-color card_fg_color #2a2926; +@define-color popover_bg_color #ffffff; +@define-color popover_fg_color #2a2926; +@define-color dialog_bg_color #faf8f4; +@define-color dialog_fg_color #2a2926; +@define-color sidebar_bg_color #ece8e1; + +/* legacy GTK3 / Adwaita names */ +@define-color theme_bg_color #f3f1ec; +@define-color theme_fg_color #2a2926; +@define-color theme_base_color #faf8f4; +@define-color theme_text_color #2a2926; +@define-color theme_selected_bg_color #6f67a8; +@define-color theme_selected_fg_color #ffffff; +@define-color insensitive_bg_color #ebe7e0; +@define-color insensitive_fg_color #9b968c; +@define-color insensitive_base_color #f1eee8; +@define-color borders rgba(0, 0, 0, 0.12); + +@define-color destructive_color #b04848; +@define-color success_color #4f7a57; +@define-color warning_color #8a7f3a; +@define-color error_color #b04848; + +/* pill-shaped controls */ +button, +entry, +combobox button, +spinbutton, +notebook > header > tabs > tab { border-radius: 999px; } + +/* Force the Fuji accent. adw-gtk3 otherwise follows the GNOME accent-color + * gsetting, so we override the accented widgets explicitly. */ +scale highlight, +scale trough progress, +progressbar progress, +levelbar block.filled { background-color: @accent_color; } +checkbutton check:checked, +radiobutton radio:checked, +check:checked, radio:checked { background-color: @accent_color; border-color: @accent_color; } +switch:checked { background-color: @accent_color; } +notebook > header tab:checked { box-shadow: inset 0 -3px @accent_color; } +notebook > header tab:checked label { color: @accent_color; } +row:selected, list row:selected, +treeview.view:selected, .view:selected, +menuitem:hover, menu menuitem:hover, +*:selected { background-color: @accent_color; color: @accent_fg_color; } +button.suggested-action { background-color: @accent_color; color: @accent_fg_color; } +entry:focus, entry:focus-within { border-color: @accent_color; } diff --git a/packaging/tanin-libadwaita/gtk-4.0/dark.css b/packaging/tanin-libadwaita/gtk-4.0/dark.css new file mode 100644 index 0000000..ec51aa3 --- /dev/null +++ b/packaging/tanin-libadwaita/gtk-4.0/dark.css @@ -0,0 +1,74 @@ +/* ========================================================================= + * tanin-theme — Fuji recolour for libadwaita / GTK4. + * Drop-in at ~/.config/gtk-4.0/gtk.css. Overrides libadwaita's NAMED colors, + * which recolours every libadwaita app (tsettings/thub, Nautilus, …) without a + * full custom theme. Palette is 1:1 with eww.scss / the greeter. + * ========================================================================= */ + +/* ---- Fuji palette -------------------------------------------------------- */ +@define-color fuji_bg #0f0f0f; +@define-color fuji_bg_alt #191919; +@define-color fuji_bg_hi #242424; +@define-color fuji_fg #f0f0f0; +@define-color fuji_muted #8a8a8a; +@define-color fuji_accent #a39ec4; + +/* ---- accent -------------------------------------------------------------- */ +@define-color accent_color #a39ec4; +@define-color accent_bg_color #a39ec4; +@define-color accent_fg_color #0f0f0f; /* dark text on the light violet */ + +/* ---- window / base surfaces --------------------------------------------- */ +@define-color window_bg_color #0f0f0f; +@define-color window_fg_color #f0f0f0; +@define-color view_bg_color #141414; +@define-color view_fg_color #f0f0f0; + +/* ---- headerbar ----------------------------------------------------------- */ +@define-color headerbar_bg_color #0f0f0f; +@define-color headerbar_fg_color #f0f0f0; +@define-color headerbar_border_color #f0f0f0; +@define-color headerbar_backdrop_color #0f0f0f; +@define-color headerbar_shade_color rgba(0, 0, 0, 0.5); + +/* ---- cards / dialogs / popovers / sidebar ------------------------------- */ +@define-color card_bg_color #191919; +@define-color card_fg_color #f0f0f0; +@define-color card_shade_color rgba(0, 0, 0, 0.45); +@define-color dialog_bg_color #141414; +@define-color dialog_fg_color #f0f0f0; +@define-color popover_bg_color #191919; +@define-color popover_fg_color #f0f0f0; +@define-color sidebar_bg_color #131313; +@define-color sidebar_fg_color #f0f0f0; +@define-color sidebar_backdrop_color #0f0f0f; +@define-color sidebar_shade_color rgba(0, 0, 0, 0.45); + +/* ---- semantic ------------------------------------------------------------ */ +@define-color destructive_color #e0a3a3; +@define-color destructive_bg_color #b45c5c; +@define-color destructive_fg_color #0f0f0f; +@define-color success_color #9ec4a3; +@define-color warning_color #c4bd9e; +@define-color error_color #e0a3a3; + +@define-color shade_color rgba(0, 0, 0, 0.4); +@define-color scrollbar_outline_color rgba(255, 255, 255, 0.06); + +/* ---- pill-shaped controls (Fuji house style) --------------------------- * + * Buttons, entries and toggles become stadium pills like the bar/greeter. */ +button, +button.toggle, +menubutton > button, +dropdown > button, +splitbutton > button, +entry, +spinbutton, +.pill { + border-radius: 999px; + min-height: 0; +} +/* containers stay softly rounded, not stadium-shaped */ +.card, +popover > contents, +.osd { border-radius: 14px; } diff --git a/packaging/tanin-libadwaita/gtk-4.0/light.css b/packaging/tanin-libadwaita/gtk-4.0/light.css new file mode 100644 index 0000000..354acf4 --- /dev/null +++ b/packaging/tanin-libadwaita/gtk-4.0/light.css @@ -0,0 +1,71 @@ +/* ========================================================================= + * tanin-libadwaita — Fuji LIGHT recolour for libadwaita / GTK4. + * Warm "Yuki/Fuji" light palette (1:1 with eww.scss light tokens), deeper + * violet accent so it reads on white. Pair with color-scheme=prefer-light. + * ========================================================================= */ + +/* ---- Fuji light palette -------------------------------------------------- */ +@define-color fuji_bg #f3f1ec; /* soft warm white */ +@define-color fuji_bg_alt #e8e4dc; /* cards / tiles */ +@define-color fuji_bg_hi #ddd8ce; /* hover */ +@define-color fuji_fg #2a2926; /* near-black ink */ +@define-color fuji_muted #6f6b62; +@define-color fuji_accent #6f67a8; /* deeper violet — readable on light */ + +/* ---- accent -------------------------------------------------------------- */ +@define-color accent_color #6f67a8; +@define-color accent_bg_color #6f67a8; +@define-color accent_fg_color #ffffff; + +/* ---- window / base surfaces --------------------------------------------- */ +@define-color window_bg_color #f3f1ec; +@define-color window_fg_color #2a2926; +@define-color view_bg_color #faf8f4; +@define-color view_fg_color #2a2926; + +/* ---- headerbar ----------------------------------------------------------- */ +@define-color headerbar_bg_color #ece8e1; +@define-color headerbar_fg_color #2a2926; +@define-color headerbar_border_color #2a2926; +@define-color headerbar_backdrop_color #f3f1ec; +@define-color headerbar_shade_color rgba(0, 0, 0, 0.08); + +/* ---- cards / dialogs / popovers / sidebar ------------------------------- */ +@define-color card_bg_color #ffffff; +@define-color card_fg_color #2a2926; +@define-color card_shade_color rgba(0, 0, 0, 0.07); +@define-color dialog_bg_color #faf8f4; +@define-color dialog_fg_color #2a2926; +@define-color popover_bg_color #ffffff; +@define-color popover_fg_color #2a2926; +@define-color sidebar_bg_color #ece8e1; +@define-color sidebar_fg_color #2a2926; +@define-color sidebar_backdrop_color #f3f1ec; +@define-color sidebar_shade_color rgba(0, 0, 0, 0.07); + +/* ---- semantic ------------------------------------------------------------ */ +@define-color destructive_color #b04848; +@define-color destructive_bg_color #b04848; +@define-color destructive_fg_color #ffffff; +@define-color success_color #4f7a57; +@define-color warning_color #8a7f3a; +@define-color error_color #b04848; + +@define-color shade_color rgba(0, 0, 0, 0.07); +@define-color scrollbar_outline_color rgba(0, 0, 0, 0.08); + +/* ---- pill-shaped controls (Fuji house style) --------------------------- */ +button, +button.toggle, +menubutton > button, +dropdown > button, +splitbutton > button, +entry, +spinbutton, +.pill { + border-radius: 999px; + min-height: 0; +} +.card, +popover > contents, +.osd { border-radius: 14px; } diff --git a/packaging/tanin-libadwaita/tanin-libadwaita b/packaging/tanin-libadwaita/tanin-libadwaita new file mode 100755 index 0000000..823f5f5 --- /dev/null +++ b/packaging/tanin-libadwaita/tanin-libadwaita @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# tanin-libadwaita {dark|light|auto|watch} +# Activates the Fuji recolour for BOTH GTK4/libadwaita and GTK3 by symlinking +# the chosen variant into ~/.config/gtk-{4.0,3.0}/gtk.css. +# dark|light set that mode (and the GNOME color-scheme + gtk-theme) +# auto follow the CURRENT color-scheme once (don't change it) +# watch follow color-scheme changes live (daemon) — the appearance +# toggle in settings then drives light/dark automatically +set -euo pipefail + +SHARE="${TANIN_LIBADWAITA_DIR:-/usr/share/tanin/libadwaita}" +CFG="${XDG_CONFIG_HOME:-$HOME/.config}" + +# Symlink both toolkits' gtk.css to the variant + pick the matching adw-gtk3. +# Does NOT touch color-scheme (so it's safe to call from a color-scheme watcher). +link_variant() { + local mode="$1" gtktheme + case "$mode" in + dark) gtktheme="adw-gtk3-dark" ;; + light) gtktheme="adw-gtk3" ;; + *) return 2 ;; + esac + local v src dst + for v in gtk-4.0 gtk-3.0; do + src="$SHARE/$v/$mode.css"; dst="$CFG/$v/gtk.css" + [ -f "$src" ] || continue + mkdir -p "$CFG/$v" + if [ -e "$dst" ] && [ ! -L "$dst" ] && [ ! -e "$dst.pre-tanin" ]; then + cp -a "$dst" "$dst.pre-tanin" # back up a real file once + fi + ln -sf "$src" "$dst" + done + gsettings set org.gnome.desktop.interface gtk-theme "$gtktheme" 2>/dev/null || true + gsettings set org.gnome.desktop.interface accent-color "purple" 2>/dev/null || true + echo "tanin-libadwaita: $mode" +} + +current_mode() { + case "$(gsettings get org.gnome.desktop.interface color-scheme 2>/dev/null)" in + *prefer-light*) echo light ;; + *) echo dark ;; + esac +} + +case "${1:-auto}" in + dark|light) + link_variant "$1" + gsettings set org.gnome.desktop.interface color-scheme \ + "$([ "$1" = light ] && echo prefer-light || echo prefer-dark)" 2>/dev/null || true + ;; + auto) + link_variant "$(current_mode)" + ;; + watch) + link_variant "$(current_mode)" + gsettings monitor org.gnome.desktop.interface color-scheme | while read -r _; do + link_variant "$(current_mode)" + done + ;; + *) + echo "usage: tanin-libadwaita {dark|light|auto|watch}" >&2; exit 2 ;; +esac diff --git a/packaging/tanin-setup/PKGBUILD b/packaging/tanin-setup/PKGBUILD new file mode 100644 index 0000000..d2bbe9a --- /dev/null +++ b/packaging/tanin-setup/PKGBUILD @@ -0,0 +1,22 @@ +# Maintainer: Karim +# +# First-run-Setup: seedet ~/.config aus /usr/share/tanin/skel und richtet das +# Session-Portal ein. Wird vom Metapackage als depends gezogen; nach der +# Installation ruft der User (oder ein Autostart) einmal `tanin-setup`. +pkgname=tanin-setup +pkgver=0.1.0 +pkgrel=1 +pkgdesc="TANINUX first-run setup — seed user configs and enable the session portal" +arch=('any') +url="https://taninux.kgva.ch" +license=('GPL-3.0-or-later') +depends=('systemd' 'xdg-desktop-portal' 'xdg-desktop-portal-gtk') + +package() { + install -Dm755 "$startdir/tanin-setup" "$pkgdir/usr/bin/tanin-setup" + install -Dm644 "$startdir/tanin-session.target" \ + "$pkgdir/usr/share/tanin/tanin-session.target" + # Default-Configs landen hier (Phase 2: tanin-eww liefert die eww/Compositor- + # Configs nach /usr/share/tanin/skel/.config/…): + install -d "$pkgdir/usr/share/tanin/skel" +} diff --git a/packaging/tanin-setup/tanin-session.target b/packaging/tanin-setup/tanin-session.target new file mode 100644 index 0000000..4bb0554 --- /dev/null +++ b/packaging/tanin-setup/tanin-session.target @@ -0,0 +1,7 @@ +[Unit] +Description=TANINUX graphical session +# Startet (per Dependency-Request) graphical-session.target, an dem +# xdg-desktop-portal hängt — sonst kann das Portal nicht aktivieren und +# libadwaita-Apps (Nautilus …) bleiben im Hell-Fallback. +Requires=graphical-session.target +After=graphical-session.target diff --git a/packaging/tanin-setup/tanin-setup b/packaging/tanin-setup/tanin-setup new file mode 100644 index 0000000..4c3becf --- /dev/null +++ b/packaging/tanin-setup/tanin-setup @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# TANINUX first-run setup: seedet Default-Configs nach ~/.config und richtet +# das Session-Portal ein (damit libadwaita-Apps Hell/Dunkel übernehmen). +# Idempotent. --force überschreibt bestehende Configs (mit .bak-Backup). +set -euo pipefail + +SKEL="/usr/share/tanin/skel" +UNIT="/usr/share/tanin/tanin-session.target" +CFG="${XDG_CONFIG_HOME:-$HOME/.config}" +FORCE=0 +[ "${1:-}" = "--force" ] && FORCE=1 + +seed_configs() { + [ -d "$SKEL" ] || return 0 + while IFS= read -r -d '' src; do + rel="${src#"$SKEL"/}" + dst="$HOME/$rel" + mkdir -p "$(dirname "$dst")" + if [ -e "$dst" ] && [ "$FORCE" -eq 0 ]; then + continue + fi + [ -e "$dst" ] && cp -a "$dst" "$dst.bak" 2>/dev/null || true + cp -a "$src" "$dst" + done < <(find "$SKEL" -type f -print0) +} + +install_session_target() { + [ -f "$UNIT" ] || return 0 + install -Dm644 "$UNIT" "$CFG/systemd/user/tanin-session.target" + systemctl --user daemon-reload 2>/dev/null || true +} + +seed_configs +install_session_target + +cat <<'EOF' +TANINUX setup complete. + • configs seeded into ~/.config + • session target installed (~/.config/systemd/user/tanin-session.target) + +Make sure your compositor autostart contains: + exec-once = dbus-update-activation-environment --systemd --all + exec-once = systemctl --user start tanin-session.target + +(The shipped TANINUX compositor config already includes these.) +EOF diff --git a/packaging/taninux/PKGBUILD b/packaging/taninux/PKGBUILD new file mode 100644 index 0000000..ceb374a --- /dev/null +++ b/packaging/taninux/PKGBUILD @@ -0,0 +1,56 @@ +# Maintainer: Karim +# +# Echtes Paket für die TANINUX-App (tsettings + thub + TUI). +# Lokal testen: makepkg -si (zieht die makedepends automatisch) +# Vor Publish: url + license bestätigen, source auf GitHub-Tag setzen. +pkgname=taninux +pkgver=0.2.0 +pkgrel=1 +pkgdesc="Central Linux management for Arch — GTK System Settings, Software Hub and TUI" +arch=('any') +url="https://taninux.kgva.ch" +license=('GPL-3.0-or-later') +depends=('python' 'python-gobject' 'gtk4' 'libadwaita' 'polkit') +optdepends=( + 'paru: AUR support in the Software Hub (opt-in)' + 'flatpak: Flatpak apps and updates' + 'pacman-contrib: update check (checkupdates) and cache cleanup (paccache)' + 'networkmanager: network settings' + 'bluez-utils: bluetooth settings' + 'wireplumber: audio settings (PipeWire)' + 'cups: printer management' + 'brightnessctl: display brightness' + 'wlsunset: night light' + 'timeshift: system snapshots/backups' +) +makedepends=('python-build' 'python-installer' 'python-wheel' 'python-hatchling' 'git') +# Lokaler Build aus dem Arbeitsbaum: source leer lassen und in build()/package() +# auf "$startdir/../.." zeigen. Für die Veröffentlichung stattdessen den Tag ziehen: +#source=("git+https://git.openbureau.ch/karim/taninux.git#tag=v$pkgver") +#sha256sums=('SKIP') + +_root="$startdir/../.." + +build() { + cd "$_root" + python -m build --wheel --no-isolation +} + +package() { + cd "$_root" + python -m installer --destdir="$pkgdir" dist/*.whl + + # Privilegierter Helper — process.py erwartet /usr/lib/taninux/ (mit Repo-Fallback). + install -Dm755 src/taninux/gui/helper/taninux-helper \ + "$pkgdir/usr/lib/taninux/taninux-helper" + + # polkit-Policy (pkexec ohne generischen Admin-Prompt). + install -Dm644 src/taninux/gui/polkit/ch.gabrielevarano.Taninux.policy \ + "$pkgdir/usr/share/polkit-1/actions/ch.gabrielevarano.Taninux.policy" + + # Desktop-Einträge für beide Frontends. + install -Dm644 data/applications/ch.gabrielevarano.Taninux.Settings.desktop \ + "$pkgdir/usr/share/applications/ch.gabrielevarano.Taninux.Settings.desktop" + install -Dm644 data/applications/ch.gabrielevarano.Taninux.SoftwareHub.desktop \ + "$pkgdir/usr/share/applications/ch.gabrielevarano.Taninux.SoftwareHub.desktop" +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6e6a9a2 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,29 @@ +[project] +name = "taninux" +version = "0.2.0" +description = "TANINUX — central Linux maintenance & management TUI for Arch. Pillars: maintain, update, kernel, suggest, health." +requires-python = ">=3.11" +dependencies = [ + "textual>=0.86", +] + +[project.optional-dependencies] +# GTK-Frontend. PyGObject + libadwaita kommen am besten als System-Pakete +# (pacman -S python-gobject libadwaita gtk4) — der pip-Build von PyGObject +# braucht sonst die GTK-Dev-Header. Daher hier bewusst KEIN harter Pin. +gui = [ + "pygobject>=3.50; sys_platform == 'linux'", +] + +[project.scripts] +taninux = "taninux.cli:main" +taninux-gtk = "taninux.gui.app:main" +tsettings = "taninux.gui.app:main_settings" +thub = "taninux.gui.app:main_hub" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/taninux"] diff --git a/src/taninux/__init__.py b/src/taninux/__init__.py new file mode 100644 index 0000000..d3ec452 --- /dev/null +++ b/src/taninux/__init__.py @@ -0,0 +1 @@ +__version__ = "0.2.0" diff --git a/src/taninux/__main__.py b/src/taninux/__main__.py new file mode 100644 index 0000000..1210ce6 --- /dev/null +++ b/src/taninux/__main__.py @@ -0,0 +1,3 @@ +from taninux.cli import main + +raise SystemExit(main()) diff --git a/src/taninux/app.py b/src/taninux/app.py new file mode 100644 index 0000000..25be30c --- /dev/null +++ b/src/taninux/app.py @@ -0,0 +1,192 @@ +"""TANINUX — Top-Level App. Sidebar links, ContentSwitcher rechts.""" +from __future__ import annotations + +from textual.app import App, ComposeResult +from textual.binding import Binding +from textual.containers import Horizontal, Vertical +from textual.reactive import reactive +from textual.theme import Theme +from textual.widgets import ContentSwitcher, Footer, Header, Static + +from taninux import __version__ +from taninux.core.theme import ( + BASE1, BASE2, BASE3, BASE4, BASE5, + CHIKYU, FG_ALT, RED, SHINKAI, TAKE, UME, YORU, YUKI, +) +from taninux.pillars import PILLARS, by_key + + +MOUNTAIN_THEME = Theme( + name="mountain-fuji", + background=YORU, + surface=YORU, + panel=YORU, + foreground=YUKI, + primary=UME, + secondary=SHINKAI, + accent=BASE5, + success=TAKE, + warning=CHIKYU, + error=RED, + dark=True, + variables={ + "footer-key-foreground": BASE5, + "footer-key-background": YORU, + "footer-description-foreground": BASE4, + "footer-description-background": YORU, + "footer-foreground": BASE4, + "footer-background": YORU, + "header-background": YORU, + "header-foreground": UME, + "border": BASE2, + "border-blurred": BASE2, + "primary-darken-1": BASE2, + "primary-darken-2": BASE1, + }, +) + + +class Sidebar(Vertical): + """Linke Pillar-Liste mit dem aktiv markierten Eintrag.""" + + DEFAULT_CSS = f""" + Sidebar {{ + width: 22; + background: {YORU}; + padding: 1 1 1 2; + border-right: vkey {BASE2}; + }} + Sidebar Static {{ + background: {YORU}; + }} + Sidebar #s-brand {{ + height: 2; + padding-bottom: 1; + color: {UME}; + }} + Sidebar #s-version {{ + height: 1; + color: {BASE4}; + }} + Sidebar .pillar-row {{ + height: 2; + padding: 0 0 0 0; + }} + """ + + current: reactive[str] = reactive("") + + def compose(self) -> ComposeResult: + yield Static(f"[{UME}]▲[/] [{UME}]TANINUX[/]", id="s-brand", markup=True) + yield Static(f"[{BASE4}]v{__version__} · fuji[/]", id="s-version", markup=True) + yield Static("", id="s-gap") + for p in PILLARS: + yield Static("", id=f"row-{p.key}", classes="pillar-row", markup=True) + + def watch_current(self, _old: str, new: str) -> None: + self._refresh_view() + + def on_mount(self) -> None: + self._refresh_view() + + def _refresh_view(self) -> None: + for p in PILLARS: + row = self.query_one(f"#row-{p.key}", Static) + is_active = (self.current == p.key) + key_color = UME if is_active else BASE4 + label_color = YUKI if is_active else BASE5 + tagline_color = BASE5 if is_active else BASE4 + marker = "▶" if is_active else " " + row.update( + f"[{key_color}]{marker} {p.icon} {p.label}[/]\n" + f"[{tagline_color}] {p.tagline}[/]" + ) + + +class TaninuxApp(App): + """TANINUX — Linux maintenance & management TUI.""" + + CSS = f""" + Screen {{ + background: {YORU}; + color: {YUKI}; + layout: vertical; + }} + Header {{ + background: {YORU}; + color: {UME}; + }} + HeaderIcon {{ + display: none; + }} + #root {{ + height: 1fr; + background: {YORU}; + }} + #content {{ + width: 1fr; + background: {YORU}; + }} + Footer {{ + background: {YORU}; + color: {BASE4}; + }} + FooterKey {{ + background: {YORU}; + color: {BASE4}; + }} + FooterKey > .footer-key--key {{ + background: {YORU}; + color: {BASE5}; + }} + FooterKey > .footer-key--description {{ + background: {YORU}; + color: {BASE4}; + }} + """ + + BINDINGS = [ + Binding("o", "switch('o')", "overview"), + Binding("p", "switch('p')", "packages"), + Binding("m", "switch('m')", "maintain"), + Binding("u", "switch('u')", "update"), + Binding("b", "switch('b')", "bundles"), + Binding("s", "switch('s')", "suggest"), + Binding("f", "switch('f')", "flatpak"), + Binding("k", "switch('k')", "kernel"), + Binding("h", "switch('h')", "health"), + Binding("q", "quit", "quit"), + ] + + TITLE = "▲ TANINUX" + SUB_TITLE = "fuji · linux maintenance" + + def __init__(self) -> None: + super().__init__() + self.register_theme(MOUNTAIN_THEME) + self.theme = "mountain-fuji" + self.current_pillar: str = PILLARS[0].key + + def compose(self) -> ComposeResult: + yield Header(show_clock=False, icon="") + with Horizontal(id="root"): + yield Sidebar(id="sidebar") + with ContentSwitcher(initial=PILLARS[0].key, id="content"): + for p in PILLARS: + yield p.factory(id=p.key) + yield Footer() + + def on_mount(self) -> None: + sidebar = self.query_one(Sidebar) + sidebar.current = self.current_pillar + + def action_switch(self, key: str) -> None: + if by_key(key) is None: + return + self.current_pillar = key + self.query_one("#content", ContentSwitcher).current = key + self.query_one(Sidebar).current = key + + +def run() -> None: + TaninuxApp().run() diff --git a/src/taninux/bundles/__init__.py b/src/taninux/bundles/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/taninux/bundles/builtins.py b/src/taninux/bundles/builtins.py new file mode 100644 index 0000000..7433ba1 --- /dev/null +++ b/src/taninux/bundles/builtins.py @@ -0,0 +1,148 @@ +"""Eingebaute Paket-Bundles — deklarative Paket-Sets nach Use-Case. + +Built-ins können nicht editiert werden; User-Bundles in +~/.local/share/taninux/bundles/.toml ergänzen sie (TODO).""" +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Bundle: + id: str + label: str + tagline: str + packages: tuple[str, ...] + tags: tuple[str, ...] = () + + +BUILTIN_BUNDLES: list[Bundle] = [ + Bundle( + id="cli-essentials", + label="CLI Essentials", + tagline="modern terminal tools", + packages=( + "bat", "eza", "fd", "ripgrep", "fzf", "zoxide", + "dust", "bottom", "starship", "tldr", + ), + tags=("cli",), + ), + Bundle( + id="rust-dev", + label="Rust Development", + tagline="rust toolchain + LSP + qol", + packages=( + "rust", "rust-analyzer", "sccache", "cargo-audit", + "cargo-watch", "cargo-edit", + ), + tags=("dev", "rust"), + ), + Bundle( + id="python-dev", + label="Python Development", + tagline="python + uv + ruff + ipython", + packages=( + "python", "uv", "ruff", "ipython", + "python-pip", "python-lsp-server", + ), + tags=("dev", "python"), + ), + Bundle( + id="web-dev", + label="Web Development", + tagline="node + typescript + pnpm", + packages=( + "nodejs", "npm", "pnpm", "typescript", + "yarn", + ), + tags=("dev", "web"), + ), + Bundle( + id="go-dev", + label="Go Development", + tagline="go toolchain + LSP", + packages=( + "go", "gopls", "delve", + ), + tags=("dev", "go"), + ), + Bundle( + id="gaming", + label="Gaming", + tagline="steam + qol for proton/wine games", + packages=( + "steam", "gamemode", "mangohud", "lutris", + "wine", "wine-mono", "wine-gecko", + "winetricks", "vulkan-tools", + ), + tags=("gaming",), + ), + Bundle( + id="multimedia", + label="Multimedia", + tagline="audio/video player, editor, recorder", + packages=( + "mpv", "ffmpeg", "obs-studio", + "kdenlive", "audacity", "imagemagick", + ), + tags=("multimedia",), + ), + Bundle( + id="niri", + label="niri Desktop", + tagline="niri scrolling compositor + eww bar/dock + launcher (wayland)", + packages=( + "niri", "eww", "swaylock", "swayidle", "wlsunset", + "waypaper", "swaybg", "wl-clipboard", "grim", "slurp", + "kitty", + ), + tags=("wm", "wayland"), + ), + Bundle( + id="security", + label="Security Tooling", + tagline="audit + scan + monitoring", + packages=( + "nmap", "arch-audit", + "rkhunter", "lynis", "wireshark-qt", + ), + tags=("security",), + ), + Bundle( + id="containers", + label="Containers", + tagline="docker + buildah + podman + compose", + packages=( + "podman", "buildah", "skopeo", + "podman-compose", "docker", "docker-compose", + ), + tags=("dev", "containers"), + ), + Bundle( + id="virtualization", + label="Virtualization", + tagline="KVM/QEMU/libvirt stack", + packages=( + "qemu-full", "libvirt", "virt-manager", + "edk2-ovmf", "dnsmasq", "bridge-utils", + ), + tags=("virt",), + ), + Bundle( + id="creative", + label="Creative Tools", + tagline="design + 3d + illustration", + packages=( + "blender", "krita", "inkscape", "gimp", + "darktable", + ), + tags=("creative",), + ), +] + + +def by_id(bid: str) -> Bundle | None: + for b in BUILTIN_BUNDLES: + if b.id == bid: + return b + return None diff --git a/src/taninux/bundles/loader.py b/src/taninux/bundles/loader.py new file mode 100644 index 0000000..968a820 --- /dev/null +++ b/src/taninux/bundles/loader.py @@ -0,0 +1,67 @@ +"""User-Bundle Loader. + +Lädt zusätzlich zu den BUILTIN_BUNDLES alle TOML-Files aus +~/.local/share/taninux/bundles/.toml. + +Format: + label = "My Setup" + tagline = "alles was ich nach einer neuinstallation brauche" + tags = ["personal"] + packages = [ + "neovim", + "git", + "tmux", + ] +""" +from __future__ import annotations + +import tomllib +from pathlib import Path + +from taninux.bundles.builtins import Bundle, BUILTIN_BUNDLES +from taninux.core.paths import bundles_dir + + +def load_user_bundles() -> list[Bundle]: + bundles_path = bundles_dir() + out: list[Bundle] = [] + for p in sorted(bundles_path.glob("*.toml")): + try: + data = tomllib.loads(p.read_text()) + except (OSError, tomllib.TOMLDecodeError): + continue + if "packages" not in data: + continue + out.append(Bundle( + id=p.stem, + label=data.get("label") or p.stem, + tagline=data.get("tagline", ""), + packages=tuple(data["packages"]), + tags=tuple(data.get("tags", ())), + )) + return out + + +def all_bundles() -> list[Bundle]: + return [*BUILTIN_BUNDLES, *load_user_bundles()] + + +def save_user_bundle(bundle_id: str, label: str, tagline: str, + packages: list[str], tags: list[str] | None = None) -> Path: + """Schreibt ein User-Bundle als TOML.""" + safe_id = "".join(c if c.isalnum() or c in "-_" else "_" for c in bundle_id) + target = bundles_dir() / f"{safe_id}.toml" + + lines = [ + f'label = "{label}"', + f'tagline = "{tagline}"', + ] + if tags: + tags_str = ", ".join(f'"{t}"' for t in tags) + lines.append(f"tags = [{tags_str}]") + lines.append("packages = [") + for p in packages: + lines.append(f' "{p}",') + lines.append("]") + target.write_text("\n".join(lines) + "\n") + return target diff --git a/src/taninux/bundles/pillar.py b/src/taninux/bundles/pillar.py new file mode 100644 index 0000000..8703761 --- /dev/null +++ b/src/taninux/bundles/pillar.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from taninux.bundles.screen import BundlesView +from taninux.pillars.base import Pillar + + +BUNDLES = Pillar( + key="b", + label="bundles", + tagline="deklarative paket-sets", + icon="◆", + factory=BundlesView, +) diff --git a/src/taninux/bundles/screen.py b/src/taninux/bundles/screen.py new file mode 100644 index 0000000..b1704fc --- /dev/null +++ b/src/taninux/bundles/screen.py @@ -0,0 +1,247 @@ +"""Bundles-Pillar — deklarative Paket-Sets installieren/checken.""" +from __future__ import annotations + +from dataclasses import dataclass + +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import VerticalScroll +from textual.widgets import DataTable, Static + +from taninux.bundles.builtins import Bundle +from taninux.bundles.loader import all_bundles +from taninux.core import pacman as pac +from taninux.core import sysinfo +from taninux.core import theme as T +from taninux.core.confirm import ConfirmModal +from taninux.core.runner import Command, run_interactive + + +@dataclass +class BundleStatus: + bundle: Bundle + installed: list[str] + missing: list[str] + + @property + def state(self) -> str: + if not self.installed: + return "none" + if not self.missing: + return "satisfied" + return "partial" + + +STATE_GLYPH = { + "satisfied": ("●", T.TAKE), + "partial": ("◐", T.CHIKYU), + "none": ("○", T.BASE4), +} +STATE_LABEL = { + "satisfied": "vollständig", + "partial": "teilweise", + "none": "fehlt", +} + + +def status_for(bundle: Bundle, installed: frozenset[str]) -> BundleStatus: + inst = [p for p in bundle.packages if p in installed] + miss = [p for p in bundle.packages if p not in installed] + return BundleStatus(bundle=bundle, installed=inst, missing=miss) + + +class BundlesView(VerticalScroll): + DEFAULT_CSS = f""" + BundlesView {{ + background: {T.YORU}; + padding: 1 2; + scrollbar-color: {T.BASE2} {T.YORU}; + scrollbar-background: {T.YORU}; + scrollbar-corner-color: {T.YORU}; + scrollbar-size-vertical: 1; + }} + BundlesView Static {{ + background: {T.YORU}; + margin-bottom: 1; + }} + BundlesView #b-table {{ + height: auto; + background: {T.YORU}; + color: {T.FG_ALT}; + border: none; + overflow-x: hidden; + }} + BundlesView #b-table:focus {{ + border: none; + }} + BundlesView DataTable > .datatable--header {{ + background: {T.YORU}; + color: {T.SHINKAI}; + }} + BundlesView DataTable > .datatable--cursor {{ + background: {T.BASE1}; + color: {T.YUKI}; + }} + BundlesView DataTable > .datatable--even-row {{ + background: {T.YORU}; + }} + BundlesView DataTable > .datatable--odd-row {{ + background: {T.YORU}; + }} + """ + + BINDINGS = [ + Binding("r", "rescan", "rescan"), + Binding("i", "install", "fehlende installieren"), + Binding("j", "cursor_down", "down", show=False), + Binding("k", "cursor_up", "up", show=False), + ] + + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + self.statuses: list[BundleStatus] = [] + + def compose(self) -> ComposeResult: + yield Static("", id="b-intro", markup=True) + yield DataTable(id="b-table", cursor_type="row", zebra_stripes=False) + yield Static("", id="b-detail", markup=True) + yield Static("", id="b-flash", markup=True) + + def on_mount(self) -> None: + t = self.query_one("#b-table", DataTable) + t.add_column("", width=2, key="glyph") + t.add_column("bundle", width=22, key="name") + t.add_column("status", width=14, key="status") + t.add_column("fortschritt", width=18, key="progress") + t.add_column("beschreibung", key="desc") + self.action_rescan() + + def action_rescan(self) -> None: + pac.invalidate() + installed = pac.installed_packages() + self.statuses = [status_for(b, installed) for b in all_bundles()] + # Sortierung: partial first (in-progress), dann none (zum vorschlagen), dann satisfied + order = {"partial": 0, "none": 1, "satisfied": 2} + self.statuses.sort(key=lambda s: (order[s.state], s.bundle.label)) + self._refresh_view() + self._flash(f"{len(self.statuses)} bundles geprüft") + + def action_install(self) -> None: + cur = self._current() + if cur is None: + return + if not cur.missing: + self._flash(f"{cur.bundle.label} bereits vollständig", color=T.TAKE) + return + body = ( + f"bundle [{cur.bundle.label}] vervollständigen?\n\n" + f"fehlende pakete ({len(cur.missing)}):\n" + f" {', '.join(cur.missing)}" + ) + self.app.push_screen( + ConfirmModal(f"install {cur.bundle.id}?", body, danger=False), + lambda ok: self._after_install(ok, cur), + ) + + def _after_install(self, ok: bool | None, cur: BundleStatus) -> None: + if not ok: + self._flash("abgebrochen") + return + helper = "paru" if sysinfo.has_binary("paru") else ( + "yay" if sysinfo.has_binary("yay") else "sudo pacman" + ) + argv = helper.split() + ["-S", "--needed", *cur.missing] + codes = run_interactive( + self.app, + [Command( + label=f"install {len(cur.missing)} pakete für [{cur.bundle.label}]", + argv=argv, + )], + ) + success = codes and codes[0] == 0 + self._flash( + f"{cur.bundle.label} installiert" if success else "install fehlgeschlagen", + color=T.TAKE if success else T.RED, + ) + self.action_rescan() + + def action_cursor_down(self) -> None: + self.query_one("#b-table", DataTable).action_cursor_down() + + def action_cursor_up(self) -> None: + self.query_one("#b-table", DataTable).action_cursor_up() + + def _current(self) -> BundleStatus | None: + t = self.query_one("#b-table", DataTable) + r = t.cursor_row + if r is None or r < 0 or r >= len(self.statuses): + return None + return self.statuses[r] + + def _flash(self, msg: str, color: str | None = None) -> None: + color = color or T.BASE4 + self.query_one("#b-flash", Static).update(f"[{color}]{msg}[/]") + + def _refresh_view(self) -> None: + total = len(self.statuses) + sat = sum(1 for s in self.statuses if s.state == "satisfied") + par = sum(1 for s in self.statuses if s.state == "partial") + non = sum(1 for s in self.statuses if s.state == "none") + intro = ( + f"[{T.UME}]◆ bundles[/]\n" + f"[{T.BASE5}]deklarative paket-sets · " + f"[{T.TAKE}]●[/] {sat} vollständig " + f"[{T.CHIKYU}]◐[/] {par} teilweise " + f"[{T.BASE4}]○[/] {non} fehlend[/]\n" + f"[{T.BASE5}]`i` installiert die fehlenden pakete des ausgewählten bundles[/]" + ) + self.query_one("#b-intro", Static).update(intro) + + t = self.query_one("#b-table", DataTable) + t.clear() + for s in self.statuses: + glyph, color = STATE_GLYPH[s.state] + label = STATE_LABEL[s.state] + total_p = len(s.bundle.packages) + done = len(s.installed) + # Progress bar als unicode block-chars + ratio = done / total_p if total_p else 0 + blocks = int(ratio * 10) + bar = "█" * blocks + "░" * (10 - blocks) + t.add_row( + f"[{color}]{glyph}[/]", + f"[{T.YUKI}]{s.bundle.label}[/]", + f"[{color}]{label}[/]", + f"[{color}]{bar}[/] [{T.BASE5}]{done}/{total_p}[/]", + f"[{T.BASE5}]{s.bundle.tagline}[/]", + ) + + # Detail: zeige fehlende Pakete des aktuellen Bundles + if self.statuses: + self._render_detail(self.statuses[0]) + + def _render_detail(self, s: BundleStatus) -> None: + lines = [ + f"[{T.SHINKAI}]{s.bundle.label}[/] [{T.BASE5}]{s.bundle.tagline}[/]", + ] + if s.bundle.tags: + lines.append(f"[{T.BASE4}]tags:[/] {', '.join(s.bundle.tags)}") + lines += [ + "", + f"[{T.SHINKAI}]installiert ({len(s.installed)})[/]", + ] + for p in s.installed: + lines.append(f" [{T.TAKE}]●[/] [{T.YUKI}]{p}[/]") + if s.missing: + lines += [ + "", + f"[{T.SHINKAI}]fehlt ({len(s.missing)})[/]", + ] + for p in s.missing: + lines.append(f" [{T.BASE4}]○[/] [{T.BASE5}]{p}[/]") + self.query_one("#b-detail", Static).update("\n".join(lines)) + + def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None: + if event.cursor_row is None or event.cursor_row >= len(self.statuses): + return + self._render_detail(self.statuses[event.cursor_row]) diff --git a/src/taninux/cli.py b/src/taninux/cli.py new file mode 100644 index 0000000..2122ca4 --- /dev/null +++ b/src/taninux/cli.py @@ -0,0 +1,837 @@ +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + + +def _human_size(n: int) -> str: + f = float(n) + for unit in ("B", "K", "M", "G", "T"): + if f < 1024 or unit == "T": + return f"{f:.0f}{unit}" if unit == "B" else f"{f:.1f}{unit}" + f /= 1024 + return f"{f:.1f}T" + + +def _format_days(d: int) -> str: + if d == 0: + return "heute" + if d < 30: + return f"{d}d" + if d < 365: + return f"{d // 30}mo" + return f"{d // 365}y" + + +def _console(): + from rich.console import Console + return Console() + + +# ---------------------------------------------------------------- scan + + +def _scan(category: str) -> int: + from rich.table import Table + + from taninux.maintain.scanners import SCANNERS, by_id + from taninux.core import theme as T + from taninux.maintain.types import ( + CLASS_COLOR, CLASS_GLYPH, CLASS_LABEL, Classification, + ) + + console = _console() + specs = SCANNERS if category == "all" else [s for s in [by_id(category)] if s] + if not specs: + console.print(f" [{T.RED}]unknown category: {category}[/]") + return 2 + + home = str(Path.home()) + for spec in specs: + scanner = spec.factory() + findings = scanner.scan() + reclaim = sum( + f.size_bytes for f in findings + if f.classification in (Classification.SAFE, Classification.LIKELY_ORPHAN) + ) + + console.print() + console.print( + f" [{T.UME}]▲ {spec.label}[/] " + f"[{T.YUKI}]{len(findings)}[/] [{T.BASE5}]einträge[/] " + f"[{T.BASE3}]·[/] " + f"[{T.TAKE}]{_human_size(reclaim)}[/] [{T.BASE5}]potentiell wegwerfbar[/]" + ) + console.print() + if not findings: + console.print(f" [{T.BASE4}]keine einträge.[/]") + continue + + table = Table( + show_header=True, + header_style=T.SHINKAI, + border_style=T.BASE2, + pad_edge=False, + box=None, + ) + table.add_column("", width=2) + table.add_column("pfad", no_wrap=True) + table.add_column("grösse", justify="right") + table.add_column("idle", justify="right") + table.add_column("status") + + for f in findings: + color = CLASS_COLOR[f.classification] + s = str(f.path) + path = s[6:] if s.startswith("pkg://") else s.replace(home, "~", 1) + size = "—" if not f.size_bytes else _human_size(f.size_bytes) + days = "—" if f.last_access_days is None else _format_days(f.last_access_days) + table.add_row( + f"[{color}]{CLASS_GLYPH[f.classification]}[/]", + f"[{T.FG_ALT}]{path}[/]", + f"[{T.CHIKYU}]{size}[/]" if f.size_bytes else f"[{T.BASE3}]—[/]", + f"[{T.BASE5}]{days}[/]", + f"[{color}]{CLASS_LABEL[f.classification]}[/]", + ) + console.print(table) + return 0 + + +# ---------------------------------------------------------------- trash + + +def _trash_list() -> int: + from rich.table import Table + + from taninux.core.trash import list_trash + from taninux.core import theme as T + + console = _console() + entries = list_trash() + if not entries: + console.print(f" [{T.BASE4}]trash ist leer.[/]") + return 0 + + total = sum(e.size_bytes for e in entries) + console.print() + console.print( + f" [{T.UME}]▲ trash[/] " + f"[{T.YUKI}]{len(entries)}[/] [{T.BASE5}]einträge[/] " + f"[{T.BASE3}]·[/] " + f"[{T.CHIKYU}]{_human_size(total)}[/]" + ) + console.print() + + table = Table( + show_header=True, + header_style=T.SHINKAI, + border_style=T.BASE2, + pad_edge=False, + box=None, + ) + table.add_column("id") + table.add_column("origin", no_wrap=True) + table.add_column("grösse", justify="right") + table.add_column("gelöscht am") + + home = str(Path.home()) + for e in entries: + origin = str(e.original).replace(home, "~", 1) + table.add_row( + f"[{T.BASE5}]{e.id}[/]", + f"[{T.FG_ALT}]{origin}[/]", + f"[{T.CHIKYU}]{_human_size(e.size_bytes)}[/]", + f"[{T.BASE5}]{e.trashed_at}[/]", + ) + console.print(table) + return 0 + + +def _trash_restore(slot_id: str) -> int: + from taninux.core.trash import restore + from taninux.core import theme as T + + console = _console() + try: + original = restore(slot_id) + except FileNotFoundError: + console.print(f" [{T.RED}]kein eintrag: {slot_id}[/]") + return 2 + except FileExistsError as e: + console.print(f" [{T.RED}]{e}[/]") + return 2 + console.print(f" [{T.TAKE}]wiederhergestellt:[/] [{T.YUKI}]{original}[/]") + return 0 + + +def _trash_purge(slot_id: str | None) -> int: + from taninux.core.trash import list_trash, purge + from taninux.core import theme as T + + console = _console() + if slot_id: + try: + purge(slot_id) + except FileNotFoundError: + console.print(f" [{T.RED}]kein eintrag: {slot_id}[/]") + return 2 + console.print(f" [{T.BASE5}]gelöscht:[/] [{T.YUKI}]{slot_id}[/]") + return 0 + + entries = list_trash() + if not entries: + console.print(f" [{T.BASE4}]trash bereits leer.[/]") + return 0 + total = sum(e.size_bytes for e in entries) + console.print( + f" [{T.RED}]purge {len(entries)} einträge[/] " + f"[{T.BASE4}]({_human_size(total)})?[/] " + f"[{T.BASE5}]bestätige mit:[/] taninux trash purge --all --yes" + ) + return 1 + + +def _trash_purge_all(confirmed: bool) -> int: + from taninux.core.trash import list_trash, purge + from taninux.core import theme as T + + console = _console() + entries = list_trash() + if not entries: + console.print(f" [{T.BASE4}]trash bereits leer.[/]") + return 0 + if not confirmed: + return _trash_purge(None) + for e in entries: + purge(e.id) + console.print(f" [{T.BASE5}]{len(entries)} einträge endgültig gelöscht.[/]") + return 0 + + +# ---------------------------------------------------------------- update + + +def _update_list() -> int: + from collections import Counter + from rich.table import Table + + from taninux.core import theme as T + from taninux.update.sources import fetch_all + + console = _console() + console.print(f"\n [{T.BASE4}]scanne pacman + AUR + flatpak + fwupd…[/]") + items = fetch_all() + + if not items: + console.print(f"\n [{T.TAKE}]system ist aktuell.[/]\n") + return 0 + + total_size = sum(i.size_bytes for i in items) + counts = Counter(i.source for i in items) + console.print() + console.print( + f" [{T.UME}]↻ updates[/] " + f"[{T.YUKI}]{len(items)}[/] [{T.BASE5}]verfügbar[/]" + + (f" [{T.BASE3}]·[/] [{T.CHIKYU}]{_human_size(total_size)}[/]" if total_size else "") + ) + parts = [] + for src in ("pacman", "aur", "flatpak", "fwupd"): + n = counts.get(src, 0) + if n: + parts.append(f"[{T.BASE5}]{src} {n}[/]") + if parts: + console.print(f" {' · '.join(parts)}") + console.print() + + order = {"pacman": 0, "aur": 1, "flatpak": 2, "fwupd": 3} + items.sort(key=lambda i: (order.get(i.source, 9), i.name)) + + table = Table( + show_header=True, + header_style=T.SHINKAI, + border_style=T.BASE2, + pad_edge=False, + box=None, + ) + table.add_column("quelle", width=9) + table.add_column("paket") + table.add_column("aktuell") + table.add_column("→", width=1) + table.add_column("neu") + table.add_column("grösse", justify="right") + + for i in items: + size = "—" if not i.size_bytes else _human_size(i.size_bytes) + table.add_row( + f"[{T.BASE5}]{i.source}[/]", + f"[{T.YUKI}]{i.name}[/]", + f"[{T.BASE4}]{i.current_version}[/]", + f"[{T.BASE3}]→[/]", + f"[{T.TAKE}]{i.new_version}[/]", + f"[{T.CHIKYU}]{size}[/]" if i.size_bytes else f"[{T.BASE3}]—[/]", + ) + console.print(table) + console.print() + + cmds = [] + if counts.get("pacman") or counts.get("aur"): + cmds.append("paru -Syu") + if counts.get("flatpak"): + cmds.append("flatpak update -y") + if counts.get("fwupd"): + cmds.append("sudo fwupdmgr update") + if cmds: + console.print(f" [{T.SHINKAI}]ausführen:[/]") + for c in cmds: + console.print(f" [{T.CHIKYU}]{c}[/]") + console.print() + return 0 + + +def _update_execute() -> int: + """Headless: führt alle pending updates aus mit --noconfirm.""" + import subprocess + + from taninux.core import sysinfo + from taninux.core import theme as T + from taninux.update.sources import fetch_all + from collections import Counter + + console = _console() + console.print(f"\n [{T.UME}]↻ scanne updates…[/]") + items = fetch_all() + if not items: + console.print(f" [{T.TAKE}]system ist aktuell.[/]\n") + return 0 + counts = Counter(i.source for i in items) + console.print(f" [{T.YUKI}]{len(items)}[/] [{T.BASE5}]updates verfügbar[/]") + console.print() + + cmds: list[list[str]] = [] + if counts.get("pacman") or counts.get("aur"): + helper = "paru" if sysinfo.has_binary("paru") else ( + "yay" if sysinfo.has_binary("yay") else None + ) + if helper: + cmds.append([ + helper, "-Syu", + "--noconfirm", "--sudoloop", + "--skipreview", "--removemake", "--cleanafter", + "--batchinstall", + ]) + elif counts.get("pacman"): + cmds.append(["sudo", "pacman", "-Syu", "--noconfirm"]) + if counts.get("flatpak"): + cmds.append(["flatpak", "update", "--noninteractive"]) + if counts.get("fwupd"): + cmds.append(["sudo", "fwupdmgr", "update", "--assume-yes", "--no-reboot-check"]) + + rc_total = 0 + for argv in cmds: + console.print(f" [{T.SHINKAI}]$ {' '.join(argv)}[/]") + rc = subprocess.run(argv, check=False).returncode + if rc != 0: + console.print(f" [{T.RED}]exit {rc}[/]\n") + rc_total = rc + else: + console.print(f" [{T.TAKE}]ok[/]\n") + return rc_total + + +# ---------------------------------------------------------------- health + + +def _health_summary() -> int: + from taninux.core import sysinfo + from taninux.core import theme as T + + console = _console() + mem = sysinfo.memory() + total = mem.get("MemTotal", 0) + avail = mem.get("MemAvailable", 0) + used = total - avail + la = sysinfo.load_avg() + ncpu = sysinfo.cpu_count() + failed = sysinfo.systemctl_failed() + errs_today = sysinfo.journal_errors_today() + boot = sysinfo.boot_time_seconds() + + console.print() + console.print(f" [{T.UME}]◈ {sysinfo.hostname()}[/] [{T.BASE5}]{sysinfo.distro_pretty()} · {sysinfo.kernel_release()}[/]") + console.print() + console.print(f" [{T.SHINKAI}]uptime[/] [{T.YUKI}]{int(sysinfo.uptime_seconds() // 3600)}h[/]") + if boot: + console.print(f" [{T.SHINKAI}]boot[/] [{T.YUKI}]{boot:.1f}s[/]") + console.print(f" [{T.SHINKAI}]load[/] [{T.YUKI}]{la[0]:.2f}[/] [{T.BASE5}]{la[1]:.2f} {la[2]:.2f} ({ncpu} cpus)[/]") + if total: + console.print(f" [{T.SHINKAI}]memory[/] [{T.YUKI}]{used/1024**3:.1f}G[/] [{T.BASE5}]/[/] [{T.YUKI}]{total/1024**3:.1f}G[/] [{T.BASE4}]({used*100/total:.0f}%)[/]") + console.print() + + for g in sysinfo.gpus(): + console.print(f" [{T.SHINKAI}]gpu[/] [{T.YUKI}]{g.vendor}[/] [{T.BASE5}]·[/] [{T.BASE5}]{g.model[:55]}[/] [{T.CHIKYU}]{g.driver or '—'}[/]") + console.print(f" [{T.SHINKAI}]audio[/] [{T.YUKI}]{sysinfo.audio_server()}[/]") + + console.print() + failed_color = T.TAKE if not failed else T.RED + console.print(f" [{failed_color}]●[/] [{T.YUKI}]{len(failed)}[/] [{T.BASE5}]failed services[/]") + for u in failed[:5]: + console.print(f" [{T.RED}]{u}[/]") + err_color = T.TAKE if errs_today == 0 else (T.CHIKYU if errs_today < 20 else T.RED) + err_label = f"{errs_today}" if errs_today >= 0 else "?" + console.print(f" [{err_color}]●[/] [{T.YUKI}]{err_label}[/] [{T.BASE5}]errors heute (journalctl -p 3)[/]") + console.print() + return 0 + + +# ---------------------------------------------------------------- suggest + + +def _suggest_list() -> int: + import subprocess + from rich.table import Table + + from taninux.core import theme as T + from taninux.suggest.rules import all_suggestions + + console = _console() + installed = { + l for l in subprocess.run( + ["pacman", "-Qq"], capture_output=True, text=True, check=False, + ).stdout.splitlines() if l + } + suggestions = sorted( + all_suggestions(installed), + key=lambda s: (s.priority, s.category), + ) + + console.print() + console.print( + f" [{T.UME}]✦ suggest[/] " + f"[{T.YUKI}]{len(suggestions)}[/] [{T.BASE5}]empfehlungen[/]" + ) + console.print() + if not suggestions: + console.print(f" [{T.TAKE}]nichts zu empfehlen — solides setup.[/]\n") + return 0 + + table = Table( + show_header=True, + header_style=T.SHINKAI, + border_style=T.BASE2, + pad_edge=False, + box=None, + ) + table.add_column("kat", width=8) + table.add_column("anlass") + table.add_column("pakete") + table.add_column("pri", width=6) + for s in suggestions: + pri_color = {1: T.RED, 2: T.CHIKYU, 3: T.BASE5}.get(s.priority, T.BASE5) + pri_label = {1: "hoch", 2: "mittel", 3: "nice"}.get(s.priority, str(s.priority)) + table.add_row( + f"[{T.SHINKAI}]{s.category}[/]", + f"[{T.YUKI}]{s.reason}[/]", + f"[{T.BASE5}]{', '.join(s.packages)}[/]", + f"[{pri_color}]{pri_label}[/]", + ) + console.print(table) + console.print() + return 0 + + +# ---------------------------------------------------------------- snapshot + + +def _snapshot_save(label: str) -> int: + from taninux.core import snapshot as snap + from taninux.core import theme as T + + console = _console() + s = snap.take_snapshot(label=label) + console.print() + console.print( + f" [{T.TAKE}]snapshot[/] [{T.YUKI}]{s.id}[/] " + f"[{T.BASE5}]({s.label})[/]" + ) + console.print( + f" [{T.BASE5}]explicit[/] [{T.YUKI}]{len(s.explicit)}[/] " + f"[{T.BASE5}]aur[/] [{T.YUKI}]{len(s.aur)}[/] " + f"[{T.BASE5}]deps[/] [{T.YUKI}]{len(s.deps)}[/]" + ) + console.print(f" [{T.BASE4}]→ {s.path}[/]") + console.print() + return 0 + + +def _snapshot_list() -> int: + from rich.table import Table + + from taninux.core import snapshot as snap + from taninux.core import theme as T + + console = _console() + snaps = snap.list_snapshots() + if not snaps: + console.print(f" [{T.BASE4}]keine snapshots — speichere mit:[/] taninux snapshot save") + return 0 + + table = Table( + show_header=True, + header_style=T.SHINKAI, + border_style=T.BASE2, + pad_edge=False, + box=None, + ) + table.add_column("id") + table.add_column("zeitpunkt") + table.add_column("label") + table.add_column("explicit", justify="right") + table.add_column("aur", justify="right") + table.add_column("deps", justify="right") + for s in snaps: + table.add_row( + f"[{T.YUKI}]{s.id}[/]", + f"[{T.BASE5}]{s.timestamp}[/]", + f"[{T.BASE5}]{s.label}[/]", + f"[{T.CHIKYU}]{len(s.explicit)}[/]", + f"[{T.CHIKYU}]{len(s.aur)}[/]", + f"[{T.BASE4}]{len(s.deps)}[/]", + ) + console.print() + console.print(table) + console.print() + return 0 + + +def _snapshot_diff(from_id: str | None, to_id: str | None) -> int: + from taninux.core import snapshot as snap + from taninux.core import theme as T + + console = _console() + if from_id is None: + old = snap.latest() + if old is None: + console.print(f" [{T.BASE4}]keine snapshots vorhanden — speichere mit:[/] taninux snapshot save") + return 1 + else: + old = snap.load(from_id) + if old is None: + console.print(f" [{T.RED}]kein snapshot mit id: {from_id}[/]") + return 2 + + if to_id is None: + new = snap.current_snapshot() + else: + new_loaded = snap.load(to_id) + if new_loaded is None: + console.print(f" [{T.RED}]kein snapshot mit id: {to_id}[/]") + return 2 + new = new_loaded + + d = snap.diff(old, new) + console.print() + console.print( + f" [{T.UME}]drift[/] " + f"[{T.BASE5}]{old.id} ({old.label})[/] [{T.BASE3}]→[/] " + f"[{T.BASE5}]{new.id} ({new.label})[/]" + ) + console.print() + + total_added = len(d.added_explicit) + len(d.added_aur) + len(d.added_deps) + total_removed = len(d.removed_explicit) + len(d.removed_aur) + len(d.removed_deps) + if total_added == 0 and total_removed == 0: + console.print(f" [{T.TAKE}]keine änderungen seit {old.timestamp}.[/]") + console.print() + return 0 + + def render_block(title: str, added: list[str], removed: list[str]) -> None: + if not added and not removed: + return + console.print(f" [{T.SHINKAI}]{title}[/]") + for p in added: + console.print(f" [{T.TAKE}]+[/] [{T.YUKI}]{p}[/]") + for p in removed: + console.print(f" [{T.RED}]-[/] [{T.BASE5}]{p}[/]") + console.print() + + render_block("explicit", d.added_explicit, d.removed_explicit) + render_block("aur", d.added_aur, d.removed_aur) + render_block("deps", d.added_deps, d.removed_deps) + + console.print( + f" [{T.BASE4}]gesamt:[/] " + f"[{T.TAKE}]+{total_added}[/] " + f"[{T.RED}]-{total_removed}[/]" + ) + console.print() + return 0 + + +# ---------------------------------------------------------------- bundles + + +def _bundles_list() -> int: + from rich.table import Table + + from taninux.bundles.loader import all_bundles + from taninux.core import pacman as pac + from taninux.core import theme as T + + console = _console() + installed = pac.installed_packages() + bundles = all_bundles() + + console.print() + console.print(f" [{T.UME}]◆ bundles[/] [{T.YUKI}]{len(bundles)}[/] [{T.BASE5}]bundles[/]") + console.print() + + table = Table( + show_header=True, + header_style=T.SHINKAI, + border_style=T.BASE2, + pad_edge=False, + box=None, + ) + table.add_column("", width=2) + table.add_column("id", width=22) + table.add_column("status") + table.add_column("fortschritt") + table.add_column("tagline") + + for b in bundles: + inst = sum(1 for p in b.packages if p in installed) + total = len(b.packages) + state = "satisfied" if inst == total else ("partial" if inst else "none") + glyph, color = { + "satisfied": ("●", T.TAKE), + "partial": ("◐", T.CHIKYU), + "none": ("○", T.BASE4), + }[state] + ratio = inst / total if total else 0 + bar = "█" * int(ratio * 10) + "░" * (10 - int(ratio * 10)) + table.add_row( + f"[{color}]{glyph}[/]", + f"[{T.YUKI}]{b.id}[/]", + f"[{color}]{state}[/]", + f"[{color}]{bar}[/] [{T.BASE5}]{inst}/{total}[/]", + f"[{T.BASE5}]{b.tagline}[/]", + ) + console.print(table) + console.print() + return 0 + + +def _bundles_export(bundle_id: str, label: str | None, tagline: str) -> int: + import subprocess + from taninux.bundles.loader import save_user_bundle + from taninux.core import theme as T + + console = _console() + try: + r = subprocess.run( + ["pacman", "-Qenq"], + capture_output=True, text=True, check=False, timeout=15, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + console.print(f" [{T.RED}]pacman nicht verfügbar[/]") + return 2 + pkgs = sorted(l.strip() for l in r.stdout.splitlines() if l.strip()) + if not pkgs: + console.print(f" [{T.RED}]keine pakete gefunden[/]") + return 2 + + target = save_user_bundle( + bundle_id=bundle_id, + label=label or bundle_id, + tagline=tagline, + packages=pkgs, + tags=["exported"], + ) + console.print() + console.print( + f" [{T.TAKE}]exportiert:[/] [{T.YUKI}]{target}[/] " + f"[{T.BASE5}]{len(pkgs)} pakete[/]" + ) + console.print( + f" [{T.BASE4}]restore via:[/] taninux bundles install {bundle_id}" + f" [{T.BASE4}](in TUI: bundles tab → cursor auf '{bundle_id}' → i)[/]" + ) + console.print() + return 0 + + +# ---------------------------------------------------------------- whitelist + + +def _whitelist_list() -> int: + from taninux.core.state import State + from taninux.core import theme as T + + console = _console() + state = State.load() + if not state.whitelist and not state.ignored: + console.print(f" [{T.BASE4}]whitelist und ignore-list sind leer.[/]") + return 0 + + home = str(Path.home()) + if state.whitelist: + console.print(f"\n [{T.SHINKAI}]whitelist[/]") + for p in sorted(state.whitelist): + console.print(f" [{T.YUKI}]{str(p).replace(home, '~', 1)}[/]") + if state.ignored: + console.print(f"\n [{T.BASE4}]ignored[/]") + for p in sorted(state.ignored): + console.print(f" [{T.BASE5}]{str(p).replace(home, '~', 1)}[/]") + return 0 + + +def _whitelist_add(path: str) -> int: + from taninux.core.state import State + from taninux.core import theme as T + + console = _console() + p = Path(path).expanduser().resolve() + state = State.load() + state.whitelist_add(p) + console.print(f" [{T.SHINKAI}]+ whitelist:[/] [{T.YUKI}]{p}[/]") + return 0 + + +def _whitelist_remove(path: str) -> int: + from taninux.core.state import State + from taninux.core import theme as T + + console = _console() + p = Path(path).expanduser().resolve() + state = State.load() + state.whitelist_remove(p) + console.print(f" [{T.BASE5}]- whitelist:[/] [{T.YUKI}]{p}[/]") + return 0 + + +# ---------------------------------------------------------------- entry + + +def main() -> int: + parser = argparse.ArgumentParser( + prog="taninux", + description="Mountain-themed Arch Linux maintenance TUI.", + ) + sub = parser.add_subparsers(dest="cmd") + + sub.add_parser("tui", help="Open the interactive TUI (default)") + p_scan = sub.add_parser("scan", help="Print findings without the TUI") + p_scan.add_argument( + "--category", + choices=[ + "all", "dotfolders", "orphans", "pacnew", "cache", + "flatpak-runtimes", "flatpak-data", "aur-cache", + "journal", "coredumps", "kernels", + ], + default="all", + help="Scanner-Kategorie (default: all)", + ) + + p_trash = sub.add_parser("trash", help="Trash management") + p_trash_sub = p_trash.add_subparsers(dest="trash_cmd") + p_trash_sub.add_parser("list", help="List trashed entries") + p_restore = p_trash_sub.add_parser("restore", help="Restore an entry") + p_restore.add_argument("id", help="Trash entry id") + p_purge = p_trash_sub.add_parser("purge", help="Permanently delete trash") + p_purge.add_argument("id", nargs="?", help="Trash entry id (or omit for --all)") + p_purge.add_argument("--all", action="store_true", help="Purge everything") + p_purge.add_argument("--yes", action="store_true", help="Skip confirmation") + + p_update = sub.add_parser("update", help="List pending updates from all sources") + p_update.add_argument("--execute", "-y", action="store_true", + help="Updates direkt ausführen (paru/flatpak/fwupdmgr, --noconfirm)") + sub.add_parser("health", help="System health summary (headless)") + sub.add_parser("suggest", help="System-aware package suggestions") + + p_snap = sub.add_parser("snapshot", help="Pacman state snapshots / drift") + p_snap_sub = p_snap.add_subparsers(dest="snap_cmd") + p_s_save = p_snap_sub.add_parser("save", help="Aktuellen State speichern") + p_s_save.add_argument("--label", default="manual", help="Optionales Label") + p_snap_sub.add_parser("list", help="Alle Snapshots zeigen") + p_s_diff = p_snap_sub.add_parser("diff", help="Diff zwischen Snapshot und jetzt (oder zweier Snapshots)") + p_s_diff.add_argument("from_id", nargs="?", help="Snapshot-ID (default: latest)") + p_s_diff.add_argument("to_id", nargs="?", help="Zweite Snapshot-ID (default: current)") + + p_bundles = sub.add_parser("bundles", help="Bundle management") + p_bundles_sub = p_bundles.add_subparsers(dest="bundles_cmd") + p_bundles_sub.add_parser("list", help="Liste alle bundles (builtin + user)") + p_b_export = p_bundles_sub.add_parser("export", help="Aktuelles System als Bundle speichern") + p_b_export.add_argument("id", help="Bundle-ID (= dateiname ohne .toml)") + p_b_export.add_argument("--label", default=None, help="Display-Name") + p_b_export.add_argument("--tagline", default="exported by taninux", help="Kurze Beschreibung") + p_b_export.add_argument("--explicit-only", action="store_true", default=True, + help="Nur pacman -Qe (explizit installierte, ohne deps)") + + p_wl = sub.add_parser("whitelist", help="Whitelist management") + p_wl_sub = p_wl.add_subparsers(dest="wl_cmd") + p_wl_sub.add_parser("list", help="Show whitelist + ignore-list") + p_add = p_wl_sub.add_parser("add", help="Add a path to the whitelist") + p_add.add_argument("path") + p_rm = p_wl_sub.add_parser("remove", help="Remove a path from the whitelist") + p_rm.add_argument("path") + + args = parser.parse_args() + + if args.cmd in (None, "tui"): + from taninux.app import run + run() + return 0 + if args.cmd == "scan": + return _scan(args.category) + if args.cmd == "update": + if args.execute: + return _update_execute() + return _update_list() + if args.cmd == "health": + return _health_summary() + if args.cmd == "suggest": + return _suggest_list() + if args.cmd == "bundles": + if args.bundles_cmd == "list": + return _bundles_list() + if args.bundles_cmd == "export": + return _bundles_export(args.id, args.label, args.tagline) + p_bundles.print_help() + return 1 + if args.cmd == "snapshot": + if args.snap_cmd == "save": + return _snapshot_save(args.label) + if args.snap_cmd == "list": + return _snapshot_list() + if args.snap_cmd == "diff": + return _snapshot_diff(args.from_id, args.to_id) + p_snap.print_help() + return 1 + if args.cmd == "trash": + if args.trash_cmd == "list": + return _trash_list() + if args.trash_cmd == "restore": + return _trash_restore(args.id) + if args.trash_cmd == "purge": + if args.all: + return _trash_purge_all(args.yes) + return _trash_purge(args.id) + p_trash.print_help() + return 1 + if args.cmd == "whitelist": + if args.wl_cmd == "list": + return _whitelist_list() + if args.wl_cmd == "add": + return _whitelist_add(args.path) + if args.wl_cmd == "remove": + return _whitelist_remove(args.path) + p_wl.print_help() + return 1 + + parser.print_help() + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/taninux/core/__init__.py b/src/taninux/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/taninux/core/accessibility.py b/src/taninux/core/accessibility.py new file mode 100644 index 0000000..e40c017 --- /dev/null +++ b/src/taninux/core/accessibility.py @@ -0,0 +1,52 @@ +"""Accessibility-Schalter über gsettings (user-level, kein root).""" +from __future__ import annotations + +import subprocess + +_IFACE = "org.gnome.desktop.interface" +_A11Y = "org.gnome.desktop.a11y.interface" +_BIG_TEXT = 1.25 + + +def _get(schema: str, key: str) -> str: + try: + r = subprocess.run(["gsettings", "get", schema, key], + capture_output=True, text=True, check=False, timeout=5) + return r.stdout.strip() + except (FileNotFoundError, subprocess.TimeoutExpired): + return "" + + +def _set(schema: str, key: str, value: str) -> None: + try: + subprocess.run(["gsettings", "set", schema, key, value], + check=False, timeout=5) + except (FileNotFoundError, subprocess.TimeoutExpired): + pass + + +def large_text() -> bool: + try: + return float(_get(_IFACE, "text-scaling-factor") or "1") > 1.1 + except ValueError: + return False + + +def set_large_text(on: bool) -> None: + _set(_IFACE, "text-scaling-factor", str(_BIG_TEXT if on else 1.0)) + + +def high_contrast() -> bool: + return _get(_A11Y, "high-contrast") == "true" + + +def set_high_contrast(on: bool) -> None: + _set(_A11Y, "high-contrast", "true" if on else "false") + + +def animations() -> bool: + return _get(_IFACE, "enable-animations") != "false" + + +def set_animations(on: bool) -> None: + _set(_IFACE, "enable-animations", "true" if on else "false") diff --git a/src/taninux/core/account.py b/src/taninux/core/account.py new file mode 100644 index 0000000..3f6c829 --- /dev/null +++ b/src/taninux/core/account.py @@ -0,0 +1,121 @@ +"""Eigenes Benutzerkonto. UI-frei. + +Konto-Infos aus pwd/grp. Passwort-Änderung des eigenen Users über `passwd`, +das interaktiv ist — wir treiben es über ein PTY (kein root, kein Terminal). +""" +from __future__ import annotations + +import grp +import os +import pwd +import select +import time +from dataclasses import dataclass, field + + +@dataclass +class Account: + username: str + fullname: str + admin: bool # in 'wheel' + groups: list[str] = field(default_factory=list) + + +def current() -> Account: + info = pwd.getpwuid(os.getuid()) + fullname = info.pw_gecos.split(",")[0].strip() or info.pw_name + gids = os.getgrouplist(info.pw_name, info.pw_gid) + names = [] + for gid in gids: + try: + names.append(grp.getgrgid(gid).gr_name) + except KeyError: + pass + return Account(info.pw_name, fullname, "wheel" in names, sorted(names)) + + +def _groups_of(name: str, gid: int) -> list[str]: + names = [] + for g in os.getgrouplist(name, gid): + try: + names.append(grp.getgrgid(g).gr_name) + except KeyError: + pass + return names + + +def list_users() -> list[Account]: + """Menschliche Konten (UID 1000–64999, ohne nobody).""" + me = os.getuid() + out: list[Account] = [] + for info in pwd.getpwall(): + if 1000 <= info.pw_uid < 65000 and info.pw_name != "nobody": + groups = _groups_of(info.pw_name, info.pw_gid) + fullname = info.pw_gecos.split(",")[0].strip() or info.pw_name + acc = Account(info.pw_name, fullname, "wheel" in groups, sorted(groups)) + out.append(acc) + out.sort(key=lambda a: a.username != pwd.getpwuid(me).pw_name) # eigener zuerst + return out + + +def add_user_cmds(username: str, fullname: str, admin: bool) -> list[list[str]]: + """Befehls-Queue: Konto anlegen, optional zu wheel, Passwort-Reset erzwingen.""" + cmds = [["useradd", "-m", "-c", fullname or username, username]] + if admin: + cmds.append(["gpasswd", "-a", username, "wheel"]) + cmds.append(["passwd", "-e", username]) # neuer User setzt PW beim 1. Login + return cmds + + +def delete_user_argv(username: str) -> list[str]: + return ["userdel", "-r", username] + + +def change_password(current_pw: str, new_pw: str, timeout: float = 15.0) -> tuple[bool, str]: + """Eigenes Passwort via passwd über ein PTY ändern. (ok, message).""" + import pty + + pid, fd = pty.fork() + if pid == 0: # child + try: + os.execvp("passwd", ["passwd"]) + except OSError: + os._exit(127) + + answers = [current_pw, new_pw, new_pw] + sent = 0 + out = b"" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + r, _, _ = select.select([fd], [], [], 0.4) + if fd not in r: + continue + try: + chunk = os.read(fd, 4096) + except OSError: + break + if not chunk: + break + out += chunk + tail = chunk.decode(errors="ignore").rstrip() + if tail.endswith(":") and sent < len(answers): + os.write(fd, (answers[sent] + "\n").encode()) + sent += 1 + + try: + _, status = os.waitpid(pid, 0) + except OSError: + status = 1 + ok = os.WIFEXITED(status) and os.WEXITSTATUS(status) == 0 + text = out.decode(errors="ignore") + msg = "Password changed." if ok else _error_hint(text) + return ok, msg + + +def _error_hint(text: str) -> str: + low = text.lower() + if "authentication" in low or "incorrect" in low: + return "Current password is incorrect." + if "too short" in low or "dictionary" in low or "simplistic" in low or "bad" in low: + return "New password is too weak." + return "Could not change the password." diff --git a/src/taninux/core/appinfo.py b/src/taninux/core/appinfo.py new file mode 100644 index 0000000..82b1686 --- /dev/null +++ b/src/taninux/core/appinfo.py @@ -0,0 +1,145 @@ +"""App-Detail-Metadaten + Quellen-Probing. UI-frei. + +Holt Beschreibung/Version/Größe/Lizenz für ein Paket aus pacman/paru/flatpak +und ermittelt, aus welchen Quellen (Repo · AUR · Flatpak) es installierbar ist. +Cross-Source-Mapping ist namensbasiert (Repo/AUR teilen oft den Paketnamen); +einen echten AppStream-Mapping gibt es bewusst nicht. +""" +from __future__ import annotations + +import shutil +import subprocess +from dataclasses import dataclass + +from taninux.core import pacman + +SOURCE_LABEL = { + "repo": "Official repository", + "aur": "AUR (built from source)", + "flatpak": "Flatpak (Flathub)", +} + + +@dataclass +class Detail: + source: str # 'repo' | 'aur' | 'flatpak' + pkg: str # pacman-Name bzw. Flatpak-App-ID + version: str = "" + summary: str = "" + description: str = "" + size: str = "" # Download-Größe + installed_size: str = "" + license: str = "" + url: str = "" + remote: str = "flathub" + available: bool = False + installed: bool = False + + +def _run(argv: list[str], timeout: int = 20): + try: + return subprocess.run(argv, capture_output=True, text=True, + check=False, timeout=timeout) + except (FileNotFoundError, subprocess.TimeoutExpired): + return None + + +def _parse_kv(text: str) -> dict[str, str]: + out: dict[str, str] = {} + for line in text.splitlines(): + if ":" not in line: + continue + key, _, val = line.partition(":") + key = key.strip().lower() + if key and key not in out: + out[key] = val.strip() + return out + + +def _pacman_detail(name: str, *, aur: bool) -> Detail | None: + helper = shutil.which("paru") or shutil.which("yay") + argv = [helper, "-Si", name] if (aur and helper) else ["pacman", "-Si", name] + r = _run(argv) + if r is None or r.returncode != 0 or not r.stdout.strip(): + return None + kv = _parse_kv(r.stdout) + # paru -Si markiert AUR-Pakete mit "Repository: aur"; sonst ist's Repo. + source = "aur" if kv.get("repository") == "aur" else "repo" + return Detail( + source=source, + pkg=name, + version=kv.get("version", ""), + summary=kv.get("description", ""), + description=kv.get("description", ""), + size=kv.get("download size", ""), + installed_size=kv.get("installed size", ""), + license=kv.get("licenses", ""), + url=kv.get("url", ""), + available=True, + installed=pacman.is_installed(name), + ) + + +def _flatpak_detail(app_id: str, remote: str = "flathub") -> Detail | None: + if not shutil.which("flatpak"): + return None + r = _run(["flatpak", "remote-info", remote, app_id]) + if r is None or r.returncode != 0 or not r.stdout.strip(): + return None + kv = _parse_kv(r.stdout) + summary = "" + first = r.stdout.strip().splitlines()[0].strip() + if " - " in first: + summary = first.split(" - ", 1)[1].strip() + installed = False + chk = _run(["flatpak", "info", app_id], timeout=8) + if chk is not None and chk.returncode == 0: + installed = True + return Detail( + source="flatpak", + pkg=app_id, + version=kv.get("version", ""), + summary=summary, + description=summary, + size=kv.get("download size", ""), + installed_size=kv.get("installed size", ""), + license=kv.get("license", ""), + url="", + remote=remote, + available=True, + installed=installed, + ) + + +def candidates(*, repo_name: str | None = None, flatpak_id: str | None = None, + remote: str = "flathub", include_aur: bool = False) -> list[Detail]: + """Verfügbare Installationsquellen für eine App (blockierend → Worker).""" + out: list[Detail] = [] + if repo_name: + d = _pacman_detail(repo_name, aur=False) + if d and d.source == "repo": + out.append(d) + if include_aur: + a = _pacman_detail(repo_name, aur=True) + if a and a.source == "aur": # nur echte AUR-Treffer + out.append(a) + if flatpak_id: + f = _flatpak_detail(flatpak_id, remote) + if f: + out.append(f) + return out + + +def install_argv(d: Detail) -> tuple[list[str], bool]: + if d.source == "flatpak": + return ["flatpak", "install", "--assumeyes", d.remote, d.pkg], False + if d.source == "aur": + helper = shutil.which("paru") or shutil.which("yay") or "paru" + return [helper, "--sudo", "pkexec", "-S", "--noconfirm", d.pkg], False + return ["pacman", "-S", "--needed", "--noconfirm", d.pkg], True + + +def remove_argv(d: Detail) -> tuple[list[str], bool]: + if d.source == "flatpak": + return ["flatpak", "uninstall", "--assumeyes", d.pkg], False + return ["pacman", "-Rns", "--noconfirm", d.pkg], True diff --git a/src/taninux/core/audio.py b/src/taninux/core/audio.py new file mode 100644 index 0000000..dd9dfd6 --- /dev/null +++ b/src/taninux/core/audio.py @@ -0,0 +1,140 @@ +"""Audio-Steuerung via pactl (PulseAudio/PipeWire). UI-frei. + +Genug für eine pavucontrol-artige Geräte+Lautstärke-Steuerung: Sinks/Sources +auflisten, Default setzen, Volume/Mute lesen+setzen. Alles User-Level (kein +root). `kind` ist "sink" (Ausgabe) oder "source" (Eingabe). +""" +from __future__ import annotations + +import re +import subprocess +from dataclasses import dataclass + +_PCT = re.compile(r"(\d+)%") + + +@dataclass +class Device: + name: str # interner pactl-Name (für set-default etc.) + description: str # menschlicher Name (fürs UI) + + +@dataclass +class Stream: + index: str # sink-input-Index (für set-sink-input-* etc.) + app: str # App-Name fürs UI + volume: int + muted: bool + + +def _pactl(args: list[str], timeout: int = 4) -> str: + try: + r = subprocess.run( + ["pactl", *args], + capture_output=True, text=True, check=False, timeout=timeout, + ) + return r.stdout if r.returncode == 0 else "" + except (FileNotFoundError, subprocess.TimeoutExpired): + return "" + + +def available() -> bool: + return bool(_pactl(["info"])) + + +def list_devices(kind: str, include_monitors: bool = False) -> list[Device]: + """Parst `pactl list sinks|sources` für Name + Description je Block.""" + out = _pactl(["list", f"{kind}s"]) + devices: list[Device] = [] + name: str | None = None + for line in out.splitlines(): + if line and not line[0].isspace(): # Block-Header "Sink #N" + name = None + continue + s = line.strip() + if s.startswith("Name:") and name is None: + name = s.split(":", 1)[1].strip() + elif s.startswith("Description:") and name is not None: + desc = s.split(":", 1)[1].strip() + if include_monitors or not name.endswith(".monitor"): + devices.append(Device(name, desc)) + name = None + return devices + + +def get_default(kind: str) -> str: + return _pactl([f"get-default-{kind}"]).strip() + + +def get_volume(kind: str, name: str) -> int: + m = _PCT.search(_pactl([f"get-{kind}-volume", name])) + return int(m.group(1)) if m else 0 + + +def get_muted(kind: str, name: str) -> bool: + return "yes" in _pactl([f"get-{kind}-mute", name]).lower() + + +def set_default(kind: str, name: str) -> None: + _pactl([f"set-default-{kind}", name]) + + +def set_volume(kind: str, name: str, pct: int) -> None: + _pactl([f"set-{kind}-volume", name, f"{int(pct)}%"]) + + +def set_mute(kind: str, name: str, muted: bool) -> None: + _pactl([f"set-{kind}-mute", name, "1" if muted else "0"]) + + +_APP_NAME = re.compile(r'application\.name\s*=\s*"([^"]*)"') +_MEDIA_NAME = re.compile(r'media\.name\s*=\s*"([^"]*)"') + + +def playback_streams() -> list[Stream]: + """Parst `pactl list sink-inputs` zu Stream je App. Robust/leer bei Fehler.""" + out = _pactl(["list", "sink-inputs"]) + streams: list[Stream] = [] + index: str | None = None + muted = False + volume = 0 + app = "" + have_vol = False + + def flush() -> None: + if index is not None: + streams.append(Stream(index, app or f"Stream #{index}", volume, muted)) + + for line in out.splitlines(): + if line and not line[0].isspace(): # Block-Header "Sink Input #N" + flush() + m = re.search(r"#(\d+)", line) + index = m.group(1) if m else None + muted, volume, app, have_vol = False, 0, "", False + continue + s = line.strip() + if s.startswith("Mute:"): + muted = "yes" in s.lower() + elif s.startswith("Volume:") and not have_vol: + m = _PCT.search(s) + if m: + volume = int(m.group(1)) + have_vol = True + else: + m = _APP_NAME.search(s) + if m and not app: + app = m.group(1) + elif not app: + m = _MEDIA_NAME.search(s) + if m: + app = m.group(1) + flush() + return streams + + +def set_stream_volume(index: str, pct: int) -> None: + _pactl(["set-sink-input-volume", str(index), f"{int(pct)}%"]) + + +def set_stream_mute(index: str, muted: bool) -> None: + _pactl(["set-sink-input-mute", str(index), "1" if muted else "0"]) diff --git a/src/taninux/core/avatar.py b/src/taninux/core/avatar.py new file mode 100644 index 0000000..598db37 --- /dev/null +++ b/src/taninux/core/avatar.py @@ -0,0 +1,64 @@ +"""Profilbild des eigenen Users. UI-frei, kein root. + +Schreibt ~/.face (von vielen Greetern/Apps gelesen) und setzt zusätzlich das +AccountsService-Icon (Greeter/Lockscreen) — Letzteres über D-Bus, das für die +EIGENEN Userdaten per polkit ohne Admin erlaubt ist. +""" +from __future__ import annotations + +import getpass +import shutil +import subprocess +from pathlib import Path + + +def _face() -> Path: + return Path.home() / ".face" + + +def current_path() -> str | None: + asv = Path("/var/lib/AccountsService/icons") / getpass.getuser() + if asv.is_file(): + return str(asv) + if _face().is_file(): + return str(_face()) + return None + + +def _user_object_path() -> str | None: + try: + r = subprocess.run( + ["gdbus", "call", "--system", "--dest", "org.freedesktop.Accounts", + "--object-path", "/org/freedesktop/Accounts", + "--method", "org.freedesktop.Accounts.FindUserByName", getpass.getuser()], + capture_output=True, text=True, check=False, timeout=8, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return None + # Ausgabe: (objectpath '/org/freedesktop/Accounts/User1000',) + s = r.stdout + if "'" in s: + return s.split("'")[1] + return None + + +def set_own(image_path: str) -> bool: + """Profilbild setzen. image_path sollte bereits skaliert sein (PNG).""" + ok = False + try: + shutil.copyfile(image_path, _face()) + ok = True + except OSError: + pass + obj = _user_object_path() + if obj: + try: + subprocess.run( + ["gdbus", "call", "--system", "--dest", "org.freedesktop.Accounts", + "--object-path", obj, + "--method", "org.freedesktop.Accounts.User.SetIconFile", image_path], + check=False, timeout=8, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + pass + return ok diff --git a/src/taninux/core/backup.py b/src/taninux/core/backup.py new file mode 100644 index 0000000..998ea66 --- /dev/null +++ b/src/taninux/core/backup.py @@ -0,0 +1,63 @@ +"""Backup-Engine — Time-Machine-artige Snapshots über Timeshift (CLI). + +UI-frei: liefert nur argv-Listen + parst die Timeshift-Ausgabe. Timeshift +braucht für ALLES root (auch `--list`) → das Frontend schleust die argv durch +den privilegierten Helper (pkexec). +""" +from __future__ import annotations + +import re +import shutil +from dataclasses import dataclass + +# Zeitstempel-Name eines Snapshots, z.B. "2024-01-02_00-00-01". +_NAME_RE = re.compile(r"\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}") + + +@dataclass +class Snapshot: + name: str # Zeitstempel, z.B. "2024-01-02_00-00-01" + tags: str # Buchstaben-Tags (O/B/H/D/W/M) + description: str # freier Kommentar + + +def available() -> bool: + return shutil.which("timeshift") is not None + + +def list_argv() -> list[str]: + return ["timeshift", "--list"] + + +def create_argv(comment: str) -> list[str]: + return ["timeshift", "--create", "--comments", comment or "TANINUX", "--scripted"] + + +def delete_argv(name: str) -> list[str]: + return ["timeshift", "--delete", "--snapshot", name, "--yes"] + + +def restore_argv(name: str) -> list[str]: + # Sehr destruktiv: stellt das System wieder her, ggf. Reboot nötig. + return ["timeshift", "--restore", "--snapshot", name, "--yes"] + + +def parse_list(text: str) -> list[Snapshot]: + """Parst die Ausgabe von `timeshift --list`. + + BTRFS-Format: nach einer Trennlinie ("----") Zeilen wie + `0 > 2024-01-02_00-00-01 O mein kommentar` + Robust per Regex auf den Zeitstempel; der Rest danach ist tags+description. + Header- und Trennzeilen werden ignoriert. + """ + snapshots: list[Snapshot] = [] + for line in text.splitlines(): + match = _NAME_RE.search(line) + if match is None: + continue + name = match.group(0) + rest = line[match.end():].split(maxsplit=1) + tags = rest[0] if rest else "" + description = rest[1].strip() if len(rest) > 1 else "" + snapshots.append(Snapshot(name=name, tags=tags, description=description)) + return snapshots diff --git a/src/taninux/core/bluetooth.py b/src/taninux/core/bluetooth.py new file mode 100644 index 0000000..718ed6d --- /dev/null +++ b/src/taninux/core/bluetooth.py @@ -0,0 +1,99 @@ +"""Bluetooth via bluetoothctl (BlueZ). UI-frei. + +Power an/aus, Geräte (paired/connected/discovered), connect/disconnect, scan. +bluetoothctl-Einzelkommandos laufen one-shot. Für den aktiven User ohne root. +Schreib-Aktionen liefern argv (Aufrufer führt via ProcessRunner aus). +""" +from __future__ import annotations + +import subprocess +from dataclasses import dataclass + + +def _run(args: list[str], timeout: int = 6) -> str: + try: + r = subprocess.run(["bluetoothctl", *args], capture_output=True, + text=True, check=False, timeout=timeout) + return r.stdout if r.returncode == 0 else r.stdout + except (FileNotFoundError, subprocess.TimeoutExpired): + return "" + + +def available() -> bool: + return bool(_run(["show"])) + + +def powered() -> bool: + for line in _run(["show"]).splitlines(): + if "Powered:" in line: + return line.split(":", 1)[1].strip() == "yes" + return False + + +def set_power_argv(on: bool) -> list[str]: + return ["bluetoothctl", "power", "on" if on else "off"] + + +def scan_argv(seconds: int = 8) -> list[str]: + return ["bluetoothctl", "--timeout", str(seconds), "scan", "on"] + + +@dataclass +class BtDevice: + mac: str + name: str + paired: bool + connected: bool + trusted: bool = False + + +def _macs(args: list[str]) -> set[str]: + macs: set[str] = set() + for line in _run(args).splitlines(): + parts = line.split() + if len(parts) >= 2 and parts[0] == "Device": + macs.add(parts[1]) + return macs + + +def devices() -> list[BtDevice]: + paired = _macs(["devices", "Paired"]) + connected = _macs(["devices", "Connected"]) + trusted = _macs(["devices", "Trusted"]) + out: list[BtDevice] = [] + seen: set[str] = set() + for line in _run(["devices"]).splitlines(): + parts = line.split(maxsplit=2) + if len(parts) >= 2 and parts[0] == "Device": + mac = parts[1] + if mac in seen: + continue + seen.add(mac) + name = parts[2] if len(parts) > 2 else mac + out.append(BtDevice( + mac=mac, name=name, + paired=mac in paired, connected=mac in connected, + trusted=mac in trusted, + )) + # Verbundene/gepairte zuerst + return sorted(out, key=lambda d: (not d.connected, not d.paired, d.name.lower())) + + +def connect_argv(mac: str) -> list[str]: + return ["bluetoothctl", "connect", mac] + + +def disconnect_argv(mac: str) -> list[str]: + return ["bluetoothctl", "disconnect", mac] + + +def pair_argv(mac: str) -> list[str]: + return ["bluetoothctl", "pair", mac] + + +def trust_argv(mac: str) -> list[str]: + return ["bluetoothctl", "trust", mac] + + +def remove_argv(mac: str) -> list[str]: + return ["bluetoothctl", "remove", mac] diff --git a/src/taninux/core/bootloader.py b/src/taninux/core/bootloader.py new file mode 100644 index 0000000..671ca7b --- /dev/null +++ b/src/taninux/core/bootloader.py @@ -0,0 +1,142 @@ +"""Bootloader-Detection + Default-Entry Management. + +Unterstützt: +- systemd-boot (via /boot/loader/entries/ + bootctl) +- GRUB (info-only — grub-set-default ist fragil, command zeigen) +""" +from __future__ import annotations + +import re +import subprocess +from dataclasses import dataclass +from pathlib import Path + + +@dataclass +class BootloaderInfo: + kind: str # "systemd-boot" / "grub" / "unknown" + config_path: Path | None + default_entry: str | None # bei systemd-boot der entry-name (z.B. "arch.conf") + entries: list[Path] # alle .conf entries (nur systemd-boot) + + +def detect() -> BootloaderInfo: + # systemd-boot + loader_conf = Path("/boot/loader/loader.conf") + entries_dir = Path("/boot/loader/entries") + if loader_conf.exists() and entries_dir.is_dir(): + default = None + try: + for line in loader_conf.read_text().splitlines(): + line = line.strip() + if line.startswith("default"): + parts = line.split(maxsplit=1) + if len(parts) == 2: + default = parts[1].strip() + break + except OSError: + pass + # bootctl status liefert oft den effektiven default (falls "default" in loader.conf einen pattern hat) + if not default or "*" in default: + try: + r = subprocess.run( + ["bootctl", "list", "--json=short"], + capture_output=True, text=True, check=False, timeout=5, + ) + if r.returncode == 0: + import json + for entry in json.loads(r.stdout): + if entry.get("isDefault"): + default = entry.get("id", default) + break + except (FileNotFoundError, subprocess.TimeoutExpired, ValueError): + pass + return BootloaderInfo( + kind="systemd-boot", + config_path=loader_conf, + default_entry=default, + entries=sorted(entries_dir.glob("*.conf")), + ) + + # GRUB + grub_cfg = Path("/boot/grub/grub.cfg") + if grub_cfg.exists(): + default = None + try: + etcdefault = Path("/etc/default/grub").read_text() + m = re.search(r'^GRUB_DEFAULT\s*=\s*"?([^"\n]+)"?', etcdefault, re.MULTILINE) + if m: + default = m.group(1).strip() + except OSError: + pass + return BootloaderInfo( + kind="grub", + config_path=grub_cfg, + default_entry=default, + entries=[], + ) + + return BootloaderInfo(kind="unknown", config_path=None, + default_entry=None, entries=[]) + + +def entry_for_kernel(bl: BootloaderInfo, kernel_pkg: str) -> Path | None: + """Findet das systemd-boot .conf entry für ein gegebenes Kernel-Paket. + + Match-Strategie: entry enthält `linux /vmlinuz-` wo suffix mit + `kernel_pkg` korrespondiert. `linux` → `/vmlinuz-linux`, + `linux-zen` → `/vmlinuz-linux-zen`.""" + if bl.kind != "systemd-boot": + return None + target = f"vmlinuz-{kernel_pkg}" + for entry in bl.entries: + try: + content = entry.read_text() + except OSError: + continue + # Match exakt: vmlinuz-linux-zen, NICHT vmlinuz-linux wenn pkg=linux-zen + for line in content.splitlines(): + line = line.strip() + if line.startswith("linux"): + # Format: "linux /vmlinuz-linux-zen" + parts = line.split() + if len(parts) >= 2: + path = parts[1].rstrip("/").split("/")[-1] + if path == target: + return entry + return None + + +def is_default(bl: BootloaderInfo, entry: Path) -> bool: + if bl.default_entry is None: + return False + return bl.default_entry == entry.name or bl.default_entry == entry.stem + + +def set_default_argv(bl: BootloaderInfo, entry) -> list[str] | None: + """ARGV zum Setzen des Default-Boot-Entry. + + core eskaliert NICHT selbst — gibt nur die Befehlsliste zurück, die der + (GTK-)Aufruferprivilegiert (pkexec) ausführt. Passt zum Muster „command zeigen". + + - systemd-boot: `bootctl set-default ` (entry-id = .conf-Dateiname). + `entry` darf ein str, eine Path oder None sein. + - GRUB: None — `grub-set-default` ist fragil (Index vs. Menüpfad, danach + `grub-mkconfig` nötig); bewusst nicht automatisiert. + """ + if bl.kind != "systemd-boot" or entry is None: + return None + # entry-id für bootctl ist der Dateiname des .conf-Eintrags + entry_id = entry.name if isinstance(entry, Path) else str(entry) + return ["bootctl", "set-default", entry_id] + + +def prune_argv(keep: int) -> list[str]: + """ARGV zum Aufräumen alter zwischengespeicherter Pakete via `paccache`. + + Meint den pacman-Cache (/var/cache/pacman/pkg) — darin liegen alte Versionen + aller Pakete, inkl. alter Kernel. `paccache -rk ` entfernt alle bis auf + die `keep` jüngsten Versionen pro Paket; idiomatischer Arch-Weg. + core eskaliert nicht selbst — der Aufrufer führt das privilegiert aus. + """ + return ["paccache", "-rk", str(max(0, int(keep)))] diff --git a/src/taninux/core/catalog.py b/src/taninux/core/catalog.py new file mode 100644 index 0000000..22a5541 --- /dev/null +++ b/src/taninux/core/catalog.py @@ -0,0 +1,110 @@ +"""Kuratierter App-Katalog für Endnutzer (GNOME-Software-Stil). UI-frei. + +Beliebte Desktop-Apps, die ein normaler User typisch braucht — gruppiert nach +Kategorie. Quelle ist entweder ein Arch-Repo-Paket (via paru) oder Flathub. +Empfohlen wird, was noch nicht installiert ist. +""" +from __future__ import annotations + +import shutil +import subprocess +from dataclasses import dataclass + + +@dataclass(frozen=True) +class App: + id: str # stabiler Key + name: str + summary: str + category: str + source: str # 'repo' | 'flatpak' + pkg: str # pacman-Name bzw. Flatpak-App-ID + icon: str # bevorzugter Icon-Name (Fallback = Kategorie-Icon) + + +CATEGORY_ORDER = [ + "Internet", "Office", "Communication", "Multimedia", + "Graphics", "Games", "Productivity", "Utilities", +] + +CATEGORY_ICON = { + "Internet": "web-browser-symbolic", + "Office": "x-office-document-symbolic", + "Communication": "mail-send-symbolic", + "Multimedia": "multimedia-player-symbolic", + "Graphics": "applications-graphics-symbolic", + "Games": "applications-games-symbolic", + "Productivity": "view-list-symbolic", + "Utilities": "applications-utilities-symbolic", +} + +CATALOG: list[App] = [ + # Internet + App("firefox", "Firefox", "Fast, private web browser", "Internet", "repo", "firefox", "firefox"), + App("chromium", "Chromium", "Open-source web browser", "Internet", "repo", "chromium", "chromium"), + App("qbittorrent", "qBittorrent", "Lightweight BitTorrent client", "Internet", "repo", "qbittorrent", "qbittorrent"), + # Office + App("libreoffice", "LibreOffice", "Word processor, spreadsheets and slides", "Office", "repo", "libreoffice-fresh", "libreoffice-startcenter"), + App("okular", "Okular", "View PDFs, comics and e-books", "Office", "repo", "okular", "okular"), + # Communication + App("thunderbird", "Thunderbird", "Email, calendar and contacts", "Communication", "repo", "thunderbird", "thunderbird"), + App("signal", "Signal", "Private encrypted messaging", "Communication", "repo", "signal-desktop", "signal-desktop"), + App("telegram", "Telegram", "Cloud-based messaging", "Communication", "repo", "telegram-desktop", "telegram"), + App("discord", "Discord", "Voice and text chat for communities", "Communication", "repo", "discord", "discord"), + # Multimedia + App("vlc", "VLC", "Plays nearly any audio or video file", "Multimedia", "repo", "vlc", "vlc"), + App("spotify", "Spotify", "Music streaming", "Multimedia", "flatpak", "com.spotify.Client", "com.spotify.Client"), + App("obs", "OBS Studio", "Screen recording and live streaming", "Multimedia", "repo", "obs-studio", "com.obsproject.Studio"), + App("kdenlive", "Kdenlive", "Non-linear video editor", "Multimedia", "repo", "kdenlive", "kdenlive"), + # Graphics + App("gimp", "GIMP", "Powerful image editor", "Graphics", "repo", "gimp", "gimp"), + App("inkscape", "Inkscape", "Vector graphics editor", "Graphics", "repo", "inkscape", "org.inkscape.Inkscape"), + App("krita", "Krita", "Digital painting studio", "Graphics", "repo", "krita", "krita"), + App("blender", "Blender", "3D modelling and animation", "Graphics", "repo", "blender", "blender"), + # Games + App("steam", "Steam", "The biggest PC game store", "Games", "repo", "steam", "steam"), + App("lutris", "Lutris", "Manage and run games from anywhere", "Games", "repo", "lutris", "lutris"), + App("heroic", "Heroic", "Epic, GOG and Amazon games launcher", "Games", "flatpak", "com.heroicgameslauncher.hgl", "com.heroicgameslauncher.hgl"), + # Productivity + App("obsidian", "Obsidian", "Markdown notes and knowledge base", "Productivity", "flatpak", "md.obsidian.Obsidian", "md.obsidian.Obsidian"), + App("keepassxc", "KeePassXC", "Secure offline password manager", "Productivity", "repo", "keepassxc", "keepassxc"), + # Utilities + App("bottles", "Bottles", "Run Windows software easily", "Utilities", "flatpak", "com.usebottles.bottles", "com.usebottles.bottles"), + App("flatseal", "Flatseal", "Manage Flatpak app permissions", "Utilities", "flatpak", "com.github.tchx84.Flatseal", "com.github.tchx84.Flatseal"), +] + + +def _flatpak_installed_ids() -> set[str]: + if not shutil.which("flatpak"): + return set() + try: + r = subprocess.run( + ["flatpak", "list", "--app", "--columns=application"], + capture_output=True, text=True, check=False, timeout=10, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return set() + return {ln.strip() for ln in r.stdout.splitlines() if ln.strip()} + + +def is_installed(app: App, pacman_set: set[str], flatpak_ids: set[str]) -> bool: + if app.source == "flatpak": + return app.pkg in flatpak_ids + return app.pkg in pacman_set + + +def recommended(pacman_set: set[str], flatpak_ids: set[str] | None = None) -> list[App]: + """Noch nicht installierte Katalog-Apps, in Kategorie-Reihenfolge.""" + if flatpak_ids is None: + flatpak_ids = _flatpak_installed_ids() + out = [a for a in CATALOG if not is_installed(a, pacman_set, flatpak_ids)] + out.sort(key=lambda a: (CATEGORY_ORDER.index(a.category) + if a.category in CATEGORY_ORDER else 99, a.name.lower())) + return out + + +def install_argv(app: App) -> tuple[list[str], bool]: + """(argv, privileged). Katalog-Apps sind Repo (via Helper) oder Flatpak.""" + if app.source == "flatpak": + return ["flatpak", "install", "--assumeyes", "flathub", app.pkg], False + return ["pacman", "-S", "--needed", "--noconfirm", app.pkg], True diff --git a/src/taninux/core/compositor.py b/src/taninux/core/compositor.py new file mode 100644 index 0000000..18d7393 --- /dev/null +++ b/src/taninux/core/compositor.py @@ -0,0 +1,37 @@ +"""Compositor — TANINUX ist niri-only. UI-frei. + +Früher unterschied dieses Modul Hyprland vs Sway/SwayFX; seit der Umstellung +auf **niri** gibt es nur noch einen Compositor. Das Modul bleibt als dünne +Schicht erhalten, damit die Session-Erkennung („läuft niri überhaupt?") an +EINER Stelle liegt — genutzt von der Display-Seite (niri-IPC verfügbar?). +""" +from __future__ import annotations + +import os +import subprocess + +NIRI = "niri" +UNKNOWN = "unknown" + + +def detect() -> str: + # niri exportiert NIRI_SOCKET in jede Session — das ist der zuverlässige Weg. + if os.environ.get("NIRI_SOCKET"): + return NIRI + # Fallback: laufender Prozess (z.B. wenn aus anderem Env gestartet). + try: + r = subprocess.run(["pgrep", "-x", "niri"], + capture_output=True, timeout=3) + if r.returncode == 0: + return NIRI + except (FileNotFoundError, subprocess.TimeoutExpired): + pass + return UNKNOWN + + +def is_niri() -> bool: + return detect() == NIRI + + +def pretty() -> str: + return "niri" if is_niri() else "unknown" diff --git a/src/taninux/core/confirm.py b/src/taninux/core/confirm.py new file mode 100644 index 0000000..b40dffb --- /dev/null +++ b/src/taninux/core/confirm.py @@ -0,0 +1,89 @@ +"""Keyboard-driven confirmation modal im Mountain-Stil.""" +from __future__ import annotations + +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Vertical +from textual.screen import ModalScreen +from textual.widgets import Static + +from taninux.core import theme as T + + +def _escape_markup(s: str) -> str: + """Escape Textual/rich markup brackets — wir wollen body als plain text.""" + return s.replace("[", "\\[").replace("]", "\\]") + + +class ConfirmModal(ModalScreen[bool]): + """Yes/No-Modal — `y` bestätigt, `n`/`esc` bricht ab.""" + + BINDINGS = [ + Binding("y", "confirm", "ja"), + Binding("n", "cancel", "nein"), + Binding("enter", "confirm", "ja", show=False), + Binding("escape", "cancel", "abbruch"), + ] + + CSS = f""" + ConfirmModal {{ + align: center middle; + background: rgba(0, 0, 0, 0.6); + }} + ConfirmModal #dialog {{ + width: 80; + height: auto; + max-height: 90%; + padding: 1 2; + background: {T.YORU}; + border: round {T.BASE3}; + }} + ConfirmModal #c-title {{ + height: 1; + background: {T.YORU}; + }} + ConfirmModal #c-body {{ + height: auto; + margin-top: 1; + background: {T.YORU}; + color: {T.YUKI}; + }} + ConfirmModal #c-hint {{ + height: 1; + margin-top: 1; + background: {T.YORU}; + color: {T.BASE4}; + }} + """ + + def __init__(self, title: str, body: str, danger: bool = False) -> None: + super().__init__() + self._title = title + self._body = body + self._danger = danger + + def compose(self) -> ComposeResult: + title_color = T.RED if self._danger else T.UME + with Vertical(id="dialog"): + yield Static( + f"[{title_color}]{self._title}[/]", + id="c-title", + markup=True, + ) + # Body: markup escaped — sonst crashen brackets aus argv-output + yield Static( + _escape_markup(self._body), + id="c-body", + ) + yield Static( + f"[{T.BASE5}]y[/]/[{T.BASE5}]enter[/] [{T.BASE4}]bestätigen[/] " + f"[{T.BASE5}]n[/]/[{T.BASE5}]esc[/] [{T.BASE4}]abbrechen[/]", + id="c-hint", + markup=True, + ) + + def action_confirm(self) -> None: + self.dismiss(True) + + def action_cancel(self) -> None: + self.dismiss(False) diff --git a/src/taninux/core/defaultapps.py b/src/taninux/core/defaultapps.py new file mode 100644 index 0000000..feda5b9 --- /dev/null +++ b/src/taninux/core/defaultapps.py @@ -0,0 +1,112 @@ +"""Standard-Anwendungen pro Kategorie. UI-frei, user-level (kein root). + +Liest/setzt die MIME-Default-Apps über `gio mime` + `xdg-mime`. Anzeigename und +Icon kommen aus den .desktop-Dateien. +""" +from __future__ import annotations + +import os +import subprocess +from dataclasses import dataclass +from pathlib import Path + +# (Label, repräsentativer MIME-Typ, Fallback-Icon) +CATEGORIES = [ + ("Web browser", "x-scheme-handler/https", "web-browser-symbolic"), + ("Email", "x-scheme-handler/mailto", "mail-send-symbolic"), + ("Files", "inode/directory", "folder-symbolic"), + ("Images", "image/png", "image-x-generic-symbolic"), + ("Audio", "audio/mpeg", "audio-x-generic-symbolic"), + ("Video", "video/mp4", "video-x-generic-symbolic"), + ("PDF documents", "application/pdf", "x-office-document-symbolic"), +] + +_DIRS = [ + Path.home() / ".local/share/applications", + Path.home() / ".local/share/flatpak/exports/share/applications", + Path("/var/lib/flatpak/exports/share/applications"), + Path("/usr/share/applications"), +] + + +@dataclass +class App: + id: str # desktop file id, z.B. firefox.desktop + name: str + icon: str + + +def _find_desktop(desktop_id: str) -> Path | None: + for d in _DIRS: + p = d / desktop_id + if p.is_file(): + return p + return None + + +def _meta(desktop_id: str) -> App: + name, icon = desktop_id.removesuffix(".desktop"), "" + path = _find_desktop(desktop_id) + if path: + try: + in_entry = False + for line in path.read_text().splitlines(): + s = line.strip() + if s.startswith("["): + in_entry = s == "[Desktop Entry]" + elif in_entry and s.startswith("Name=") and name == desktop_id.removesuffix(".desktop"): + name = s[5:].strip() + elif in_entry and s.startswith("Icon="): + icon = s[5:].strip() + except OSError: + pass + return App(id=desktop_id, name=name, icon=icon) + + +def _gio_mime(mime: str) -> tuple[str | None, list[str]]: + env = {**os.environ, "LC_ALL": "C"} + try: + r = subprocess.run(["gio", "mime", mime], capture_output=True, text=True, + check=False, timeout=8, env=env) + except (FileNotFoundError, subprocess.TimeoutExpired): + return None, [] + default: str | None = None + recommended: list[str] = [] + section = None + for line in r.stdout.splitlines(): + if line.startswith("Default application"): + default = line.split(":", 1)[1].strip() or None + elif line.startswith("Recommended applications"): + section = "rec" + elif line.startswith("Registered applications"): + section = "reg" if not recommended else section + elif line.startswith("\t") or line.startswith(" "): + app = line.strip() + if app.endswith(".desktop") and app not in recommended and section in ("rec", "reg"): + recommended.append(app) + return default, recommended + + +def apps_for(mime: str) -> tuple[App | None, list[App]]: + """(aktueller Default, verfügbare Apps) für einen MIME-Typ.""" + default_id, ids = _gio_mime(mime) + apps = [_meta(i) for i in ids] + current = _meta(default_id) if default_id else None + return current, apps + + +def set_default(desktop_id: str, mime: str) -> bool: + ok = True + try: + subprocess.run(["xdg-mime", "default", desktop_id, mime], + check=False, timeout=8) + except (FileNotFoundError, subprocess.TimeoutExpired): + ok = False + if mime.startswith("x-scheme-handler/http"): + # Browser zusätzlich als System-Default registrieren. + try: + subprocess.run(["xdg-settings", "set", "default-web-browser", desktop_id], + check=False, timeout=8) + except (FileNotFoundError, subprocess.TimeoutExpired): + pass + return ok diff --git a/src/taninux/core/display.py b/src/taninux/core/display.py new file mode 100644 index 0000000..852fb5e --- /dev/null +++ b/src/taninux/core/display.py @@ -0,0 +1,291 @@ +"""Display / Output-Einstellungen — niri-only über native IPC. UI-frei. + +Liest Monitore via `niri msg --json outputs` und normalisiert sie auf ein +`Output`-Dataclass. Apply erzeugt das passende argv (`niri msg output …`). + +Bewusst NUR live: `niri msg output` ändert die Konfiguration temporär, nichts +wird in die niri-Config (config.kdl) geschrieben. Nach einem Reload/Login sind +die Änderungen wieder weg. +""" +from __future__ import annotations + +import json +import re +import shutil +import subprocess +from dataclasses import dataclass, field +from pathlib import Path + +# Eigener TANINUX-User-Service für wlsunset (analog swayidle in core/power.py). +# Der niri-Autostart (spawn-at-startup) wird NICHT angefasst. +WLSUNSET_UNIT = ( + Path.home() / ".config" / "systemd" / "user" / "taninux-wlsunset.service" +) + + +@dataclass +class Output: + name: str + make: str + model: str + width: int + height: int + refresh_hz: float + x: int + y: int + scale: float + transform: str # "normal" / "90" / "180" / "270" + enabled: bool + modes: list[tuple[int, int, float]] = field(default_factory=list) # (w, h, hz) + + +# -- Lesen ---------------------------------------------------------------- + +def _run_json(argv: list[str]): + try: + r = subprocess.run(argv, capture_output=True, text=True, timeout=5) + except (FileNotFoundError, subprocess.TimeoutExpired): + return None + if r.returncode != 0 or not r.stdout.strip(): + return None + try: + return json.loads(r.stdout) + except json.JSONDecodeError: + return None + + +def _norm_transform(value) -> str: + """niri-Transform → "normal"/"90"/"180"/"270". + + niri liefert z.B. "Normal", "_90", "Flipped", "Flipped90". Die UI bietet + nur die vier rechtwinkligen Rotationen; flipped-* fallen auf "normal" / + deren Rotationsanteil zurück. + """ + s = str(value).lower().replace("flipped", "").strip("_-") + return s if s in ("90", "180", "270") else "normal" + + +def outputs() -> list[Output]: + # `niri msg --json outputs` liefert ein dict {name: output}. + data = _run_json(["niri", "msg", "--json", "outputs"]) + if not isinstance(data, dict): + return [] + result: list[Output] = [] + for name, o in sorted(data.items()): + modes: list[tuple[int, int, float]] = [] + for md in o.get("modes", []) or []: + modes.append(( + int(md.get("width", 0)), + int(md.get("height", 0)), + int(md.get("refresh_rate", 0)) / 1000.0, # niri: mHz -> Hz + )) + # logical ist null, wenn der Output deaktiviert ist. + logical = o.get("logical") or None + enabled = logical is not None + + # current_mode ist ein Index in modes (null wenn aus / unbekannt). + cur_idx = o.get("current_mode") + if isinstance(cur_idx, int) and 0 <= cur_idx < len(modes): + cw, ch, chz = modes[cur_idx] + elif modes: + cw, ch, chz = modes[0] + else: + cw, ch, chz = 0, 0, 0.0 + + result.append(Output( + name=name, + make=o.get("make", "") or "", + model=o.get("model", "") or "", + width=cw, + height=ch, + refresh_hz=chz, + x=int(logical.get("x", 0)) if logical else 0, + y=int(logical.get("y", 0)) if logical else 0, + scale=float(logical.get("scale", 1.0)) if logical else 1.0, + transform=_norm_transform(logical.get("transform", "normal")) if logical else "normal", + enabled=enabled, + modes=modes, + )) + return result + + +# -- Apply ---------------------------------------------------------------- + +def _fmt_hz(hz: float) -> str: + """Refresh kompakt: '143.973' statt '143.97300'. Ganze Zahlen ohne Punkt. + + Drei Nachkommastellen, weil niri die Modes mit mHz-Präzision führt + (z.B. 59.951) und den Mode-String sonst nicht trifft. + """ + s = f"{hz:.3f}".rstrip("0").rstrip(".") + return s or "0" + + +def _fmt_scale(scale: float) -> str: + s = f"{scale:.2f}".rstrip("0").rstrip(".") + return s or "1" + + +def apply_argv( + out: Output, + *, + mode: tuple[int, int, float] | None = None, + scale: float | None = None, + transform: str | None = None, + enabled: bool | None = None, +) -> list[str]: + """argv für GENAU eine Änderung an `out` via `niri msg output`. + Unprivilegiert ausführbar. Genau einer der Keyword-Parameter sollte gesetzt sein. + + niri ändert die Output-Config temporär (nicht persistent) — passt zur + bewussten „nur live"-Semantik dieses Moduls. + """ + base = ["niri", "msg", "output", out.name] + if enabled is not None: + return base + (["on"] if enabled else ["off"]) + if mode is not None: + w, h, hz = mode + return base + ["mode", f"{w}x{h}@{_fmt_hz(hz)}"] + if scale is not None: + return base + ["scale", _fmt_scale(scale)] + if transform is not None: + return base + ["transform", transform] + return base + + +# -- Night Light / Farbtemperatur (wlsunset) ------------------------------ +# +# TANINUX verwaltet einen EIGENEN systemd-User-Service +# taninux-wlsunset.service (analog zum swayidle-Ansatz in core/power.py). +# wlsunset hat keine Config-Datei — alle Parameter stehen in den ExecStart-Args. +# Karims Compositor-Autostart bleibt unberührt. + +# Defaults = Karims Werte aus dem Autostart. +_NL_DEFAULT_DAY = 6500 +_NL_DEFAULT_NIGHT = 4000 +_NL_DEFAULT_LAT = 47.0 +_NL_DEFAULT_LON = 8.3 + + +@dataclass +class NightLight: + enabled: bool + day_temp: int + night_temp: int + lat: float + lon: float + + +def wlsunset_installed() -> bool: + return shutil.which("wlsunset") is not None + + +def night_light() -> NightLight: + """Liest die aktuellen Night-Light-Werte aus der TANINUX-Unit. + + enabled = Unit existiert (wir verwalten genau diesen Service). Die + Parameter kommen aus den ExecStart-Args: -t , -T , + -l , -L . Fehlt etwas, greifen die Defaults. Robust gegen + fehlende/kaputte Unit. + """ + day = _NL_DEFAULT_DAY + night = _NL_DEFAULT_NIGHT + lat = _NL_DEFAULT_LAT + lon = _NL_DEFAULT_LON + enabled = WLSUNSET_UNIT.is_file() + if enabled: + try: + text = WLSUNSET_UNIT.read_text(errors="ignore") + except OSError: + text = "" + m = re.search(r"^\s*ExecStart\s*=\s*(.+)$", text, re.MULTILINE) + exec_line = m.group(1) if m else "" + + def _int_arg(flag: str, fallback: int) -> int: + mm = re.search(rf"(?:^|\s){re.escape(flag)}\s+(\d+)", exec_line) + return int(mm.group(1)) if mm else fallback + + def _float_arg(flag: str, fallback: float) -> float: + mm = re.search( + rf"(?:^|\s){re.escape(flag)}\s+(-?[\d.]+)", exec_line + ) + try: + return float(mm.group(1)) if mm else fallback + except ValueError: + return fallback + + night = _int_arg("-t", night) + day = _int_arg("-T", day) + lat = _float_arg("-l", lat) + lon = _float_arg("-L", lon) + return NightLight( + enabled=enabled, day_temp=day, night_temp=night, lat=lat, lon=lon + ) + + +def _fmt_coord(v: float) -> str: + """Koordinate kompakt: '47' statt '47.0', '8.3' bleibt '8.3'.""" + s = f"{v:.4f}".rstrip("0").rstrip(".") + return s or "0" + + +def write_night_light( + day_temp: int, night_temp: int, lat: float, lon: float +) -> Path: + """Schreibt die TANINUX-verwaltete wlsunset-User-Unit. Alle Parameter + landen in der ExecStart-Zeile. Gibt den geschriebenen Pfad zurück.""" + wlsunset = shutil.which("wlsunset") or "/usr/bin/wlsunset" + exec_start = ( + f"{wlsunset} -l {_fmt_coord(lat)} -L {_fmt_coord(lon)} " + f"-t {int(night_temp)} -T {int(day_temp)}" + ) + lines = [ + "[Unit]", + "Description=TANINUX-managed wlsunset (night light)", + "Documentation=man:wlsunset(1)", + "PartOf=graphical-session.target", + "", + "[Service]", + "Type=simple", + f"ExecStart={exec_start}", + "Restart=on-failure", + "", + "[Install]", + "WantedBy=default.target", + "", + ] + WLSUNSET_UNIT.parent.mkdir(parents=True, exist_ok=True) + WLSUNSET_UNIT.write_text("\n".join(lines)) + return WLSUNSET_UNIT + + +def apply_night_light(enable: bool) -> bool: + """User-Service anwenden (best-effort, unprivilegiert). + + enable → daemon-reload + enable --now + restart. + disable → disable --now. True wenn die Befehle abgesetzt wurden. + """ + try: + subprocess.run( + ["systemctl", "--user", "daemon-reload"], + check=False, timeout=8, + ) + if enable: + subprocess.run( + ["systemctl", "--user", "enable", "--now", + "taninux-wlsunset.service"], + check=False, timeout=8, + ) + subprocess.run( + ["systemctl", "--user", "restart", "taninux-wlsunset.service"], + check=False, timeout=8, + ) + else: + subprocess.run( + ["systemctl", "--user", "disable", "--now", + "taninux-wlsunset.service"], + check=False, timeout=8, + ) + return True + except (FileNotFoundError, subprocess.TimeoutExpired): + return False diff --git a/src/taninux/core/firewall.py b/src/taninux/core/firewall.py new file mode 100644 index 0000000..3231220 --- /dev/null +++ b/src/taninux/core/firewall.py @@ -0,0 +1,45 @@ +"""Firewall — einfacher An/Aus-Status. UI-frei. Bevorzugt ufw, sonst firewalld. + +Status ist ohne root lesbar (systemctl is-active); Umschalten läuft privilegiert +über den Helper. +""" +from __future__ import annotations + +import shutil +import subprocess + + +def backend() -> str | None: + if shutil.which("ufw"): + return "ufw" + if shutil.which("firewall-cmd"): + return "firewalld" + return None + + +def installed() -> bool: + return backend() is not None + + +def _service() -> str: + return "firewalld" if backend() == "firewalld" else "ufw" + + +def enabled() -> bool: + try: + r = subprocess.run(["systemctl", "is-active", _service()], + capture_output=True, text=True, check=False, timeout=5) + return r.stdout.strip() == "active" + except (FileNotFoundError, subprocess.TimeoutExpired): + return False + + +def set_enabled_argv(on: bool) -> list[str]: + if backend() == "ufw": + return ["ufw", "enable"] if on else ["ufw", "disable"] + action = "enable" if on else "disable" + return ["systemctl", action, "--now", "firewalld"] + + +def install_argv() -> list[str]: + return ["pacman", "-S", "--needed", "--noconfirm", "ufw"] diff --git a/src/taninux/core/localization.py b/src/taninux/core/localization.py new file mode 100644 index 0000000..ea878fe --- /dev/null +++ b/src/taninux/core/localization.py @@ -0,0 +1,117 @@ +"""Locale + Tastatur-Layout via localectl (systemd). + +UI-frei — von TUI und GTK-Frontend nutzbar. Lese-Funktionen laufen +unprivilegiert; die Schreib-Aktionen liefern nur das ARGV (wie bei +core.bootloader), das der Aufrufer privilegiert über den Helper ausführt. +core eskaliert NIE selbst. +""" +from __future__ import annotations + +import subprocess +from dataclasses import dataclass + + +def _run(args: list[str], timeout: int = 5) -> str: + try: + r = subprocess.run( + args, capture_output=True, text=True, check=False, timeout=timeout, + ) + return r.stdout if r.returncode == 0 else "" + except (FileNotFoundError, subprocess.TimeoutExpired): + return "" + + +@dataclass +class LocaleStatus: + locale: str # LANG-Wert, z.B. "de_DE.UTF-8" + vc_keymap: str # Konsolen-Keymap, z.B. "de-latin1" + x11_layout: str # XKB-Layout, z.B. "de" + x11_variant: str # XKB-Variante, z.B. "nodeadkeys" + + +def status() -> LocaleStatus: + """Parst `localectl status`.""" + out = _run(["localectl", "status"]) + fields = {"locale": "", "vc_keymap": "", "x11_layout": "", "x11_variant": ""} + for line in out.splitlines(): + line = line.strip() + if line.startswith("System Locale:"): + val = line.split(":", 1)[1].strip() + # "LANG=de_DE.UTF-8" (evtl. mehrere, durch Whitespace getrennt) + for part in val.split(): + if part.startswith("LANG="): + fields["locale"] = part.split("=", 1)[1] + elif line.startswith("VC Keymap:"): + fields["vc_keymap"] = line.split(":", 1)[1].strip() + elif line.startswith("X11 Layout:"): + fields["x11_layout"] = line.split(":", 1)[1].strip() + elif line.startswith("X11 Variant:"): + fields["x11_variant"] = line.split(":", 1)[1].strip() + return LocaleStatus(**fields) + + +def list_locales() -> list[str]: + """Verfügbare (generierte) Locales. Fallback auf `locale -a`.""" + out = _run(["localectl", "list-locales"]) + if not out: + out = _run(["locale", "-a"]) + return [l.strip() for l in out.splitlines() if l.strip()] + + +def current_timezone() -> str: + """Aktuelle System-Zeitzone, z.B. "Europe/Zurich". '' bei Fehler.""" + return _run(["timedatectl", "show", "-p", "Timezone", "--value"]).strip() + + +def list_timezones() -> list[str]: + """Alle bekannten Zeitzonen (timedatectl list-timezones).""" + out = _run(["timedatectl", "list-timezones"]) + return [l.strip() for l in out.splitlines() if l.strip()] + + +def ntp_enabled() -> bool: + """Ob NTP-Zeitsynchronisation (systemd-timesyncd o.ä.) aktiv ist.""" + return _run(["timedatectl", "show", "-p", "NTP", "--value"]).strip() == "yes" + + +def local_time() -> str: + """Kurze, lesbare lokale Zeit inkl. Zeitzonen-Kürzel (reiner Read).""" + return _run(["date", "+%Y-%m-%d %H:%M %Z"]).strip() + + +def list_x11_layouts() -> list[str]: + out = _run(["localectl", "list-x11-keymap-layouts"]) + return [l.strip() for l in out.splitlines() if l.strip()] + + +def list_x11_variants(layout: str) -> list[str]: + """XKB-Varianten für ein Layout (z.B. nodeadkeys, dvorak). '' = Standard.""" + out = _run(["localectl", "list-x11-keymap-variants", layout]) + return [""] + [l.strip() for l in out.splitlines() if l.strip()] + + +# ---- Schreib-Aktionen: nur ARGV liefern (Aufrufer führt privilegiert aus) ---- + + +def set_locale_argv(lang: str) -> list[str]: + return ["localectl", "set-locale", f"LANG={lang}"] + + +def set_x11_keymap_argv(layout: str, variant: str = "") -> list[str]: + # Syntax: set-x11-keymap LAYOUT [MODEL [VARIANT [OPTIONS]]]. + # Variante ohne Model -> leeres Model als Platzhalter durchreichen. + if variant: + return ["localectl", "set-x11-keymap", layout, "", variant] + return ["localectl", "set-x11-keymap", layout] + + +def set_keymap_argv(keymap: str) -> list[str]: + return ["localectl", "set-keymap", keymap] + + +def set_timezone_argv(tz: str) -> list[str]: + return ["timedatectl", "set-timezone", tz] + + +def set_ntp_argv(on: bool) -> list[str]: + return ["timedatectl", "set-ntp", "true" if on else "false"] diff --git a/src/taninux/core/lock.py b/src/taninux/core/lock.py new file mode 100644 index 0000000..3693f23 --- /dev/null +++ b/src/taninux/core/lock.py @@ -0,0 +1,28 @@ +"""Screen lock. UI-frei. niri-only — Locker via swaylock (ext-session-lock).""" +from __future__ import annotations + +import shutil + +# swaylock spricht das ext-session-lock-Protokoll, das niri implementiert; +# gtklock als Fallback. (hyprlock entfällt — Hyprland-only.) +_LOCKERS = ("swaylock", "gtklock") + + +def locker() -> str | None: + for name in _LOCKERS: + if shutil.which(name): + return name + return None + + +def preferred() -> str: + return "swaylock" + + +def lock_now_argv() -> list[str] | None: + name = locker() + return [name] if name else None + + +def install_argv() -> list[str]: + return ["pacman", "-S", "--needed", "--noconfirm", preferred()] diff --git a/src/taninux/core/network.py b/src/taninux/core/network.py new file mode 100644 index 0000000..48dbbae --- /dev/null +++ b/src/taninux/core/network.py @@ -0,0 +1,211 @@ +"""Netzwerk via nmcli (NetworkManager). UI-frei. + +WiFi an/aus, Status, verfügbare Netze, Verbinden. Für den aktiven User ohne +root (NetworkManager-polkit erlaubt network-control). Schreib-Aktionen liefern +argv (der Aufrufer führt sie via ProcessRunner aus, mit Live-Output). +""" +from __future__ import annotations + +import subprocess +from dataclasses import dataclass + + +def _run(args: list[str], timeout: int = 8) -> str: + try: + r = subprocess.run(["nmcli", *args], capture_output=True, text=True, + check=False, timeout=timeout) + return r.stdout if r.returncode == 0 else "" + except (FileNotFoundError, subprocess.TimeoutExpired): + return "" + + +def available() -> bool: + return bool(_run(["-t", "-f", "RUNNING", "general"])) + + +def _split(line: str) -> list[str]: + """nmcli -t Felder splitten, escaped '\\:' respektieren.""" + out: list[str] = [] + cur = "" + i = 0 + while i < len(line): + c = line[i] + if c == "\\" and i + 1 < len(line): + cur += line[i + 1] + i += 2 + continue + if c == ":": + out.append(cur) + cur = "" + i += 1 + continue + cur += c + i += 1 + out.append(cur) + return out + + +@dataclass +class WifiNet: + ssid: str + signal: int # 0..100 + security: str # "" = offen + in_use: bool + + +def wifi_enabled() -> bool: + return _run(["-t", "-f", "WIFI", "radio"]).strip() == "enabled" + + +def set_wifi_argv(on: bool) -> list[str]: + return ["nmcli", "radio", "wifi", "on" if on else "off"] + + +def active_summary() -> str: + """Kurzbeschreibung der aktiven Verbindung(en).""" + out = _run(["-t", "-f", "NAME,TYPE,DEVICE", "connection", "show", "--active"]) + parts = [] + for line in out.splitlines(): + f = _split(line) + if len(f) >= 3 and f[1] != "loopback": + parts.append(f"{f[0]} ({f[2]})") + return ", ".join(parts) if parts else "Not connected" + + +def wifi_networks(rescan: bool = False) -> list[WifiNet]: + # rescan=False -> NetworkManagers Cache (instant); True -> aktiver Scan (langsam). + out = _run(["-t", "-f", "IN-USE,SSID,SIGNAL,SECURITY", "device", "wifi", "list", + "--rescan", "yes" if rescan else "no"]) + best: dict[str, WifiNet] = {} + for line in out.splitlines(): + f = _split(line) + if len(f) < 4: + continue + in_use, ssid, sig, sec = f[0], f[1], f[2], f[3] + if not ssid: + continue + try: + signal = int(sig) + except ValueError: + signal = 0 + net = WifiNet(ssid=ssid, signal=signal, security=sec.strip(), + in_use=in_use.strip() == "*") + cur = best.get(ssid) + if cur is None or net.signal > cur.signal or net.in_use: + best[ssid] = net + return sorted(best.values(), key=lambda n: (not n.in_use, -n.signal)) + + +def connect_argv(ssid: str, password: str = "") -> list[str]: + argv = ["nmcli", "device", "wifi", "connect", ssid] + if password: + argv += ["password", password] + return argv + + +def saved_connections() -> set[str]: + """Namen gespeicherter NetworkManager-Verbindungen.""" + out = _run(["-t", "-f", "NAME", "connection", "show"]) + names: set[str] = set() + for line in out.splitlines(): + f = _split(line) + if f and f[0]: + names.add(f[0]) + return names + + +def wifi_device() -> str: + """Erstes Wi-Fi-Device (z.B. 'wlp3s0'), sonst leer.""" + out = _run(["-t", "-f", "DEVICE,TYPE", "device"]) + for line in out.splitlines(): + f = _split(line) + if len(f) >= 2 and f[1].strip() == "wifi": + return f[0] + return "" + + +def disconnect_argv() -> list[str]: + """Disconnect des aktiven Wi-Fi via Device. Leer, wenn kein Device.""" + dev = wifi_device() + if not dev: + return [] + return ["nmcli", "device", "disconnect", dev] + + +def forget_argv(ssid: str) -> list[str]: + """Gespeicherte Verbindung löschen.""" + return ["nmcli", "connection", "delete", ssid] + + +# ----------------------------------------------------------------- VPN + +@dataclass +class Vpn: + name: str + vtype: str # 'vpn' | 'wireguard' + active: bool + + +def vpn_connections() -> list[Vpn]: + active = set() + for line in _run(["-t", "-f", "NAME", "connection", "show", "--active"]).splitlines(): + if line.strip(): + active.add(line.strip()) + out: list[Vpn] = [] + for line in _run(["-t", "-f", "NAME,TYPE", "connection", "show"]).splitlines(): + parts = _split(line) + if len(parts) >= 2 and parts[1] in ("vpn", "wireguard"): + out.append(Vpn(parts[0], parts[1], parts[0] in active)) + return out + + +def vpn_up_argv(name: str) -> list[str]: + return ["nmcli", "connection", "up", name] + + +def vpn_down_argv(name: str) -> list[str]: + return ["nmcli", "connection", "down", name] + + +def vpn_import_argv(path: str) -> list[str] | None: + low = path.lower() + if low.endswith(".ovpn"): + vtype = "openvpn" + elif low.endswith(".conf"): + vtype = "wireguard" + else: + return None + return ["nmcli", "connection", "import", "type", vtype, "file", path] + + +# ----------------------------------------------------------------- DNS + +DNS_PRESETS = { + "Automatic": "", + "Cloudflare": "1.1.1.1 1.0.0.1", + "Google": "8.8.8.8 8.8.4.4", + "Quad9": "9.9.9.9 149.112.112.112", +} + + +def primary_connection() -> str | None: + for line in _run(["-t", "-f", "NAME,TYPE", "connection", "show", "--active"]).splitlines(): + parts = _split(line) + if len(parts) >= 2 and parts[1] in ("802-11-wireless", "802-3-ethernet"): + return parts[0] + return None + + +def get_dns(name: str) -> str: + return _run(["-g", "ipv4.dns", "connection", "show", name]).strip().replace("|", " ") + + +def set_dns_cmds(name: str, servers: str) -> list[list[str]]: + """modify + reaktivieren. servers='' = automatisch (DHCP).""" + if servers: + modify = ["nmcli", "connection", "modify", name, + "ipv4.dns", servers, "ipv4.ignore-auto-dns", "yes"] + else: + modify = ["nmcli", "connection", "modify", name, + "ipv4.dns", "", "ipv4.ignore-auto-dns", "no"] + return [modify, ["nmcli", "connection", "up", name]] diff --git a/src/taninux/core/nextcloud.py b/src/taninux/core/nextcloud.py new file mode 100644 index 0000000..c19cde6 --- /dev/null +++ b/src/taninux/core/nextcloud.py @@ -0,0 +1,135 @@ +"""Nextcloud — bindet den vorhandenen Desktop-Client ein. UI-frei. + +Liest die Konten aus `~/.config/Nextcloud/nextcloud.cfg` (INI, Keys im Abschnitt +`[Accounts]` mit Index-Präfix wie `0\\url`, `0\\dav_user`, `0\\displayName`), +startet den Client, verwaltet den Autostart und liefert die WebDAV-URI, um ein +Konto als Nautilus-Bookmark zu hinterlegen. Alles User-Level, kein root. +""" +from __future__ import annotations + +import configparser +import os +import shutil +import subprocess +from dataclasses import dataclass + + +def _config_home() -> str: + return os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config") + + +def _cfg_path() -> str: + return os.path.join(_config_home(), "Nextcloud", "nextcloud.cfg") + + +def _autostart_path() -> str: + return os.path.join(_config_home(), "autostart", "Nextcloud.desktop") + + +def client_installed() -> bool: + return shutil.which("nextcloud") is not None + + +@dataclass +class Account: + name: str + url: str + dav_user: str + + +def accounts() -> list[Account]: + """Konten aus nextcloud.cfg. Robust: leere Liste bei Fehler/fehlender Datei.""" + cp = configparser.ConfigParser() + cp.optionxform = str # Groß-/Kleinschreibung der Keys erhalten + try: + cp.read(_cfg_path(), encoding="utf-8") + except (OSError, configparser.Error): + return [] + + if not cp.has_section("Accounts"): + return [] + + # Keys nach führendem Index gruppieren: "0\url" → idx "0", key "url". + grouped: dict[str, dict[str, str]] = {} + for key, value in cp.items("Accounts"): + if "\\" not in key: + continue + idx, sub = key.split("\\", 1) + grouped.setdefault(idx, {})[sub] = value + + out: list[Account] = [] + for idx in sorted(grouped): + data = grouped[idx] + url = data.get("url", "").strip() + dav_user = data.get("dav_user", "").strip() + name = data.get("displayName", "").strip() or dav_user or url + if not url and not dav_user: + continue + out.append(Account(name=name, url=url, dav_user=dav_user)) + return out + + +def webdav_uri_from(server_url: str, user: str) -> str: + """GVfs-WebDAV-URI (davs:// = HTTPS) aus Server-URL + User bauen — ganz + ohne Client. Nimmt rohe Eingaben (mit/ohne https://, mit/ohne Slash).""" + host = server_url.strip() + for prefix in ("https://", "http://"): + if host.startswith(prefix): + host = host[len(prefix):] + break + host = host.rstrip("/") + return f"davs://{host}/remote.php/dav/files/{user.strip()}/" + + +def webdav_uri(acc: Account) -> str: + """WebDAV-URI für ein erkanntes Client-Konto (Komfort).""" + return webdav_uri_from(acc.url, acc.dav_user) + + +def autostart_enabled() -> bool: + path = _autostart_path() + if not os.path.exists(path): + return False + try: + with open(path, encoding="utf-8") as fh: + for line in fh: + if line.strip().lower() == "hidden=true": + return False + except OSError: + return False + return True + + +_DESKTOP_ENTRY = ( + "[Desktop Entry]\n" + "Type=Application\n" + "Name=Nextcloud\n" + "Exec=nextcloud --background\n" + "Icon=Nextcloud\n" + "Terminal=false\n" + "X-GNOME-Autostart-enabled=true\n" +) + + +def set_autostart(on: bool) -> None: + """Autostart über eine .desktop-Datei in ~/.config/autostart steuern.""" + path = _autostart_path() + if on: + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as fh: + fh.write(_DESKTOP_ENTRY) + else: + try: + os.remove(path) + except FileNotFoundError: + pass + except OSError: + pass + + +def launch() -> None: + """Client starten, ohne zu blockieren. Fehler werden geschluckt.""" + try: + subprocess.Popen(["nextcloud"], start_new_session=True) + except (OSError, subprocess.SubprocessError): + pass diff --git a/src/taninux/core/package_index.py b/src/taninux/core/package_index.py new file mode 100644 index 0000000..821ed4d --- /dev/null +++ b/src/taninux/core/package_index.py @@ -0,0 +1,81 @@ +"""Index aller pacman-bekannten Namen, gegen die Dotfolder gematcht werden.""" +from __future__ import annotations + +import re +import subprocess +from dataclasses import dataclass, field +from functools import lru_cache +from pathlib import Path + +# Pfade, deren Basename ein guter Hint auf einen App-Namen ist +_BIN_PREFIXES = ("/usr/bin/", "/usr/local/bin/", "/usr/lib/") + + +def _normalize(name: str) -> str: + """App-Namen für Fuzzy-Match normalisieren: lowercase, ohne -_. Trennzeichen.""" + return re.sub(r"[-_.\s]", "", name.lower()) + + +@dataclass +class PackageIndex: + """Set von normalisierten Tokens (Paketnamen + Binary-Namen), + gegen die ein Dotfolder gematcht werden kann.""" + + tokens: set[str] = field(default_factory=set) + package_names: set[str] = field(default_factory=set) + + def matches(self, dirname: str) -> str | None: + """Gibt den Original-Paketnamen zurück wenn ein Match gefunden wird.""" + norm = _normalize(dirname) + if not norm: + return None + if norm in self.tokens: + for pkg in self.package_names: + if _normalize(pkg) == norm: + return pkg + return norm + # Substring-Match (eine Richtung) - z.B. "discord-canary" matcht "discord" + for pkg in self.package_names: + npkg = _normalize(pkg) + if not npkg or len(npkg) < 3: + continue + if npkg in norm or norm in npkg: + return pkg + return None + + +@lru_cache(maxsize=1) +def build_index() -> PackageIndex: + """Index einmalig bauen, danach gecached. Liest pacman -Ql.""" + idx = PackageIndex() + try: + result = subprocess.run( + ["pacman", "-Ql"], + capture_output=True, + text=True, + check=True, + timeout=30, + ) + except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError): + return idx + + for line in result.stdout.splitlines(): + # Format: " " + parts = line.split(" ", 1) + if len(parts) != 2: + continue + pkg, path = parts + idx.package_names.add(pkg) + idx.tokens.add(_normalize(pkg)) + + # Auch Binary-Basenames als Tokens (z.B. "code" für "code-oss") + for prefix in _BIN_PREFIXES: + if path.startswith(prefix): + tail = path[len(prefix):].rstrip("/") + if "/" in tail: + continue # Subverzeichnisse überspringen, nur direkte Binaries + if tail: + idx.tokens.add(_normalize(tail)) + break + + return idx diff --git a/src/taninux/core/packages.py b/src/taninux/core/packages.py new file mode 100644 index 0000000..7243a7a --- /dev/null +++ b/src/taninux/core/packages.py @@ -0,0 +1,46 @@ +"""Paket-Zähler — pacman + flatpak. UI-frei. + +Schnelle Counts fürs Dashboard. pacman-Queries sind billig; flatpak kann etwas +dauern, daher im Aufrufer am besten async laden. +""" +from __future__ import annotations + +import shutil +import subprocess + +from taninux.core.pacman import installed_packages + + +def _count(args: list[str], timeout: int = 8) -> int: + try: + r = subprocess.run(args, capture_output=True, text=True, + check=False, timeout=timeout) + if r.returncode != 0: + return 0 + return sum(1 for line in r.stdout.splitlines() if line.strip()) + except (FileNotFoundError, subprocess.TimeoutExpired): + return 0 + + +def pacman_total() -> int: + return len(installed_packages()) + + +def pacman_explicit() -> int: + return _count(["pacman", "-Qeq"]) + + +def pacman_foreign() -> int: + """AUR / nicht-Repo-Pakete.""" + return _count(["pacman", "-Qmq"]) + + +def flatpak_installed() -> bool: + return shutil.which("flatpak") is not None + + +def flatpak_apps() -> int: + """Anzahl installierter Flatpak-Apps; -1 wenn flatpak fehlt.""" + if not flatpak_installed(): + return -1 + return _count(["flatpak", "list", "--app", "--columns=application"]) diff --git a/src/taninux/core/pacman.py b/src/taninux/core/pacman.py new file mode 100644 index 0000000..14a734c --- /dev/null +++ b/src/taninux/core/pacman.py @@ -0,0 +1,29 @@ +"""Shared pacman queries mit Cache-Invalidation. + +Zentral statt in jedem Pillar einen Subprocess-Call. Cache wird gecleart +nach jedem write-Aktion (install/remove).""" +from __future__ import annotations + +import subprocess +from functools import lru_cache + + +@lru_cache(maxsize=1) +def installed_packages() -> frozenset[str]: + try: + r = subprocess.run( + ["pacman", "-Qq"], + capture_output=True, text=True, check=False, timeout=10, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return frozenset() + return frozenset(l.strip() for l in r.stdout.splitlines() if l.strip()) + + +def is_installed(name: str) -> bool: + return name in installed_packages() + + +def invalidate() -> None: + """Nach pacman -S/-R aufrufen.""" + installed_packages.cache_clear() diff --git a/src/taninux/core/panel.py b/src/taninux/core/panel.py new file mode 100644 index 0000000..133d3f8 --- /dev/null +++ b/src/taninux/core/panel.py @@ -0,0 +1,169 @@ +"""Panel & Dock — schreibt panel.json + dock-pins, die eww konsumiert. UI-frei. + +Vertrag: docs/eww-panel-dock-integration.md. TANINUX fasst ~/eww NIE direkt an; +es schreibt nur die Config-Dateien und feuert optional den eww-Apply-Hook +(scripts/panelcfg.sh apply) — das ist ein eww-BEREITGESTELLTES Skript, kein +Edit. eww zieht sonst per Watcher (≤2 s) nach. +""" +from __future__ import annotations + +import json +import os +import subprocess +from dataclasses import dataclass +from pathlib import Path + +from taninux.core.paths import data_dir + +MODULES = ("music", "sys", "updates", "net", "bt", "vol") + +_DEFAULTS = { + "version": 1, + "dock": {"autohide": True, "icon_size": 42, "hide_delay": 1.2}, + "bar": { + "clock_format": "%H:%M %A, %d.%m.%Y", + "modules": {m: True for m in MODULES}, + }, + # Update-Scope: gilt für Hub UND den eww-Update-Zähler. AUR ist Opt-in + # (braucht paru/base-devel) — Default Repo + Flatpak. + "updates": {"include_aur": False}, +} + + +def _panel_path() -> Path: + return data_dir() / "panel.json" + + +def _pins_path() -> Path: + base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config") + return Path(base) / "eww" / "dock-pins" + + +def _apply_script() -> Path: + base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config") + return Path(base) / "eww" / "scripts" / "panelcfg.sh" + + +# ----------------------------------------------------------- panel.json + +def load() -> dict: + """panel.json lesen, mit Defaults auffüllen (defensiv).""" + cfg = json.loads(json.dumps(_DEFAULTS)) # deep copy + try: + data = json.loads(_panel_path().read_text()) + except (OSError, json.JSONDecodeError): + return cfg + if isinstance(data.get("dock"), dict): + cfg["dock"].update({k: v for k, v in data["dock"].items() + if k in cfg["dock"]}) + bar = data.get("bar") + if isinstance(bar, dict): + if isinstance(bar.get("clock_format"), str): + cfg["bar"]["clock_format"] = bar["clock_format"] + if isinstance(bar.get("modules"), dict): + for m in MODULES: + if m in bar["modules"]: + cfg["bar"]["modules"][m] = bool(bar["modules"][m]) + upd = data.get("updates") + if isinstance(upd, dict) and "include_aur" in upd: + cfg["updates"]["include_aur"] = bool(upd["include_aur"]) + return cfg + + +def save(cfg: dict, *, apply: bool = True) -> None: + """panel.json schreiben (mit Clamping) und optional eww anwenden lassen.""" + dock = cfg.get("dock", {}) + try: + icon = int(dock.get("icon_size", 42)) + except (TypeError, ValueError): + icon = 42 + try: + delay = float(dock.get("hide_delay", 1.2)) + except (TypeError, ValueError): + delay = 1.2 + out = { + "version": 1, + "dock": { + "autohide": bool(dock.get("autohide", True)), + "icon_size": max(24, min(64, icon)), + "hide_delay": max(0.2, min(5.0, round(delay, 1))), + }, + "bar": { + "clock_format": str(cfg.get("bar", {}).get("clock_format") + or _DEFAULTS["bar"]["clock_format"]), + "modules": { + m: bool(cfg.get("bar", {}).get("modules", {}).get(m, True)) + for m in MODULES + }, + }, + "updates": { + "include_aur": bool(cfg.get("updates", {}).get("include_aur", False)), + }, + } + p = _panel_path() + try: + p.write_text(json.dumps(out, indent=2)) + except OSError: + return + if apply: + apply_now() + + +def include_aur() -> bool: + """Ob AUR im Update-/Such-Scope ist (Opt-in). Single source: panel.json.""" + return bool(load().get("updates", {}).get("include_aur", False)) + + +def set_include_aur(value: bool) -> None: + """AUR-Scope umschalten — panel.json aktualisieren + eww anwenden lassen.""" + cfg = load() + cfg.setdefault("updates", {})["include_aur"] = bool(value) + save(cfg, apply=True) + + +def apply_now() -> None: + """eww-Apply-Hook feuern (best-effort). Skript gehört eww, wird nur ausgeführt.""" + script = _apply_script() + if not script.exists(): + return + try: + subprocess.run([str(script), "apply"], check=False, timeout=5) + except (FileNotFoundError, subprocess.TimeoutExpired, PermissionError): + pass + + +# ----------------------------------------------------------- dock-pins + +@dataclass +class Pin: + exec: str + wmclass: str + icon: str + + +def load_pins() -> list[Pin]: + """dock-pins lesen — Zeilen `exec|class|icon`.""" + pins: list[Pin] = [] + try: + text = _pins_path().read_text() + except OSError: + return pins + for line in text.splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + parts = line.split("|") + if len(parts) >= 3: + pins.append(Pin(parts[0].strip(), parts[1].strip(), parts[2].strip())) + return pins + + +def save_pins(pins: list[Pin]) -> None: + """dock-pins komplett neu schreiben (idempotent; eww pollt alle 2 s).""" + lines = [f"{p.exec}|{p.wmclass}|{p.icon}" for p in pins] + path = _pins_path() + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("\n".join(lines) + ("\n" if lines else "")) + except OSError: + pass diff --git a/src/taninux/core/paths.py b/src/taninux/core/paths.py new file mode 100644 index 0000000..80a677a --- /dev/null +++ b/src/taninux/core/paths.py @@ -0,0 +1,34 @@ +"""XDG-konforme Pfade für taninux.""" +from __future__ import annotations + +import os +from pathlib import Path + + +def data_dir() -> Path: + base = os.environ.get("XDG_DATA_HOME") or str(Path.home() / ".local" / "share") + d = Path(base) / "taninux" + d.mkdir(parents=True, exist_ok=True) + return d + + +def trash_dir() -> Path: + d = data_dir() / "trash" + d.mkdir(parents=True, exist_ok=True) + return d + + +def state_file() -> Path: + return data_dir() / "state.json" + + +def bundles_dir() -> Path: + d = data_dir() / "bundles" + d.mkdir(parents=True, exist_ok=True) + return d + + +def snapshots_dir() -> Path: + d = data_dir() / "snapshots" + d.mkdir(parents=True, exist_ok=True) + return d diff --git a/src/taninux/core/pkg.py b/src/taninux/core/pkg.py new file mode 100644 index 0000000..6d13cee --- /dev/null +++ b/src/taninux/core/pkg.py @@ -0,0 +1,154 @@ +"""Pacman-Pakete-Metadaten (name/version/size/reason/repo/desc). + +Bevorzugt `expac` für Geschwindigkeit (parse alle 6k pakete in <500ms). +Fallback auf `pacman -Qi` falls expac nicht installiert (~5s).""" +from __future__ import annotations + +import re +import shutil +import subprocess +from dataclasses import dataclass + + +_SIZE_RE = re.compile(r"([\d.]+)\s*([KMGT]i?B|B)") +_UNITS = { + "B": 1, "KiB": 1024, "MiB": 1024**2, "GiB": 1024**3, "TiB": 1024**4, + "KB": 1000, "MB": 1000**2, "GB": 1000**3, "TB": 1000**4, +} + + +def _parse_size(s: str) -> int: + m = _SIZE_RE.search(s) + if not m: + return 0 + val = float(m.group(1)) + return int(val * _UNITS.get(m.group(2), 1)) + + +@dataclass +class Package: + name: str + version: str + size_bytes: int + reason: str # 'explicit' / 'dependency' + repo: str # 'core' / 'extra' / 'multilib' / 'aur' (foreign) / 'local' + desc: str = "" + is_orphan: bool = False + + +def _foreign_set() -> set[str]: + try: + r = subprocess.run( + ["pacman", "-Qmq"], + capture_output=True, text=True, check=False, timeout=10, + ) + return {l.strip() for l in r.stdout.splitlines() if l.strip()} + except (FileNotFoundError, subprocess.TimeoutExpired): + return set() + + +def _orphan_set() -> set[str]: + try: + r = subprocess.run( + ["pacman", "-Qdtq"], + capture_output=True, text=True, check=False, timeout=10, + ) + return {l.strip() for l in r.stdout.splitlines() if l.strip()} + except (FileNotFoundError, subprocess.TimeoutExpired): + return set() + + +def _via_expac() -> list[Package]: + try: + r = subprocess.run( + ["expac", "-Q", "%n\t%v\t%m\t%w\t%r\t%d"], + capture_output=True, text=True, check=False, timeout=30, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return [] + out: list[Package] = [] + for line in r.stdout.splitlines(): + parts = line.split("\t") + if len(parts) < 5: + continue + try: + size = int(parts[2]) + except ValueError: + size = 0 + out.append(Package( + name=parts[0], + version=parts[1], + size_bytes=size, + reason=parts[3] or "explicit", + repo=parts[4] or "local", + desc=parts[5] if len(parts) > 5 else "", + )) + return out + + +def _via_pacman() -> list[Package]: + try: + r = subprocess.run( + ["pacman", "-Qi"], + capture_output=True, text=True, check=False, timeout=60, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return [] + out: list[Package] = [] + cur: dict[str, object] = {} + for line in r.stdout.splitlines(): + if not line.strip(): + if cur.get("name"): + out.append(Package( + name=str(cur["name"]), + version=str(cur.get("version", "")), + size_bytes=int(cur.get("size", 0)), + reason=str(cur.get("reason", "explicit")), + repo=str(cur.get("repo", "local")), + desc=str(cur.get("desc", "")), + )) + cur = {} + continue + if ":" not in line: + continue + key, _, val = line.partition(":") + key = key.strip().lower() + val = val.strip() + if key == "name": + cur["name"] = val + elif key == "version": + cur["version"] = val + elif key == "installed size": + cur["size"] = _parse_size(val) + elif key == "install reason": + cur["reason"] = "explicit" if "explicit" in val.lower() else "dependency" + elif key == "repository": + cur["repo"] = val + elif key == "description": + cur["desc"] = val + if cur.get("name"): + out.append(Package( + name=str(cur["name"]), + version=str(cur.get("version", "")), + size_bytes=int(cur.get("size", 0)), + reason=str(cur.get("reason", "explicit")), + repo=str(cur.get("repo", "local")), + desc=str(cur.get("desc", "")), + )) + return out + + +def all_installed() -> list[Package]: + if shutil.which("expac"): + pkgs = _via_expac() + else: + pkgs = _via_pacman() + + foreign = _foreign_set() + orphans = _orphan_set() + for p in pkgs: + if p.name in foreign: + p.repo = "aur" + if p.name in orphans: + p.is_orphan = True + return pkgs diff --git a/src/taninux/core/power.py b/src/taninux/core/power.py new file mode 100644 index 0000000..1a4343c --- /dev/null +++ b/src/taninux/core/power.py @@ -0,0 +1,271 @@ +"""Power: Akku-Info (read) + Idle-Timeouts (niri-only über swayidle). + +Akku/AC kommen aus /sys/class/power_supply (reiner Read). Idle-Timeouts +(Bildschirm aus / Suspend) verwaltet TANINUX über swayidle: niri implementiert +das ext-idle-notify-Protokoll, daher ist swayidle der passende Idle-Daemon. +Verwaltet wird ein eigener taninux-swayidle.service (swayidle hat KEINE +Config-Datei — alles steht in den ExecStart-Args); der Bildschirm wird per +`niri msg action power-off-monitors` ab- und wieder angeschaltet. + +Die niri-Config (config.kdl) wird nicht angefasst; Autostart läuft über den +systemd-User-Service. +""" +from __future__ import annotations + +import re +import shutil +import subprocess +from dataclasses import dataclass +from pathlib import Path + +_PS = Path("/sys/class/power_supply") +SWAYIDLE_UNIT = ( + Path.home() / ".config" / "systemd" / "user" / "taninux-swayidle.service" +) + + +# ----------------------------------------------------------- Akku / AC + +@dataclass +class Battery: + name: str + capacity: int # Prozent, -1 = unbekannt + status: str # Charging / Discharging / Full / ? + + +def _read(p: Path) -> str: + try: + return p.read_text().strip() + except OSError: + return "" + + +def batteries() -> list[Battery]: + out: list[Battery] = [] + if not _PS.is_dir(): + return out + for d in sorted(_PS.iterdir()): + if _read(d / "type") == "Battery": + cap = _read(d / "capacity") + out.append(Battery( + name=d.name, + capacity=int(cap) if cap.isdigit() else -1, + status=_read(d / "status") or "?", + )) + return out + + +def ac_online() -> bool | None: + """True/False wenn ein AC-Adapter gefunden wird, sonst None.""" + if not _PS.is_dir(): + return None + for d in sorted(_PS.iterdir()): + if _read(d / "type") == "Mains" or d.name.startswith(("AC", "ADP")): + v = _read(d / "online") + if v in ("0", "1"): + return v == "1" + return None + + +# --------------------------------------------------- Energy-Mode (PPD) + +def _out(args: list[str], timeout: int = 5) -> str: + try: + r = subprocess.run(args, capture_output=True, text=True, + check=False, timeout=timeout) + return r.stdout if r.returncode == 0 else "" + except (FileNotFoundError, subprocess.TimeoutExpired): + return "" + + +def ppd_installed() -> bool: + return shutil.which("powerprofilesctl") is not None + + +def power_profiles() -> list[str]: + """Verfügbare Profile aus `powerprofilesctl list` (z.B. performance, + balanced, power-saver). Leer wenn Daemon nicht läuft.""" + profs: list[str] = [] + for line in _out(["powerprofilesctl", "list"]).splitlines(): + s = line.strip() + if s.startswith("* "): # aktiv markiert + s = s[2:] + if re.fullmatch(r"[a-z][a-z-]*:", s): # reine "name:"-Zeile + profs.append(s[:-1]) + return profs + + +def current_profile() -> str: + return _out(["powerprofilesctl", "get"]).strip() + + +def set_profile(name: str) -> None: + try: + subprocess.run(["powerprofilesctl", "set", name], + check=False, timeout=5) + except (FileNotFoundError, subprocess.TimeoutExpired): + pass + + +# ------------------------------------------------- Bildschirmhelligkeit + +def brightness_available() -> bool: + """True, wenn brightnessctl da ist UND es ein Backlight-Gerät gibt. + Desktops ohne Backlight → False (Gruppe wird dann weggelassen).""" + if shutil.which("brightnessctl") is None: + return False + # `-c backlight info` gibt rc 0 nur bei vorhandenem Backlight. + try: + r = subprocess.run( + ["brightnessctl", "-c", "backlight", "info"], + capture_output=True, text=True, check=False, timeout=5, + ) + if r.returncode == 0: + return True + except (FileNotFoundError, subprocess.TimeoutExpired): + return False + # Fallback: device-Liste nach class 'backlight' durchsuchen. + return "class 'backlight'" in _out(["brightnessctl", "-l"]) + + +def brightness_percent() -> int: + """Aktuelle Helligkeit in Prozent. 0 bei Fehler.""" + # machine-readable: device,class,current,percent,max — Feld 4 ist "NN%". + line = _out(["brightnessctl", "-m", "-c", "backlight", "info"]).strip() + if line: + fields = line.splitlines()[0].split(",") + if len(fields) >= 4: + pct = fields[3].rstrip("%") + if pct.isdigit(): + return int(pct) + # Fallback: aus get/max berechnen. + cur = _out(["brightnessctl", "-c", "backlight", "get"]).strip() + mx = _out(["brightnessctl", "-c", "backlight", "max"]).strip() + if cur.isdigit() and mx.isdigit() and int(mx) > 0: + return round(int(cur) * 100 / int(mx)) + return 0 + + +def set_brightness_argv(pct: int) -> list[str]: + """argv zum Setzen der Helligkeit (min 1 %, nie 0).""" + return ["brightnessctl", "-c", "backlight", "set", + f"{max(1, min(100, int(pct)))}%"] + + +# ----------------------------------------------------------- swayidle (niri) + +def swayidle_installed() -> bool: + return shutil.which("swayidle") is not None + + +def _read_idle_swayidle() -> dict[str, int]: + """Liest screen_off/suspend (Sekunden) aus der ExecStart-Zeile des + taninux-swayidle.service. swayidle hat keine Config-Datei — die Timeouts + stehen als `timeout ''`-Paare in den Args. 0 = aus.""" + res = {"screen_off": 0, "suspend": 0} + if not SWAYIDLE_UNIT.is_file(): + return res + text = SWAYIDLE_UNIT.read_text(errors="ignore") + m = re.search(r"^\s*ExecStart\s*=\s*(.+)$", text, re.MULTILINE) + if not m: + return res + exec_line = m.group(1) + # Jedes timeout-Paar: timeout '' — der cmd entscheidet das Ziel. + for tm in re.finditer(r"timeout\s+(\d+)\s+(['\"])(.*?)\2", exec_line): + sec = int(tm.group(1)) + cmd = tm.group(3).lower() + if "power-off-monitors" in cmd or "dpms" in cmd: + res["screen_off"] = sec + elif "suspend" in cmd: + res["suspend"] = sec + return res + + +def _write_idle_swayidle(screen_off: int, suspend: int) -> Path: + """Schreibt die TANINUX-verwaltete swayidle-User-Unit. Nur Listener mit + Wert >0 kommen in die ExecStart-Zeile. Der Bildschirm wird per niri-Action + (DPMS) ab-/angeschaltet; die niri-Config bleibt unberührt.""" + swayidle = shutil.which("swayidle") or "/usr/bin/swayidle" + parts = [swayidle, "-w"] + if screen_off > 0: + parts += [ + "timeout", str(screen_off), + "'niri msg action power-off-monitors'", + "resume", "'niri msg action power-on-monitors'", + ] + if suspend > 0: + parts += ["timeout", str(suspend), "'systemctl suspend'"] + exec_start = " ".join(parts) + + lines = [ + "[Unit]", + "Description=TANINUX-managed swayidle (idle timeouts)", + "Documentation=man:swayidle(1)", + "PartOf=graphical-session.target", + "", + "[Service]", + "Type=simple", + f"ExecStart={exec_start}", + "Restart=on-failure", + "", + "[Install]", + "WantedBy=default.target", + "", + ] + SWAYIDLE_UNIT.parent.mkdir(parents=True, exist_ok=True) + SWAYIDLE_UNIT.write_text("\n".join(lines)) + return SWAYIDLE_UNIT + + +def apply_swayidle() -> bool: + """daemon-reload + User-Service enablen/(re)starten. True wenn ausgeführt.""" + if not swayidle_installed(): + return False + try: + subprocess.run( + ["systemctl", "--user", "daemon-reload"], + check=False, timeout=8, + ) + subprocess.run( + ["systemctl", "--user", "enable", "--now", + "taninux-swayidle.service"], + check=False, timeout=8, + ) + subprocess.run( + ["systemctl", "--user", "restart", "taninux-swayidle.service"], + check=False, timeout=8, + ) + return True + except (FileNotFoundError, subprocess.TimeoutExpired): + return False + + +# -------------------------------------------------- Idle (niri-only) + +def idle_daemon_name() -> str: + """Idle-Daemon für niri — swayidle (spricht ext-idle-notify).""" + return "swayidle" + + +def idle_pkg() -> str: + """Paketname für pacman — identisch zum Daemon-Namen.""" + return idle_daemon_name() + + +def idle_daemon_installed() -> bool: + return swayidle_installed() + + +def read_idle_timeouts() -> dict[str, int]: + """screen_off/suspend (Sekunden) aus der swayidle-Unit lesen. 0 = aus.""" + return _read_idle_swayidle() + + +def write_idle_config(screen_off: int, suspend: int) -> Path: + """swayidle-Unit schreiben. 0 = Listener weglassen. Gibt den Pfad zurück.""" + return _write_idle_swayidle(screen_off, suspend) + + +def apply_idle() -> bool: + """Idle-Daemon anwenden (best-effort, unprivilegiert).""" + return apply_swayidle() diff --git a/src/taninux/core/printers.py b/src/taninux/core/printers.py new file mode 100644 index 0000000..15d11e4 --- /dev/null +++ b/src/taninux/core/printers.py @@ -0,0 +1,125 @@ +"""Printers: CUPS-Druckerverwaltung (read + argv für Schreib-Aktionen). + +Read kommt aus den CUPS-Clienttools (lpstat). Schreib-Aktionen werden NICHT +hier ausgeführt — wir liefern nur das argv, das der GUI-Aufrufer (privilegiert +via Helper oder unprivilegiert) selbst startet. So bleibt das Modul UI-frei. + +Drucker HINZUFÜGEN (Treiberauswahl) ist bewusst nicht enthalten — das gehört +in ein dediziertes System-Tool. +""" +from __future__ import annotations + +import re +import shutil +import subprocess +from dataclasses import dataclass + + +def available() -> bool: + """True, wenn die CUPS-Clienttools (lpstat) vorhanden sind.""" + return shutil.which("lpstat") is not None + + +@dataclass +class Printer: + name: str + info: str # Location o.ä., leer wenn unbekannt + state: str # "idle" / "printing" / "stopped" + enabled: bool + is_default: bool + + +def _run(argv: list[str]) -> str: + """Führt ein read-only Kommando aus, gibt stdout zurück (leer bei Fehler).""" + try: + out = subprocess.run( + argv, capture_output=True, text=True, timeout=10, check=False + ) + except (OSError, subprocess.SubprocessError): + return "" + return out.stdout + + +def _info_map() -> dict[str, str]: + """Location pro Drucker aus `lpstat -l -p` (optional, best effort).""" + info: dict[str, str] = {} + name: str | None = None + for line in _run(["lpstat", "-l", "-p"]).splitlines(): + m = re.match(r"printer (\S+) ", line) + if m: + name = m.group(1) + continue + stripped = line.strip() + if name and stripped.lower().startswith("location:"): + info[name] = stripped.split(":", 1)[1].strip() + return info + + +def printers() -> list[Printer]: + """Parst `lpstat -p -d`. + + Zeilen sehen so aus: + printer Brother is idle. enabled since … + printer HP_LaserJet disabled since … - (stopped, …) + system default destination: Brother + """ + out = _run(["lpstat", "-p", "-d"]) + default = "" + result: list[Printer] = [] + + for line in out.splitlines(): + m = re.match(r"system default destination:\s*(\S+)", line) + if m: + default = m.group(1) + continue + m = re.match(r"printer (\S+) (?:is )?(\S+)", line) + if not m: + continue + name = m.group(1) + word = m.group(2).rstrip(".").lower() + if word == "disabled": + state, enabled = "stopped", False + elif word == "printing": + state, enabled = "printing", True + else: + state, enabled = "idle", True + result.append(Printer(name=name, info="", state=state, + enabled=enabled, is_default=False)) + + locations = _info_map() + for p in result: + p.info = locations.get(p.name, "") + p.is_default = (p.name == default) + return result + + +def queue() -> list[str]: + """Job-Zeilen aus `lpstat -o`. Leere Liste, wenn nichts in der Queue.""" + return [ln for ln in _run(["lpstat", "-o"]).splitlines() if ln.strip()] + + +# ----------------------------------------------------------- argv (Aufrufer führt aus) + +def set_default_argv(name: str) -> list[str]: + """Default-Drucker setzen (root → via Helper).""" + return ["lpadmin", "-d", name] + + +def enable_argv(name: str) -> list[str]: + """Drucker freigeben (root → via Helper).""" + return ["cupsenable", name] + + +def disable_argv(name: str) -> list[str]: + """Drucker anhalten (root → via Helper).""" + return ["cupsdisable", name] + + +def remove_argv(name: str) -> list[str]: + """Drucker entfernen (root → via Helper).""" + return ["lpadmin", "-x", name] + + +def cancel_all_argv() -> list[str]: + """Gesamte Druck-Queue leeren (unprivilegiert).""" + return ["cancel", "-a"] diff --git a/src/taninux/core/runner.py b/src/taninux/core/runner.py new file mode 100644 index 0000000..e67cfeb --- /dev/null +++ b/src/taninux/core/runner.py @@ -0,0 +1,51 @@ +"""Subprocess-Runner: suspendet die TUI und führt Befehle interaktiv aus. + +So kann pacman/paru/sudo seinen eigenen Passwort-Prompt + Progress +direkt im Terminal anzeigen, ohne dass wir parsen müssen. +""" +from __future__ import annotations + +import subprocess +from dataclasses import dataclass + +from textual.app import App + + +@dataclass +class Command: + label: str + argv: list[str] + + +def run_interactive(app: App, commands: list[Command]) -> list[int]: + """Suspendet die App, führt jeden Befehl nacheinander aus, kehrt zurück. + + Returns: liste der exit codes (in Reihenfolge der Befehle). + """ + codes: list[int] = [] + with app.suspend(): + print("\n" + "═" * 64) + print(f" TANINUX — {len(commands)} befehl(e)") + print("═" * 64) + for cmd in commands: + print() + print(f"┌── {cmd.label} " + "─" * max(0, 56 - len(cmd.label))) + print(f"│ $ {' '.join(cmd.argv)}") + print("└" + "─" * 63) + try: + rc = subprocess.run(cmd.argv, check=False).returncode + except KeyboardInterrupt: + print("\n[abgebrochen]") + codes.append(130) + break + except FileNotFoundError as e: + print(f"[fehler: {e}]") + rc = 127 + codes.append(rc) + print() + print("═" * 64) + try: + input(" ENTER zum zurück zu TANINUX … ") + except (EOFError, KeyboardInterrupt): + pass + return codes diff --git a/src/taninux/core/search.py b/src/taninux/core/search.py new file mode 100644 index 0000000..d53f1da --- /dev/null +++ b/src/taninux/core/search.py @@ -0,0 +1,152 @@ +"""Paket-Suche über Repo + AUR (paru) und Flatpak. UI-frei. + +`paru -Ss` deckt offizielle Repos UND AUR in einem Aufruf ab; Flatpak wird +separat durchsucht. Ergebnisse werden zu einem einheitlichen `Result` normiert. + +Install/Remove laufen als User: paru eskaliert den pacman-Teil selbst über +`--sudo pkexec` (grafischer Polkit-Dialog, kein TTY nötig); Flatpak braucht +ohnehin kein root. +""" +from __future__ import annotations + +import re +import shutil +import subprocess +from dataclasses import dataclass, field + +MAX_RESULTS = 60 + +_HEAD = re.compile(r"^(?P[\w.-]+)/(?P\S+)\s+(?P\S+)(?P.*)$") + + +@dataclass +class Result: + source: str # 'repo' | 'aur' | 'flatpak' + name: str # Paketname (Repo/AUR) bzw. Flatpak-App-ID + version: str + description: str = "" + installed: bool = False + repo: str = "" # Repo-Name bzw. Flatpak-Remote + extra: dict[str, str] = field(default_factory=dict) + + +def _helper() -> str | None: + for h in ("paru", "yay"): + if shutil.which(h): + return h + return None + + +def _search_pacman(query: str, include_aur: bool) -> list[Result]: + # AUR nur via paru/yay; ohne AUR plain pacman (kein paru/base-devel nötig). + helper = _helper() if include_aur else None + argv = [helper, "-Ss", query] if helper else ["pacman", "-Ss", query] + try: + r = subprocess.run( + argv, capture_output=True, text=True, check=False, timeout=45, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return [] + if r.returncode not in (0, 1): + return [] + + out: list[Result] = [] + cur: Result | None = None + for line in r.stdout.splitlines(): + if line[:1].isspace(): + if cur is not None and not cur.description: + cur.description = line.strip() + continue + m = _HEAD.match(line) + if not m: + continue + repo = m.group("repo") + cur = Result( + source="aur" if repo == "aur" else "repo", + name=m.group("name"), + version=m.group("ver"), + installed="[installed" in m.group("rest").lower(), + repo=repo, + ) + out.append(cur) + return out + + +def _flatpak_installed_ids() -> set[str]: + try: + r = subprocess.run( + ["flatpak", "list", "--app", "--columns=application"], + capture_output=True, text=True, check=False, timeout=10, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return set() + return {ln.strip() for ln in r.stdout.splitlines() if ln.strip()} + + +def _search_flatpak(query: str) -> list[Result]: + if not shutil.which("flatpak"): + return [] + try: + r = subprocess.run( + ["flatpak", "search", + "--columns=application,version,name,description,remotes", query], + capture_output=True, text=True, check=False, timeout=30, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return [] + if r.returncode != 0: + return [] + + installed = _flatpak_installed_ids() + out: list[Result] = [] + for line in r.stdout.splitlines(): + s = line.strip() + if not s or s.lower().startswith("application"): + continue + parts = s.split("\t") + if len(parts) < 1: + continue + app = parts[0] + ver = parts[1] if len(parts) > 1 and parts[1] != "" else "?" + name = parts[2] if len(parts) > 2 else app + desc = parts[3] if len(parts) > 3 else "" + remote = parts[4].split(",")[0] if len(parts) > 4 and parts[4] else "flathub" + out.append(Result( + source="flatpak", + name=app, + version=ver, + description=desc if desc and desc != name else "", + installed=app in installed, + repo=remote, + extra={"title": name}, + )) + return out + + +def search(query: str, include_aur: bool | None = None) -> list[Result]: + """Repo (+AUR falls Opt-in) und Flatpak durchsuchen, zusammengeführt.""" + if include_aur is None: + from taninux.core import panel + include_aur = panel.include_aur() + query = query.strip() + if len(query) < 2: + return [] + results = _search_pacman(query, include_aur) + _search_flatpak(query) + return results[:MAX_RESULTS] + + +def install_argv(item: Result) -> tuple[list[str], bool]: + """(argv, privileged). Repo läuft über den Helper (pacman), AUR über paru.""" + if item.source == "flatpak": + return ["flatpak", "install", "--assumeyes", item.repo, item.name], False + if item.source == "aur": + helper = _helper() or "paru" + return [helper, "--sudo", "pkexec", "-S", "--noconfirm", item.name], False + return ["pacman", "-S", "--needed", "--noconfirm", item.name], True + + +def remove_argv(item: Result) -> tuple[list[str], bool]: + """(argv, privileged). Entfernen baut nie — Repo+AUR über den Helper.""" + if item.source == "flatpak": + return ["flatpak", "uninstall", "--assumeyes", item.name], False + return ["pacman", "-Rns", "--noconfirm", item.name], True diff --git a/src/taninux/core/shares.py b/src/taninux/core/shares.py new file mode 100644 index 0000000..f59477f --- /dev/null +++ b/src/taninux/core/shares.py @@ -0,0 +1,153 @@ +"""Network Shares — Remote-Bookmarks für den Dateimanager (Nautilus). UI-frei. + +Verwaltet Remote-Verbindungen (SFTP/FTP/SMB/WebDAV/NFS) als GTK-3-Bookmarks +in `~/.config/gtk-3.0/bookmarks` plus `gio mount` zum Verbinden. Alles +User-Level: GVfs läuft im Session-Bus, kein root. Lokale `file://`-Bookmarks +werden NIE angefasst — nur Remote-Zeilen (scheme in REMOTE_SCHEMES). + +SMB/NFS brauchen die GVfs-Backends `gvfs-smb`/`gvfs-nfs` (eigene Pakete). +""" +from __future__ import annotations + +import os +import subprocess +from dataclasses import dataclass + +REMOTE_SCHEMES = {"sftp", "ftp", "smb", "dav", "davs", "nfs"} + + +def _config_home() -> str: + return os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config") + + +def bookmarks_path() -> str: + return os.path.join(_config_home(), "gtk-3.0", "bookmarks") + + +@dataclass +class Bookmark: + uri: str + label: str + scheme: str + + +def _scheme(uri: str) -> str: + return uri.split("://", 1)[0].lower() if "://" in uri else "" + + +def _read_lines() -> list[str]: + try: + with open(bookmarks_path(), encoding="utf-8") as fh: + return [ln.rstrip("\n") for ln in fh] + except OSError: + return [] + + +def list_remote() -> list[Bookmark]: + """Alle Remote-Bookmarks (scheme in REMOTE_SCHEMES). file:// bleibt außen vor.""" + out: list[Bookmark] = [] + for line in _read_lines(): + line = line.strip() + if not line: + continue + parts = line.split(maxsplit=1) + uri = parts[0] + label = parts[1] if len(parts) > 1 else uri + sch = _scheme(uri) + if sch in REMOTE_SCHEMES: + out.append(Bookmark(uri=uri, label=label, scheme=sch)) + return out + + +def build_uri(scheme: str, host: str, path: str = "", + user: str = "", port: str = "") -> str: + """Baut eine GVfs-URI, z. B. `sftp://user@host:2222/srv`. + + user/port optional. path wird mit führendem „/" normalisiert. + SMB-Konvention: `smb://host/share` (share = path ohne führenden Slash-Zwang, + wird aber ebenfalls über den Slash drangehängt). + """ + scheme = scheme.strip().lower() + host = host.strip() + user = user.strip() + port = port.strip() + path = path.strip() + + authority = host + if user: + authority = f"{user}@{authority}" + if port: + authority = f"{authority}:{port}" + + uri = f"{scheme}://{authority}" + if path: + if not path.startswith("/"): + path = "/" + path + uri += path + return uri + + +def add_bookmark(uri: str, label: str) -> None: + """Remote-Bookmark hinzufügen (idempotent). Alle anderen Zeilen — inkl. + lokaler file://-Bookmarks — bleiben unverändert erhalten.""" + uri = uri.strip() + if not uri: + return + lines = _read_lines() + # Vorhandenen Eintrag mit derselben URI entfernen (Duplikate vermeiden). + kept = [ln for ln in lines if ln.strip().split(maxsplit=1)[:1] != [uri]] + entry = uri if not label.strip() else f"{uri} {label.strip()}" + kept.append(entry) + _write_lines(kept) + + +def remove_bookmark(uri: str) -> None: + """Entfernt genau die Bookmark-Zeile(n) mit dieser URI. Alles andere bleibt.""" + uri = uri.strip() + lines = _read_lines() + kept = [ln for ln in lines if ln.strip().split(maxsplit=1)[:1] != [uri]] + _write_lines(kept) + + +def _write_lines(lines: list[str]) -> None: + path = bookmarks_path() + os.makedirs(os.path.dirname(path), exist_ok=True) + # Leere Zeilen am Ende vermeiden, genau ein abschließendes Newline. + body = "\n".join(ln for ln in lines if ln.strip() != "") + with open(path, "w", encoding="utf-8") as fh: + fh.write(body + ("\n" if body else "")) + + +def mount_argv(uri: str) -> list[str]: + return ["gio", "mount", uri] + + +def unmount_argv(uri: str) -> list[str]: + return ["gio", "mount", "-u", uri] + + +def _pkg_installed(name: str) -> bool: + """True, wenn das Pacman-Paket installiert ist (rc 0). GVfs-Backends sind + Libs ohne eigenes Binary, deshalb Paketabfrage statt shutil.which.""" + try: + r = subprocess.run(["pacman", "-Q", name], capture_output=True, + text=True, timeout=6, check=False) + return r.returncode == 0 + except (OSError, subprocess.SubprocessError): + return False + + +def smb_available() -> bool: + return _pkg_installed("gvfs-smb") + + +def nfs_available() -> bool: + return _pkg_installed("gvfs-nfs") + + +def install_smb_argv() -> list[str]: + return ["pacman", "-S", "--needed", "--noconfirm", "gvfs-smb"] + + +def install_nfs_argv() -> list[str]: + return ["pacman", "-S", "--needed", "--noconfirm", "gvfs-nfs"] diff --git a/src/taninux/core/snapshot.py b/src/taninux/core/snapshot.py new file mode 100644 index 0000000..c1d4f33 --- /dev/null +++ b/src/taninux/core/snapshot.py @@ -0,0 +1,137 @@ +"""Snapshot/Drift: speichere den Paket-Stand und vergleiche über Zeit. + +Snapshots leben unter ~/.local/share/taninux/snapshots/.json mit: + - timestamp + - explicit: pacman -Qenq (explicit installiert, native repos) + - aur: pacman -Qmq (foreign — meist AUR) + - deps: pacman -Qdq (als dependency installiert) +""" +from __future__ import annotations + +import json +import subprocess +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path + +from taninux.core.paths import snapshots_dir + + +@dataclass +class Snapshot: + id: str # e.g. '20260526T195430' + path: Path + timestamp: str + label: str + explicit: set[str] = field(default_factory=set) + aur: set[str] = field(default_factory=set) + deps: set[str] = field(default_factory=set) + + @property + def all_pkgs(self) -> set[str]: + return self.explicit | self.aur | self.deps + + +def _pacman_qq(args: list[str]) -> set[str]: + try: + r = subprocess.run( + ["pacman", *args], + capture_output=True, text=True, check=False, timeout=10, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return set() + return {l.strip() for l in r.stdout.splitlines() if l.strip()} + + +def take_snapshot(label: str | None = None) -> Snapshot: + ts = datetime.now().strftime("%Y%m%dT%H%M%S") + iso = datetime.now().isoformat(timespec="seconds") + snapshot_id = ts + path = snapshots_dir() / f"{snapshot_id}.json" + explicit = _pacman_qq(["-Qenq"]) + aur = _pacman_qq(["-Qmq"]) + deps = _pacman_qq(["-Qdq"]) + payload = { + "id": snapshot_id, + "label": label or "manual", + "timestamp": iso, + "explicit": sorted(explicit), + "aur": sorted(aur), + "deps": sorted(deps), + } + path.write_text(json.dumps(payload, indent=2)) + return Snapshot( + id=snapshot_id, + path=path, + timestamp=iso, + label=payload["label"], + explicit=explicit, + aur=aur, + deps=deps, + ) + + +def list_snapshots() -> list[Snapshot]: + out: list[Snapshot] = [] + for p in sorted(snapshots_dir().glob("*.json")): + try: + data = json.loads(p.read_text()) + except (OSError, json.JSONDecodeError): + continue + out.append(Snapshot( + id=data.get("id", p.stem), + path=p, + timestamp=data.get("timestamp", ""), + label=data.get("label", ""), + explicit=set(data.get("explicit", [])), + aur=set(data.get("aur", [])), + deps=set(data.get("deps", [])), + )) + return out + + +def load(snapshot_id: str) -> Snapshot | None: + for s in list_snapshots(): + if s.id == snapshot_id: + return s + return None + + +@dataclass +class Diff: + added_explicit: list[str] + removed_explicit: list[str] + added_aur: list[str] + removed_aur: list[str] + added_deps: list[str] + removed_deps: list[str] + + +def current_snapshot() -> Snapshot: + return Snapshot( + id="current", + path=Path("/dev/null"), + timestamp=datetime.now().isoformat(timespec="seconds"), + label="now", + explicit=_pacman_qq(["-Qenq"]), + aur=_pacman_qq(["-Qmq"]), + deps=_pacman_qq(["-Qdq"]), + ) + + +def diff(old: Snapshot, new: Snapshot) -> Diff: + def sorted_list(s: set[str]) -> list[str]: + return sorted(s) + return Diff( + added_explicit=sorted_list(new.explicit - old.explicit), + removed_explicit=sorted_list(old.explicit - new.explicit), + added_aur=sorted_list(new.aur - old.aur), + removed_aur=sorted_list(old.aur - new.aur), + added_deps=sorted_list(new.deps - old.deps), + removed_deps=sorted_list(old.deps - new.deps), + ) + + +def latest() -> Snapshot | None: + snaps = list_snapshots() + return snaps[-1] if snaps else None diff --git a/src/taninux/core/state.py b/src/taninux/core/state.py new file mode 100644 index 0000000..88b4258 --- /dev/null +++ b/src/taninux/core/state.py @@ -0,0 +1,69 @@ +"""Persistenter User-State: Whitelist (immer behalten) + Ignored (nicht mehr anzeigen).""" +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from pathlib import Path + +from taninux.core.paths import state_file + + +@dataclass +class State: + whitelist: set[Path] = field(default_factory=set) + ignored: set[Path] = field(default_factory=set) + + # ---- load / save ---- + + @classmethod + def load(cls) -> "State": + f = state_file() + if not f.exists(): + return cls() + try: + data = json.loads(f.read_text()) + except (json.JSONDecodeError, OSError): + return cls() + return cls( + whitelist={Path(p) for p in data.get("whitelist", [])}, + ignored={Path(p) for p in data.get("ignored", [])}, + ) + + def save(self) -> None: + f = state_file() + payload = { + "version": 1, + "whitelist": sorted(str(p) for p in self.whitelist), + "ignored": sorted(str(p) for p in self.ignored), + } + f.write_text(json.dumps(payload, indent=2)) + + # ---- queries ---- + + def is_whitelisted(self, p: Path) -> bool: + return p in self.whitelist + + def is_ignored(self, p: Path) -> bool: + return p in self.ignored + + # ---- mutations (auto-save) ---- + + def whitelist_add(self, p: Path) -> None: + self.whitelist.add(p) + self.ignored.discard(p) + self.save() + + def whitelist_remove(self, p: Path) -> None: + if p in self.whitelist: + self.whitelist.discard(p) + self.save() + + def ignore_add(self, p: Path) -> None: + self.ignored.add(p) + self.whitelist.discard(p) + self.save() + + def ignore_remove(self, p: Path) -> None: + if p in self.ignored: + self.ignored.discard(p) + self.save() diff --git a/src/taninux/core/storage.py b/src/taninux/core/storage.py new file mode 100644 index 0000000..de01e52 --- /dev/null +++ b/src/taninux/core/storage.py @@ -0,0 +1,73 @@ +"""Laufwerke und Belegung. UI-frei. Lesen via lsblk; mount/eject via udisksctl +(user-level, polkit-backed — kein root-Helper nötig).""" +from __future__ import annotations + +import json +import subprocess +from dataclasses import dataclass + + +@dataclass +class Volume: + dev: str # /dev/sdX1 + size: int # bytes + fstype: str + label: str + mountpoint: str + removable: bool + used: int # bytes (0 wenn nicht gemountet) + use_pct: int # 0..100 + + +def _lsblk() -> list[dict]: + try: + r = subprocess.run( + ["lsblk", "-J", "-b", "-o", + "NAME,PATH,SIZE,TYPE,MOUNTPOINT,FSTYPE,LABEL,RM,FSUSED,FSUSE%"], + capture_output=True, text=True, check=False, timeout=8, + ) + return json.loads(r.stdout).get("blockdevices", []) + except (FileNotFoundError, subprocess.TimeoutExpired, json.JSONDecodeError): + return [] + + +def _walk(node: dict, removable: bool, out: list[Volume]) -> None: + rm = removable or node.get("rm", False) + if (node.get("fstype") and node.get("fstype") != "swap" + and node.get("type") in ("part", "crypt", "lvm", "disk")): + pct = node.get("fsuse%") or "" + try: + use_pct = int(str(pct).rstrip("%")) if pct else 0 + except ValueError: + use_pct = 0 + out.append(Volume( + dev=node.get("path", node.get("name", "")), + size=int(node.get("size") or 0), + fstype=node.get("fstype", ""), + label=node.get("label") or "", + mountpoint=node.get("mountpoint") or "", + removable=bool(rm), + used=int(node.get("fsused") or 0), + use_pct=use_pct, + )) + for child in node.get("children", []): + _walk(child, rm, out) + + +def volumes() -> list[Volume]: + out: list[Volume] = [] + for dev in _lsblk(): + _walk(dev, False, out) + return out + + +def mount_argv(dev: str) -> list[str]: + return ["udisksctl", "mount", "-b", dev] + + +def unmount_argv(dev: str) -> list[str]: + return ["udisksctl", "unmount", "-b", dev] + + +def eject_argv(dev: str) -> list[str]: + return ["udisksctl", "power-off", "-b", dev] diff --git a/src/taninux/core/streaming.py b/src/taninux/core/streaming.py new file mode 100644 index 0000000..b8f8fe7 --- /dev/null +++ b/src/taninux/core/streaming.py @@ -0,0 +1,169 @@ +"""Streaming-Runner: führt Subprozesse aus, streamt stdout in einen RichLog +innerhalb eines Modals. Kein TUI-suspend — der user sieht alles live.""" +from __future__ import annotations + +import asyncio +import shlex +from dataclasses import dataclass + +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Vertical +from textual.screen import ModalScreen +from textual.widgets import RichLog, Static + +from taninux.core import theme as T + + +@dataclass +class StreamCommand: + label: str + argv: list[str] + + +class RunnerScreen(ModalScreen[list[int]]): + """Modal das Commands sequenziell ausführt + stdout/stderr streamt. + + Returns list[int] — ein exit-code pro Command in Reihenfolge.""" + + BINDINGS = [ + Binding("ctrl+c", "cancel", "abbruch"), + Binding("escape", "close", "schliessen", show=True), + Binding("q", "close", "quit", show=False), + ] + + CSS = f""" + RunnerScreen {{ + align: center middle; + background: rgba(0, 0, 0, 0.85); + }} + RunnerScreen #runner-dialog {{ + width: 90%; + height: 85%; + padding: 1 2; + background: {T.YORU}; + border: round {T.BASE3}; + }} + RunnerScreen #runner-title {{ + height: 1; + background: {T.YORU}; + color: {T.UME}; + }} + RunnerScreen #runner-log {{ + height: 1fr; + background: {T.YORU}; + color: {T.FG_ALT}; + border: none; + scrollbar-color: {T.BASE2} {T.YORU}; + scrollbar-background: {T.YORU}; + scrollbar-corner-color: {T.YORU}; + scrollbar-size-vertical: 1; + }} + RunnerScreen #runner-hint {{ + height: 1; + background: {T.YORU}; + color: {T.BASE4}; + margin-top: 1; + }} + """ + + def __init__(self, commands: list[StreamCommand]) -> None: + super().__init__() + self.commands = commands + self.codes: list[int] = [] + self.done = False + self._current_proc: asyncio.subprocess.Process | None = None + self._cancelled = False + + def compose(self) -> ComposeResult: + with Vertical(id="runner-dialog"): + yield Static("", id="runner-title", markup=True) + yield RichLog(id="runner-log", highlight=False, markup=True, wrap=True) + yield Static("", id="runner-hint", markup=True) + + def on_mount(self) -> None: + self._set_title(f"running {len(self.commands)} command(s)…") + self._set_hint(f"[{T.BASE5}]ctrl+c[/] [{T.BASE4}]abbruch[/]") + self.run_worker(self._run_all(), exclusive=True) + + def _set_title(self, msg: str) -> None: + self.query_one("#runner-title", Static).update(f"[{T.UME}]▲ {msg}[/]") + + def _set_hint(self, msg: str) -> None: + self.query_one("#runner-hint", Static).update(msg) + + def _log(self, line: str) -> None: + self.query_one("#runner-log", RichLog).write(line) + + async def _run_all(self) -> None: + log = self.query_one("#runner-log", RichLog) + for i, cmd in enumerate(self.commands, 1): + if self._cancelled: + self._log(f"[{T.RED}]abgebrochen[/]") + break + log.write("") + log.write(f"[{T.SHINKAI}]┌── [{i}/{len(self.commands)}] {cmd.label}[/]") + log.write(f"[{T.BASE5}]│ $ {shlex.join(cmd.argv)}[/]") + log.write(f"[{T.BASE3}]└────────────────[/]") + code = await self._run_one(cmd) + self.codes.append(code) + color = T.TAKE if code == 0 else T.RED + log.write(f"[{color}]→ exit {code}[/]") + + self.done = True + self._current_proc = None + ok = sum(1 for c in self.codes if c == 0) + if self._cancelled: + self._set_title(f"abgebrochen ({ok}/{len(self.codes)} ok)") + elif ok == len(self.codes): + self._set_title(f"fertig — {ok}/{len(self.codes)} ok") + else: + self._set_title(f"fertig mit fehlern — {ok}/{len(self.codes)} ok") + self._set_hint( + f"[{T.BASE5}]ESC[/] [{T.BASE4}]schliessen + zurück[/]" + ) + + async def _run_one(self, cmd: StreamCommand) -> int: + try: + self._current_proc = await asyncio.create_subprocess_exec( + *cmd.argv, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + stdin=asyncio.subprocess.DEVNULL, + ) + except FileNotFoundError as e: + self._log(f"[{T.RED}]fehler: {e}[/]") + return 127 + except Exception as e: # noqa: BLE001 + self._log(f"[{T.RED}]start fehlgeschlagen: {e}[/]") + return 1 + + assert self._current_proc.stdout is not None + try: + async for raw in self._current_proc.stdout: + line = raw.decode("utf-8", errors="replace").rstrip("\n").rstrip("\r") + if line: + # Escape rich markup chars so program output isn't parsed + self._log(line.replace("[", "\\[")) + except asyncio.CancelledError: + self._current_proc.terminate() + raise + + return await self._current_proc.wait() + + def action_cancel(self) -> None: + if self.done: + self.dismiss(self.codes) + return + self._cancelled = True + if self._current_proc is not None: + try: + self._current_proc.terminate() + except ProcessLookupError: + pass + self._set_title("abbruch läuft…") + + def action_close(self) -> None: + if self.done: + self.dismiss(self.codes) + # Wenn noch running: ignore (use ctrl+c) diff --git a/src/taninux/core/sudo.py b/src/taninux/core/sudo.py new file mode 100644 index 0000000..7ccd013 --- /dev/null +++ b/src/taninux/core/sudo.py @@ -0,0 +1,45 @@ +"""Sudo-Helper: Password-Cache via `sudo -v`.""" +from __future__ import annotations + +import subprocess + + +def is_cached() -> bool: + """Prüft ob sudo-credentials noch im 5-min cache stehen.""" + try: + r = subprocess.run( + ["sudo", "-n", "-v"], + capture_output=True, check=False, timeout=5, + ) + return r.returncode == 0 + except (FileNotFoundError, subprocess.TimeoutExpired): + return False + + +def cache(password: str) -> bool: + """Validiert Passwort + cached sudo-Credentials. + + Wir nutzen `sudo -S -v -p ''` (lies pw von stdin, nur cache update, + leerer prompt). Returns True wenn akzeptiert.""" + try: + r = subprocess.run( + ["sudo", "-S", "-v", "-p", ""], + input=password + "\n", + capture_output=True, text=True, check=False, timeout=10, + ) + return r.returncode == 0 + except (FileNotFoundError, subprocess.TimeoutExpired): + return False + + +def keep_alive() -> None: + """Verlängert den sudo cache nochmal, falls noch gültig. + + Praktisch in laufenden Updates die mehrere Minuten dauern können.""" + try: + subprocess.run( + ["sudo", "-n", "-v"], + capture_output=True, check=False, timeout=5, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + pass diff --git a/src/taninux/core/sudo_modal.py b/src/taninux/core/sudo_modal.py new file mode 100644 index 0000000..dad65f6 --- /dev/null +++ b/src/taninux/core/sudo_modal.py @@ -0,0 +1,78 @@ +"""Sudo-Passwort-Modal — fragt das Passwort einmal ab und gibt es zurück.""" +from __future__ import annotations + +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Vertical +from textual.screen import ModalScreen +from textual.widgets import Input, Static + +from taninux.core import theme as T + + +class SudoModal(ModalScreen[str | None]): + """Fragt sudo-passwort. Liefert das Passwort oder None bei abbruch. + + Eingabe ist gemaskt. Das Passwort wird NICHT geloggt oder gespeichert — + nur an `sudo -S -v` weitergereicht zum cachen.""" + + BINDINGS = [ + Binding("escape", "cancel", "abbruch"), + ] + + CSS = f""" + SudoModal {{ + align: center middle; + background: rgba(0, 0, 0, 0.6); + }} + SudoModal #sudo-dialog {{ + width: auto; + max-width: 70; + height: auto; + padding: 1 3; + background: {T.YORU}; + border: round {T.BASE3}; + }} + SudoModal Static {{ + background: {T.YORU}; + margin-bottom: 1; + }} + SudoModal Input {{ + background: {T.BASE1}; + color: {T.YUKI}; + border: round {T.BASE2}; + }} + SudoModal Input:focus {{ + border: round {T.UME}; + }} + SudoModal #sudo-hint {{ + margin-top: 1; + margin-bottom: 0; + color: {T.BASE4}; + }} + """ + + def compose(self) -> ComposeResult: + with Vertical(id="sudo-dialog"): + yield Static(f"[{T.UME}]sudo passwort[/]", markup=True) + yield Static( + f"[{T.BASE5}]wird via sudo -S -v gecached (5 min ttl), " + f"dann updates laufen im TUI.[/]", + markup=True, + ) + yield Input(password=True, id="sudo-pw") + yield Static( + f"[{T.BASE5}]ENTER[/] [{T.BASE4}]bestätigen[/] " + f"[{T.BASE5}]ESC[/] [{T.BASE4}]abbrechen[/]", + id="sudo-hint", + markup=True, + ) + + def on_mount(self) -> None: + self.query_one(Input).focus() + + def on_input_submitted(self, event: Input.Submitted) -> None: + self.dismiss(event.value) + + def action_cancel(self) -> None: + self.dismiss(None) diff --git a/src/taninux/core/sysinfo.py b/src/taninux/core/sysinfo.py new file mode 100644 index 0000000..fcdc457 --- /dev/null +++ b/src/taninux/core/sysinfo.py @@ -0,0 +1,274 @@ +"""Geteilte System-Info-Erkennung — genutzt von health, suggest, kernel.""" +from __future__ import annotations + +import os +import platform +import re +import shutil +import subprocess +from dataclasses import dataclass, field +from pathlib import Path + + +# ----------------------------------------------------------------- base + + +def hostname() -> str: + return platform.node() or "?" + + +def kernel_release() -> str: + return platform.release() + + +def distro_pretty() -> str: + try: + for line in Path("/etc/os-release").read_text().splitlines(): + if line.startswith("PRETTY_NAME="): + return line.split("=", 1)[1].strip().strip('"') + except OSError: + pass + return "?" + + +def uptime_seconds() -> float: + try: + with open("/proc/uptime") as f: + return float(f.read().split()[0]) + except OSError: + return 0.0 + + +def load_avg() -> tuple[float, float, float]: + try: + return os.getloadavg() + except OSError: + return (0.0, 0.0, 0.0) + + +def cpu_model() -> str: + try: + with open("/proc/cpuinfo") as f: + for line in f: + if line.startswith("model name"): + return line.split(":", 1)[1].strip() + except OSError: + pass + return "Unknown CPU" + + +def cpu_count() -> int: + return os.cpu_count() or 1 + + +def memory() -> dict[str, int]: + """MemTotal/MemAvailable/SwapTotal/SwapFree etc in Bytes.""" + out: dict[str, int] = {} + try: + with open("/proc/meminfo") as f: + for line in f: + parts = line.split() + if len(parts) >= 2: + key = parts[0].rstrip(":") + try: + out[key] = int(parts[1]) * 1024 + except ValueError: + continue + except OSError: + pass + return out + + +def boot_time_seconds() -> float | None: + """systemd-analyze boot duration.""" + try: + r = subprocess.run( + ["systemd-analyze", "time"], + capture_output=True, text=True, check=False, timeout=5, + ) + m = re.search(r"=\s*([\d.]+)s", r.stdout) + if m: + return float(m.group(1)) + except (FileNotFoundError, subprocess.TimeoutExpired): + return None + return None + + +# ----------------------------------------------------------------- hardware + + +@dataclass +class GPU: + vendor: str # "nvidia" / "amd" / "intel" / "unknown" + model: str + pci_slot: str + driver: str | None = None + + +def _detect_gpu_vendor(text: str) -> str: + t = text.lower() + if "nvidia" in t: + return "nvidia" + if "amd" in t or "radeon" in t or "ati " in t: + return "amd" + if "intel" in t: + return "intel" + return "unknown" + + +def gpus() -> list[GPU]: + """Parse `lspci -k` für VGA/3D/Display controller.""" + try: + r = subprocess.run( + ["lspci", "-k"], + capture_output=True, text=True, check=False, timeout=5, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return [] + found: list[GPU] = [] + current: GPU | None = None + # lspci -k Format: jede Top-Level-Zeile beginnt mit "BB:DD.F" (bus:device.function) + # Sub-Zeilen ("Kernel driver…", "Kernel modules…") sind eingerückt. + for line in r.stdout.splitlines(): + # Neue Top-Level-Zeile? + if line and not line.startswith((" ", "\t")) and ":" in line.split()[0]: + # Reset: alte GPU bekommt keine driver-zeile mehr von hier + current = None + if any(k in line for k in ( + "VGA compatible controller", + "3D controller", + "Display controller", + )): + parts = line.split(":", 2) + if len(parts) >= 3: + pci = parts[0].strip() + model = parts[2].strip() + current = GPU( + vendor=_detect_gpu_vendor(model), + model=model, + pci_slot=pci, + driver=None, + ) + found.append(current) + elif current and line.lstrip().startswith("Kernel driver in use:"): + current.driver = line.split(":", 1)[1].strip() + return found + + +def audio_server() -> str: + """pipewire / pulse / ? (best-effort heuristic).""" + uid = os.getuid() + runtime = Path(f"/run/user/{uid}") + if (runtime / "pipewire-0").exists(): + return "pipewire" + if (runtime / "pulse").exists(): + return "pulse" + return "?" + + +@dataclass +class NetIface: + name: str + state: str # "UP" / "DOWN" / "UNKNOWN" + addrs: list[str] = field(default_factory=list) + + +def network_interfaces() -> list[NetIface]: + try: + r = subprocess.run( + ["ip", "-br", "addr"], + capture_output=True, text=True, check=False, timeout=5, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return [] + out: list[NetIface] = [] + for line in r.stdout.splitlines(): + parts = line.split() + if len(parts) < 2: + continue + name = parts[0] + state = parts[1] + addrs = parts[2:] if len(parts) > 2 else [] + out.append(NetIface(name=name, state=state, addrs=addrs)) + return out + + +@dataclass +class Disk: + name: str + size_bytes: int + model: str + rotational: bool + + +def disks() -> list[Disk]: + try: + r = subprocess.run( + ["lsblk", "-d", "-b", "-n", "-o", "NAME,SIZE,ROTA,MODEL"], + capture_output=True, text=True, check=False, timeout=5, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return [] + out: list[Disk] = [] + for line in r.stdout.splitlines(): + parts = line.split(None, 3) + if len(parts) < 3: + continue + try: + size = int(parts[1]) + except ValueError: + continue + out.append(Disk( + name=parts[0], + size_bytes=size, + rotational=parts[2] == "1", + model=parts[3].strip() if len(parts) > 3 else "", + )) + return out + + +# ----------------------------------------------------------------- services / logs + + +def systemctl_failed() -> list[str]: + try: + r = subprocess.run( + ["systemctl", "list-units", "--failed", "--no-legend", "--plain"], + capture_output=True, text=True, check=False, timeout=5, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return [] + return [ + line.split()[0] + for line in r.stdout.splitlines() + if line.strip() + ] + + +def journal_errors_today() -> int: + try: + r = subprocess.run( + ["journalctl", "-p", "3", "--since", "today", "-q", "--no-pager"], + capture_output=True, text=True, check=False, timeout=10, + ) + return len([l for l in r.stdout.splitlines() if l.strip()]) + except (FileNotFoundError, subprocess.TimeoutExpired): + return -1 + + +def journal_errors_boot() -> int: + try: + r = subprocess.run( + ["journalctl", "-p", "3", "-b", "-q", "--no-pager"], + capture_output=True, text=True, check=False, timeout=10, + ) + return len([l for l in r.stdout.splitlines() if l.strip()]) + except (FileNotFoundError, subprocess.TimeoutExpired): + return -1 + + +# ----------------------------------------------------------------- binaries + + +def has_binary(name: str) -> bool: + return shutil.which(name) is not None diff --git a/src/taninux/core/theme.py b/src/taninux/core/theme.py new file mode 100644 index 0000000..e737bfc --- /dev/null +++ b/src/taninux/core/theme.py @@ -0,0 +1,29 @@ +"""Mountain (Fuji) Palette. + +Reine Farben-Konstanten, keine textual-Dependency — damit auch CLI-Code +und Scanner ohne installed textual importieren können. +Das Textual-`Theme`-Objekt lebt in `taninux.app.MOUNTAIN_THEME`. +""" +from __future__ import annotations + +# ---- Mountain Fuji Palette (adaptiert von mountain-theme/Mountain) ---- + +# Backgrounds +YORU = "#0f0f0f" # bg +BASE0 = "#191919" # subtle alt bg +BASE1 = "#262626" # hover / selection / modeline +BASE2 = "#393939" # divider +BASE3 = "#4c4c4c" # muted border / comment + +# Foregrounds +YUKI = "#f0f0f0" # fg +FG_ALT = "#e7e7e7" +BASE4 = "#767676" # secondary text +BASE5 = "#a0a0a0" # tertiary text + +# Accents — bewusst reduziert auf 4 muted hues + ein gedämpftes Rot +UME = "#8f8aac" # violet — title / active / primary +TAKE = "#8aac8b" # green — safe / success +SHINKAI = "#8a98ac" # blue — section headers / secondary +CHIKYU = "#aca98a" # yellow — sizes / warning +RED = "#ac8a8c" # red — destructive (gedämpft) diff --git a/src/taninux/core/trash.py b/src/taninux/core/trash.py new file mode 100644 index 0000000..7df69d2 --- /dev/null +++ b/src/taninux/core/trash.py @@ -0,0 +1,128 @@ +"""Safe-Trash: Pfade wandern in einen Timestamp-Slot mit Manifest, statt rm. + +Restore und Purge sind vom User aus CLI/TUI ansteuerbar. +""" +from __future__ import annotations + +import json +import os +import shutil +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path + +from taninux.core.paths import trash_dir + + +@dataclass +class TrashEntry: + id: str # slot directory name + slot: Path # absolute path of the slot + original: Path # where it came from + trashed_at: str # ISO timestamp + size_bytes: int + + +def _slot_for(name: str) -> Path: + ts = datetime.now().strftime("%Y%m%dT%H%M%S") + base = trash_dir() / f"{ts}-{name}" + slot = base + n = 1 + while slot.exists(): + slot = trash_dir().joinpath(f"{ts}-{name}-{n}") + n += 1 + return slot + + +def _dir_size(p: Path) -> int: + if p.is_symlink(): + return 0 + if p.is_file(): + try: + return p.stat(follow_symlinks=False).st_size + except OSError: + return 0 + if not p.is_dir(): + return 0 + total = 0 + for root, _dirs, files in os.walk(p, followlinks=False): + for f in files: + try: + total += os.stat(os.path.join(root, f), follow_symlinks=False).st_size + except OSError: + continue + return total + + +def move_to_trash(path: Path) -> TrashEntry: + """Atomically move `path` into a new trash slot. Returns the entry.""" + if not path.exists(): + raise FileNotFoundError(path) + + size = _dir_size(path) + slot = _slot_for(path.name) + slot.mkdir(parents=True) + + payload_dir = slot / path.name + shutil.move(str(path), str(payload_dir)) + + manifest = { + "original": str(path), + "trashed_at": datetime.now().isoformat(timespec="seconds"), + "size_bytes": size, + "payload": path.name, + } + (slot / "manifest.json").write_text(json.dumps(manifest, indent=2)) + + return TrashEntry( + id=slot.name, + slot=slot, + original=path, + trashed_at=manifest["trashed_at"], + size_bytes=size, + ) + + +def list_trash() -> list[TrashEntry]: + entries: list[TrashEntry] = [] + for slot in sorted(trash_dir().iterdir()): + if not slot.is_dir(): + continue + mf = slot / "manifest.json" + if not mf.exists(): + continue + try: + data = json.loads(mf.read_text()) + except (json.JSONDecodeError, OSError): + continue + entries.append(TrashEntry( + id=slot.name, + slot=slot, + original=Path(data["original"]), + trashed_at=data.get("trashed_at", ""), + size_bytes=int(data.get("size_bytes", 0)), + )) + return entries + + +def restore(slot_id: str) -> Path: + slot = trash_dir() / slot_id + if not slot.is_dir(): + raise FileNotFoundError(slot_id) + manifest = json.loads((slot / "manifest.json").read_text()) + original = Path(manifest["original"]) + payload = slot / manifest.get("payload", original.name) + if original.exists(): + raise FileExistsError(f"original path already exists: {original}") + original.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(payload), str(original)) + (slot / "manifest.json").unlink() + slot.rmdir() + return original + + +def purge(slot_id: str) -> None: + slot = trash_dir() / slot_id + if not slot.is_dir(): + raise FileNotFoundError(slot_id) + shutil.rmtree(slot) diff --git a/src/taninux/core/wallpaper.py b/src/taninux/core/wallpaper.py new file mode 100644 index 0000000..a69e781 --- /dev/null +++ b/src/taninux/core/wallpaper.py @@ -0,0 +1,135 @@ +"""Wallpaper-Verwaltung über waypaper (Backend swaybg). UI-frei. + +Liest die waypaper-Config (~/.config/waypaper/config.ini), liefert den +Hintergrundordner, das aktuelle Bild, erkannte Monitore und die flache Liste +der Bilddateien. Setzen läuft über die waypaper-CLI (argv für ProcessRunner). + +WICHTIG: Dieses Modul lädt NIEMALS Bilddaten — nur Pfade. Das Skalieren / +Rendern der Thumbnails passiert ausschließlich in der GUI (RAM-kontrolliert). +""" +from __future__ import annotations + +import configparser +import os +import shutil +from pathlib import Path + +_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".bmp"} + + +def _config_path() -> Path: + base = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config") + return Path(base) / "waypaper" / "config.ini" + + +def _expand(value: str) -> str: + """`~` und Umgebungs-Tilde expandieren, Whitespace strippen.""" + return os.path.expanduser(value.strip()) + + +def config() -> dict[str, str]: + """Liest [Settings] aus der waypaper-Config. Defensiv: leeres dict bei Fehler. + + `folder`/`wallpaper` werden bei der weiteren Auswertung expandiert; hier + bleiben die Rohwerte (mehrzeilige Werte wie `monitors`/`wallpaper` erhalten + ihre Zeilenumbrüche). + """ + path = _config_path() + if not path.exists(): + return {} + parser = configparser.ConfigParser() + try: + parser.read(path, encoding="utf-8") + except (configparser.Error, OSError): + return {} + if not parser.has_section("Settings"): + return {} + out: dict[str, str] = {} + for key in ("folder", "wallpaper", "backend", "monitors"): + if parser.has_option("Settings", key): + out[key] = parser.get("Settings", key) + return out + + +def wallpaper_dir() -> Path: + """Expandierter Hintergrundordner; Fallback ~/Pictures.""" + folder = config().get("folder", "").strip() + if folder: + return Path(_expand(folder)) + return Path(os.path.expanduser("~/Pictures")) + + +def set_folder(path: str) -> None: + """Hintergrundordner in waypapers config.ini setzen (andere Keys bleiben).""" + cfg_path = _config_path() + parser = configparser.ConfigParser() + try: + if cfg_path.exists(): + parser.read(cfg_path, encoding="utf-8") + except (configparser.Error, OSError): + pass + if not parser.has_section("Settings"): + parser.add_section("Settings") + parser.set("Settings", "folder", path) + try: + cfg_path.parent.mkdir(parents=True, exist_ok=True) + with cfg_path.open("w", encoding="utf-8") as f: + parser.write(f) + except OSError: + pass + + +def current() -> str: + """Aktuell gesetztes Wallpaper (expandiert) oder "" wenn keiner. + + waypaper kann pro Monitor ein Bild speichern (mehrzeiliger Wert); wir nehmen + die erste nicht-leere Zeile als „das" aktuelle Bild. + """ + raw = config().get("wallpaper", "") + for line in raw.splitlines(): + line = line.strip() + if line: + return _expand(line) + return "" + + +def monitors() -> list[str]: + """["All"] + erkannte Monitornamen aus dem (mehrzeiligen) monitors-Block.""" + out = ["All"] + raw = config().get("monitors", "") + for line in raw.splitlines(): + name = line.strip() + if name and name != "All" and name not in out: + out.append(name) + return out + + +def list_wallpapers() -> list[Path]: + """Bilddateien im wallpaper_dir (flach, case-insensitive), nach Name sortiert. + + Lädt KEINE Bilddaten — gibt nur Pfade zurück. + """ + base = wallpaper_dir() + try: + entries = [ + p for p in base.iterdir() + if p.is_file() and p.suffix.lower() in _EXTS + ] + except OSError: + return [] + return sorted(entries, key=lambda p: p.name.lower()) + + +def set_argv(path, monitor: str = "All") -> list[str]: + argv = ["waypaper", "--wallpaper", str(path)] + if monitor and monitor != "All": + argv += ["--monitor", monitor] + return argv + + +def random_argv() -> list[str]: + return ["waypaper", "--random"] + + +def available() -> bool: + return shutil.which("waypaper") is not None diff --git a/src/taninux/flatpak/__init__.py b/src/taninux/flatpak/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/taninux/flatpak/pillar.py b/src/taninux/flatpak/pillar.py new file mode 100644 index 0000000..440793d --- /dev/null +++ b/src/taninux/flatpak/pillar.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from taninux.flatpak.screen import FlatpakView +from taninux.pillars.base import Pillar + + +FLATPAK = Pillar( + key="f", + label="flatpak", + tagline="apps & runtimes", + icon="●", + factory=FlatpakView, +) diff --git a/src/taninux/flatpak/screen.py b/src/taninux/flatpak/screen.py new file mode 100644 index 0000000..547f3f3 --- /dev/null +++ b/src/taninux/flatpak/screen.py @@ -0,0 +1,341 @@ +"""Flatpak-Pillar: installierte Apps + Runtimes verwalten.""" +from __future__ import annotations + +import subprocess +from dataclasses import dataclass + +from textual import work +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Vertical +from textual.widgets import DataTable, Static, Tab, Tabs + +from taninux.core import theme as T +from taninux.core.confirm import ConfirmModal +from taninux.core.runner import Command, run_interactive +from taninux.maintain.scanners.pacman import _parse_size + + +@dataclass +class FlatpakItem: + ref: str # full ref like 'org.gnome.Calculator/x86_64/stable' + app_id: str # 'org.gnome.Calculator' + branch: str + arch: str + version: str + size_bytes: int + origin: str # remote name like 'flathub' + kind: str # 'app' or 'runtime' + + +def _flatpak_available() -> bool: + try: + subprocess.run(["flatpak", "--version"], + capture_output=True, check=False, timeout=5) + return True + except (FileNotFoundError, subprocess.TimeoutExpired): + return False + + +def _list_kind(kind: str) -> list[FlatpakItem]: + if not _flatpak_available(): + return [] + flag = "--app" if kind == "app" else "--runtime" + try: + r = subprocess.run( + ["flatpak", "list", flag, + "--columns=application,branch,arch,version,size,origin"], + capture_output=True, text=True, check=False, timeout=10, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return [] + items: list[FlatpakItem] = [] + for line in r.stdout.splitlines(): + s = line.strip() + if not s or s.startswith("Application"): + continue + parts = s.split("\t") + if len(parts) < 3: + continue + app_id = parts[0] + branch = parts[1] + arch = parts[2] + version = parts[3] if len(parts) > 3 else "" + size_str = parts[4] if len(parts) > 4 else "" + origin = parts[5] if len(parts) > 5 else "" + items.append(FlatpakItem( + ref=f"{app_id}/{arch}/{branch}", + app_id=app_id, + branch=branch, + arch=arch, + version=version, + size_bytes=_parse_size(size_str) if size_str else 0, + origin=origin, + kind=kind, + )) + return items + + +def _human_size(n: int) -> str: + f = float(n) + for unit in ("B", "K", "M", "G", "T"): + if f < 1024 or unit == "T": + return f"{f:.0f}{unit}" if unit == "B" else f"{f:.1f}{unit}" + f /= 1024 + return f"{f:.1f}T" + + +class FlatpakView(Vertical): + """Flatpak Apps + Runtimes verwalten (list, sizes, remove).""" + + DEFAULT_CSS = f""" + FlatpakView {{ + background: {T.YORU}; + color: {T.YUKI}; + layout: vertical; + }} + FlatpakView Tabs {{ + background: {T.YORU}; + height: 3; + }} + FlatpakView Tab {{ + background: {T.YORU}; + color: {T.BASE4}; + padding: 0 2; + }} + FlatpakView Tab.-active {{ + color: {T.UME}; + }} + FlatpakView Underline {{ + background: {T.YORU}; + color: {T.BASE2}; + }} + FlatpakView Underline > .underline--bar {{ + background: {T.YORU}; + color: {T.UME}; + }} + FlatpakView #f-stats {{ + height: 1; + padding: 0 2; + background: {T.YORU}; + color: {T.BASE5}; + }} + FlatpakView #f-table {{ + height: 1fr; + background: {T.YORU}; + color: {T.FG_ALT}; + border: none; + overflow-x: hidden; + scrollbar-size-vertical: 1; + scrollbar-color: {T.BASE2} {T.YORU}; + scrollbar-background: {T.YORU}; + scrollbar-corner-color: {T.YORU}; + }} + FlatpakView #f-table:focus {{ + border: none; + }} + FlatpakView DataTable > .datatable--header {{ + background: {T.YORU}; + color: {T.SHINKAI}; + }} + FlatpakView DataTable > .datatable--cursor {{ + background: {T.BASE1}; + color: {T.YUKI}; + }} + FlatpakView DataTable > .datatable--hover {{ + background: {T.YORU}; + }} + FlatpakView DataTable > .datatable--even-row {{ + background: {T.YORU}; + }} + FlatpakView DataTable > .datatable--odd-row {{ + background: {T.YORU}; + }} + FlatpakView #f-flash {{ + height: 1; + padding: 0 2; + background: {T.YORU}; + color: {T.BASE4}; + }} + """ + + BINDINGS = [ + Binding("r", "rescan", "rescan"), + Binding("x", "remove", "entfernen"), + Binding("u", "update_all", "alle updaten", show=False), + Binding("ctrl+a", "remove_unused", "unused runtimes"), + Binding("j", "cursor_down", "down", show=False), + Binding("k", "cursor_up", "up", show=False), + ] + + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + self.current_tab = "app" + self.items_by_tab: dict[str, list[FlatpakItem]] = {"app": [], "runtime": []} + + def compose(self) -> ComposeResult: + yield Tabs( + Tab("apps", id="app"), + Tab("runtimes", id="runtime"), + id="f-tabs", + ) + yield Static("", id="f-stats", markup=True) + yield DataTable(id="f-table", cursor_type="row", zebra_stripes=False) + yield Static("", id="f-flash", markup=True) + + def on_mount(self) -> None: + if not _flatpak_available(): + self._flash("flatpak ist nicht installiert", color=T.RED) + return + t = self.query_one("#f-table", DataTable) + t.add_column("app-id", key="appid") + t.add_column("branch", width=10, key="branch") + t.add_column("version", width=18, key="version") + t.add_column("grösse", width=10, key="size") + t.add_column("origin", width=14, key="origin") + + # ---- actions ---- + + def action_rescan(self) -> None: + self._flash(f"scanne flatpak {self.current_tab}s…") + self._rescan_worker(self.current_tab) + + @work(thread=True, exclusive=True, group="flatpak-scan") + def _rescan_worker(self, kind: str) -> None: + items = _list_kind(kind) + self.app.call_from_thread(self._after_rescan, kind, items) + + def _after_rescan(self, kind: str, items: list[FlatpakItem]) -> None: + self.items_by_tab[kind] = items + if kind == self.current_tab: + self._refresh_view() + self._flash(f"{len(items)} {kind}s") + + def action_remove(self) -> None: + cur = self._current() + if cur is None: + return + body = ( + f"flatpak entfernen?\n\n" + f" {cur.app_id}\n" + f" branch: {cur.branch}\n" + f" grösse: {_human_size(cur.size_bytes)}" + ) + self.app.push_screen( + ConfirmModal(f"uninstall {cur.app_id}?", body, danger=True), + lambda ok: self._after_remove(ok, cur), + ) + + def _after_remove(self, ok: bool | None, item: FlatpakItem) -> None: + if not ok: + self._flash("abgebrochen") + return + codes = run_interactive( + self.app, + [Command( + label=f"uninstall {item.app_id}", + argv=["flatpak", "uninstall", "-y", item.app_id], + )], + ) + success = codes and codes[0] == 0 + self._flash( + f"{item.app_id} entfernt" if success else "uninstall fehlgeschlagen", + color=T.TAKE if success else T.RED, + ) + self.action_rescan() + + def action_remove_unused(self) -> None: + body = "alle unused runtimes via `flatpak uninstall --unused -y` entfernen?" + self.app.push_screen( + ConfirmModal("unused runtimes weg?", body, danger=False), + self._after_remove_unused, + ) + + def _after_remove_unused(self, ok: bool | None) -> None: + if not ok: + self._flash("abgebrochen") + return + codes = run_interactive( + self.app, + [Command( + label="flatpak uninstall --unused", + argv=["flatpak", "uninstall", "--unused", "-y"], + )], + ) + success = codes and codes[0] == 0 + self._flash( + "unused runtimes weg" if success else "fehlgeschlagen", + color=T.TAKE if success else T.RED, + ) + self.action_rescan() + + def action_update_all(self) -> None: + codes = run_interactive( + self.app, + [Command(label="flatpak update", argv=["flatpak", "update", "-y"])], + ) + success = codes and codes[0] == 0 + self._flash("update fertig" if success else "update fehlgeschlagen", + color=T.TAKE if success else T.RED) + self.action_rescan() + + def action_cursor_down(self) -> None: + self.query_one("#f-table", DataTable).action_cursor_down() + + def action_cursor_up(self) -> None: + self.query_one("#f-table", DataTable).action_cursor_up() + + # ---- helpers ---- + + @property + def items(self) -> list[FlatpakItem]: + return self.items_by_tab.get(self.current_tab, []) + + def _current(self) -> FlatpakItem | None: + t = self.query_one("#f-table", DataTable) + r = t.cursor_row + if r is None or r < 0 or r >= len(self.items): + return None + return self.items[r] + + def _flash(self, msg: str, color: str | None = None) -> None: + color = color or T.BASE4 + self.query_one("#f-flash", Static).update(f"[{color}]{msg}[/]") + + def _refresh_view(self) -> None: + items = self.items + t = self.query_one("#f-table", DataTable) + t.clear() + + total_size = sum(i.size_bytes for i in items) + kind_label = "apps" if self.current_tab == "app" else "runtimes" + stats = ( + f"[{T.YUKI}]{len(items)}[/] [{T.BASE5}]{kind_label}[/]" + + (f" [{T.BASE3}]·[/] [{T.CHIKYU}]{_human_size(total_size)}[/] [{T.BASE5}]installiert[/]" + if total_size else "") + ) + self.query_one("#f-stats", Static).update(stats) + + for i in items: + size_styled = ( + f"[{T.CHIKYU}]{_human_size(i.size_bytes)}[/]" + if i.size_bytes else f"[{T.BASE3}]—[/]" + ) + t.add_row( + f"[{T.YUKI}]{i.app_id}[/]", + f"[{T.BASE5}]{i.branch}[/]", + f"[{T.BASE5}]{i.version}[/]", + size_styled, + f"[{T.BASE5}]{i.origin}[/]", + ) + + # ---- events ---- + + def on_tabs_tab_activated(self, event: Tabs.TabActivated) -> None: + if event.tab is None: + return + self.current_tab = event.tab.id + if not self.items_by_tab.get(self.current_tab): + self.action_rescan() + else: + self._refresh_view() diff --git a/src/taninux/gui/README.md b/src/taninux/gui/README.md new file mode 100644 index 0000000..457a178 --- /dev/null +++ b/src/taninux/gui/README.md @@ -0,0 +1,86 @@ +# TANINUX GTK-Frontend + +GTK4 + libadwaita Desktop-App neben der Textual-TUI. Teilt sich die +`taninux.core`-Engine (sysinfo / pacman / bootloader / snapshot …). + +## Start + +```sh +# System-Abhängigkeiten (empfohlen statt pip-Build von PyGObject): +sudo pacman -S python-gobject gtk4 libadwaita + +# starten +python -m taninux.gui +# oder nach `uv pip install -e .`: +taninux-gtk +``` + +## Architektur + +``` +gui/ + app.py Adw.Application + main() + window.py Adw.NavigationSplitView: Sidebar (Pillar-Liste) + Content-Stack + process.py async Gio.Subprocess-Runner (non-blocking), pkexec für root + console.py LogConsole + RunnerCard — Button → privilegierter Befehl → Live-Output + pages/ + __init__.py PAGES-Registry (key, label, icon, builder) — Pendant zur PILLARS-Liste + overview.py read-only, komplett aus core.sysinfo + update.py `pacman -Syu` via pkexec, Live-Stream (Async-Showcase) + kernel.py installierte Kernel + bootloader, linux-lts via pkexec installieren + settings.py SwitchRow/ComboRow/SpinRow/ColorScheme — das libadwaita-Vokabular +``` + +Neue Seite = Modul in `pages/` mit `build() -> Gtk.Widget` + ein Eintrag in +`PAGES`. Genau wie ein neuer Pillar in der TUI. + +## Privilege Escalation — dedizierter Helper + +`process.ProcessRunner(... privileged=True)` führt root-Befehle **nicht** mehr +als `pkexec pacman …` (= beliebiges root) aus, sondern schleust das argv durch +einen dedizierten Helper: + +``` +pkexec /usr/lib/taninux/taninux-helper +``` + +Der Helper (`helper/taninux-helper`) validiert das argv gegen eine feste +**Allowlist** und lehnt alles andere mit Exit ≠ 0 ab. Erlaubt sind nur: + +| Kommando | Operationen | erlaubte Flags | +|------------|----------------------------------------------|---------------------------------------------| +| `pacman` | `-S` `-Sy` `-Su` `-Syu` `-R` `-Rs` `-Rns` | `--noconfirm` `--needed` `--noprogressbar` | +| `bootctl` | `set-default` `set-oneshot` | — | +| `paccache` | `-r` `-rk1` `-rk0` `-ruk0` | `-k` `0`–`3` `--remove` `--uninstalled` | + +Operanden (Paketnamen, Boot-Einträge) sind frei — Tokens mit führendem `-`, +die nicht oben stehen, werden abgelehnt. Es läuft KEINE Shell, der Helper +`execvp()`t exakt das übergebene argv. Die argv-Schnittstelle nach aussen +(`RunnerCard(label, ["pacman","-Syu"], privileged=True)`) bleibt unverändert. + +Findet `process.py` `/usr/lib/taninux/taninux-helper` nicht, fällt es auf den +Helper im Repo zurück (`gui/helper/taninux-helper`) — praktisch für Dev ohne +Installation. + +### Installation (Helper + Policy) + +```sh +sudo install -Dm755 src/taninux/gui/helper/taninux-helper \ + /usr/lib/taninux/taninux-helper +sudo cp src/taninux/gui/polkit/ch.gabrielevarano.Taninux.policy \ + /usr/share/polkit-1/actions/ +``` + +Die Policy (`action id = ch.gabrielevarano.taninux.maintain`, +`exec.path = /usr/lib/taninux/taninux-helper`, `allow_active = auth_admin_keep`) +sorgt für **einmal Passwort pro Session**, danach gecached. + +### polkit-Auth-Agent + +Damit pkexec **ohne** Terminal nach dem Passwort fragen kann, muss ein +polkit-Auth-Agent laufen. Auf wlroots/eww-Setups ist der oft nicht automatisch +da — starte einen in deiner Autostart-Config: + +- `polkit-gnome` Agent, oder +- `lxqt-policykit` (`/usr/lib/lxqt-policykit/lxqt-policykit-agent`), oder +- `mate-polkit` / `hyprpolkitagent` diff --git a/src/taninux/gui/__init__.py b/src/taninux/gui/__init__.py new file mode 100644 index 0000000..78a6fb3 --- /dev/null +++ b/src/taninux/gui/__init__.py @@ -0,0 +1,10 @@ +"""TANINUX GTK-Frontend (GTK4 + libadwaita). + +Zweites Frontend neben der Textual-TUI — teilt sich dieselbe `taninux.core`- +Engine (sysinfo / pacman / bootloader / snapshot …). Die TUI bleibt für +Terminal/SSH, das GTK-Frontend ist die Desktop-Verwaltungs-App, die sich in +ein GTK-Setup (eww-Panel + Dock) einfügt. +""" +from __future__ import annotations + +APP_ID = "ch.gabrielevarano.Taninux" diff --git a/src/taninux/gui/__main__.py b/src/taninux/gui/__main__.py new file mode 100644 index 0000000..d02cbf4 --- /dev/null +++ b/src/taninux/gui/__main__.py @@ -0,0 +1,3 @@ +from taninux.gui.app import main + +raise SystemExit(main()) diff --git a/src/taninux/gui/accents.py b/src/taninux/gui/accents.py new file mode 100644 index 0000000..bb598eb --- /dev/null +++ b/src/taninux/gui/accents.py @@ -0,0 +1,36 @@ +"""Fuji-Akzent-Definitionen — reine Daten, KEINE GTK-Abhängigkeit. + +Bewusst GTK-frei, damit sowohl das GTK-Frontend (gui.style) als auch der +Persistenz-Writer (gui.config) und externe Consumer (z.B. die eww-Bar, die +gui.json liest) dieselbe Quelle nutzen. Hex-Werte kommen aus core.theme, +damit GTK-App und TUI identisch sind. +""" +from __future__ import annotations + +from dataclasses import dataclass + +from taninux.core import theme as T + + +@dataclass(frozen=True) +class Accent: + key: str + label: str + hex: str + + +# Reihenfolge = Anzeige-Reihenfolge der Swatches. +FUJI_ACCENTS: list[Accent] = [ + Accent("ume", "Ume · violet", T.UME), + Accent("shinkai", "Shinkai · blue", T.SHINKAI), + Accent("take", "Take · green", T.TAKE), + Accent("chikyu", "Chikyū · yellow", T.CHIKYU), + Accent("beni", "Beni · red", T.RED), +] + +_BY_KEY = {a.key: a for a in FUJI_ACCENTS} +DEFAULT_ACCENT = FUJI_ACCENTS[0].key + + +def by_key(key: str) -> Accent: + return _BY_KEY.get(key, _BY_KEY[DEFAULT_ACCENT]) diff --git a/src/taninux/gui/app.py b/src/taninux/gui/app.py new file mode 100644 index 0000000..118f150 --- /dev/null +++ b/src/taninux/gui/app.py @@ -0,0 +1,54 @@ +"""Adw.Application-Einstiegspunkte fürs GTK-Frontend. + +Zwei Apps auf derselben Codebasis (teilen core/ + die GUI-Bausteine): + + tsettings System Settings — Kernel/Connectivity/Hardware/… + thub Software Hub — Pakete suchen/installieren, Updates… + +Jede ist nur ein dünner Shell, der seine Sektionsliste an MainWindow gibt. +""" +from __future__ import annotations + +import sys + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") +from gi.repository import Adw, Gio # noqa: E402 + +from taninux.gui import APP_ID, style +from taninux.gui.pages import HUB_SECTIONS, SETTINGS_SECTIONS +from taninux.gui.window import MainWindow + + +class _ShellApp(Adw.Application): + def __init__(self, app_id: str, title: str, sections) -> None: + super().__init__(application_id=app_id, flags=Gio.ApplicationFlags.DEFAULT_FLAGS) + self._title = title + self._sections = sections + self._window: MainWindow | None = None + + def do_activate(self) -> None: + if self._window is None: + style.apply_persisted() # gespeichertes Farbschema + Fuji-Akzent + self._window = MainWindow(self, self._sections, self._title) + self._window.present() + + +def main_settings(argv: list[str] | None = None) -> int: + app = _ShellApp(f"{APP_ID}.Settings", "System Settings", SETTINGS_SECTIONS) + return app.run(argv if argv is not None else sys.argv) + + +def main_hub(argv: list[str] | None = None) -> int: + app = _ShellApp(f"{APP_ID}.SoftwareHub", "Software Hub", HUB_SECTIONS) + return app.run(argv if argv is not None else sys.argv) + + +# Rückwärtskompatibel: `taninux-gtk` / `python -m taninux.gui` -> System Settings. +main = main_settings + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/taninux/gui/config.py b/src/taninux/gui/config.py new file mode 100644 index 0000000..8686b2c --- /dev/null +++ b/src/taninux/gui/config.py @@ -0,0 +1,44 @@ +"""Persistenter Appearance-State fürs GTK-Frontend. + +Eigene kleine JSON neben dem TUI-State (core.state ist whitelist-fokussiert, +das hier ist reine GUI-Präferenz). Liegt in der XDG-data-dir von taninux. +""" +from __future__ import annotations + +import json + +from taninux.core.paths import data_dir +from taninux.gui.accents import by_key + +_DEFAULTS = {"color_scheme": "system", "accent": "ume"} + + +def _path(): + return data_dir() / "gui.json" + + +def load() -> dict: + try: + data = json.loads(_path().read_text()) + except (json.JSONDecodeError, OSError): + return dict(_DEFAULTS) + return {**_DEFAULTS, **{k: data[k] for k in _DEFAULTS if k in data}} + + +def save(color_scheme: str, accent: str) -> None: + # accent_hex/-label gleich mit auflösen, damit externe Consumer (eww-Bar) + # nur diese eine JSON lesen müssen — kein Key→Hex-Mapping nachbauen. + acc = by_key(accent) + try: + _path().write_text(json.dumps( + { + "version": 1, + "color_scheme": color_scheme, + "accent": accent, + "accent_hex": acc.hex, + "accent_label": acc.label, + }, + indent=2, + )) + except OSError: + pass diff --git a/src/taninux/gui/confirm.py b/src/taninux/gui/confirm.py new file mode 100644 index 0000000..fc1d976 --- /dev/null +++ b/src/taninux/gui/confirm.py @@ -0,0 +1,42 @@ +"""Wiederverwendbare Bestätigungs-Dialoge fürs GTK-Frontend. + +Vor destruktiven Aktionen (Pakete entfernen, Cache leeren, Bootloader ändern) +zeigen die Seiten einen `Adw.AlertDialog` und rufen den Callback nur bei +„Fortfahren". So liegt das Bestätigungsmuster an EINER Stelle. +""" +from __future__ import annotations + +from typing import Callable + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") +from gi.repository import Adw, Gtk # noqa: E402 + + +def confirm( + parent: Gtk.Widget, + heading: str, + body: str, + on_confirm: Callable[[], None], +) -> None: + """Zeigt einen Bestätigungs-Dialog. Ruft `on_confirm()`, wenn der Nutzer + „Fortfahren" wählt; bei „Abbrechen" passiert nichts. + + `parent` ist irgendein Widget im Fenster — der Dialog hängt sich an dessen + Root (Gtk.Window) an. Nicht-blockierend (libadwaita-Async-Pattern). + """ + dialog = Adw.AlertDialog(heading=heading, body=body) + dialog.add_response("cancel", "Cancel") + dialog.add_response("confirm", "Continue") + dialog.set_response_appearance("confirm", Adw.ResponseAppearance.DESTRUCTIVE) + dialog.set_default_response("cancel") + dialog.set_close_response("cancel") + + def _on_response(_dlg: Adw.AlertDialog, response: str) -> None: + if response == "confirm": + on_confirm() + + dialog.connect("response", _on_response) + dialog.present(parent) diff --git a/src/taninux/gui/console.py b/src/taninux/gui/console.py new file mode 100644 index 0000000..47526e7 --- /dev/null +++ b/src/taninux/gui/console.py @@ -0,0 +1,123 @@ +"""Scrollbare Monospace-Log-Konsole + Run-Button — wiederverwendbar. + +Kapselt das Muster „Button drücken → privilegierten Befehl async ausführen → +Output live mitlesen". Genutzt von der Update- und Kernel-Seite. +""" +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") +from gi.repository import Adw, Gtk # noqa: E402 + +from taninux.gui import confirm as confirm_mod +from taninux.gui.process import ProcessRunner + + +class LogConsole(Gtk.Box): + """TextView in einem ScrolledWindow, das automatisch nach unten scrollt.""" + + def __init__(self) -> None: + super().__init__(orientation=Gtk.Orientation.VERTICAL) + self.add_css_class("card") + + self._view = Gtk.TextView(editable=False, cursor_visible=False, monospace=True) + self._view.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) + self._view.set_left_margin(10) + self._view.set_right_margin(10) + self._view.set_top_margin(8) + self._view.set_bottom_margin(8) + self._buffer = self._view.get_buffer() + + # Höhe auf ~5 Zeilen begrenzt (vorher 220px = riesiges Feld), scrollbar. + scroller = Gtk.ScrolledWindow(hexpand=True) + scroller.set_min_content_height(112) + scroller.set_max_content_height(112) + scroller.set_propagate_natural_height(False) + scroller.set_child(self._view) + self.set_vexpand(False) + self.append(scroller) + + def clear(self) -> None: + self._buffer.set_text("") + + def append_line(self, text: str) -> None: + end = self._buffer.get_end_iter() + self._buffer.insert(end, text + "\n") + # ans Ende scrollen + mark = self._buffer.create_mark(None, self._buffer.get_end_iter(), False) + self._view.scroll_mark_onscreen(mark) + self._buffer.delete_mark(mark) + + +def collapsible_log(console: LogConsole, *, title: str = "Log") -> Gtk.Expander: + """Konsole in einem zugeklappten, dezenten „Log"-Disclosure.""" + exp = Gtk.Expander(label=title) + exp.set_margin_top(6) + exp.set_child(console) + return exp + + +class RunnerCard(Gtk.Box): + """Konsole + Action-Button, verdrahtet mit einem ProcessRunner.""" + + def __init__( + self, + button_label: str, + argv: list[str], + *, + privileged: bool = True, + confirm: tuple[str, str] | None = None, + ) -> None: + super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=12) + self._argv = argv + self._privileged = privileged + self._confirm = confirm + self._runner = ProcessRunner() + self._runner.connect("line", lambda _r, line: self._console.append_line(line)) + self._runner.connect("done", self._on_done) + + self._button = Gtk.Button(label=button_label) + self._button.add_css_class("suggested-action") + self._button.add_css_class("pill") + self._button.set_halign(Gtk.Align.START) + self._button.connect("clicked", self._on_click) + self.append(self._button) + + self._console = LogConsole() + self.append(collapsible_log(self._console)) + + def _run(self) -> None: + self._button.set_sensitive(False) + self._console.clear() + self._runner.run(self._argv, privileged=self._privileged) + + def _on_click(self, _button: Gtk.Button) -> None: + if self._confirm is not None: + heading, body = self._confirm + confirm_mod.confirm(self._button, heading, body, self._run) + else: + self._run() + + def _on_done(self, _runner: ProcessRunner, code: int) -> None: + self._console.append_line("") + self._console.append_line( + f"✔ done (exit {code})" if code == 0 else f"✘ exit {code}" + ) + self._button.set_sensitive(True) + + +def log_expander(console: LogConsole, *, title: str = "Log", + expanded: bool = False) -> Adw.PreferencesGroup: + """Verpackt eine LogConsole in eine einklappbare „Log"-Zeile (Pfeil). + + Standardmäßig zugeklappt; aufgeklappt zeigt sie die auf ~5 Zeilen + begrenzte, scrollbare Konsole. Rückgabe = PreferencesGroup (für page.add()). + Ersetzt das frühere „große schwarze Log-Feld" auf jeder Seite. + """ + group = Adw.PreferencesGroup() + exp = collapsible_log(console, title=title) + exp.set_expanded(expanded) + group.add(exp) + return group diff --git a/src/taninux/gui/desktop.py b/src/taninux/gui/desktop.py new file mode 100644 index 0000000..546cc8d --- /dev/null +++ b/src/taninux/gui/desktop.py @@ -0,0 +1,256 @@ +"""Desktop-Look: GTK3-Theme, Icon-Theme, System-Font — wie nwg-look. + +Alles User-Level (kein root). Robuster Doppel-Weg wie nwg-look: wir setzen +sowohl die gsettings-Keys (org.gnome.desktop.interface) ALS AUCH die +~/.config/gtk-{3,4}.0/settings.ini — so greift es unter GNOME (gsettings) +genauso wie unter niri (settings.ini), ohne laufenden Daemon. + +Hinweis: libadwaita-Apps ignorieren das GTK-*Theme* bewusst (nur GTK3/alte +GTK4-Apps folgen). Icon-Theme und Font gelten dagegen auch für libadwaita. +""" +from __future__ import annotations + +import configparser +import subprocess +from pathlib import Path + +_IFACE = "org.gnome.desktop.interface" +_SKIP_ICONS = {"default", "hicolor", "locolor"} + + +# ------------------------------------------------------------- discovery + +def _theme_dirs() -> list[Path]: + return [ + Path("/usr/share/themes"), + Path.home() / ".themes", + Path.home() / ".local/share/themes", + ] + + +def _icon_dirs() -> list[Path]: + return [ + Path("/usr/share/icons"), + Path.home() / ".local/share/icons", + Path.home() / ".icons", + ] + + +def list_gtk_themes() -> list[str]: + """Theme-Verzeichnisse mit gtk-3.0/ oder gtk-4.0/ Unterordner.""" + names: set[str] = set() + for d in _theme_dirs(): + if not d.is_dir(): + continue + for t in d.iterdir(): + if t.is_dir() and any((t / s).is_dir() for s in ("gtk-3.0", "gtk-4.0")): + names.add(t.name) + return sorted(names, key=str.lower) + + +def list_icon_themes() -> list[str]: + """Echte Icon-Themes (index.theme mit nicht-leerem Directories=).""" + names: set[str] = set() + for d in _icon_dirs(): + if not d.is_dir(): + continue + for t in d.iterdir(): + if t.name in _SKIP_ICONS: + continue + idx = t / "index.theme" + if not idx.is_file(): + continue + try: + text = idx.read_text(errors="ignore") + except OSError: + continue + for line in text.splitlines(): + s = line.strip() + if s.startswith("Directories=") and s.split("=", 1)[1].strip(): + names.add(t.name) + break + return sorted(names, key=str.lower) + + +def list_cursor_themes() -> list[str]: + """Cursor-Themes = Verzeichnisse mit einem cursors/ Unterordner.""" + names: set[str] = set() + for d in _icon_dirs(): + if not d.is_dir(): + continue + for t in d.iterdir(): + if t.is_dir() and (t / "cursors").is_dir(): + names.add(t.name) + return sorted(names, key=str.lower) + + +# ------------------------------------------------------------- read state + +def _gget(key: str) -> str: + try: + r = subprocess.run( + ["gsettings", "get", _IFACE, key], + capture_output=True, text=True, check=False, timeout=5, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return "" + return r.stdout.strip().strip("'\"") if r.returncode == 0 else "" + + +def current() -> dict[str, str]: + return { + "gtk_theme": _gget("gtk-theme"), + "icon_theme": _gget("icon-theme"), + "font": _gget("font-name"), + "cursor_theme": _gget("cursor-theme"), + "cursor_size": _gget("cursor-size"), + } + + +# ------------------------------------------------------------- apply + +def _gset(key: str, value: str) -> None: + try: + subprocess.run( + ["gsettings", "set", _IFACE, key, value], + check=False, timeout=5, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + pass + + +def _write_ini(key: str, value: str) -> None: + """Schreibt [Settings] key=value in gtk-3.0 und gtk-4.0 settings.ini.""" + for sub in ("gtk-3.0", "gtk-4.0"): + d = Path.home() / ".config" / sub + d.mkdir(parents=True, exist_ok=True) + ini = d / "settings.ini" + cp = configparser.ConfigParser() + cp.optionxform = str # Key-Case erhalten + if ini.exists(): + try: + cp.read(ini) + except configparser.Error: + pass + if "Settings" not in cp: + cp["Settings"] = {} + cp["Settings"][key] = value + try: + with ini.open("w") as f: + cp.write(f) + except OSError: + pass + + +def apply_gtk_theme(name: str) -> None: + _gset("gtk-theme", name) + _write_ini("gtk-theme-name", name) + + +def apply_icon_theme(name: str) -> None: + _gset("icon-theme", name) + _write_ini("gtk-icon-theme-name", name) + + +def apply_font(name: str) -> None: + _gset("font-name", name) + _write_ini("gtk-font-name", name) + + +def apply_cursor_theme(name: str) -> None: + _gset("cursor-theme", name) + _write_ini("gtk-cursor-theme-name", name) + + +def apply_cursor_size(size: int) -> None: + _gset("cursor-size", str(size)) + _write_ini("gtk-cursor-theme-size", str(size)) + + +# ------------------------------------------------------------- titlebar buttons +# +# button-layout (Schema org.gnome.desktop.wm.preferences) bestimmt, welche +# Knöpfe in der Titlebar erscheinen und auf welcher Seite. Format ":", +# Buttons komma-getrennt aus {minimize,maximize,close,appmenu,icon}. +# Beispiele: ":minimize,maximize,close" (alle rechts), "close:" (nur Close links). +# libadwaita respektiert diesen Key (auch diese App). + +_WM = "org.gnome.desktop.wm.preferences" + + +def _wm_get(key: str) -> str: + try: + r = subprocess.run( + ["gsettings", "get", _WM, key], + capture_output=True, text=True, check=False, timeout=5, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return "" + return r.stdout.strip().strip("'\"") if r.returncode == 0 else "" + + +def _wm_set(key: str, value: str) -> None: + try: + subprocess.run( + ["gsettings", "set", _WM, key, value], + check=False, timeout=5, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + pass + + +def _read_ini(key: str) -> str: + """Liest [Settings] key aus gtk-4.0 (bevorzugt) bzw. gtk-3.0 settings.ini.""" + for sub in ("gtk-4.0", "gtk-3.0"): + ini = Path.home() / ".config" / sub / "settings.ini" + if not ini.is_file(): + continue + cp = configparser.ConfigParser() + cp.optionxform = str + try: + cp.read(ini) + except configparser.Error: + continue + if cp.has_option("Settings", key): + return cp.get("Settings", key) + return "" + + +def _parse_layout(raw: str) -> dict: + left_part, _, right_part = raw.partition(":") + left = {b.strip() for b in left_part.split(",") if b.strip()} + right = {b.strip() for b in right_part.split(",") if b.strip()} + wanted = ("minimize", "maximize", "close") + side = "left" if sum(b in left for b in wanted) > sum(b in right for b in wanted) else "right" + present = left | right + return { + "minimize": "minimize" in present, + "maximize": "maximize" in present, + "close": "close" in present, + "side": side, + } + + +def window_buttons() -> dict: + """Effektives Decoration-Layout → {minimize, maximize, close, side}. + + Quelle ist `gtk-decoration-layout` aus settings.ini (das, was GTK wirklich + nutzt — der gnome-gsettings-Key allein greift auf portal-losen Sessions + NICHT). Wenn noch nichts gesetzt: GTK-Default (alle drei, rechts). + """ + raw = _read_ini("gtk-decoration-layout") + if not raw or ":" not in raw: + return {"minimize": True, "maximize": True, "close": True, "side": "right"} + return _parse_layout(raw) + + +def set_window_buttons(minimize: bool, maximize: bool, close: bool, side: str) -> None: + """Schreibt das Decoration-Layout. WIRKSAM ist `gtk-decoration-layout` in + settings.ini (greift bei NEU geöffneten Fenstern). Zusätzlich der + gnome-gsettings-Key, falls ein Settings-Portal ihn live übersetzt.""" + joined = ",".join( + b for b, on in (("minimize", minimize), ("maximize", maximize), ("close", close)) if on + ) + value = joined + ":" if side == "left" else ":" + joined + _write_ini("gtk-decoration-layout", value) # wirksam (settings.ini) + _wm_set("button-layout", value) # zusätzlich (gsettings) diff --git a/src/taninux/gui/eww.py b/src/taninux/gui/eww.py new file mode 100644 index 0000000..b8f87b7 --- /dev/null +++ b/src/taninux/gui/eww.py @@ -0,0 +1,33 @@ +"""eww-Reload — Bar nimmt neuen Akzent sofort über gui.json auf. + +Best-effort: re-evaluiert eww-Config (liest ihrerseits gui.json), verändert +eww selbst NICHT direkt. Das eww-Setup gehört Karims separater Config in ~/eww. +Reines stdlib (subprocess), keine GTK-Importe. +""" +from __future__ import annotations + +import subprocess + + +def reload_if_running() -> bool: + """Löst `eww reload` aus, falls der eww-Daemon läuft. Gibt True zurück, + wenn `eww reload` ausgeführt wurde, sonst False. Best-effort: fängt + FileNotFoundError/TimeoutExpired ab und crasht nie.""" + try: + running = subprocess.run( + ["pgrep", "-x", "eww"], check=False, timeout=5, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return False + if running.returncode != 0: + return False # Daemon läuft nicht + + try: + subprocess.run( + ["eww", "reload"], check=False, timeout=5, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return False + return True diff --git a/src/taninux/gui/helper/taninux-helper b/src/taninux/gui/helper/taninux-helper new file mode 100755 index 0000000..dc8daa0 --- /dev/null +++ b/src/taninux/gui/helper/taninux-helper @@ -0,0 +1,198 @@ +#!/usr/bin/env python3 +"""TANINUX root-Helper — wird via pkexec mit erhöhten Rechten gestartet. + +Installationspfad: /usr/lib/taninux/taninux-helper +(passend zur polkit-Action ch.gabrielevarano.taninux.maintain, deren +exec.path genau auf diesen Pfad zeigt.) + +Idee: Statt `pkexec pacman …` direkt laufen zu lassen (= beliebiges root!), +reicht das GTK-Frontend sein argv durch DIESEN Helper. Der prüft das Kommando +gegen eine feste ALLOWLIST und lehnt alles andere ab. So bleibt die +argv-basierte Schnittstelle (`["pacman","-Syu"]`, `["bootctl","set-default",…]`) +nach aussen unverändert, aber root kann nur noch die erlaubten Operationen. + +Aufruf: + taninux-helper [args…] → validieren + ausführen (exec) + taninux-helper --check [..] → nur validieren, Exit 0/2 (für Tests) + +Keine externen Deps — reines Stdlib, damit der Helper auch ohne installiertes +taninux-Paket aus dem Repo heraus läuft. +""" +from __future__ import annotations + +import os +import sys + +# ALLOWLIST: erste argv-Komponente → erlaubte Operationen. +# +# Pro Kommando definieren wir, welche der nachfolgenden Tokens als Sub-/Flag +# zulässig sind. Tokens, die mit "-" beginnen, müssen in "flags" stehen +# (oder einer der erlaubten Operatoren sein). Nicht-Flag-Tokens gelten als +# Operanden (Paketnamen, Boot-Einträge) und werden NICHT eingeschränkt — +# pacman/bootctl validieren die selbst, und sie können kein anderes Programm +# starten. +# +# Wichtig: KEINE Shell, kein eval. Wir führen via os.execvp() exakt das +# übergebene argv aus, ohne Interpretation durch eine Shell. +ALLOWLIST: dict[str, dict[str, set[str]]] = { + # Pakete (de)installieren + Vollupgrade. --noconfirm erlaubt, damit das + # GUI-Frontend nicht in einem TTY-Prompt hängen bleibt. + "pacman": { + "ops": {"-S", "-Syu", "-Su", "-Sy", "-R", "-Rs", "-Rns"}, + "flags": {"--noconfirm", "--needed", "--noprogressbar"}, + }, + # Firewall an/aus (ufw). Nur enable/disable — keine Regel-Manipulation. + "ufw": { + "ops": {"enable", "disable"}, + "flags": set(), + }, + # Benutzerverwaltung. useradd bewusst NUR mit -m/-c (KEIN -G/-u/-p/-o → + # keine Gruppen-/UID-Injektion); Admin-Rechte laufen separat über gpasswd + # (zu/aus wheel). passwd nur -e (Reset beim 1. Login erzwingen, kein Setzen). + "useradd": { + "ops": set(), + "flags": {"-m", "-c"}, + }, + "userdel": { + "ops": set(), + "flags": {"-r"}, + }, + "gpasswd": { + "ops": {"-a", "-d"}, + "flags": set(), + }, + "passwd": { + "ops": {"-e"}, + "flags": set(), + }, + # Bootloader-Default / einmaliger Boot (systemd-boot). + "bootctl": { + "ops": {"set-default", "set-oneshot"}, + "flags": set(), + }, + # Paket-Cache aufräumen. + "paccache": { + "ops": {"-r", "-rk1", "-rk0", "-ruk0"}, + "flags": {"-k", "0", "1", "2", "3", "--remove", "--uninstalled"}, + }, + # System-Locale + Tastatur-Layout (systemd). Operanden (LANG=…, Layout, + # Variante) sind frei; localectl validiert sie selbst. + "localectl": { + "ops": {"set-locale", "set-keymap", "set-x11-keymap"}, + "flags": set(), + }, + # Zeitzone + NTP (systemd-timesyncd). Operanden (Zeitzone, true/false) + # sind freie Operanden; timedatectl validiert sie selbst. set-time o.ä. + # ist bewusst NICHT erlaubt. + "timedatectl": { + "ops": {"set-timezone", "set-ntp", "set-local-rtc"}, + "flags": set(), + }, + # Dienste verwalten (z.B. power-profiles-daemon enablen). Unit-Namen sind + # freie Operanden; nur diese Verben sind erlaubt — kein mask/edit/o.ä. + "systemctl": { + "ops": {"enable", "disable", "start", "stop", "restart", "reenable"}, + "flags": {"--now"}, + }, + # CUPS-Druckerverwaltung. lpadmin: Default setzen (-d) / Drucker entfernen + # (-x); -p/-E nur als Flags zugelassen (kein Hinzufügen mit Treiber). + "lpadmin": { + "ops": {"-d", "-x"}, + "flags": {"-p", "-E"}, + }, + # Drucker an-/abschalten. KEINE Op — nur ein Operand (der Druckername). + "cupsenable": { + "ops": set(), + "flags": set(), + }, + "cupsdisable": { + "ops": set(), + "flags": set(), + }, + # Journal aufräumen (vacuum). Nur feste, ungefährliche Vacuum-Flags. + "journalctl": { + "ops": set(), + "flags": {"--vacuum-time=2weeks", "--vacuum-size=200M"}, + }, + # Snapshots (Timeshift). Braucht für ALLES root, auch --list. Snapshot- + # Namen/Kommentare sind freie Operanden (kein "-" am Anfang). + "timeshift": { + "ops": {"--list", "--create", "--delete", "--delete-all", "--restore", "--check"}, + "flags": {"--snapshot", "--comments", "--yes", "--scripted", "--tags"}, + }, +} + + +class Rejected(Exception): + """Argv hat die Allowlist nicht passiert.""" + + +def validate(argv: list[str]) -> list[str]: + """Prüft argv gegen die ALLOWLIST. Gibt das (unveränderte) argv zurück + oder wirft Rejected mit einer Begründung.""" + if not argv: + raise Rejected("leeres kommando") + + cmd = argv[0] + rule = ALLOWLIST.get(cmd) + if rule is None: + raise Rejected(f"kommando nicht erlaubt: {cmd!r}") + + allowed_ops = rule["ops"] + allowed_flags = rule["flags"] + saw_op = False + + for tok in argv[1:]: + if tok in allowed_ops: + saw_op = True + continue + if tok in allowed_flags: + continue + if tok.startswith("-"): + # ein Flag/Operator, der weder Operation noch erlaubtes Flag ist + raise Rejected(f"option nicht erlaubt für {cmd}: {tok!r}") + # ein Operand (Paketname, Boot-Eintrag) — keine Optionen-Injektion + # möglich, da kein "-" am Anfang. Operanden sind frei. + + # Kommandos mit erlaubten Operationen müssen eine davon nennen. Kommandos + # OHNE Ops (z.B. cupsenable/cupsdisable: nur Operand = Druckername) sind + # auch ohne Op gültig. + if allowed_ops and not saw_op: + raise Rejected(f"keine erlaubte operation für {cmd} angegeben") + + # gpasswd darf NUR die wheel-Gruppe verwalten (Admin-Toggle) — keine + # beliebige Gruppen-Mitgliedschaft (z.B. root). + if cmd == "gpasswd" and (not argv or argv[-1] != "wheel"): + raise Rejected("gpasswd nur für die wheel-Gruppe erlaubt") + + return argv + + +def main(raw_argv: list[str]) -> int: + check_only = False + args = raw_argv[1:] + if args and args[0] == "--check": + check_only = True + args = args[1:] + + try: + argv = validate(args) + except Rejected as err: + print(f"taninux-helper: abgelehnt: {err}", file=sys.stderr) + return 2 + + if check_only: + print(f"taninux-helper: ok: {' '.join(argv)}") + return 0 + + # erlaubtes Kommando ausführen — execvp ersetzt den Helper-Prozess, + # damit pkexec den echten Exit-Code des Kommandos durchreicht. + try: + os.execvp(argv[0], argv) + except OSError as err: # Programm nicht gefunden o.ä. + print(f"taninux-helper: exec fehlgeschlagen: {err}", file=sys.stderr) + return 127 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/src/taninux/gui/pages/__init__.py b/src/taninux/gui/pages/__init__.py new file mode 100644 index 0000000..f014d7d --- /dev/null +++ b/src/taninux/gui/pages/__init__.py @@ -0,0 +1,149 @@ +"""Seiten-Registry fürs GTK-Frontend. + +Seiten sind in logische SECTIONS gruppiert (Sidebar zeigt Überschriften). +PAGES ist die flache Ableitung (für Stack-Lookup / Navigation). +Neue Seite = Modul in pages/ mit build() + Eintrag in einer Section unten. +""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Callable + +from gi.repository import Gtk + +from taninux.gui.pages import ( + about, + accessibility, + audio, + backup, + bluetooth, + bundles, + dashboard, + defaultapps, + display, + firewall, + hub_home, + kernel, + locale, + lock, + maintain, + network, + panel, + power, + printers, + settings, + shares, + storage, + store, + update, + users, + vpn, +) + + +@dataclass(frozen=True) +class Page: + key: str + label: str + icon: str + build: Callable[[], Gtk.Widget] + keywords: tuple[str, ...] = () + + +@dataclass(frozen=True) +class Section: + title: str | None # None = keine Überschrift (Home) + pages: list[Page] + + +# -- System Settings — Hardware/System/Personalisierung -------------------- +SETTINGS_SECTIONS: list[Section] = [ + Section(None, [ + Page("dashboard", "Dashboard", "go-home-symbolic", dashboard.build, + ("overview", "status", "summary", "system", "home")), + ]), + Section("System", [ + Page("about", "About", "help-about-symbolic", about.build, + ("about", "system", "info", "version", "hardware", "cpu", "memory")), + Page("kernel", "Kernel", "application-x-firmware-symbolic", kernel.build, + ("kernel", "boot", "bootloader", "lts", "prune", "headers")), + Page("backup", "Timeshift", "drive-multidisk-symbolic", backup.build, + ("backup", "backups", "snapshot", "timeshift", "restore", "btrfs")), + Page("storage", "Storage", "drive-harddisk-symbolic", storage.build, + ("storage", "disk", "drive", "mount", "usb", "space", "eject")), + Page("users", "Users", "system-users-symbolic", users.build, + ("users", "account", "password", "login", "name")), + Page("defaultapps", "Default Apps", "emblem-default-symbolic", defaultapps.build, + ("default", "apps", "browser", "mail", "open with", "associations")), + ]), + Section("Connectivity", [ + Page("network", "Network", "network-wireless-symbolic", network.build, + ("network", "wifi", "wi-fi", "internet", "connection", "ethernet")), + Page("vpn", "VPN", "network-vpn-symbolic", vpn.build, + ("vpn", "wireguard", "openvpn", "dns", "privacy", "tunnel")), + Page("bluetooth", "Bluetooth", "bluetooth-symbolic", bluetooth.build, + ("bluetooth", "pair", "devices", "headset")), + Page("shares", "Network Shares", "folder-remote-symbolic", shares.build, + ("shares", "smb", "samba", "sftp", "ftp", "webdav", "nextcloud", + "nfs", "mount", "nautilus", "cloud")), + ]), + Section("Hardware", [ + Page("display", "Display", "video-display-symbolic", display.build, + ("display", "monitor", "resolution", "scale", "refresh", "rotation", + "night light", "color temperature")), + Page("audio", "Audio", "audio-volume-high-symbolic", audio.build, + ("audio", "sound", "volume", "output", "input", "microphone", + "per-app", "pavucontrol")), + Page("power", "Power", "battery-full-symbolic", power.build, + ("power", "battery", "energy", "profile", "brightness", "suspend", + "idle", "screen off", "sleep")), + Page("printers", "Printers", "printer-symbolic", printers.build, + ("printer", "cups", "print")), + ]), + Section("Privacy & Security", [ + Page("firewall", "Firewall", "security-high-symbolic", firewall.build, + ("firewall", "ufw", "security", "incoming", "block", "ports")), + Page("lock", "Screen Lock", "system-lock-screen-symbolic", lock.build, + ("lock", "screen", "lockscreen", "security", "password")), + ]), + Section("Personalization", [ + Page("panel", "Panel & Dock", "view-dual-symbolic", panel.build, + ("panel", "dock", "bar", "eww", "taskbar")), + Page("locale", "Region & Time", "input-keyboard-symbolic", locale.build, + ("region", "time", "timezone", "date", "language", "locale", + "keyboard", "layout", "ntp")), + Page("settings", "Appearance", "emblem-system-symbolic", settings.build, + ("appearance", "theme", "accent", "color scheme", "dark", "light", + "icons", "font", "cursor", "wallpaper", "window buttons", "gtk")), + Page("accessibility", "Accessibility", "preferences-desktop-accessibility-symbolic", + accessibility.build, + ("accessibility", "a11y", "large text", "contrast", "animations", "zoom")), + ]), +] + +# -- Software Hub — Paketverwaltung ---------------------------------------- +HUB_SECTIONS: list[Section] = [ + Section(None, [ + Page("home", "Home", "go-home-symbolic", hub_home.build, + ("home", "recommended", "discover", "suggestions", "featured")), + Page("store", "Browse", "system-search-symbolic", store.build, + ("search", "install", "remove", "packages", "pacman", "aur", + "flatpak", "software", "apps")), + ]), + Section("Packages", [ + Page("update", "Updates", "software-update-available-symbolic", update.build, + ("update", "upgrade", "pacman", "flatpak", "aur", "packages")), + Page("maintain", "Cleanup", "user-trash-symbolic", maintain.build, + ("cleanup", "clean", "cache", "orphans", "trash", "disk space", + "journal", "coredumps")), + Page("bundles", "Bundles", "application-x-addon-symbolic", bundles.build, + ("bundles", "packages", "install", "recommended", "suggestions", + "dev", "gaming")), + ]), +] + +# Default-Sektionen = System Settings (Rückwärtskompatibilität). +SECTIONS: list[Section] = SETTINGS_SECTIONS + +# Flache Liste über BEIDE Apps (für _select / Lookup / Tooling). +PAGES: list[Page] = [p for s in (SETTINGS_SECTIONS + HUB_SECTIONS) for p in s.pages] diff --git a/src/taninux/gui/pages/about.py b/src/taninux/gui/pages/about.py new file mode 100644 index 0000000..213dd77 --- /dev/null +++ b/src/taninux/gui/pages/about.py @@ -0,0 +1,66 @@ +"""About — Überblick über System und Hardware (read-only).""" +from __future__ import annotations + +import gi + +gi.require_version("Adw", "1") +from gi.repository import Adw, Gtk # noqa: E402 + +from taninux.core import sysinfo + + +def _ver() -> str: + try: + from importlib.metadata import version + return version("taninux") + except Exception: # noqa: BLE001 + return "0.2.0" + + +def _row(title: str, value: str) -> Adw.ActionRow: + row = Adw.ActionRow(title=title) + lbl = Gtk.Label(label=value, xalign=1.0, wrap=True) + lbl.add_css_class("dim-label") + lbl.set_selectable(True) + row.add_suffix(lbl) + return row + + +def _fmt_gib(b: int) -> str: + return f"{b / 1024**3:.1f} GiB" if b else "—" + + +def _uptime(sec: float) -> str: + m = int(sec // 60) + d, h, m = m // 1440, (m % 1440) // 60, m % 60 + if d: + return f"{d}d {h}h {m}m" + return f"{h}h {m}m" if h else f"{m}m" + + +def build() -> Gtk.Widget: + page = Adw.PreferencesPage() + mem = sysinfo.memory() + gpus = sysinfo.gpus() + + g_sys = Adw.PreferencesGroup(title="System") + g_sys.add(_row("Device", sysinfo.hostname())) + g_sys.add(_row("Operating system", sysinfo.distro_pretty())) + g_sys.add(_row("Kernel", sysinfo.kernel_release())) + g_sys.add(_row("TANINUX", _ver())) + page.add(g_sys) + + g_hw = Adw.PreferencesGroup(title="Hardware") + g_hw.add(_row("Processor", f"{sysinfo.cpu_model()} · {sysinfo.cpu_count()} cores")) + g_hw.add(_row("Memory", _fmt_gib(mem.get("MemTotal", 0)))) + if gpus: + for i, gpu in enumerate(gpus): + title = "Graphics" if i == 0 else "" + g_hw.add(_row(title, gpu.model)) + page.add(g_hw) + + g_st = Adw.PreferencesGroup(title="Status") + g_st.add(_row("Uptime", _uptime(sysinfo.uptime_seconds()))) + page.add(g_st) + + return page diff --git a/src/taninux/gui/pages/accessibility.py b/src/taninux/gui/pages/accessibility.py new file mode 100644 index 0000000..d1ee2d0 --- /dev/null +++ b/src/taninux/gui/pages/accessibility.py @@ -0,0 +1,35 @@ +"""Accessibility — Lesbarkeit und Bewegung (gsettings, user-level).""" +from __future__ import annotations + +import gi + +gi.require_version("Adw", "1") +from gi.repository import Adw, Gtk # noqa: E402 + +from taninux.core import accessibility as a11y + + +def _switch(title: str, subtitle: str, getter, setter) -> Adw.SwitchRow: + row = Adw.SwitchRow(title=title, subtitle=subtitle) + row.set_active(getter()) + row.connect("notify::active", lambda r, _p: setter(r.get_active())) + return row + + +def build() -> Gtk.Widget: + page = Adw.PreferencesPage() + + g_see = Adw.PreferencesGroup(title="Seeing") + g_see.add(_switch("Large text", "Increase the size of text", + a11y.large_text, a11y.set_large_text)) + g_see.add(_switch("High contrast", "Make windows and buttons stand out", + a11y.high_contrast, a11y.set_high_contrast)) + page.add(g_see) + + g_motion = Adw.PreferencesGroup(title="Motion") + g_motion.add(_switch("Reduce animations", "Minimise motion across the system", + lambda: not a11y.animations(), + lambda on: a11y.set_animations(not on))) + page.add(g_motion) + + return page diff --git a/src/taninux/gui/pages/appdetail.py b/src/taninux/gui/pages/appdetail.py new file mode 100644 index 0000000..101e17d --- /dev/null +++ b/src/taninux/gui/pages/appdetail.py @@ -0,0 +1,203 @@ +"""App-Detailansicht (Adw.Dialog) im GNOME-Software-Stil. + +Zeigt Icon · Name · Beschreibung · Version/Größe/Lizenz/Website und lässt die +Installationsquelle wählen (Repo · Flatpak · AUR falls aktiviert). Quellen und +Metadaten kommen async aus core.appinfo. +""" +from __future__ import annotations + +import threading + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") +from gi.repository import Adw, Gdk, GLib, Gtk # noqa: E402 + +from taninux.core import appinfo, panel +from taninux.gui.console import LogConsole, log_expander +from taninux.gui.process import ProcessRunner + + +def _icon_name(name: str, fallback: str = "package-x-generic-symbolic") -> str: + theme = Gtk.IconTheme.get_for_display(Gdk.Display.get_default()) + return name if (theme and theme.has_icon(name)) else fallback + + +def open_app_detail(parent: Gtk.Widget, *, title: str, icon: str, + repo_name: str | None = None, flatpak_id: str | None = None, + remote: str = "flathub", on_changed=None) -> None: + dialog = Adw.Dialog() + dialog.set_title(title) + dialog.set_content_width(540) + dialog.set_content_height(640) + + console = LogConsole() + runner = ProcessRunner() + runner.connect("line", lambda _r, line: console.append_line(line)) + + cand: list[appinfo.Detail] = [] + + page = Adw.PreferencesPage() + tv = Adw.ToolbarView() + tv.add_top_bar(Adw.HeaderBar()) + tv.set_content(page) + dialog.set_child(tv) + + # -- Kopf: Icon + Name + Summary -------------------------------------- + hg = Adw.PreferencesGroup() + head = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=16) + head.set_margin_top(8) + head.set_margin_bottom(8) + img = Gtk.Image.new_from_icon_name(_icon_name(icon)) + img.set_pixel_size(72) + head.append(img) + tbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) + tbox.set_valign(Gtk.Align.CENTER) + name_lbl = Gtk.Label(label=title, xalign=0.0) + name_lbl.add_css_class("title-1") + summary_lbl = Gtk.Label(label="", xalign=0.0, wrap=True) + summary_lbl.add_css_class("dim-label") + tbox.append(name_lbl) + tbox.append(summary_lbl) + head.append(tbox) + hg.add(head) + page.add(hg) + + # -- Quelle + Aktion -------------------------------------------------- + sg = Adw.PreferencesGroup(title="Install") + source_combo = Adw.ComboRow(title="Source") + sg.add(source_combo) + action_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) + action_box.set_margin_top(10) + action_btn = Gtk.Button(label="Install") + action_btn.add_css_class("suggested-action") + action_btn.add_css_class("pill") + action_btn.set_sensitive(False) + spinner = Adw.Spinner() + spinner.set_valign(Gtk.Align.CENTER) + spinner.set_visible(False) + action_box.append(action_btn) + action_box.append(spinner) + sg.add(action_box) + page.add(sg) + + # -- Details ---------------------------------------------------------- + dg = Adw.PreferencesGroup(title="Details") + page.add(dg) + detail_rows: list[Gtk.Widget] = [] + + page.add(log_expander(console)) + + def _selected() -> appinfo.Detail | None: + idx = source_combo.get_selected() + return cand[idx] if 0 <= idx < len(cand) else None + + def _static_row(label: str, value: str, *, link: bool = False) -> Gtk.Widget: + row = Adw.ActionRow(title=label) + if link: + btn = Gtk.LinkButton(uri=value, label=value) + btn.set_valign(Gtk.Align.CENTER) + row.add_suffix(btn) + else: + lab = Gtk.Label(label=value, xalign=1.0, wrap=True) + lab.add_css_class("dim-label") + lab.set_selectable(True) + row.add_suffix(lab) + return row + + def _show_details(d: appinfo.Detail) -> None: + for r in detail_rows: + dg.remove(r) + detail_rows.clear() + summary_lbl.set_label(d.summary or "") + fields = [ + ("Version", d.version), + ("Download size", d.size), + ("Installed size", d.installed_size), + ("License", d.license), + ] + for lab, val in fields: + if val: + r = _static_row(lab, val) + dg.add(r) + detail_rows.append(r) + if d.url: + r = _static_row("Website", d.url, link=True) + dg.add(r) + detail_rows.append(r) + if d.installed: + action_btn.set_label("Remove") + action_btn.remove_css_class("suggested-action") + action_btn.add_css_class("destructive-action") + else: + action_btn.set_label("Install") + action_btn.remove_css_class("destructive-action") + action_btn.add_css_class("suggested-action") + action_btn.set_sensitive(True) + + def _on_source(_combo, _pspec=None) -> None: + d = _selected() + if d: + _show_details(d) + + source_combo.connect("notify::selected", _on_source) + + def _on_action(_b) -> None: + d = _selected() + if not d: + return + argv, priv = (appinfo.remove_argv if d.installed else appinfo.install_argv)(d) + action_btn.set_sensitive(False) + spinner.set_visible(True) + console.clear() + runner.run(argv, privileged=priv) + + action_btn.connect("clicked", _on_action) + + def on_done(_r, code: int) -> None: + console.append_line("✔ done" if code == 0 else f"✘ exit {code}") + spinner.set_visible(False) + if callable(on_changed): + on_changed() + _load() # Quellen/Installed-Status neu ermitteln + + runner.connect("done", on_done) + + def _populate(found: list[appinfo.Detail]) -> None: + cand.clear() + cand.extend(found) + if not found: + source_combo.set_model(Gtk.StringList.new(["Not available"])) + source_combo.set_sensitive(False) + action_btn.set_sensitive(False) + summary_lbl.set_label("This app could not be found in any enabled source.") + return + source_combo.set_sensitive(True) + labels = [] + for d in found: + lab = appinfo.SOURCE_LABEL.get(d.source, d.source) + labels.append(f"{lab} · {d.version}" if d.version else lab) + source_combo.set_model(Gtk.StringList.new(labels)) + # bevorzugt eine bereits installierte Quelle vorwählen + sel = next((i for i, d in enumerate(found) if d.installed), 0) + source_combo.set_selected(sel) + _show_details(found[sel]) + + def _load() -> None: + include_aur = panel.include_aur() + + def worker() -> None: + try: + found = appinfo.candidates( + repo_name=repo_name, flatpak_id=flatpak_id, + remote=remote, include_aur=include_aur, + ) + except Exception: # noqa: BLE001 + found = [] + GLib.idle_add(lambda: (_populate(found), False)[1]) + + threading.Thread(target=worker, daemon=True).start() + + _load() + dialog.present(parent) diff --git a/src/taninux/gui/pages/audio.py b/src/taninux/gui/pages/audio.py new file mode 100644 index 0000000..7c23880 --- /dev/null +++ b/src/taninux/gui/pages/audio.py @@ -0,0 +1,197 @@ +"""Audio — pavucontrol-artige Steuerung (Output/Input) über core.audio (pactl). + +Pro Richtung: Geräte-Auswahl (= setzt Default), Lautstärke-Slider, Mute. +Alles User-Level, kein root. Live-Updates von aussen (anderes Tool ändert +Volume) holt man per Refresh — eine pactl-subscribe-Schleife wäre der nächste +Ausbau. +""" +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") +from gi.repository import Adw, Gtk # noqa: E402 + +from taninux.core import audio + + +def build() -> Gtk.Widget: + page = Adw.PreferencesPage() + if not audio.available(): + group = Adw.PreferencesGroup(title="Audio") + group.add(Adw.ActionRow( + title="No audio server", + subtitle="pactl found no PulseAudio/PipeWire server.", + )) + page.add(group) + return page + + page.add(_audio_group( + "sink", "Output", + "audio-volume-high-symbolic", "audio-volume-muted-symbolic", + )) + page.add(_audio_group( + "source", "Input", + "audio-input-microphone-symbolic", "microphone-disabled-symbolic", + )) + page.add(_apps_group()) + return page + + +def _apps_group() -> Adw.PreferencesGroup: + """Per-App-Lautstärke der gerade abspielenden Sink-Inputs.""" + group = Adw.PreferencesGroup(title="Applications") + + refresh = Gtk.Button(icon_name="view-refresh-symbolic") + refresh.add_css_class("flat") + refresh.set_valign(Gtk.Align.CENTER) + refresh.set_tooltip_text("Refresh") + group.set_header_suffix(refresh) + + rows: list[Gtk.Widget] = [] + + def populate() -> None: + for r in rows: + group.remove(r) + rows.clear() + + streams = audio.playback_streams() + if not streams: + row = Adw.ActionRow(title="No apps are playing audio") + group.add(row) + rows.append(row) + return + + for st in streams: + rows.append(_stream_row(st)) + group.add(rows[-1]) + + refresh.connect("clicked", lambda _b: populate()) + populate() + return group + + +def _stream_row(st: audio.Stream) -> Adw.ActionRow: + icon_on = "audio-volume-high-symbolic" + icon_off = "audio-volume-muted-symbolic" + row = Adw.ActionRow(title=st.app) + + adj = Gtk.Adjustment(value=0, lower=0, upper=150, step_increment=1, + page_increment=5) + scale = Gtk.Scale(orientation=Gtk.Orientation.HORIZONTAL, adjustment=adj) + scale.set_hexpand(True) + scale.set_size_request(200, -1) + scale.set_draw_value(False) + scale.set_valign(Gtk.Align.CENTER) + scale.add_mark(100, Gtk.PositionType.BOTTOM, None) + + mute_btn = Gtk.ToggleButton() + mute_btn.add_css_class("flat") + mute_btn.set_valign(Gtk.Align.CENTER) + + row.add_suffix(scale) + row.add_suffix(mute_btn) + + # -- Initialwerte VOR connect setzen (kein Initial-Fire) ----------- + adj.set_value(min(st.volume, 150)) + mute_btn.set_active(st.muted) + mute_btn.set_icon_name(icon_off if st.muted else icon_on) + + def on_vol(a: Gtk.Adjustment) -> None: + audio.set_stream_volume(st.index, int(a.get_value())) + + def on_mute(b: Gtk.ToggleButton) -> None: + m = b.get_active() + audio.set_stream_mute(st.index, m) + b.set_icon_name(icon_off if m else icon_on) + + adj.connect("value-changed", on_vol) + mute_btn.connect("toggled", on_mute) + return row + + +def _audio_group(kind: str, title: str, icon_on: str, icon_off: str) -> Adw.PreferencesGroup: + devices = audio.list_devices(kind) + group = Adw.PreferencesGroup(title=title) + + if not devices: + group.add(Adw.ActionRow(title="No devices found")) + return group + + names = [d.name for d in devices] + descs = [d.description for d in devices] + default = audio.get_default(kind) + state = {"name": default if default in names else names[0]} + + # -- Geräte-Auswahl (= Default setzen) ---------------------------- + combo = Adw.ComboRow(title="Device") + combo.set_model(Gtk.StringList.new(descs)) + combo.set_selected(names.index(state["name"])) + + # -- Lautstärke-Zeile: Slider + % + Mute -------------------------- + vol_row = Adw.ActionRow(title="Volume") + adj = Gtk.Adjustment(value=0, lower=0, upper=150, step_increment=1, + page_increment=5) + scale = Gtk.Scale(orientation=Gtk.Orientation.HORIZONTAL, adjustment=adj) + scale.set_hexpand(True) + scale.set_size_request(220, -1) + scale.set_draw_value(False) + scale.set_valign(Gtk.Align.CENTER) + scale.add_mark(100, Gtk.PositionType.BOTTOM, None) # 100%-Markierung + + pct = Gtk.Label() + pct.add_css_class("dim-label") + pct.add_css_class("numeric") + pct.set_width_chars(4) + + mute_btn = Gtk.ToggleButton() + mute_btn.add_css_class("flat") + mute_btn.set_valign(Gtk.Align.CENTER) + + vol_row.add_suffix(scale) + vol_row.add_suffix(pct) + vol_row.add_suffix(mute_btn) + + guard = {"sync": False} + + def load(name: str) -> None: + guard["sync"] = True + v = audio.get_volume(kind, name) + m = audio.get_muted(kind, name) + adj.set_value(min(v, 150)) + pct.set_label(f"{v}%") + mute_btn.set_active(m) + mute_btn.set_icon_name(icon_off if m else icon_on) + guard["sync"] = False + + load(state["name"]) + + def on_combo(c: Adw.ComboRow, _p) -> None: + i = c.get_selected() + if 0 <= i < len(names): + state["name"] = names[i] + audio.set_default(kind, names[i]) + load(names[i]) + + def on_vol(a: Gtk.Adjustment) -> None: + if guard["sync"]: + return + v = int(a.get_value()) + pct.set_label(f"{v}%") + audio.set_volume(kind, state["name"], v) + + def on_mute(b: Gtk.ToggleButton) -> None: + if guard["sync"]: + return + m = b.get_active() + audio.set_mute(kind, state["name"], m) + b.set_icon_name(icon_off if m else icon_on) + + combo.connect("notify::selected", on_combo) + adj.connect("value-changed", on_vol) + mute_btn.connect("toggled", on_mute) + + group.add(combo) + group.add(vol_row) + return group diff --git a/src/taninux/gui/pages/backup.py b/src/taninux/gui/pages/backup.py new file mode 100644 index 0000000..dfd9f3f --- /dev/null +++ b/src/taninux/gui/pages/backup.py @@ -0,0 +1,219 @@ +"""Backups-Seite — Time-Machine-artige Snapshots über Timeshift. + +ALLE Timeshift-Aufrufe (auch --list) brauchen root → laufen privilegiert via +pkexec durch den taninux-helper. Restore ist sehr destruktiv (stellt das System +wieder her, ggf. Reboot) → mit Bestätigungsdialog. +""" +from __future__ import annotations + +import gi + +gi.require_version("Adw", "1") +from gi.repository import Adw, Gtk # noqa: E402 + +from taninux.core import backup +from taninux.gui import confirm +from taninux.gui.console import LogConsole, log_expander +from taninux.gui.process import ProcessRunner + + +def build() -> Gtk.Widget: + page = Adw.PreferencesPage() + + if not backup.available(): + _build_install(page) + return page + + # Gemeinsame Konsole + Action-Runner (create/delete/restore). Nach jeder + # Aktion wird die Snapshot-Liste neu geladen. + console = LogConsole() + action_runner = ProcessRunner() + action_runner.connect("line", lambda _r, line: console.append_line(line)) + + # Snapshots-Gruppe (wird in-place neu befüllt nach jeder Aktion). + g_snaps = Adw.PreferencesGroup( + title="Snapshots", + description="Restore reverts the system and may require a reboot.", + ) + rows: list[Gtk.Widget] = [] + + def reload_snapshots() -> None: + _load_snapshots(g_snaps, rows, console, action_runner, reload_snapshots) + + def on_action_done(_r: ProcessRunner, code: int) -> None: + console.append_line("") + console.append_line("✔ done (exit 0)" if code == 0 else f"✘ exit {code}") + reload_snapshots() + + action_runner.connect("done", on_action_done) + + # -- Create-Gruppe ------------------------------------------------ + page.add(_create_group(console, action_runner)) + + # -- Snapshots-Gruppe (initial laden) ----------------------------- + page.add(g_snaps) + reload_snapshots() + + # -- Log-Gruppe --------------------------------------------------- + page.add(log_expander(console)) + + return page + + +def _build_install(page: Adw.PreferencesPage) -> None: + """Fallback-UI, wenn timeshift nicht installiert ist.""" + from taninux.gui.console import RunnerCard + + group = Adw.PreferencesGroup( + title="Timeshift not installed", + description="Backups use Timeshift for system snapshots.", + ) + card = RunnerCard( + "Install timeshift", + ["pacman", "-S", "--needed", "--noconfirm", "timeshift"], + privileged=True, + ) + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + box.set_margin_top(6) + box.append(card) + group.add(box) + page.add(group) + + +def _create_group(console: LogConsole, runner: ProcessRunner) -> Adw.PreferencesGroup: + """EntryRow für Kommentar + Button zum Erstellen eines Snapshots.""" + group = Adw.PreferencesGroup(title="Create snapshot") + + entry = Adw.EntryRow(title="Comment") + group.add(entry) + + button = Gtk.Button(label="Create snapshot") + button.add_css_class("suggested-action") + button.add_css_class("pill") + button.set_halign(Gtk.Align.START) + + def on_click(_b: Gtk.Button) -> None: + if runner.running: + return + console.clear() + runner.run(backup.create_argv(entry.get_text()), privileged=True) + + button.connect("clicked", on_click) + + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + box.set_margin_top(6) + box.append(button) + group.add(box) + return group + + +def _load_snapshots( + group: Adw.PreferencesGroup, + rows: list[Gtk.Widget], + console: LogConsole, + action_runner: ProcessRunner, + reload: "callable", +) -> None: + """Lädt `timeshift --list` privilegiert über einen EIGENEN Runner, sammelt + die Ausgabe in einem Buffer und baut bei `done` die Zeilen neu auf.""" + # bestehende Zeilen entfernen + for row in rows: + group.remove(row) + rows.clear() + + spinner_row = Adw.ActionRow(title="Loading snapshots…") + spinner = Gtk.Spinner() + spinner.start() + spinner_row.add_prefix(spinner) + group.add(spinner_row) + rows.append(spinner_row) + + buffer: list[str] = [] + list_runner = ProcessRunner() + list_runner.connect("line", lambda _r, line: buffer.append(line)) + + def on_done(_r: ProcessRunner, code: int) -> None: + spinner.stop() + group.remove(spinner_row) + rows.remove(spinner_row) + + if code != 0: + err_row = Adw.ActionRow(title="Could not load snapshots") + group.add(err_row) + rows.append(err_row) + return + + snapshots = backup.parse_list("\n".join(buffer)) + if not snapshots: + empty_row = Adw.ActionRow(title="No snapshots yet") + group.add(empty_row) + rows.append(empty_row) + return + + for snap in snapshots: + subtitle = " ".join(p for p in (snap.tags, snap.description) if p) + row = Adw.ActionRow(title=snap.name, subtitle=subtitle or None) + row.add_suffix(_restore_button(snap.name, console, action_runner, reload)) + row.add_suffix(_delete_button(snap.name, console, action_runner, reload)) + group.add(row) + rows.append(row) + + list_runner.connect("done", on_done) + list_runner.run(backup.list_argv(), privileged=True) + + +def _restore_button( + name: str, + console: LogConsole, + runner: ProcessRunner, + reload: "callable", +) -> Gtk.Button: + button = Gtk.Button(label="Restore") + button.add_css_class("destructive-action") + button.set_valign(Gtk.Align.CENTER) + + def do_run() -> None: + if runner.running: + return + console.clear() + runner.run(backup.restore_argv(name), privileged=True) + + def on_click(_b: Gtk.Button) -> None: + confirm.confirm( + button, + "Restore this snapshot?", + f"This reverts the entire system to {name}. All changes since then " + "will be lost and a reboot may be required. Runs as root via pkexec.", + do_run, + ) + + button.connect("clicked", on_click) + return button + + +def _delete_button( + name: str, + console: LogConsole, + runner: ProcessRunner, + reload: "callable", +) -> Gtk.Button: + button = Gtk.Button(label="Delete") + button.add_css_class("destructive-action") + button.set_valign(Gtk.Align.CENTER) + + def do_run() -> None: + if runner.running: + return + console.clear() + runner.run(backup.delete_argv(name), privileged=True) + + def on_click(_b: Gtk.Button) -> None: + confirm.confirm( + button, + "Delete this snapshot?", + f"Snapshot {name} will be permanently removed. Runs as root via pkexec.", + do_run, + ) + + button.connect("clicked", on_click) + return button diff --git a/src/taninux/gui/pages/bluetooth.py b/src/taninux/gui/pages/bluetooth.py new file mode 100644 index 0000000..5885e41 --- /dev/null +++ b/src/taninux/gui/pages/bluetooth.py @@ -0,0 +1,173 @@ +"""Bluetooth — über core.bluetooth (bluetoothctl / BlueZ). + +Power-Toggle, Scan (timed), Geräteliste mit Connect/Disconnect. User-Level, +kein root. Live-Output im Log. +""" +from __future__ import annotations + +import threading + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") +from gi.repository import Adw, GLib, Gtk # noqa: E402 + +from taninux.core import bluetooth +from taninux.gui import confirm +from taninux.gui.console import LogConsole, log_expander +from taninux.gui.process import ProcessRunner + + +def build() -> Gtk.Widget: + page = Adw.PreferencesPage() + + if not bluetooth.available(): + g = Adw.PreferencesGroup(title="Bluetooth") + g.add(Adw.ActionRow( + title="No Bluetooth controller", + subtitle="bluetoothctl found no adapter / BlueZ not running.", + )) + page.add(g) + return page + + console = LogConsole() + runner = ProcessRunner() + runner.connect("line", lambda _r, ln: console.append_line(ln)) + + # -- Power + Scan -------------------------------------------------- + g_ctrl = Adw.PreferencesGroup(title="Bluetooth") + power_sw = Adw.SwitchRow(title="Bluetooth", subtitle="Power the adapter") + power_sw.set_active(bluetooth.powered()) + + def on_power(sw: Adw.SwitchRow, _p) -> None: + runner.run(bluetooth.set_power_argv(sw.get_active())) + + power_sw.connect("notify::active", on_power) + g_ctrl.add(power_sw) + page.add(g_ctrl) + + # -- Geräte (async) ------------------------------------------------ + g_dev = Adw.PreferencesGroup( + title="Devices", + description="Pairing PIN-protected devices may need a Bluetooth agent " + "and can fail here — check the log.", + ) + scan_btn = Gtk.Button(icon_name="view-refresh-symbolic") + scan_btn.add_css_class("flat") + scan_btn.set_valign(Gtk.Align.CENTER) + scan_btn.set_tooltip_text("Scan for devices (8 s)") + g_dev.set_header_suffix(scan_btn) + page.add(g_dev) + + rows: list[Gtk.Widget] = [] + # Nach erfolgreichem Pair automatisch Trust nachschieben. Da der Runner + # nur EINEN Prozess gleichzeitig führt, wird die Folge-Aktion erst im + # nächsten `done` ausgeführt. Liste = [argv, …], FIFO. + pending: list[list[str]] = [] + + def _btn(label: str, *, css: str | None = None) -> Gtk.Button: + b = Gtk.Button(label=label) + b.add_css_class("flat") + if css: + b.add_css_class(css) + b.set_valign(Gtk.Align.CENTER) + return b + + def _clear() -> None: + for r in rows: + g_dev.remove(r) + rows.clear() + + def _render(devs: list[bluetooth.BtDevice]) -> None: + _clear() + if not devs: + empty = Adw.ActionRow(title="No devices", + subtitle="Hit rescan to discover nearby devices.") + g_dev.add(empty) + rows.append(empty) + return + for d in devs: + sub = "connected" if d.connected else ("paired" if d.paired else "available") + if d.paired and d.trusted: + sub += " · trusted" + row = Adw.ActionRow(title=d.name, subtitle=sub) + row.add_prefix(Gtk.Image.new_from_icon_name("bluetooth-symbolic")) + + if not d.paired: + # Unbekanntes Gerät: Pair (und danach automatisch Trust). + pair = _btn("Pair", css="suggested-action") + pair.connect("clicked", lambda _b, m=d.mac: _pair(m)) + row.add_suffix(pair) + else: + if d.connected: + primary = _btn("Disconnect") + primary.connect("clicked", + lambda _b, m=d.mac: _run(bluetooth.disconnect_argv(m))) + else: + primary = _btn("Connect", css="suggested-action") + primary.connect("clicked", + lambda _b, m=d.mac: _run(bluetooth.connect_argv(m))) + row.add_suffix(primary) + + forget = _btn("Forget", css="destructive-action") + forget.connect("clicked", + lambda _b, m=d.mac, n=d.name: _forget(m, n)) + row.add_suffix(forget) + + g_dev.add(row) + rows.append(row) + + def _run(argv: list[str]) -> None: + console.clear() + runner.run(argv) + + def _pair(mac: str) -> None: + # Pair zuerst; Trust wird nach erfolgreichem Pair nachgereicht. + pending.append(bluetooth.trust_argv(mac)) + _run(bluetooth.pair_argv(mac)) + + def _forget(mac: str, name: str) -> None: + confirm.confirm( + page, + heading="Forget device?", + body=f"Remove the pairing for {name}. You will have to pair it again.", + on_confirm=lambda: _run(bluetooth.remove_argv(mac)), + ) + + def _load() -> None: + def worker() -> None: + devs = bluetooth.devices() + GLib.idle_add(lambda: (_render(devs), False)[1]) + threading.Thread(target=worker, daemon=True).start() + + def _scan() -> None: + _clear() + spin_row = Adw.ActionRow(title="Scanning … (8 s)") + sp = Adw.Spinner() + sp.set_valign(Gtk.Align.CENTER) + spin_row.add_prefix(sp) + g_dev.add(spin_row) + rows.append(spin_row) + console.clear() + runner.run(bluetooth.scan_argv(8)) + + scan_btn.connect("clicked", lambda _b: _scan()) + + def on_done(_r: ProcessRunner, code: int) -> None: + console.append_line("✔ done" if code == 0 else f"✘ exit {code}") + power_sw.set_active(bluetooth.powered()) + # Verkettete Folge-Aktion (z. B. Trust nach Pair) nur bei Erfolg. + if code == 0 and pending: + nxt = pending.pop(0) + runner.run(nxt) + return + pending.clear() # bei Fehler offene Kette verwerfen + _load() # Liste nach jeder Aktion (Scan/Connect) auffrischen + + runner.connect("done", on_done) + GLib.idle_add(lambda: (_load(), False)[1]) + + # -- Log ----------------------------------------------------------- + page.add(log_expander(console)) + return page diff --git a/src/taninux/gui/pages/bundles.py b/src/taninux/gui/pages/bundles.py new file mode 100644 index 0000000..1dda576 --- /dev/null +++ b/src/taninux/gui/pages/bundles.py @@ -0,0 +1,140 @@ +"""Bundles — deklarative Paket-Sets über bundles.loader. + +Pro Bundle eine ExpanderRow mit installiert/fehlend-Status pro Paket und einem +„Install missing"-Button (pacman -S --needed via Helper). Status wird hier +selbst berechnet (kein Import aus der Textual-screen.py). Nach jedem Lauf wird +neu berechnet. +""" +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") +from gi.repository import Adw, Gtk # noqa: E402 + +from taninux.bundles.loader import all_bundles +from taninux.core import pacman +from taninux.gui import confirm +from taninux.gui.console import LogConsole, log_expander +from taninux.gui.process import ProcessRunner +from taninux.suggest import rules + + +def build() -> Gtk.Widget: + page = Adw.PreferencesPage() + + console = LogConsole() + runner = ProcessRunner() + runner.connect("line", lambda _r, ln: console.append_line(ln)) + + g_rec = Adw.PreferencesGroup( + title="Recommended", + description="System-aware suggestions for your hardware and tools.", + ) + page.add(g_rec) + + g = Adw.PreferencesGroup(title="Bundles") + page.add(g) + + rows: list[Gtk.Widget] = [] + rec_rows: list[Gtk.Widget] = [] + + def _clear() -> None: + for r in rows: + g.remove(r) + rows.clear() + + def _install_pkgs(pkgs: list[str], parent: Gtk.Widget, title: str) -> None: + def do() -> None: + console.clear() + runner.run(["pacman", "-S", "--needed", "--noconfirm", *pkgs], privileged=True) + + confirm.confirm( + parent, f"Install {title}", + "Install via pacman: " + ", ".join(pkgs), do, + ) + + def _install_one(pkg: str, parent: Gtk.Widget) -> None: + _install_pkgs([pkg], parent, pkg) + + def _render() -> None: + _clear() + inst = pacman.installed_packages() + for b in all_bundles(): + n_inst = sum(1 for p in b.packages if p in inst) + total = len(b.packages) + complete = n_inst == total + + row = Adw.ExpanderRow(title=b.label, subtitle=b.tagline) + + # Count-Badge als Suffix (statt "Install missing"-Button). + badge = Gtk.Label(label=f"{n_inst}/{total}") + badge.add_css_class("numeric") + badge.add_css_class("success" if complete else "dim-label") + badge.add_css_class("caption-heading") + badge.set_valign(Gtk.Align.CENTER) + row.add_suffix(badge) + + # Pro Paket eine Zeile: installiert = dim-Tag, fehlend = Install-Button. + for pkg in b.packages: + pr = Adw.ActionRow(title=pkg) + if pkg in inst: + tag = Gtk.Label(label="installed") + tag.add_css_class("dim-label") + tag.add_css_class("caption") + tag.set_valign(Gtk.Align.CENTER) + pr.add_suffix(tag) + else: + ib = Gtk.Button(label="Install") + ib.add_css_class("suggested-action") + ib.set_valign(Gtk.Align.CENTER) + ib.connect("clicked", lambda _b, p=pkg: _install_one(p, _b)) + pr.add_suffix(ib) + row.add_row(pr) + + g.add(row) + rows.append(row) + + def _render_rec() -> None: + for r in rec_rows: + g_rec.remove(r) + rec_rows.clear() + sugg = rules.all_suggestions(set(pacman.installed_packages())) + if not sugg: + row = Adw.ActionRow(title="Nothing recommended", + subtitle="Your system looks complete.") + g_rec.add(row) + rec_rows.append(row) + return + for s in sorted(sugg, key=lambda x: x.priority): + row = Adw.ActionRow(title=s.title or s.reason, subtitle=s.reason) + btn = Gtk.Button(label="Install") + btn.add_css_class("suggested-action") + btn.set_valign(Gtk.Align.CENTER) + btn.set_tooltip_text(", ".join(s.packages)) + btn.connect( + "clicked", + lambda _b, p=list(s.packages), t=s.title or "packages": + _install_pkgs(p, _b, t), + ) + row.add_suffix(btn) + g_rec.add(row) + rec_rows.append(row) + + def _refresh() -> None: + _render_rec() + _render() + + def on_done(_r: ProcessRunner, code: int) -> None: + console.append_line("✔ done" if code == 0 else f"✘ exit {code}") + pacman.installed_packages.cache_clear() + _refresh() + + runner.connect("done", on_done) + + # -- Log ---------------------------------------------------------- + page.add(log_expander(console)) + + _refresh() + return page diff --git a/src/taninux/gui/pages/dashboard.py b/src/taninux/gui/pages/dashboard.py new file mode 100644 index 0000000..aa50db8 --- /dev/null +++ b/src/taninux/gui/pages/dashboard.py @@ -0,0 +1,281 @@ +"""Dashboard — Startseite als responsives Card-Grid. + +Kein abgespecktes Listen-Klon der System-Seite, sondern Karten mit großen +Werten, Level-Bars und Akzenten. Read-only aus den core-Modulen; die langsamen +Teile (Updates, Flatpak) laden async und füllen ihre Karten nach. +""" +from __future__ import annotations + +import json +import threading +from pathlib import Path + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") +from gi.repository import Adw, GLib, Gtk # noqa: E402 + +from taninux.core import audio, network, packages, power, sysinfo + + +def _bytes(n: int) -> str: + f = float(n) + for unit in ("B", "KiB", "MiB", "GiB", "TiB"): + if f < 1024 or unit == "TiB": + return f"{f:.0f} {unit}" if unit == "B" else f"{f:.1f} {unit}" + f /= 1024 + return f"{f:.1f} TiB" + + +def _uptime(secs: float) -> str: + s = int(secs) + d, s = divmod(s, 86400) + h, s = divmod(s, 3600) + m, _ = divmod(s, 60) + parts = [] + if d: + parts.append(f"{d}d") + if h: + parts.append(f"{h}h") + parts.append(f"{m}m") + return " ".join(parts) + + +# ----------------------------------------------------------- card builders + +def _shell(icon: str, title: str) -> tuple[Gtk.Widget, Gtk.Box]: + """Karten-Hülle (.card) + innerer Content-Box. Gibt (card, content).""" + card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + card.add_css_class("card") + card.set_valign(Gtk.Align.START) + + inner = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) + inner.set_margin_top(16) + inner.set_margin_bottom(16) + inner.set_margin_start(16) + inner.set_margin_end(16) + + head = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + img = Gtk.Image.new_from_icon_name(icon) + img.add_css_class("dim-label") + head.append(img) + t = Gtk.Label(label=title, xalign=0.0) + t.add_css_class("caption-heading") + t.add_css_class("dim-label") + head.append(t) + inner.append(head) + + card.append(inner) + return card, inner + + +def _metric(icon: str, title: str, value: str, *, sub: str | None = None, + frac: float | None = None, value_css: str | None = None): + """Stat-Karte: großer Wert + optional Untertitel + Level-Bar. + Gibt (card, value_label, sub_label) — Labels für async-Updates.""" + card, inner = _shell(icon, title) + + v = Gtk.Label(label=value, xalign=0.0) + v.add_css_class("title-2") + if value_css: + v.add_css_class(value_css) + inner.append(v) + + s = Gtk.Label(label=sub or "", xalign=0.0) + s.add_css_class("dim-label") + s.add_css_class("caption") + s.set_wrap(True) + s.set_visible(bool(sub)) + inner.append(s) + + if frac is not None: + lb = Gtk.LevelBar() + lb.set_min_value(0.0) + lb.set_max_value(1.0) + lb.set_value(max(0.0, min(1.0, frac))) + lb.set_margin_top(6) + inner.append(lb) + + return card, v, s + + +def _info(icon: str, title: str, items: list[tuple[str, str]]) -> Gtk.Widget: + """Info-Karte mit mehreren Label/Wert-Zeilen.""" + card, inner = _shell(icon, title) + grid = Gtk.Grid() + grid.set_row_spacing(4) + grid.set_column_spacing(16) + grid.set_margin_top(2) + for r, (k, val) in enumerate(items): + kl = Gtk.Label(label=k, xalign=0.0) + kl.add_css_class("dim-label") + vl = Gtk.Label(label=val, xalign=1.0, hexpand=True) + vl.set_selectable(True) + vl.set_ellipsize(3) # END + grid.attach(kl, 0, r, 1, 1) + grid.attach(vl, 1, r, 1, 1) + inner.append(grid) + return card + + +def _timeshift_summary() -> tuple[str, str]: + """(value, sub) aus der world-readable Timeshift-Config — ohne root.""" + cfg = Path("/etc/timeshift/timeshift.json") + if not cfg.is_file(): + return "Not set up", "Timeshift not configured" + try: + data = json.loads(cfg.read_text()) + except (OSError, json.JSONDecodeError): + return "—", "config unreadable" + mode = "BTRFS" if data.get("btrfs_mode") == "true" else "rsync" + sched = [k[len("schedule_"):] for k, v in data.items() + if k.startswith("schedule_") and v == "true"] + sub = ("schedule: " + ", ".join(sched)) if sched else "manual snapshots only" + return mode, sub + + +def build() -> Gtk.Widget: + flow = Gtk.FlowBox() + flow.set_selection_mode(Gtk.SelectionMode.NONE) + flow.set_homogeneous(True) + flow.set_min_children_per_line(2) + flow.set_max_children_per_line(2) + flow.set_column_spacing(12) + flow.set_row_spacing(12) + flow.set_valign(Gtk.Align.START) + + # -- System (Info-Karte) ------------------------------------------ + flow.append(_info("computer-symbolic", "System", [ + ("Host", sysinfo.hostname()), + ("Distro", sysinfo.distro_pretty()), + ("Kernel", sysinfo.kernel_release()), + ("Uptime", _uptime(sysinfo.uptime_seconds())), + ])) + + # -- Memory ------------------------------------------------------- + mem = sysinfo.memory() + total = mem.get("MemTotal", 0) + used = total - mem.get("MemAvailable", 0) + if total: + flow.append(_metric( + "drive-harddisk-symbolic", "Memory", _bytes(used), + sub=f"of {_bytes(total)} used", frac=used / total, + )[0]) + + # -- CPU load ----------------------------------------------------- + la = sysinfo.load_avg() + cpus = sysinfo.cpu_count() + flow.append(_metric( + "utilities-system-monitor-symbolic", "CPU load", f"{la[0]:.2f}", + sub=f"{cpus} cores · 5m {la[1]:.2f} · 15m {la[2]:.2f}", + frac=la[0] / cpus if cpus else 0, + )[0]) + + # -- Updates (async) ---------------------------------------------- + upd_card, upd_v, upd_s = _metric( + "software-update-available-symbolic", "Updates", "…", + sub="checking", value_css="accent", + ) + flow.append(upd_card) + + # -- Packages (pacman sync, flatpak async) ------------------------ + pk_total = packages.pacman_total() + pkg_card, pkg_v, pkg_s = _metric( + "package-x-generic-symbolic", "Packages", str(pk_total), + sub=f"{packages.pacman_foreign()} AUR · {packages.pacman_explicit()} explicit · flatpak …", + ) + flow.append(pkg_card) + + # -- Backups (Timeshift, unprivilegiert aus config) --------------- + bk_val, bk_sub = _timeshift_summary() + flow.append(_metric("drive-multidisk-symbolic", "Backups", bk_val, sub=bk_sub)[0]) + + # -- Network ------------------------------------------------------ + flow.append(_metric( + "network-wireless-symbolic", "Network", + network.active_summary(), sub="NetworkManager", + )[0]) + + # -- Audio -------------------------------------------------------- + ds = audio.get_default("sink") + if ds: + vol = audio.get_volume("sink", ds) + muted = audio.get_muted("sink", ds) + name = next((d.description for d in audio.list_devices("sink") + if d.name == ds), ds) + flow.append(_metric( + "audio-volume-high-symbolic", "Audio", + "muted" if muted else f"{vol}%", sub=name, + )[0]) + + # -- Graphics ----------------------------------------------------- + gpus = sysinfo.gpus() + if gpus: + g0 = gpus[0] + sub = g0.model[:42] + (f" · {g0.driver}" if g0.driver else "") + flow.append(_metric( + "video-display-symbolic", "Graphics", g0.vendor.upper(), sub=sub, + )[0]) + + # -- Storage ------------------------------------------------------ + disks = sysinfo.disks() + if disks: + total = sum(d.size_bytes for d in disks) + flow.append(_metric( + "drive-multidisk-symbolic", "Storage", _bytes(total), + sub=f"{len(disks)} drive(s)", + )[0]) + + # -- Health ------------------------------------------------------- + failed = sysinfo.systemctl_failed() + je = sysinfo.journal_errors_boot() + if not failed: + h_sub = "no failed units" + (f" · {je} journal errors" if je > 0 else "") + else: + h_sub = ", ".join(failed) + flow.append(_metric( + "emblem-ok-symbolic" if not failed else "dialog-warning-symbolic", + "Health", + "Healthy" if not failed else f"{len(failed)} failed", + sub=h_sub, + value_css="success" if not failed else "error", + )[0]) + + # -- async: Updates + Flatpak nachladen --------------------------- + def worker() -> None: + fp = packages.flatpak_apps() + try: + from taninux.update.sources import pacman as upd_src + n = len(upd_src.fetch()) + except Exception: # pragma: no cover + n = -1 + + def deliver() -> bool: + if n < 0: + upd_v.set_label("—") + upd_s.set_visible(False) + elif n == 0: + upd_v.set_label("0") + upd_v.remove_css_class("accent") + upd_v.add_css_class("success") + upd_s.set_label("up to date") + else: + upd_v.set_label(str(n)) + upd_s.set_label("updates available") + fp_txt = "no flatpak" if fp < 0 else f"{fp} flatpak" + pkg_s.set_label( + f"{packages.pacman_foreign()} AUR · " + f"{packages.pacman_explicit()} explicit · {fp_txt}" + ) + return False + + GLib.idle_add(deliver) + + threading.Thread(target=worker, daemon=True).start() + + page = Adw.PreferencesPage() + grp = Adw.PreferencesGroup() + grp.add(flow) + page.add(grp) + return page diff --git a/src/taninux/gui/pages/defaultapps.py b/src/taninux/gui/pages/defaultapps.py new file mode 100644 index 0000000..cd1e759 --- /dev/null +++ b/src/taninux/gui/pages/defaultapps.py @@ -0,0 +1,48 @@ +"""Default apps — Standard-Anwendung pro Kategorie wählen (user-level).""" +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") +from gi.repository import Adw, Gdk, Gtk # noqa: E402 + +from taninux.core import defaultapps + + +def _icon(name: str, fallback: str) -> str: + theme = Gtk.IconTheme.get_for_display(Gdk.Display.get_default()) + return name if (name and theme and theme.has_icon(name)) else fallback + + +def build() -> Gtk.Widget: + page = Adw.PreferencesPage() + group = Adw.PreferencesGroup(title="Default applications") + page.add(group) + + for label, mime, fallback in defaultapps.CATEGORIES: + current, apps = defaultapps.apps_for(mime) + row = Adw.ComboRow(title=label) + row.add_prefix(Gtk.Image.new_from_icon_name(fallback)) + + if not apps: + row.set_subtitle("No app installed") + row.set_sensitive(False) + group.add(row) + continue + + names = [a.name for a in apps] + row.set_model(Gtk.StringList.new(names)) + if current: + sel = next((i for i, a in enumerate(apps) if a.id == current.id), 0) + row.set_selected(sel) + + def on_sel(r, _p, apps=apps, mime=mime) -> None: + i = r.get_selected() + if 0 <= i < len(apps): + defaultapps.set_default(apps[i].id, mime) + + row.connect("notify::selected", on_sel) + group.add(row) + + return page diff --git a/src/taninux/gui/pages/display.py b/src/taninux/gui/pages/display.py new file mode 100644 index 0000000..7a4dcbf --- /dev/null +++ b/src/taninux/gui/pages/display.py @@ -0,0 +1,212 @@ +"""Display — Monitor-/Output-Einstellungen, niri-only über native IPC. + +Pro Output: An/Aus, Auflösung+Refresh, Scale, Rotation. Alles live über +core.display (`niri msg output`), unprivilegiert. Bewusst KEINE Persistenz in +die niri-Config (config.kdl). +""" +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") +from gi.repository import Adw, Gtk # noqa: E402 + +from taninux.core import compositor, display +from taninux.gui.console import RunnerCard +from taninux.gui.process import ProcessRunner + +# gängige Scale-Stufen +_SCALES = [1.0, 1.25, 1.5, 1.75, 2.0] +_ROTATIONS = ["normal", "90", "180", "270"] + + +def build() -> Gtk.Widget: + page = Adw.PreferencesPage() + + if compositor.detect() == compositor.UNKNOWN: + group = Adw.PreferencesGroup(title="Display") + group.add(Adw.ActionRow( + title="Display settings aren't available in this session", + )) + page.add(group) + return page + + outs = display.outputs() + if not outs: + group = Adw.PreferencesGroup(title="Display") + group.add(Adw.ActionRow(title="No monitors detected")) + page.add(group) + return page + + # Änderungen sind sofort am Bildschirm sichtbar (nicht persistent) — kein Log. + runner = ProcessRunner() + + def apply(**kwargs) -> None: + out = kwargs.pop("out") + runner.run(display.apply_argv(out, **kwargs), privileged=False) + + for out in outs: + page.add(_output_group(out, apply)) + + page.add(_night_light_group()) + return page + + +def _output_group(out: display.Output, apply) -> Adw.PreferencesGroup: + desc = f"{out.make} {out.model}".strip() or None + group = Adw.PreferencesGroup(title=out.name, description=desc) + + # -- Enabled ----------------------------------------------------------- + enable_row = Adw.SwitchRow(title="Enabled") + enable_row.set_active(out.enabled) + enable_row.connect( + "notify::active", + lambda r, _p: apply(out=out, enabled=r.get_active()), + ) + group.add(enable_row) + + # -- Resolution -------------------------------------------------------- + # eindeutige (w,h,hz) absteigend sortiert; aktueller Modus vorwählen. + seen: set[tuple[int, int, float]] = set() + modes: list[tuple[int, int, float]] = [] + for m in out.modes: + key = (m[0], m[1], round(m[2], 2)) + if key not in seen: + seen.add(key) + modes.append(m) + cur_mode = (out.width, out.height, out.refresh_hz) + cur_key = (out.width, out.height, round(out.refresh_hz, 2)) + if cur_key not in seen and out.width: + modes.insert(0, cur_mode) + modes.sort(key=lambda m: (m[0], m[1], m[2]), reverse=True) + + if modes: + labels = [f"{w}x{h} @ {hz:.2f}Hz" for (w, h, hz) in modes] + res_row = Adw.ComboRow(title="Resolution") + res_row.set_model(Gtk.StringList.new(labels)) + sel = next( + (i for i, m in enumerate(modes) + if (m[0], m[1], round(m[2], 2)) == cur_key), + 0, + ) + res_row.set_selected(sel) + # connect NACH set_selected, damit Initial nicht feuert + res_row.connect( + "notify::selected", + lambda r, _p: apply(out=out, mode=modes[r.get_selected()]), + ) + group.add(res_row) + + # -- Scale ------------------------------------------------------------- + scales = list(_SCALES) + if not any(abs(s - out.scale) < 1e-3 for s in scales): + scales.append(out.scale) + scales.sort() + scale_row = Adw.ComboRow(title="Scale") + scale_row.set_model(Gtk.StringList.new([f"{s:g}" for s in scales])) + sidx = next( + (i for i, s in enumerate(scales) if abs(s - out.scale) < 1e-3), 0, + ) + scale_row.set_selected(sidx) + scale_row.connect( + "notify::selected", + lambda r, _p: apply(out=out, scale=scales[r.get_selected()]), + ) + group.add(scale_row) + + # -- Rotation ---------------------------------------------------------- + rot_row = Adw.ComboRow(title="Rotation") + rot_row.set_model(Gtk.StringList.new(_ROTATIONS)) + ridx = _ROTATIONS.index(out.transform) if out.transform in _ROTATIONS else 0 + rot_row.set_selected(ridx) + rot_row.connect( + "notify::selected", + lambda r, _p: apply(out=out, transform=_ROTATIONS[r.get_selected()]), + ) + group.add(rot_row) + + return group + + +def _night_light_group() -> Adw.PreferencesGroup: + group = Adw.PreferencesGroup( + title="Night light", + description="Warm the screen colours in the evening.", + ) + + if not display.wlsunset_installed(): + group.add(Adw.ActionRow(title="Install to enable night light")) + group.add(RunnerCard( + "Install", + ["pacman", "-S", "--needed", "--noconfirm", "wlsunset"], + privileged=True, + )) + return group + + nl = display.night_light() + + enable_row = Adw.SwitchRow(title="Night light") + enable_row.set_active(nl.enabled) + group.add(enable_row) + + night_row = Adw.SpinRow.new_with_range(2500, 6500, 100) + night_row.set_title("Night temperature") + night_row.set_subtitle("Kelvin") + night_row.set_value(nl.night_temp) + group.add(night_row) + + day_row = Adw.SpinRow.new_with_range(3000, 6500, 100) + day_row.set_title("Day temperature") + day_row.set_subtitle("Kelvin") + day_row.set_value(nl.day_temp) + group.add(day_row) + + lat_row = Adw.EntryRow(title="Latitude") + lat_row.set_text(f"{nl.lat:g}") + group.add(lat_row) + + lon_row = Adw.EntryRow(title="Longitude") + lon_row.set_text(f"{nl.lon:g}") + group.add(lon_row) + + status = Gtk.Label(xalign=0.0, wrap=True) + status.add_css_class("dim-label") + + def _parse_float(text: str, fallback: float) -> float: + try: + return float(text.strip().replace(",", ".")) + except (ValueError, AttributeError): + return fallback + + def _on_apply(_btn: Gtk.Button) -> None: + lat = _parse_float(lat_row.get_text(), nl.lat) + lon = _parse_float(lon_row.get_text(), nl.lon) + path = display.write_night_light( + day_temp=int(day_row.get_value()), + night_temp=int(night_row.get_value()), + lat=lat, + lon=lon, + ) + enabled = enable_row.get_active() + ok = display.apply_night_light(enabled) + state = "enabled" if enabled else "disabled" + if ok: + status.set_text(f"Night light {state}. Service unit: {path}") + else: + status.set_text(f"Wrote {path}, but systemctl --user failed.") + + apply_btn = Gtk.Button(label="Apply") + apply_btn.add_css_class("suggested-action") + apply_btn.add_css_class("pill") + apply_btn.set_halign(Gtk.Align.START) + apply_btn.set_margin_top(6) + apply_btn.connect("clicked", _on_apply) + + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) + box.set_margin_top(6) + box.append(apply_btn) + box.append(status) + group.add(box) + + return group diff --git a/src/taninux/gui/pages/firewall.py b/src/taninux/gui/pages/firewall.py new file mode 100644 index 0000000..17df9bf --- /dev/null +++ b/src/taninux/gui/pages/firewall.py @@ -0,0 +1,50 @@ +"""Firewall — An/Aus (ufw/firewalld via Helper).""" +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") +from gi.repository import Adw, Gtk # noqa: E402 + +from taninux.core import firewall +from taninux.gui.console import RunnerCard +from taninux.gui.process import ProcessRunner + + +def build() -> Gtk.Widget: + page = Adw.PreferencesPage() + group = Adw.PreferencesGroup(title="Firewall") + page.add(group) + + if not firewall.installed(): + group.add(Adw.ActionRow(title="Install to turn on the firewall")) + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + box.set_margin_top(6) + box.append(RunnerCard("Install", firewall.install_argv(), privileged=True)) + group.add(box) + return page + + runner = ProcessRunner() + status = Gtk.Label(xalign=0.0) + status.add_css_class("dim-label") + + row = Adw.SwitchRow( + title="Firewall", + subtitle="Block unsolicited incoming connections", + ) + row.set_active(firewall.enabled()) + + def on_toggle(r, _p) -> None: + runner.run(firewall.set_enabled_argv(r.get_active()), privileged=True) + + row.connect("notify::active", on_toggle) + runner.connect("done", lambda _r, code: status.set_label( + "✔ Updated" if code == 0 else f"✘ failed (exit {code})")) + group.add(row) + + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) + box.set_margin_top(6) + box.append(status) + group.add(box) + return page diff --git a/src/taninux/gui/pages/hub_home.py b/src/taninux/gui/pages/hub_home.py new file mode 100644 index 0000000..e4675f5 --- /dev/null +++ b/src/taninux/gui/pages/hub_home.py @@ -0,0 +1,130 @@ +"""Home — App-Store-Startseite im GNOME-Software-Stil. + +Kuratierte Endnutzer-Apps (core/catalog), nach Kategorie gruppiert, als +klickbare Icon-Tiles. Klick öffnet die Detailansicht (Info + Quellenwahl + +Install). Async geladen. +""" +from __future__ import annotations + +import threading + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") +from gi.repository import Adw, Gdk, GLib, Gtk # noqa: E402 + +from taninux.core import catalog, pacman +from taninux.gui.pages.appdetail import open_app_detail + + +def build() -> Gtk.Widget: + page = Adw.PreferencesPage() + groups: list[Adw.PreferencesGroup] = [] + + intro = Adw.PreferencesGroup( + title="Recommended apps", + description="Popular apps for everyday use — click for details.", + ) + refresh = Gtk.Button(icon_name="view-refresh-symbolic") + refresh.add_css_class("flat") + refresh.set_valign(Gtk.Align.CENTER) + intro.set_header_suffix(refresh) + page.add(intro) + + def _icon_name(app: catalog.App) -> str: + theme = Gtk.IconTheme.get_for_display(Gdk.Display.get_default()) + if theme and theme.has_icon(app.icon): + return app.icon + return catalog.CATEGORY_ICON.get(app.category, "system-software-install-symbolic") + + def _open(app: catalog.App) -> None: + open_app_detail( + page, + title=app.name, + icon=app.icon, + repo_name=app.pkg if app.source == "repo" else None, + flatpak_id=app.pkg if app.source == "flatpak" else None, + on_changed=_load, + ) + + def _tile(app: catalog.App) -> Gtk.Widget: + tile = Gtk.Button() + tile.add_css_class("card") + tile.connect("clicked", lambda _b, a=app: _open(a)) + + row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=14) + row.set_margin_top(14) + row.set_margin_bottom(14) + row.set_margin_start(14) + row.set_margin_end(14) + icon = Gtk.Image.new_from_icon_name(_icon_name(app)) + icon.set_pixel_size(48) + row.append(icon) + text = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2, hexpand=True) + text.set_valign(Gtk.Align.CENTER) + name = Gtk.Label(label=app.name, xalign=0.0) + name.add_css_class("title-4") + summary = Gtk.Label(label=app.summary, xalign=0.0, wrap=True) + summary.add_css_class("dim-label") + summary.add_css_class("caption") + summary.set_lines(2) + summary.set_ellipsize(3) + text.append(name) + text.append(summary) + row.append(text) + tile.set_child(row) + return tile + + def _category_group(category: str, apps: list[catalog.App]) -> Adw.PreferencesGroup: + group = Adw.PreferencesGroup(title=category) + flow = Gtk.FlowBox() + flow.set_selection_mode(Gtk.SelectionMode.NONE) + flow.set_max_children_per_line(2) + flow.set_min_children_per_line(1) + flow.set_column_spacing(12) + flow.set_row_spacing(12) + flow.set_homogeneous(True) + for app in apps: + flow.append(_tile(app)) + group.add(flow) + return group + + def _render(apps: list[catalog.App]) -> None: + for g in groups: + page.remove(g) + groups.clear() + if not apps: + g = Adw.PreferencesGroup() + g.add(Adw.StatusPage( + icon_name="emblem-ok-symbolic", + title="You're all set", + description="All recommended apps are already installed.", + )) + page.add(g) + groups.append(g) + return + by_cat: dict[str, list[catalog.App]] = {} + for app in apps: + by_cat.setdefault(app.category, []).append(app) + for cat in catalog.CATEGORY_ORDER: + if cat in by_cat: + g = _category_group(cat, by_cat[cat]) + page.add(g) + groups.append(g) + + def _load() -> None: + def worker() -> None: + try: + pac = set(pacman.installed_packages()) + fp = catalog._flatpak_installed_ids() + apps = catalog.recommended(pac, fp) + except Exception: # noqa: BLE001 + apps = [] + GLib.idle_add(lambda: (_render(apps), False)[1]) + + threading.Thread(target=worker, daemon=True).start() + + refresh.connect("clicked", lambda _b: (pacman.invalidate(), _load())) + GLib.idle_add(lambda: (_load(), False)[1]) + return page diff --git a/src/taninux/gui/pages/kernel.py b/src/taninux/gui/pages/kernel.py new file mode 100644 index 0000000..1c9fcf9 --- /dev/null +++ b/src/taninux/gui/pages/kernel.py @@ -0,0 +1,138 @@ +"""Kernel — installierte Kernel, installierbare Alternativen, Bootloader.""" +from __future__ import annotations + +import gi + +gi.require_version("Adw", "1") +from gi.repository import Adw, GLib, Gtk # noqa: E402 + +from taninux.core import bootloader, pacman, sysinfo +from taninux.gui.console import LogConsole, log_expander +from taninux.gui.process import ProcessRunner + +# Paketname → Anzeigename +KNOWN_KERNELS: dict[str, str] = { + "linux": "Linux (stable)", + "linux-lts": "Linux LTS (longterm)", + "linux-zen": "Linux Zen (desktop-tuned)", + "linux-hardened": "Linux Hardened (security)", + "linux-rt": "Linux RT (realtime)", + "linux-rt-lts": "Linux RT LTS (realtime longterm)", +} + + +def _is_running(pkg: str, running: str) -> bool: + suffix = pkg.removeprefix("linux").lstrip("-") + tag = "-".join(running.split("-")[1:]) if "-" in running else "" + return (pkg == "linux" and not tag) or (bool(suffix) and suffix in running) + + +def build() -> Gtk.Widget: + page = Adw.PreferencesPage() + console = LogConsole() + runner = ProcessRunner() + runner.connect("line", lambda _r, line: console.append_line(line)) + groups: list[Adw.PreferencesGroup] = [] + log_group = log_expander(console) + + def _install(pkg: str, btn: Gtk.Button) -> None: + btn.set_sensitive(False) + btn.set_label("Installing…") + console.clear() + runner.run(["pacman", "-S", "--needed", "--noconfirm", pkg, f"{pkg}-headers"], + privileged=True) + + def _set_default(bl, combo: Adw.ComboRow, btn: Gtk.Button) -> None: + idx = combo.get_selected() + if not (0 <= idx < len(bl.entries)): + return + argv = bootloader.set_default_argv(bl, bl.entries[idx]) + if not argv: + console.append_line("⚠ not supported for this bootloader") + return + btn.set_sensitive(False) + console.clear() + runner.run(argv, privileged=True) + + def _render() -> None: + for g in groups: + page.remove(g) + groups.clear() + page.remove(log_group) + + installed = pacman.installed_packages() + running = sysinfo.kernel_release() + + g_inst = Adw.PreferencesGroup(title="Installed kernels", + description=f"Running: {running}") + any_inst = False + for pkg, label in KNOWN_KERNELS.items(): + if pkg in installed: + any_inst = True + row = Adw.ActionRow(title=label, subtitle=pkg) + if _is_running(pkg, running): + badge = Gtk.Label(label="active") + badge.add_css_class("success") + badge.add_css_class("caption-heading") + badge.set_valign(Gtk.Align.CENTER) + row.add_suffix(badge) + g_inst.add(row) + if not any_inst: + g_inst.add(Adw.ActionRow(title="No known kernel packages found")) + page.add(g_inst) + groups.append(g_inst) + + available = [(p, l) for p, l in KNOWN_KERNELS.items() if p not in installed] + if available: + g_avail = Adw.PreferencesGroup(title="Available to install") + for pkg, label in available: + row = Adw.ActionRow(title=label, subtitle=f"{pkg} · {pkg}-headers") + btn = Gtk.Button(label="Install") + btn.add_css_class("suggested-action") + btn.set_valign(Gtk.Align.CENTER) + btn.connect("clicked", lambda _b, p=pkg: _install(p, _b)) + row.add_suffix(btn) + g_avail.add(row) + page.add(g_avail) + groups.append(g_avail) + + bl = bootloader.detect() + g_boot = Adw.PreferencesGroup(title="Bootloader") + g_boot.add(_static_row("Type", bl.kind)) + g_boot.add(_static_row("Default entry", bl.default_entry or "—")) + if bl.kind == "systemd-boot" and bl.entries: + combo = Adw.ComboRow(title="Default boot entry", + model=Gtk.StringList.new([e.name for e in bl.entries])) + for i, e in enumerate(bl.entries): + if bootloader.is_default(bl, e): + combo.set_selected(i) + break + btn = Gtk.Button(label="Set") + btn.add_css_class("suggested-action") + btn.set_valign(Gtk.Align.CENTER) + btn.connect("clicked", lambda _b, c=combo: _set_default(bl, c, _b)) + combo.add_suffix(btn) + g_boot.add(combo) + page.add(g_boot) + groups.append(g_boot) + + page.add(log_group) + + def on_done(_r: ProcessRunner, code: int) -> None: + console.append_line("✔ done" if code == 0 else f"✘ exit {code}") + pacman.invalidate() + _render() + + runner.connect("done", on_done) + page.add(log_group) + GLib.idle_add(lambda: (_render(), False)[1]) + return page + + +def _static_row(title: str, value: str) -> Adw.ActionRow: + row = Adw.ActionRow(title=title) + label = Gtk.Label(label=value) + label.add_css_class("dim-label") + label.set_selectable(True) + row.add_suffix(label) + return row diff --git a/src/taninux/gui/pages/locale.py b/src/taninux/gui/pages/locale.py new file mode 100644 index 0000000..0de5c85 --- /dev/null +++ b/src/taninux/gui/pages/locale.py @@ -0,0 +1,247 @@ +"""Region & Input — System-Locale (Sprache) + Tastatur-Layout via localectl. + +Lese-Werte kommen aus core.localization (unprivilegiert). Das Setzen läuft +privilegiert über den Helper (localectl steht in dessen Allowlist). + +Ehrlicher Hinweis: niri nimmt sein aktives Tastaturlayout aus der EIGENEN +Config (config.kdl, input/keyboard). `localectl set-x11-keymap` setzt das +System-Default (Konsole, X11-Apps, XKB) — nicht zwingend das Live-Layout in +niri. Die Konsolen-Keymap (`set-keymap`) und das Locale gelten dagegen voll +system-weit. +""" +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") +from gi.repository import Adw, Gtk # noqa: E402 + +from taninux.core import localization +from taninux.gui import confirm +from taninux.gui.process import ProcessRunner + + +def build() -> Gtk.Widget: + page = Adw.PreferencesPage() + page.add(_language_group()) + page.add(_datetime_group()) + page.add(_keyboard_group()) + return page + + +# -- gemeinsamer Button+Konsole-Block (argv erst zur Klickzeit bekannt) ----- + +def _action_block(button_label: str): + """Liefert (box, button, runner, status). Caller verdrahtet button 'clicked'.""" + runner = ProcessRunner() + + button = Gtk.Button(label=button_label) + button.add_css_class("suggested-action") + button.add_css_class("pill") + button.set_halign(Gtk.Align.START) + + status = Gtk.Label(xalign=0.0, wrap=True) + status.add_css_class("dim-label") + + def on_done(_r: ProcessRunner, code: int) -> None: + status.set_label("✔ Applied" if code == 0 else f"✘ failed (exit {code})") + button.set_sensitive(True) + + runner.connect("done", on_done) + + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) + box.set_margin_top(6) + box.append(button) + box.append(status) + return box, button, runner, status + + +# -- Sprache -------------------------------------------------------------- + +def _language_group() -> Adw.PreferencesGroup: + st = localization.status() + locales = localization.list_locales() + + group = Adw.PreferencesGroup( + title="Language", + description="Applies on next login.", + ) + + combo = Adw.ComboRow(title="System language") + combo.set_model(Gtk.StringList.new(locales or ["(none generated)"])) + combo.set_enable_search(True) + if st.locale in locales: + combo.set_selected(locales.index(st.locale)) + group.add(combo) + + box, button, runner, _status = _action_block("Apply language") + + def on_click(_b: Gtk.Button) -> None: + idx = combo.get_selected() + if not locales or idx < 0 or idx >= len(locales): + return + locale = locales[idx] + + def do_run() -> None: + button.set_sensitive(False) + runner.run(localization.set_locale_argv(locale), privileged=True) + + confirm.confirm( + button, + "Change system language?", + f"Sets LANG={locale}. Applies on next login.", + do_run, + ) + + button.connect("clicked", on_click) + group.add(box) + return group + + +# -- Datum & Zeit --------------------------------------------------------- + +def _datetime_group() -> Adw.PreferencesGroup: + timezones = localization.list_timezones() + cur_tz = localization.current_timezone() + + group = Adw.PreferencesGroup(title="Date and Time") + + # Aktuelle lokale Zeit (read-only). + cur = Adw.ActionRow(title="Current") + cur_label = Gtk.Label(label=localization.local_time() or "—") + cur_label.add_css_class("dim-label") + cur_label.set_selectable(True) + cur.add_suffix(cur_label) + group.add(cur) + + # Zeitzone wählen + privilegiert anwenden. + tz_combo = Adw.ComboRow(title="Time zone") + tz_combo.set_model(Gtk.StringList.new(timezones or ["(none)"])) + tz_combo.set_enable_search(True) + if cur_tz in timezones: + tz_combo.set_selected(timezones.index(cur_tz)) + group.add(tz_combo) + + box, button, runner, status = _action_block("Apply time zone") + + def on_click(_b: Gtk.Button) -> None: + idx = tz_combo.get_selected() + if not timezones or idx < 0 or idx >= len(timezones): + return + tz = timezones[idx] + + def do_run() -> None: + button.set_sensitive(False) + runner.run(localization.set_timezone_argv(tz), privileged=True) + + confirm.confirm( + button, + "Change system time zone?", + f"Sets the system time zone to {tz}.", + do_run, + ) + + button.connect("clicked", on_click) + + # Automatische Zeit (NTP) — meldet in dieselbe Status-Zeile. + ntp_runner = ProcessRunner() + ntp_runner.connect( + "done", + lambda _r, code: status.set_label("✔ Applied" if code == 0 else f"✘ exit {code}"), + ) + + ntp_row = Adw.SwitchRow( + title="Automatic time (NTP)", + subtitle="Synchronize the clock over the network.", + ) + ntp_row.set_active(localization.ntp_enabled()) + + def on_ntp(_row: Adw.SwitchRow, _pspec) -> None: + ntp_runner.run(localization.set_ntp_argv(ntp_row.get_active()), privileged=True) + + ntp_row.connect("notify::active", on_ntp) + group.add(ntp_row) + + group.add(box) + return group + + +# -- Tastatur ------------------------------------------------------------- + +def _keyboard_group() -> Adw.PreferencesGroup: + st = localization.status() + layouts = localization.list_x11_layouts() + + group = Adw.PreferencesGroup(title="Keyboard") + + # Ist-Zustand sichtbar machen (gerade weil niri davon abweichen kann). + cur = Adw.ActionRow(title="Current (system)") + cur_val = f"{st.x11_layout or '—'}" + if st.x11_variant: + cur_val += f" · {st.x11_variant}" + cur_val += f" · console: {st.vc_keymap or '—'}" + cur_label = Gtk.Label(label=cur_val) + cur_label.add_css_class("dim-label") + cur_label.set_selectable(True) + cur.add_suffix(cur_label) + group.add(cur) + + layout_combo = Adw.ComboRow(title="Layout") + layout_combo.set_model(Gtk.StringList.new(layouts or ["(none)"])) + layout_combo.set_enable_search(True) + if st.x11_layout in layouts: + layout_combo.set_selected(layouts.index(st.x11_layout)) + group.add(layout_combo) + + variant_combo = Adw.ComboRow(title="Variant") + group.add(variant_combo) + + def _selected_layout() -> str: + idx = layout_combo.get_selected() + return layouts[idx] if layouts and 0 <= idx < len(layouts) else "" + + variants_state: dict[str, list[str]] = {"list": [""]} + + def _reload_variants(*_a) -> None: + layout = _selected_layout() + variants = localization.list_x11_variants(layout) if layout else [""] + variants_state["list"] = variants + # "" als "default" anzeigen + labels = ["default" if v == "" else v for v in variants] + variant_combo.set_model(Gtk.StringList.new(labels)) + # aktuelle Variante vorwählen, falls sie zum Layout passt + if st.x11_variant in variants: + variant_combo.set_selected(variants.index(st.x11_variant)) + else: + variant_combo.set_selected(0) + + layout_combo.connect("notify::selected", _reload_variants) + _reload_variants() + + box, button, runner, _status = _action_block("Apply layout") + + def on_click(_b: Gtk.Button) -> None: + layout = _selected_layout() + if not layout: + return + vidx = variant_combo.get_selected() + variants = variants_state["list"] + variant = variants[vidx] if 0 <= vidx < len(variants) else "" + + def do_run() -> None: + button.set_sensitive(False) + runner.run( + localization.set_x11_keymap_argv(layout, variant), privileged=True + ) + + confirm.confirm( + button, + "Change keyboard layout?", + f"Sets the system XKB layout to {layout}.", + do_run, + ) + + button.connect("clicked", on_click) + group.add(box) + return group diff --git a/src/taninux/gui/pages/lock.py b/src/taninux/gui/pages/lock.py new file mode 100644 index 0000000..d1bdc16 --- /dev/null +++ b/src/taninux/gui/pages/lock.py @@ -0,0 +1,39 @@ +"""Screen lock — jetzt sperren + Locker installieren.""" +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") +from gi.repository import Adw, Gtk # noqa: E402 + +from taninux.core import lock +from taninux.gui.console import RunnerCard +from taninux.gui.process import ProcessRunner + + +def build() -> Gtk.Widget: + page = Adw.PreferencesPage() + group = Adw.PreferencesGroup(title="Screen lock") + page.add(group) + + if not lock.locker(): + group.add(Adw.ActionRow(title="Install to enable screen locking")) + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + box.set_margin_top(6) + box.append(RunnerCard("Install", lock.install_argv(), privileged=True)) + group.add(box) + return page + + runner = ProcessRunner() + row = Adw.ActionRow( + title="Lock now", + subtitle="Auto-lock timing is set under Power.", + ) + btn = Gtk.Button(label="Lock") + btn.add_css_class("pill") + btn.set_valign(Gtk.Align.CENTER) + btn.connect("clicked", lambda _b: runner.run(lock.lock_now_argv() or [])) + row.add_suffix(btn) + group.add(row) + return page diff --git a/src/taninux/gui/pages/maintain.py b/src/taninux/gui/pages/maintain.py new file mode 100644 index 0000000..dc8e625 --- /dev/null +++ b/src/taninux/gui/pages/maintain.py @@ -0,0 +1,394 @@ +"""Cleanup — System aufräumen über die Maintain-Scanner. + +Portiert die UI-freien Scanner (taninux.maintain) in eine libadwaita-Seite. +Scannen läuft async im Worker-Thread; Findings werden per GLib.idle_add +gerendert. Destruktive Aktionen gehen ausschliesslich über core.trash +(move-to-trash, kein echtes rm) — das Sicherheitsnetz der App. + +trash-Scanner (dotfolders, fp-data, aur-cache): User-Dateien → Trash/Keep. +view-Scanner (orphans, cache, …): nur anzeigen + Hinweis-Befehl zum Kopieren. +""" +from __future__ import annotations + +import threading + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") +from gi.repository import Adw, Gdk, GLib, Gtk # noqa: E402 + +from taninux.core import trash +from taninux.core.state import State +from taninux.gui import confirm +from taninux.gui.console import LogConsole, log_expander +from taninux.gui.process import ProcessRunner +from taninux.maintain.scanners import SCANNERS, ScannerSpec +from taninux.maintain.types import Classification, Finding + +# Klassifizierung → CSS-Klasse für den farbigen Punkt + englisches Label. +_DOT_CLASS = { + Classification.SAFE: "success", + Classification.LIKELY_ORPHAN: "warning", + Classification.UNKNOWN: "dim-label", + Classification.ACTIVE: "dim-label", + Classification.WHITELISTED: "accent", +} +_EN_LABEL = { + Classification.SAFE: "safe", + Classification.LIKELY_ORPHAN: "likely orphan", + Classification.UNKNOWN: "unknown", + Classification.WHITELISTED: "kept", + Classification.ACTIVE: "in use", +} +_RECLAIMABLE = (Classification.SAFE, Classification.LIKELY_ORPHAN) +_MAX_ROWS = 50 # pro Scanner, gegen UI-Overload + + +def _clean_argv(spec_id: str, findings: list[Finding]): + """Sicheres One-Click-Clean für view-Scanner → (argv, privileged) | None. + + Nur ungefährliche, nicht-interaktive Befehle. pacnew (interaktiver Merge) + und kernels (rm, gefährlich) bleiben bewusst Hinweis-only. + """ + if spec_id == "cache": + return ["paccache", "-ruk0"], True + if spec_id == "journal": + return ["journalctl", "--vacuum-time=2weeks"], True + if spec_id == "flatpak-runtimes": + return ["flatpak", "uninstall", "--unused", "--assumeyes"], False + if spec_id == "orphans": + pkgs = [f.matched_package or f.path.name for f in findings] + pkgs = [p for p in pkgs if p] + return (["pacman", "-Rns", "--noconfirm", *pkgs], True) if pkgs else None + return None + + +def _human(n: int) -> str: + """Bytes menschlich (B/KiB/MiB/GiB).""" + size = float(n) + for unit in ("B", "KiB", "MiB", "GiB", "TiB"): + if size < 1024 or unit == "TiB": + if unit == "B": + return f"{int(size)} {unit}" + return f"{size:.1f} {unit}" + size /= 1024 + return f"{n} B" # unerreichbar + + +def _dot(cls: Classification) -> Gtk.Label: + dot = Gtk.Label(label="●") + dot.add_css_class(_DOT_CLASS.get(cls, "dim-label")) + dot.set_valign(Gtk.Align.CENTER) + return dot + + +def _legend() -> Adw.ActionRow: + """Erklärt die farbigen Punkte.""" + row = Adw.ActionRow(title="Legend") + box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=14) + box.set_valign(Gtk.Align.CENTER) + for cls in (Classification.SAFE, Classification.LIKELY_ORPHAN, + Classification.UNKNOWN): + item = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5) + item.append(_dot(cls)) + lbl = Gtk.Label(label=_EN_LABEL[cls]) + lbl.add_css_class("dim-label") + lbl.add_css_class("caption") + item.append(lbl) + box.append(item) + row.add_suffix(box) + return row + + +def _flat_btn(label: str, cb) -> Gtk.Button: + btn = Gtk.Button(label=label) + btn.add_css_class("flat") + btn.set_valign(Gtk.Align.CENTER) + btn.connect("clicked", lambda _b: cb()) + return btn + + +def _fill_advice(advice: str, first: Finding | None) -> str: + """Platzhalter {pkg}/{path} mit dem ersten Finding füllen.""" + if first is None: + return advice + pkg = first.matched_package or first.path.name + return advice.replace("{pkg}", pkg).replace("{path}", str(first.path)) + + +def build() -> Gtk.Widget: + page = Adw.PreferencesPage() + + # -- Summary (oben) ----------------------------------------------- + g_summary = Adw.PreferencesGroup(title="Cleanup") + rescan_btn = Gtk.Button(icon_name="view-refresh-symbolic") + rescan_btn.add_css_class("flat") + rescan_btn.set_valign(Gtk.Align.CENTER) + rescan_btn.set_tooltip_text("Rescan") + g_summary.set_header_suffix(rescan_btn) + + summary_row = Adw.ActionRow(title="Reclaimable", subtitle="Scanning …") + summary_spin = Adw.Spinner() + summary_spin.set_valign(Gtk.Align.CENTER) + summary_row.add_prefix(summary_spin) + g_summary.add(summary_row) + g_summary.add(_legend()) + page.add(g_summary) + + console = LogConsole() + runner = ProcessRunner() + runner.connect("line", lambda _r, ln: console.append_line(ln)) + + # Dynamische Scanner-Gruppen (zwischen Summary und Trash) --------- + scanner_groups: list[Gtk.Widget] = [] + + # -- Trash-Gruppe (unten) ----------------------------------------- + g_trash = Adw.PreferencesGroup( + title="Trash", + description="Files moved here are recoverable, nothing is deleted.", + ) + trash_rows: list[Gtk.Widget] = [] + page.add(g_trash) + + # -- Log-Gruppe (ganz unten, für Clean-Befehle) ------------------- + g_log = log_expander(console) + page.add(g_log) + + def _clean_run(argv: list[str], priv: bool, label: str) -> None: + def go() -> None: + console.clear() + runner.run(argv, privileged=priv) + confirm.confirm(rescan_btn, f"Clean {label}", + "Run: " + " ".join(argv), go) + + # ---------------------------------------------------------------- + def _clipboard_set(text: str) -> None: + disp = Gdk.Display.get_default() + if disp is not None: + disp.get_clipboard().set(text) + + def _clear_scanner_groups() -> None: + for grp in scanner_groups: + page.remove(grp) + scanner_groups.clear() + + # -- Trash-Liste -------------------------------------------------- + def _refresh_trash() -> None: + for r in trash_rows: + g_trash.remove(r) + trash_rows.clear() + + entries = trash.list_trash() + if not entries: + empty = Adw.ActionRow( + title="Trash is empty", + subtitle="Trashed items will appear here.", + ) + g_trash.add(empty) + trash_rows.append(empty) + return + + for entry in entries: + row = Adw.ActionRow( + title=str(entry.original), + subtitle=f"{_human(entry.size_bytes)} · {entry.trashed_at}", + ) + + def _restore(eid: str = entry.id) -> None: + try: + trash.restore(eid) + except (FileExistsError, FileNotFoundError, OSError): + pass + _refresh_trash() + + def _purge(eid: str = entry.id, orig: str = str(entry.original)) -> None: + def do() -> None: + try: + trash.purge(eid) + except (FileNotFoundError, OSError): + pass + _refresh_trash() + confirm.confirm( + rescan_btn, + "Purge permanently", + f"Delete {orig} for good. This cannot be undone.", + do, + ) + + row.add_suffix(_flat_btn("Restore", _restore)) + purge_b = _flat_btn("Purge", _purge) + purge_b.add_css_class("destructive-action") + row.add_suffix(purge_b) + g_trash.add(row) + trash_rows.append(row) + + # -- eine Scanner-Gruppe rendern ---------------------------------- + def _build_group(spec: ScannerSpec, findings: list[Finding]) -> Gtk.Widget: + findings = sorted(findings, key=lambda f: f.sort_key) + reclaim = sum(f.size_bytes for f in findings + if f.classification in _RECLAIMABLE) + shown = findings[:_MAX_ROWS] + hidden = len(findings) - len(shown) + + desc = f"{len(findings)} findings · {_human(reclaim)} reclaimable" + if hidden > 0: + desc += f" · showing first {_MAX_ROWS}" + if spec.actions == "view" and spec.advice: + desc += "\n" + _fill_advice(spec.advice, findings[0] if findings else None) + + grp = Adw.PreferencesGroup(title=spec.label, description=desc) + + # view-Scanner: Header mit Copy-Button + (falls sicher) Clean-Button. + if spec.actions == "view": + hdr = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + if spec.advice: + cmd = _fill_advice(spec.advice, findings[0] if findings else None) + copy = Gtk.Button(icon_name="edit-copy-symbolic") + copy.add_css_class("flat") + copy.set_valign(Gtk.Align.CENTER) + copy.set_tooltip_text("Copy command") + copy.connect("clicked", lambda _b, c=cmd: _clipboard_set(c)) + hdr.append(copy) + clean = _clean_argv(spec.id, findings) + if clean is not None: + argv, priv = clean + cb = Gtk.Button(label="Clean") + cb.add_css_class("destructive-action") + cb.set_valign(Gtk.Align.CENTER) + cb.connect("clicked", + lambda _b, a=argv, p=priv, lb=spec.label: _clean_run(a, p, lb)) + hdr.append(cb) + if hdr.get_first_child() is not None: + grp.set_header_suffix(hdr) + + # Findings in eine scrollbare ListBox: ~5 Zeilen sichtbar, dann scrollen. + listbox = Gtk.ListBox() + listbox.add_css_class("boxed-list") + listbox.set_selection_mode(Gtk.SelectionMode.NONE) + + for finding in shown: + cls = finding.classification + title = finding.matched_package or str(finding.path) + sub = f"{_EN_LABEL.get(cls, cls.value)} · {_human(finding.size_bytes)}" + if finding.reason: + sub += f" · {finding.reason}" + + row = Adw.ActionRow(title=title, subtitle=sub) + row.add_prefix(_dot(cls)) + + if spec.actions == "trash": + def _do_trash(f: Finding = finding, r: Adw.ActionRow = row, + lb: Gtk.ListBox = listbox) -> None: + def confirmed() -> None: + def worker() -> None: + ok = True + try: + trash.move_to_trash(f.path) + except (FileNotFoundError, OSError): + ok = False + GLib.idle_add(lambda: (_after_trash(r, lb, ok), False)[1]) + threading.Thread(target=worker, daemon=True).start() + confirm.confirm( + rescan_btn, + "Move to Trash", + f"Move {f.path} to the recoverable Trash.", + confirmed, + ) + + def _do_keep(f: Finding = finding, r: Adw.ActionRow = row, + lb: Gtk.ListBox = listbox) -> None: + State.load().whitelist_add(f.path) + lb.remove(r) + + row.add_suffix(_flat_btn("Keep", _do_keep)) + trash_b = _flat_btn("Trash", _do_trash) + trash_b.add_css_class("destructive-action") + row.add_suffix(trash_b) + + listbox.append(row) + + if hidden > 0: + more = Adw.ActionRow(title=f"… +{hidden} more") + more.add_css_class("dim-label") + listbox.append(more) + + visible = min(len(shown) + (1 if hidden else 0), 5) + h = visible * 56 + scroller = Gtk.ScrolledWindow() + scroller.set_min_content_height(h) + scroller.set_max_content_height(h) + scroller.set_child(listbox) + grp.add(scroller) + return grp + + def _after_trash(row: Adw.ActionRow, listbox: Gtk.ListBox, ok: bool) -> None: + if ok: + listbox.remove(row) + _refresh_trash() + + # -- Scan async --------------------------------------------------- + def _render(results: list[tuple[ScannerSpec, list[Finding]]]) -> None: + _clear_scanner_groups() + summary_spin.set_visible(False) + + # Trash + Log temporär entfernen, damit Scanner-Gruppen davor landen. + page.remove(g_trash) + page.remove(g_log) + total = 0 + for spec, findings in results: + if not findings: + continue + total += sum(f.size_bytes for f in findings + if f.classification in _RECLAIMABLE) + grp = _build_group(spec, findings) + page.add(grp) + scanner_groups.append(grp) + + page.add(g_trash) + page.add(g_log) + summary_row.set_subtitle(f"{_human(total)} can be reclaimed safely") + + def _scan() -> None: + _clear_scanner_groups() + summary_spin.set_visible(True) + summary_row.set_subtitle("Scanning …") + + def worker() -> None: + # State einmal laden, um whitelisted/ignored Findings auszublenden. + try: + state = State.load() + except Exception: # noqa: BLE001 — UI darf nie crashen + state = None + results: list[tuple[ScannerSpec, list[Finding]]] = [] + for spec in SCANNERS: + try: + findings = spec.factory().scan() + except Exception: # noqa: BLE001 — ein Scanner darf nicht alles killen + findings = [] + if state is not None: + try: + findings = [ + f for f in findings + if not state.is_whitelisted(f.path) + and not state.is_ignored(f.path) + ] + except OSError: + pass + results.append((spec, findings)) + GLib.idle_add(lambda: (_render(results), False)[1]) + + threading.Thread(target=worker, daemon=True).start() + + rescan_btn.connect("clicked", lambda _b: _scan()) + + def _on_clean_done(_r: ProcessRunner, code: int) -> None: + console.append_line("✔ done" if code == 0 else f"✘ exit {code}") + _refresh_trash() + _scan() + + runner.connect("done", _on_clean_done) + + _refresh_trash() + GLib.idle_add(lambda: (_scan(), False)[1]) + return page diff --git a/src/taninux/gui/pages/network.py b/src/taninux/gui/pages/network.py new file mode 100644 index 0000000..0070ca2 --- /dev/null +++ b/src/taninux/gui/pages/network.py @@ -0,0 +1,194 @@ +"""Network — Wi-Fi über core.network (nmcli). + +Radio an/aus, Status, verfügbare Netze (async geladen) mit Verbinden inkl. +Passwort-Dialog für gesicherte Netze. Alles User-Level (NetworkManager-polkit), +kein root. Live-Output im Connection-Log. +""" +from __future__ import annotations + +import threading + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") +from gi.repository import Adw, GLib, Gtk # noqa: E402 + +from taninux.core import network +from taninux.gui import confirm +from taninux.gui.console import LogConsole, log_expander +from taninux.gui.process import ProcessRunner + + +def _signal_icon(sig: int) -> str: + if sig >= 75: + return "network-wireless-signal-excellent-symbolic" + if sig >= 50: + return "network-wireless-signal-good-symbolic" + if sig >= 25: + return "network-wireless-signal-ok-symbolic" + return "network-wireless-signal-weak-symbolic" + + +def build() -> Gtk.Widget: + page = Adw.PreferencesPage() + + if not network.available(): + g = Adw.PreferencesGroup(title="Network") + g.add(Adw.ActionRow( + title="NetworkManager not available", + subtitle="nmcli found no running NetworkManager.", + )) + page.add(g) + return page + + console = LogConsole() + runner = ProcessRunner() + runner.connect("line", lambda _r, ln: console.append_line(ln)) + + # -- Wi-Fi radio + Status ----------------------------------------- + g_wifi = Adw.PreferencesGroup(title="Wi-Fi") + wifi_sw = Adw.SwitchRow(title="Wi-Fi", subtitle="Enable the wireless radio") + wifi_sw.set_active(network.wifi_enabled()) + status_row = Adw.ActionRow(title="Status") + status_lbl = Gtk.Label(label=network.active_summary()) + status_lbl.add_css_class("dim-label") + status_lbl.set_selectable(True) + status_row.add_suffix(status_lbl) + + def on_wifi(sw: Adw.SwitchRow, _p) -> None: + runner.run(network.set_wifi_argv(sw.get_active())) + + wifi_sw.connect("notify::active", on_wifi) + g_wifi.add(wifi_sw) + g_wifi.add(status_row) + page.add(g_wifi) + + # -- Verfügbare Netze (async) ------------------------------------- + g_nets = Adw.PreferencesGroup(title="Available networks") + refresh = Gtk.Button(icon_name="view-refresh-symbolic") + refresh.add_css_class("flat") + refresh.set_valign(Gtk.Align.CENTER) + refresh.set_tooltip_text("Rescan") + g_nets.set_header_suffix(refresh) + page.add(g_nets) + + rows: list[Gtk.Widget] = [] + + def _clear() -> None: + for r in rows: + g_nets.remove(r) + rows.clear() + + def _connect(net: network.WifiNet, saved: bool) -> None: + def do(pw: str) -> None: + console.clear() + runner.run(network.connect_argv(net.ssid, pw)) + # Gesichert, aber schon gespeichert -> ohne Passwort-Dialog verbinden. + if net.security and not saved: + _ask_password(refresh, net.ssid, do) + else: + do("") + + def _disconnect() -> None: + argv = network.disconnect_argv() + if not argv: + return + console.clear() + runner.run(argv) + + def _forget(net: network.WifiNet) -> None: + def do() -> None: + console.clear() + runner.run(network.forget_argv(net.ssid)) + confirm.confirm( + refresh, + f"Forget {net.ssid}", + "Delete this saved Wi-Fi connection.", + do, + ) + + def _btn(label: str, cb) -> Gtk.Button: + btn = Gtk.Button(label=label) + btn.add_css_class("flat") + btn.set_valign(Gtk.Align.CENTER) + btn.connect("clicked", lambda _b: cb()) + return btn + + def _render(nets: list[network.WifiNet], saved: set[str]) -> None: + _clear() + for net in nets: + row = Adw.ActionRow(title=net.ssid, + subtitle=net.security or "open") + row.add_prefix(Gtk.Image.new_from_icon_name(_signal_icon(net.signal))) + if net.security: + lock = Gtk.Image.new_from_icon_name("network-wireless-encrypted-symbolic") + lock.add_css_class("dim-label") + row.add_suffix(lock) + is_saved = net.ssid in saved + if net.in_use: + tag = Gtk.Label(label="connected") + tag.add_css_class("success") + tag.add_css_class("caption-heading") + row.add_suffix(tag) + row.add_suffix(_btn("Disconnect", _disconnect)) + else: + row.add_suffix(_btn("Connect", + lambda n=net, s=is_saved: _connect(n, s))) + if is_saved: + row.add_suffix(_btn("Forget", lambda n=net: _forget(n))) + g_nets.add(row) + rows.append(row) + + def _load(rescan: bool = False) -> None: + _clear() + spin_row = Adw.ActionRow(title="Scanning …" if rescan else "Loading …") + sp = Adw.Spinner() + sp.set_valign(Gtk.Align.CENTER) + spin_row.add_prefix(sp) + g_nets.add(spin_row) + rows.append(spin_row) + status_lbl.set_label(network.active_summary()) + + def worker() -> None: + nets = network.wifi_networks(rescan=rescan) + saved = network.saved_connections() + GLib.idle_add(lambda: (_render(nets, saved), False)[1]) + + threading.Thread(target=worker, daemon=True).start() + + refresh.connect("clicked", lambda _b: _load(rescan=True)) + GLib.idle_add(lambda: (_load(rescan=False), False)[1]) + + # -- Connection log ----------------------------------------------- + def on_done(_r: ProcessRunner, code: int) -> None: + status_lbl.set_label(network.active_summary()) + console.append_line("✔ done" if code == 0 else f"✘ exit {code}") + # Liste + Status nach jeder Aktion neu laden. + _load(rescan=False) + + runner.connect("done", on_done) + page.add(log_expander(console, title="Connection log")) + return page + + +def _ask_password(parent: Gtk.Widget, ssid: str, on_ok) -> None: + dialog = Adw.AlertDialog( + heading=f"Connect to {ssid}", + body="Enter the Wi-Fi password.", + ) + entry = Gtk.PasswordEntry(show_peek_icon=True) + entry.set_margin_top(8) + dialog.set_extra_child(entry) + dialog.add_response("cancel", "Cancel") + dialog.add_response("connect", "Connect") + dialog.set_response_appearance("connect", Adw.ResponseAppearance.SUGGESTED) + dialog.set_default_response("connect") + dialog.set_close_response("cancel") + + def on_resp(_d: Adw.AlertDialog, resp: str) -> None: + if resp == "connect": + on_ok(entry.get_text()) + + dialog.connect("response", on_resp) + dialog.present(parent) diff --git a/src/taninux/gui/pages/panel.py b/src/taninux/gui/pages/panel.py new file mode 100644 index 0000000..0a31697 --- /dev/null +++ b/src/taninux/gui/pages/panel.py @@ -0,0 +1,199 @@ +"""Panel & Dock — steuert die eww-Bar + das Dock über panel.json + dock-pins. + +Schreibt ausschließlich Config-Dateien (core.panel); eww zieht live nach. Fasst +~/eww NIE direkt an. Vertrag: docs/eww-panel-dock-integration.md. +""" +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") +from gi.repository import Adw, Gtk # noqa: E402 + +from taninux.core import panel + +_MODULE_LABELS = { + "music": "Now playing", + "sys": "System (CPU / RAM)", + "updates": "Updates", + "net": "Network", + "bt": "Bluetooth", + "vol": "Volume", +} + + +def build() -> Gtk.Widget: + page = Adw.PreferencesPage() + cfg = panel.load() + state = {"ready": False} + + def persist() -> None: + if state["ready"]: + panel.save(cfg) + + page.add(_topbar_group(cfg, persist)) + page.add(_dock_group(cfg, persist)) + page.add(_pins_group()) + + state["ready"] = True + return page + + +# -- Top bar -------------------------------------------------------------- + +def _topbar_group(cfg: dict, persist) -> Adw.PreferencesGroup: + group = Adw.PreferencesGroup( + title="Top bar", + description="Which modules the eww bar shows, and the clock format.", + ) + + clock = Adw.EntryRow(title="Clock format") + clock.set_text(cfg["bar"]["clock_format"]) + clock.set_show_apply_button(True) + + def on_clock(_e: Adw.EntryRow) -> None: + cfg["bar"]["clock_format"] = clock.get_text() + persist() + + clock.connect("apply", on_clock) + group.add(clock) + + mods = cfg["bar"]["modules"] + for key in panel.MODULES: + row = Adw.SwitchRow(title=_MODULE_LABELS.get(key, key)) + row.set_active(bool(mods.get(key, True))) + + def on_toggle(r: Adw.SwitchRow, _p, k: str = key) -> None: + cfg["bar"]["modules"][k] = r.get_active() + persist() + + row.connect("notify::active", on_toggle) + group.add(row) + + return group + + +# -- Dock ----------------------------------------------------------------- + +def _dock_group(cfg: dict, persist) -> Adw.PreferencesGroup: + group = Adw.PreferencesGroup( + title="Dock", + description="Auto-hide, icon size and hide delay. Applies live.", + ) + dock = cfg["dock"] + + autohide = Adw.SwitchRow( + title="Auto-hide", + subtitle="Hide the dock until you reach the screen edge", + ) + autohide.set_active(bool(dock.get("autohide", True))) + + def on_autohide(r: Adw.SwitchRow, _p) -> None: + dock["autohide"] = r.get_active() + persist() + + autohide.connect("notify::active", on_autohide) + group.add(autohide) + + size = Adw.SpinRow.new_with_range(24, 64, 1) + size.set_title("Icon size") + size.set_subtitle("Pixels (24–64)") + size.set_value(int(dock.get("icon_size", 42))) + + def on_size(r: Adw.SpinRow, _p) -> None: + dock["icon_size"] = int(r.get_value()) + persist() + + size.connect("notify::value", on_size) + group.add(size) + + delay_adj = Gtk.Adjustment(value=float(dock.get("hide_delay", 1.2)), + lower=0.2, upper=5.0, + step_increment=0.1, page_increment=0.5) + delay = Adw.SpinRow(title="Hide delay", adjustment=delay_adj, digits=1) + delay.set_subtitle("Seconds before the dock hides (0.2–5.0)") + + def on_delay(r: Adw.SpinRow, _p) -> None: + dock["hide_delay"] = round(r.get_value(), 1) + persist() + + delay.connect("notify::value", on_delay) + group.add(delay) + + return group + + +# -- Pinned apps (dock-pins) ---------------------------------------------- + +def _pins_group() -> Adw.PreferencesGroup: + group = Adw.PreferencesGroup( + title="Pinned apps", + description="Apps pinned to the dock (exec · class · icon).", + ) + + pins = panel.load_pins() + rows: list[Gtk.Widget] = [] + + def _save() -> None: + panel.save_pins(pins) + + def _refresh() -> None: + for r in rows: + group.remove(r) + rows.clear() + for i, pin in enumerate(pins): + row = Adw.ActionRow(title=pin.wmclass or pin.exec, subtitle=pin.exec) + row.add_prefix(Gtk.Image.new_from_icon_name(pin.icon or "application-x-executable")) + rm = Gtk.Button(icon_name="user-trash-symbolic") + rm.add_css_class("flat") + rm.set_valign(Gtk.Align.CENTER) + rm.connect("clicked", lambda _b, idx=i: (_remove(idx))) + row.add_suffix(rm) + group.add(row) + rows.append(row) + group.add(_add_row) + rows.append(_add_row) + + def _remove(idx: int) -> None: + if 0 <= idx < len(pins): + del pins[idx] + _save() + _refresh() + + # -- Add-Zeile (Expander mit 3 Feldern) -- + _add_row = Adw.ExpanderRow(title="Add app", subtitle="exec · class · icon") + e_exec = Adw.EntryRow(title="Exec (command)") + e_class = Adw.EntryRow(title="Window class (app_id / WM_CLASS)") + e_icon = Adw.EntryRow(title="Icon name (Papirus)") + _add_row.add_row(e_exec) + _add_row.add_row(e_class) + _add_row.add_row(e_icon) + add_btn_row = Adw.ActionRow() + add_btn = Gtk.Button(label="Add to dock") + add_btn.add_css_class("suggested-action") + add_btn.add_css_class("pill") + add_btn.set_halign(Gtk.Align.START) + add_btn.set_margin_top(4) + add_btn.set_margin_bottom(4) + add_btn_row.set_child(add_btn) + _add_row.add_row(add_btn_row) + + def _on_add(_b: Gtk.Button) -> None: + ex = e_exec.get_text().strip() + cl = e_class.get_text().strip() or ex + ic = e_icon.get_text().strip() or ex + if not ex: + return + pins.append(panel.Pin(ex, cl, ic)) + e_exec.set_text("") + e_class.set_text("") + e_icon.set_text("") + _add_row.set_expanded(False) + _save() + _refresh() + + add_btn.connect("clicked", _on_add) + + _refresh() + return group diff --git a/src/taninux/gui/pages/power.py b/src/taninux/gui/pages/power.py new file mode 100644 index 0000000..6f3ee24 --- /dev/null +++ b/src/taninux/gui/pages/power.py @@ -0,0 +1,213 @@ +"""Power — Akku-Info (falls vorhanden) + Idle-Timeouts (Bildschirm aus / Suspend). + +Akku/AC sind reiner Read aus core.power. Die Idle-Timeouts laufen niri-only +über swayidle (eigener taninux-swayidle.service, DPMS via niri-Action). Der +Service läuft als User-Service (kein root); die niri-Config wird nicht +angefasst. Fehlt swayidle, gibt's einen Install-Block (pacman via Helper). +""" +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") +from gi.repository import Adw, Gtk # noqa: E402 + +from taninux.core import power +from taninux.gui.console import RunnerCard +from taninux.gui.process import ProcessRunner + + +def build() -> Gtk.Widget: + page = Adw.PreferencesPage() + page.add(_power_source_group()) + if power.brightness_available(): + page.add(_brightness_group()) + page.add(_energy_mode_group()) + page.add(_idle_group()) + if not power.idle_daemon_installed(): + page.add(_install_group()) + return page + + +def _card_box(card: Gtk.Widget) -> Gtk.Box: + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + box.set_margin_top(6) + box.append(card) + return box + + +def _energy_mode_group() -> Adw.PreferencesGroup: + group = Adw.PreferencesGroup( + title="Energy mode", + description="Most effective on laptops.", + ) + + if not power.ppd_installed(): + group.add(Adw.ActionRow(title="Install to enable energy modes")) + group.add(_card_box(RunnerCard( + "Install", + ["pacman", "-S", "--needed", "--noconfirm", "power-profiles-daemon"], + privileged=True, + ))) + return group + + profs = power.power_profiles() + if not profs: + group.add(Adw.ActionRow(title="Enable to use energy modes")) + group.add(_card_box(RunnerCard( + "Enable", + ["systemctl", "enable", "--now", "power-profiles-daemon"], + privileged=True, + ))) + return group + + cur = power.current_profile() + combo = Adw.ComboRow(title="Power profile") + combo.set_model(Gtk.StringList.new(profs)) + if cur in profs: + combo.set_selected(profs.index(cur)) + + def on_sel(c: Adw.ComboRow, _p) -> None: + i = c.get_selected() + if 0 <= i < len(profs): + power.set_profile(profs[i]) + + combo.connect("notify::selected", on_sel) + group.add(combo) + return group + + +def _power_source_group() -> Adw.PreferencesGroup: + group = Adw.PreferencesGroup(title="Power source") + bats = power.batteries() + if bats: + for b in bats: + row = Adw.ActionRow(title=b.name.upper(), subtitle=b.status) + cap = f"{b.capacity}%" if b.capacity >= 0 else "—" + if b.capacity >= 0: + level = Gtk.LevelBar.new_for_interval(0, 100) + level.set_value(b.capacity) + level.set_size_request(120, -1) + level.set_valign(Gtk.Align.CENTER) + row.add_suffix(level) + lbl = Gtk.Label(label=cap) + lbl.add_css_class("dim-label") + lbl.add_css_class("numeric") + row.add_suffix(lbl) + group.add(row) + else: + ac = power.ac_online() + sub = ( + "On AC power" if ac is True + else "No battery detected — desktop system" + ) + group.add(Adw.ActionRow(title="Battery", subtitle=sub)) + return group + + +def _brightness_group() -> Adw.PreferencesGroup: + group = Adw.PreferencesGroup(title="Display brightness") + row = Adw.ActionRow(title="Brightness") + + scale = Gtk.Scale.new_with_range(Gtk.Orientation.HORIZONTAL, 1, 100, 1) + scale.set_hexpand(True) + scale.set_size_request(220, -1) + scale.set_draw_value(False) + scale.set_valign(Gtk.Align.CENTER) + + pct_lbl = Gtk.Label() + pct_lbl.add_css_class("dim-label") + pct_lbl.add_css_class("numeric") + + # Startwert VOR connect setzen, sonst feuert es beim Aufbau ein set. + cur = power.brightness_percent() + scale.set_value(max(1, min(100, cur))) + pct_lbl.set_label(f"{cur}%") + + runner = ProcessRunner() + # Runner am Widget verankern, damit er nicht weg-gc't wird. + row._brightness_runner = runner # noqa: SLF001 + + def on_changed(s: Gtk.Scale) -> None: + val = int(s.get_value()) + pct_lbl.set_label(f"{val}%") + if not runner.running: + runner.run(power.set_brightness_argv(val), privileged=False) + + scale.connect("value-changed", on_changed) + + row.add_suffix(scale) + row.add_suffix(pct_lbl) + group.add(row) + return group + + +def _idle_group() -> Adw.PreferencesGroup: + t = power.read_idle_timeouts() + daemon = power.idle_daemon_name() + group = Adw.PreferencesGroup( + title="Idle · sleep", + description="Turn the screen off or suspend after inactivity. 0 = never.", + ) + + so_adj = Gtk.Adjustment(value=t["screen_off"] // 60, lower=0, upper=240, + step_increment=1, page_increment=5) + so = Adw.SpinRow(title="Screen off after", adjustment=so_adj) + so.set_subtitle("minutes (0 = never)") + group.add(so) + + su_adj = Gtk.Adjustment(value=t["suspend"] // 60, lower=0, upper=480, + step_increment=1, page_increment=5) + su = Adw.SpinRow(title="Suspend after", adjustment=su_adj) + su.set_subtitle("minutes (0 = never)") + group.add(su) + + btn = Gtk.Button(label="Apply idle settings") + btn.add_css_class("suggested-action") + btn.add_css_class("pill") + btn.set_halign(Gtk.Align.START) + status = Gtk.Label(xalign=0.0) + status.add_css_class("dim-label") + status.set_wrap(True) + + def on_apply(_b: Gtk.Button) -> None: + so_sec = int(so_adj.get_value()) * 60 + su_sec = int(su_adj.get_value()) * 60 + p = power.write_idle_config(so_sec, su_sec) + if power.idle_daemon_installed(): + ok = power.apply_idle() + status.set_label( + f"Saved → {p}. {daemon} " + + ("restarted." if ok else "service could not start.") + ) + else: + status.set_label( + f"Saved → {p}. Install {daemon} below to activate." + ) + + btn.connect("clicked", on_apply) + + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) + box.set_margin_top(6) + box.append(btn) + box.append(status) + group.add(box) + return group + + +def _install_group() -> Adw.PreferencesGroup: + pkg = power.idle_pkg() + group = Adw.PreferencesGroup( + title="Idle timers", + description="Install to enable the timers above.", + ) + card = RunnerCard( + "Install", ["pacman", "-S", "--needed", "--noconfirm", pkg], + privileged=True, + ) + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + box.set_margin_top(6) + box.append(card) + group.add(box) + return group diff --git a/src/taninux/gui/pages/printers.py b/src/taninux/gui/pages/printers.py new file mode 100644 index 0000000..7d7e06a --- /dev/null +++ b/src/taninux/gui/pages/printers.py @@ -0,0 +1,195 @@ +"""Printers — CUPS-Druckerverwaltung (libadwaita). + +Read kommt aus core.printers (lpstat). Schreib-Aktionen (Default setzen, +enable/disable, entfernen) liefern dort nur argv und laufen hier über einen +gemeinsamen ProcessRunner — privilegiert via Helper (lpadmin/cupsenable/ +cupsdisable) bzw. unprivilegiert (cancel -a für die Queue). + +Drucker HINZUFÜGEN (Treiberauswahl) ist NICHT enthalten — dafür ein +dediziertes System-Tool nutzen. +""" +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") +from gi.repository import Adw, Gtk # noqa: E402 + +from taninux.core import printers +from taninux.gui import confirm as confirm_mod +from taninux.gui.console import LogConsole, RunnerCard, log_expander +from taninux.gui.process import ProcessRunner + + +def build() -> Gtk.Widget: + page = Adw.PreferencesPage() + + if not printers.available(): + page.add(_unavailable_group()) + return page + + # Gemeinsame Konsole + Runner für alle Aktionen dieser Seite. + console = LogConsole() + runner = ProcessRunner() + runner.connect("line", lambda _r, line: console.append_line(line)) + + # Druckergruppe wird nach jeder Aktion neu befüllt (live). + printers_group = Adw.PreferencesGroup( + title="Printers", + description="Adding a printer (driver selection) isn't here — use a " + "dedicated tool for that.", + ) + queue_group = Adw.PreferencesGroup( + title="Print queue", + description="Pending jobs across all printers.", + ) + + def run(argv: list[str], *, privileged: bool) -> None: + console.clear() + runner.run(argv, privileged=privileged) + + def refresh() -> None: + _fill_printers(printers_group, run) + _fill_queue(queue_group, run) + + runner.connect("done", lambda _r, code: _on_done(console, code, refresh)) + + refresh() + page.add(printers_group) + page.add(queue_group) + page.add(log_expander(console, title="Output")) + return page + + +def _on_done(console: LogConsole, code: int, refresh) -> None: + console.append_line("") + console.append_line( + f"✔ done (exit {code})" if code == 0 else f"✘ exit {code}" + ) + refresh() + + +def _clear_group(group: Adw.PreferencesGroup) -> None: + """Entfernt alle bisher per _track() hinzugefügten Rows der Gruppe.""" + rows = getattr(group, "_tani_rows", None) + if not rows: + return + for row in list(rows): + group.remove(row) + rows.clear() + + +def _track(group: Adw.PreferencesGroup, row: Gtk.Widget) -> None: + rows = getattr(group, "_tani_rows", None) + if rows is None: + rows = [] + group._tani_rows = rows + group.add(row) + rows.append(row) + + +def _fill_printers(group: Adw.PreferencesGroup, run) -> None: + _clear_group(group) + plist = printers.printers() + if not plist: + _track(group, Adw.ActionRow( + title="No printers configured", + subtitle="Add one with a system printer tool.", + )) + return + + for p in plist: + subtitle = p.state + if p.is_default: + subtitle += " · default" + if p.info: + subtitle += f" · {p.info}" + row = Adw.ActionRow(title=p.name, subtitle=subtitle) + + if not p.is_default: + row.add_suffix(_btn( + "Set default", + lambda _b, n=p.name: run(printers.set_default_argv(n), + privileged=True), + )) + + if p.enabled: + row.add_suffix(_btn( + "Disable", + lambda _b, n=p.name: run(printers.disable_argv(n), + privileged=True), + )) + else: + row.add_suffix(_btn( + "Enable", + lambda _b, n=p.name: run(printers.enable_argv(n), + privileged=True), + suggested=True, + )) + + remove = _btn( + "Remove", + lambda _b, n=p.name: confirm_mod.confirm( + _b, "Remove printer?", + f"Permanently remove the printer “{n}” from CUPS.", + lambda n=n: run(printers.remove_argv(n), privileged=True), + ), + destructive=True, + ) + row.add_suffix(remove) + _track(group, row) + + +def _fill_queue(group: Adw.PreferencesGroup, run) -> None: + _clear_group(group) + jobs = printers.queue() + if not jobs: + _track(group, Adw.ActionRow( + title="Queue empty", subtitle="No pending print jobs." + )) + return + + for job in jobs: + _track(group, Adw.ActionRow(title=job)) + + clear_row = Adw.ActionRow(title="Clear queue", + subtitle="Cancel all pending jobs.") + clear_row.add_suffix(_btn( + "Clear queue", + lambda _b: run(printers.cancel_all_argv(), privileged=False), + destructive=True, + )) + _track(group, clear_row) + + +def _btn(label: str, on_click, *, suggested: bool = False, + destructive: bool = False) -> Gtk.Button: + btn = Gtk.Button(label=label) + btn.set_valign(Gtk.Align.CENTER) + if suggested: + btn.add_css_class("suggested-action") + if destructive: + btn.add_css_class("destructive-action") + btn.connect("clicked", on_click) + return btn + + +def _unavailable_group() -> Adw.PreferencesGroup: + group = Adw.PreferencesGroup( + title="Printing", + description="CUPS is not installed. Printer management is unavailable.", + ) + group.add(Adw.ActionRow( + title="CUPS not available", + subtitle="Install cups to manage printers.", + )) + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + box.set_margin_top(6) + box.append(RunnerCard( + "Install CUPS", + ["pacman", "-S", "--needed", "--noconfirm", "cups"], + privileged=True, + )) + group.add(box) + return group diff --git a/src/taninux/gui/pages/settings.py b/src/taninux/gui/pages/settings.py new file mode 100644 index 0000000..19cec2d --- /dev/null +++ b/src/taninux/gui/pages/settings.py @@ -0,0 +1,233 @@ +"""Appearance-Seite — Farbschema + Fuji-Akzent. + +Voll funktional und persistent (gui.config): Farbschema System/Light/Dark als +ComboRow, die Fuji-Palette als wählbare Swatches. Beide wirken sofort app-weit +über gui.style. +""" +from __future__ import annotations + +import gi + +gi.require_version("Adw", "1") +from gi.repository import Adw, Gtk, Pango # noqa: E402 + +from taninux.gui import config, desktop, eww, style +from taninux.gui.pages.wallpaper import wallpaper_group + +# Schema-Reihenfolge der ComboRow ↔ Persistenz-Keys +_SCHEME_LABELS = ["System", "Light", "Dark"] +_SCHEME_KEYS = ["system", "light", "dark"] + + +def build() -> Gtk.Widget: + page = Adw.PreferencesPage() + page.add(_appearance_group()) + page.add(wallpaper_group()) # sehr visuell -> direkt nach Color scheme/Accent + page.add(_window_buttons_group()) + page.add(_theme_icons_group()) + page.add(_font_group()) + page.add(_cursor_group()) + return page + + +# -- GTK-Theme + Icon-Theme (nwg-look-Stil, kein root) -------------------- + +def _combo(title: str, items: list[str], current_value: str, apply_fn) -> Adw.ComboRow: + """ComboRow, die bei Auswahl apply_fn(value) ruft. Initial-Set feuert nicht + (connect erst NACH set_selected).""" + opts = list(items) + if current_value and current_value not in opts: + opts.insert(0, current_value) # Ist-Wert immer wählbar halten + row = Adw.ComboRow(title=title) + row.set_model(Gtk.StringList.new(opts or ["(none)"])) + row.set_enable_search(True) + if current_value in opts: + row.set_selected(opts.index(current_value)) + + def on_sel(r: Adw.ComboRow, _p) -> None: + i = r.get_selected() + if 0 <= i < len(opts): + apply_fn(opts[i]) + + row.connect("notify::selected", on_sel) + return row + + +def _window_buttons_group() -> Adw.PreferencesGroup: + """Einzelne Titlebar-Knöpfe an/aus + Platzierung (wie GNOME Tweaks).""" + cur = desktop.window_buttons() + group = Adw.PreferencesGroup( + title="Window buttons", + description="Show or hide the titlebar buttons.", + ) + + state = {"ready": False} + + min_row = Adw.SwitchRow(title="Minimize") + max_row = Adw.SwitchRow(title="Maximize") + close_row = Adw.SwitchRow(title="Close") + place_row = Adw.ComboRow(title="Placement") + place_row.set_model(Gtk.StringList.new(["Right", "Left"])) + + def apply(*_a) -> None: + if not state["ready"]: + return + side = "left" if place_row.get_selected() == 1 else "right" + desktop.set_window_buttons( + min_row.get_active(), max_row.get_active(), close_row.get_active(), side, + ) + + # Startwerte ZUERST setzen, dann erst Signale verbinden (kein Initial-Feuern). + min_row.set_active(cur["minimize"]) + max_row.set_active(cur["maximize"]) + close_row.set_active(cur["close"]) + place_row.set_selected(1 if cur["side"] == "left" else 0) + + min_row.connect("notify::active", apply) + max_row.connect("notify::active", apply) + close_row.connect("notify::active", apply) + place_row.connect("notify::selected", apply) + + group.add(min_row) + group.add(max_row) + group.add(close_row) + group.add(place_row) + + state["ready"] = True + return group + + +def _theme_icons_group() -> Adw.PreferencesGroup: + cur = desktop.current() + group = Adw.PreferencesGroup( + title="Theme · icons", + description="Running apps may need a restart.", + ) + group.add(_combo( + "GTK theme", desktop.list_gtk_themes(), cur["gtk_theme"], + desktop.apply_gtk_theme, + )) + group.add(_combo( + "Icon theme", desktop.list_icon_themes(), cur["icon_theme"], + desktop.apply_icon_theme, + )) + return group + + +def _font_group() -> Adw.PreferencesGroup: + cur = desktop.current() + group = Adw.PreferencesGroup(title="Font") + row = Adw.ActionRow( + title="System font", + subtitle="Default interface font", + ) + dialog = Gtk.FontDialog() + fbtn = Gtk.FontDialogButton.new(dialog) + fbtn.set_valign(Gtk.Align.CENTER) + if cur["font"]: + fbtn.set_font_desc(Pango.FontDescription.from_string(cur["font"])) + + def on_font(btn: Gtk.FontDialogButton, _p) -> None: + desc = btn.get_font_desc() + if desc is not None: + desktop.apply_font(desc.to_string()) + + fbtn.connect("notify::font-desc", on_font) + row.add_suffix(fbtn) + group.add(row) + return group + + +def _cursor_group() -> Adw.PreferencesGroup: + cur = desktop.current() + group = Adw.PreferencesGroup( + title="Cursor", + description="Cursor theme and size.", + ) + group.add(_combo( + "Cursor theme", desktop.list_cursor_themes(), cur["cursor_theme"], + desktop.apply_cursor_theme, + )) + + try: + size = int(cur["cursor_size"]) + except (ValueError, TypeError): + size = 24 + adj = Gtk.Adjustment(value=size, lower=8, upper=128, step_increment=2, + page_increment=8) + spin = Adw.SpinRow(title="Cursor size", adjustment=adj) + spin.set_subtitle("Pixel") + # connect NACH dem Setzen des Startwerts -> Initial feuert nicht + adj.connect("value-changed", lambda a: desktop.apply_cursor_size(int(a.get_value()))) + group.add(spin) + return group + + +def _appearance_group() -> Adw.PreferencesGroup: + cfg = config.load() + group = Adw.PreferencesGroup( + title="Appearance", + description="Color scheme and Fuji accent — applied instantly, saved.", + ) + + # Guard: programmatisches Setzen der Startwerte soll nicht schon speichern. + state = {"ready": False} + + def persist() -> None: + if state["ready"]: + config.save(state["scheme"], state["accent"]) + style.apply(state["scheme"], state["accent"]) # diese App live + style.write_global_theme(state["scheme"], state["accent"]) # ALLE GTK3+GTK4-Apps + gsettings + eww.reload_if_running() # Bar live nachziehen + + state["scheme"] = cfg["color_scheme"] + state["accent"] = cfg["accent"] + + # -- Color scheme -------------------------------------------------- + scheme_row = Adw.ComboRow(title="Color scheme") + scheme_row.set_model(Gtk.StringList.new(_SCHEME_LABELS)) + cur = _SCHEME_KEYS.index(cfg["color_scheme"]) if cfg["color_scheme"] in _SCHEME_KEYS else 0 + scheme_row.set_selected(cur) + + def on_scheme(row: Adw.ComboRow, _param) -> None: + state["scheme"] = _SCHEME_KEYS[row.get_selected()] + persist() + + scheme_row.connect("notify::selected", on_scheme) + group.add(scheme_row) + + # -- Fuji accent (Swatch-Reihe) ----------------------------------- + accent_row = Adw.ActionRow( + title="Accent color", + subtitle="Fuji palette", + ) + swatches = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + swatches.set_valign(Gtk.Align.CENTER) + + leader: Gtk.ToggleButton | None = None + for acc in style.FUJI_ACCENTS: + btn = Gtk.ToggleButton() + btn.add_css_class("fuji-swatch") + btn.add_css_class(f"swatch-{acc.key}") + btn.set_tooltip_text(acc.label) + btn.set_valign(Gtk.Align.CENTER) + if leader is None: + leader = btn + else: + btn.set_group(leader) + if acc.key == cfg["accent"]: + btn.set_active(True) + + def on_toggle(b: Gtk.ToggleButton, key: str = acc.key) -> None: + if b.get_active(): + state["accent"] = key + persist() + + btn.connect("toggled", on_toggle) + swatches.append(btn) + + accent_row.add_suffix(swatches) + group.add(accent_row) + + state["ready"] = True + return group diff --git a/src/taninux/gui/pages/shares.py b/src/taninux/gui/pages/shares.py new file mode 100644 index 0000000..1c39329 --- /dev/null +++ b/src/taninux/gui/pages/shares.py @@ -0,0 +1,282 @@ +"""Network Shares — Remote-Verbindungen für Nautilus (SMB/SFTP/FTP/WebDAV/NFS). + +Schreibt Remote-Bookmarks über core.shares (GTK-Bookmarks, die Nautilus liest) +und verbindet via `gio mount` (unprivilegiert; GVfs fragt das Passwort selbst +via Keyring/Agent). Nextcloud ist hier als WebDAV-Preset integriert — kein +Sync-Client nötig (leichtgewichtig, Dateien on-demand). + +Felder passen sich pro Protokoll an, eine Live-Vorschau zeigt die Ziel-URI. +SMB/NFS brauchen gvfs-smb/gvfs-nfs; fehlen sie, gibt's einen Install-Block. +""" +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") +from gi.repository import Adw, Gtk # noqa: E402 + +from taninux.core import nextcloud as nc +from taninux.core import shares +from taninux.gui import confirm as confirm_mod +from taninux.gui.console import LogConsole, RunnerCard, log_expander +from taninux.gui.process import ProcessRunner + +# (Label, scheme). "nextcloud" ist ein Spezial-Scheme (baut den WebDAV-Pfad). +_PROTOCOLS: list[tuple[str, str]] = [ + ("SFTP", "sftp"), + ("SMB · Windows share", "smb"), + ("WebDAV", "davs"), + ("Nextcloud", "nextcloud"), + ("FTP", "ftp"), + ("NFS", "nfs"), +] + +# Pro Scheme: welche Felder sichtbar sind + ihr Titel. (title, visible) +_FIELDS: dict[str, dict[str, tuple[str, bool]]] = { + "sftp": {"host": ("Host", True), "path": ("Path (optional)", True), + "user": ("User (optional)", True), "port": ("Port (optional)", True)}, + "ftp": {"host": ("Host", True), "path": ("Path (optional)", True), + "user": ("User (optional)", True), "port": ("Port (optional)", True)}, + "smb": {"host": ("Host", True), "path": ("Share", True), + "user": ("User (optional)", True), "port": ("", False)}, + "davs": {"host": ("Host", True), "path": ("Path (optional)", True), + "user": ("User (optional)", True), "port": ("", False)}, + "nextcloud": {"host": ("Server · e.g. cloud.example.com", True), + "path": ("", False), "user": ("Username", True), + "port": ("", False)}, + "nfs": {"host": ("Host", True), "path": ("Export path", True), + "user": ("", False), "port": ("", False)}, +} + +_SCHEME_ICON = { + "smb": "folder-remote-symbolic", "sftp": "folder-remote-symbolic", + "ftp": "folder-remote-symbolic", "nfs": "folder-remote-symbolic", + "dav": "folder-cloud-symbolic", "davs": "folder-cloud-symbolic", +} + + +def build() -> Gtk.Widget: + page = Adw.PreferencesPage() + + console = LogConsole() + runner = ProcessRunner() + runner.connect("line", lambda _r, line: console.append_line(line)) + runner.connect("done", lambda _r, code: console.append_line( + "✔ done" if code == 0 else f"✘ exit {code}")) + + # ============================================================ Your shares + list_group = Adw.PreferencesGroup( + title="Your shares", + description="Saved remote locations — they appear in the file manager.", + ) + refresh_btn = Gtk.Button(icon_name="view-refresh-symbolic") + refresh_btn.add_css_class("flat") + refresh_btn.set_valign(Gtk.Align.CENTER) + refresh_btn.set_tooltip_text("Reload") + list_group.set_header_suffix(refresh_btn) + page.add(list_group) + + def _mount(uri: str) -> None: + console.clear() + runner.run(shares.mount_argv(uri)) + + def _remove(uri: str, label: str) -> None: + confirm_mod.confirm( + page, "Remove share?", + f"Remove “{label}”. The remote data is not deleted.", + lambda: (shares.remove_bookmark(uri), _fill_list()), + ) + + def _fill_list() -> None: + _clear_group(list_group) + bms = shares.list_remote() + if not bms: + _track(list_group, Adw.ActionRow( + title="No shares yet", + subtitle="Add one below — SFTP, SMB, WebDAV, Nextcloud, FTP or NFS.", + )) + return + for bm in bms: + row = Adw.ActionRow(title=bm.label or bm.uri, subtitle=bm.uri) + icon = _SCHEME_ICON.get(bm.scheme, "folder-remote-symbolic") + row.add_prefix(Gtk.Image.new_from_icon_name(icon)) + connect = _btn("Connect", suggested=True) + connect.connect("clicked", lambda _b, u=bm.uri: _mount(u)) + row.add_suffix(connect) + remove = _btn("Remove", destructive=True) + remove.connect("clicked", lambda _b, u=bm.uri, lb=bm.label: _remove(u, lb)) + row.add_suffix(remove) + _track(list_group, row) + + refresh_btn.connect("clicked", lambda _b: _fill_list()) + + # ============================================================ Add + add_group = Adw.PreferencesGroup( + title="Add a connection", + description="You'll be asked for the password on first connect.", + ) + + proto_row = Adw.ComboRow(title="Type") + proto_model = Gtk.StringList() + for label, _scheme in _PROTOCOLS: + proto_model.append(label) + proto_row.set_model(proto_model) + add_group.add(proto_row) + + host_row = Adw.EntryRow(title="Host") + path_row = Adw.EntryRow(title="Path") + user_row = Adw.EntryRow(title="User") + port_row = Adw.EntryRow(title="Port") + field_rows = {"host": host_row, "path": path_row, + "user": user_row, "port": port_row} + for r in field_rows.values(): + add_group.add(r) + + # Live-Vorschau der Ziel-URI. + preview = Adw.ActionRow(title="Will create") + preview_lbl = Gtk.Label(label="—") + preview_lbl.add_css_class("dim-label") + preview_lbl.set_selectable(True) + preview_lbl.set_ellipsize(3) # END + preview.add_suffix(preview_lbl) + add_group.add(preview) + + add_btn = Gtk.Button(label="Add") + add_btn.add_css_class("suggested-action") + add_btn.set_valign(Gtk.Align.CENTER) + add_row = Adw.ActionRow(title="Add to file manager") + add_row.add_suffix(add_btn) + add_group.add(add_row) + page.add(add_group) + + def _scheme() -> str: + return _PROTOCOLS[proto_row.get_selected()][1] + + def _current_uri() -> str: + host = host_row.get_text().strip() + if not host: + return "" + scheme = _scheme() + if scheme == "nextcloud": + u = user_row.get_text().strip() + return nc.webdav_uri_from(host, u) if u else "" + return shares.build_uri( + scheme, host, path=path_row.get_text(), + user=user_row.get_text(), port=port_row.get_text(), + ) + + def _update_preview(*_a) -> None: + preview_lbl.set_label(_current_uri() or "—") + + def _sync_fields(*_a) -> None: + conf = _FIELDS[_scheme()] + for key, row in field_rows.items(): + title, visible = conf[key] + row.set_visible(visible) + if title: + row.set_title(title) + _update_preview() + + proto_row.connect("notify::selected", _sync_fields) + for r in field_rows.values(): + r.connect("changed", _update_preview) + + def _on_add(_b: Gtk.Button) -> None: + uri = _current_uri() + console.clear() + if not uri: + console.append_line("✘ fill in the required fields first") + return + label = host_row.get_text().strip() + shares.add_bookmark(uri, label=label) + for r in field_rows.values(): + r.set_text("") + console.append_line(f"✔ added {uri}") + _update_preview() + _fill_list() + + add_btn.connect("clicked", _on_add) + + # ============================================================ Nextcloud accounts + nc_accounts = nc.accounts() + if nc_accounts: + nc_group = Adw.PreferencesGroup( + title="Detected Nextcloud accounts", + description="From the Nextcloud client — add as WebDAV in one click.", + ) + for acc in nc_accounts: + row = Adw.ActionRow(title=acc.name, subtitle=acc.url) + row.add_prefix(Gtk.Image.new_from_icon_name("folder-cloud-symbolic")) + add = _btn("Add to Files", suggested=True) + + def _add_acc(_b, a=acc) -> None: + uri = nc.webdav_uri(a) + shares.add_bookmark(uri, label=a.name) + console.clear() + console.append_line(f"✔ added {uri}") + _fill_list() + + add.connect("clicked", _add_acc) + row.add_suffix(add) + nc_group.add(row) + page.add(nc_group) + + # ============================================================ Backends + missing: list[tuple[str, str, list[str]]] = [] + if not shares.smb_available(): + missing.append(("SMB · Windows shares", "gvfs-smb", shares.install_smb_argv())) + if not shares.nfs_available(): + missing.append(("NFS shares", "gvfs-nfs", shares.install_nfs_argv())) + if missing: + be_group = Adw.PreferencesGroup( + title="More connection types", + description="SMB and NFS need an extra component.", + ) + for label, pkg, argv in missing: + be_group.add(Adw.ActionRow( + title=label, subtitle="Install to enable this connection type.")) + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + box.set_margin_top(6) + box.append(RunnerCard("Install", argv, privileged=True)) + be_group.add(box) + page.add(be_group) + + # ============================================================ Log + page.add(log_expander(console)) + + _sync_fields() + _fill_list() + return page + + +# ----------------------------------------------------------- Helpers + +def _clear_group(group: Adw.PreferencesGroup) -> None: + rows = getattr(group, "_tani_rows", None) + if not rows: + return + for row in list(rows): + group.remove(row) + rows.clear() + + +def _track(group: Adw.PreferencesGroup, row: Gtk.Widget) -> None: + rows = getattr(group, "_tani_rows", None) + if rows is None: + rows = [] + group._tani_rows = rows + group.add(row) + rows.append(row) + + +def _btn(label: str, *, suggested: bool = False, + destructive: bool = False) -> Gtk.Button: + btn = Gtk.Button(label=label) + btn.add_css_class("flat") + btn.set_valign(Gtk.Align.CENTER) + if suggested: + btn.add_css_class("suggested-action") + if destructive: + btn.add_css_class("destructive-action") + return btn diff --git a/src/taninux/gui/pages/storage.py b/src/taninux/gui/pages/storage.py new file mode 100644 index 0000000..0e85a4c --- /dev/null +++ b/src/taninux/gui/pages/storage.py @@ -0,0 +1,80 @@ +"""Storage — Laufwerke, Belegung, Wechseldatenträger (udisksctl).""" +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") +from gi.repository import Adw, GLib, Gtk # noqa: E402 + +from taninux.core import storage +from taninux.gui.process import ProcessRunner + + +def _gib(b: int) -> str: + if b >= 1024**3: + return f"{b / 1024**3:.0f} GiB" + return f"{b / 1024**2:.0f} MiB" if b else "—" + + +def build() -> Gtk.Widget: + page = Adw.PreferencesPage() + runner = ProcessRunner() + groups: list[Adw.PreferencesGroup] = [] + + def _row(vol: storage.Volume) -> Adw.ActionRow: + title = vol.label or vol.dev.split("/")[-1] + sub = vol.mountpoint or "Not mounted" + row = Adw.ActionRow(title=title, subtitle=f"{sub} · {_gib(vol.size)} · {vol.fstype}") + if vol.mountpoint and vol.use_pct: + level = Gtk.LevelBar.new_for_interval(0, 100) + level.set_value(vol.use_pct) + level.set_size_request(120, -1) + level.set_valign(Gtk.Align.CENTER) + row.add_suffix(level) + pct = Gtk.Label(label=f"{vol.use_pct}%") + pct.add_css_class("dim-label") + pct.add_css_class("numeric") + row.add_suffix(pct) + if vol.removable: + if vol.mountpoint: + row.add_suffix(_btn("Eject", lambda: runner.run(storage.eject_argv(vol.dev)))) + else: + row.add_suffix(_btn("Mount", lambda: runner.run(storage.mount_argv(vol.dev)))) + return row + + def _render() -> None: + for g in groups: + page.remove(g) + groups.clear() + vols = storage.volumes() + internal = [v for v in vols if not v.removable] + removable = [v for v in vols if v.removable] + + g_int = Adw.PreferencesGroup(title="This device") + if internal: + for v in internal: + g_int.add(_row(v)) + else: + g_int.add(Adw.ActionRow(title="No filesystems found")) + page.add(g_int) + groups.append(g_int) + + if removable: + g_rm = Adw.PreferencesGroup(title="Removable") + for v in removable: + g_rm.add(_row(v)) + page.add(g_rm) + groups.append(g_rm) + + runner.connect("done", lambda _r, _c: _render()) + GLib.idle_add(lambda: (_render(), False)[1]) + return page + + +def _btn(label: str, cb) -> Gtk.Button: + b = Gtk.Button(label=label) + b.add_css_class("flat") + b.set_valign(Gtk.Align.CENTER) + b.connect("clicked", lambda _b: cb()) + return b diff --git a/src/taninux/gui/pages/store.py b/src/taninux/gui/pages/store.py new file mode 100644 index 0000000..27e2d9f --- /dev/null +++ b/src/taninux/gui/pages/store.py @@ -0,0 +1,158 @@ +"""Browse — Pakete suchen und installieren/entfernen. + +Default-Scope: offizielle Repos + Flatpak. AUR ist Opt-in (Schalter „Sources") +und braucht paru. Repo-Pakete laufen über den Helper (pacman), AUR über paru, +Flatpak als User. Async via core.search. +""" +from __future__ import annotations + +import threading + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") +from gi.repository import Adw, GLib, Gtk # noqa: E402 + +from taninux.core import panel +from taninux.core import search as pkgsearch +from taninux.gui.console import LogConsole, log_expander +from taninux.gui.pages.appdetail import open_app_detail +from taninux.gui.process import ProcessRunner + + +def build() -> Gtk.Widget: + page = Adw.PreferencesPage() + + console = LogConsole() + runner = ProcessRunner() + runner.connect("line", lambda _r, line: console.append_line(line)) + + token = {"n": 0} # gegen veraltete Such-Antworten + + g_sources = Adw.PreferencesGroup(title="Sources") + aur_row = Adw.SwitchRow( + title="Arch User Repository (AUR)", + subtitle="Community packages built from source — requires paru.", + ) + aur_row.set_active(panel.include_aur()) + g_sources.add(aur_row) + page.add(g_sources) + + g_search = Adw.PreferencesGroup() + entry = Gtk.SearchEntry() + entry.set_placeholder_text("Search apps and packages") + entry.set_hexpand(True) + g_search.add(entry) + page.add(g_search) + + g_results = Adw.PreferencesGroup(title="Results") + page.add(g_results) + rows: list[Gtk.Widget] = [] + + page.add(log_expander(console)) + + def _clear() -> None: + for r in rows: + g_results.remove(r) + rows.clear() + + def _placeholder(text: str) -> None: + _clear() + row = Adw.ActionRow(title=text) + g_results.add(row) + rows.append(row) + + def _act(item: pkgsearch.Result, btn: Gtk.Button) -> None: + argv, priv = (pkgsearch.remove_argv if item.installed else pkgsearch.install_argv)(item) + console.clear() + btn.set_sensitive(False) + runner.run(argv, privileged=priv) + + def _open(item: pkgsearch.Result) -> None: + flatpak = item.source == "flatpak" + open_app_detail( + page, + title=item.extra.get("title", item.name), + icon=item.name, + repo_name=None if flatpak else item.name, + flatpak_id=item.name if flatpak else None, + remote=item.repo or "flathub", + on_changed=lambda: _run_search(entry.get_text()), + ) + + def _render(items: list[pkgsearch.Result]) -> None: + _clear() + if not items: + _placeholder("No packages found") + return + g_results.set_title(f"Results ({len(items)})") + for item in items: + subtitle = f"{item.repo} · {item.version}" + if item.description: + subtitle += f" — {item.description}" + row = Adw.ActionRow(title=item.extra.get("title", item.name)) + row.set_subtitle(subtitle) + row.set_subtitle_lines(2) + row.set_activatable(True) + row.connect("activated", lambda _r, it=item: _open(it)) + tag = Gtk.Label(label=item.source) + tag.add_css_class("dim-label") + tag.add_css_class("caption") + tag.set_valign(Gtk.Align.CENTER) + row.add_prefix(tag) + btn = Gtk.Button(label="Remove" if item.installed else "Install") + btn.add_css_class("flat" if item.installed else "suggested-action") + btn.set_valign(Gtk.Align.CENTER) + btn.connect("clicked", lambda b, it=item: _act(it, b)) + row.add_suffix(btn) + g_results.add(row) + rows.append(row) + + def _run_search(query: str) -> None: + token["n"] += 1 + my = token["n"] + if len(query.strip()) < 2: + g_results.set_title("Results") + _clear() + return + _placeholder(f"Searching “{query}” …") + + def worker() -> None: + try: + items = pkgsearch.search(query) + except Exception: # noqa: BLE001 + items = [] + + def deliver() -> bool: + if my == token["n"]: + _render(items) + return False + + GLib.idle_add(deliver) + + threading.Thread(target=worker, daemon=True).start() + + def on_done(_r: ProcessRunner, code: int) -> None: + console.append_line("✔ done" if code == 0 else f"✘ exit {code}") + _run_search(entry.get_text()) # Status (installed/…) auffrischen + + debounce = {"id": 0} + + def _on_changed(e: Gtk.SearchEntry) -> None: + if debounce["id"]: + GLib.source_remove(debounce["id"]) + text = e.get_text() + debounce["id"] = GLib.timeout_add( + 400, lambda: (debounce.update(id=0), _run_search(text), False)[-1] + ) + + def _on_aur(row: Adw.SwitchRow, _pspec) -> None: + panel.set_include_aur(row.get_active()) + _run_search(entry.get_text()) + + runner.connect("done", on_done) + aur_row.connect("notify::active", _on_aur) + entry.connect("activate", lambda e: _run_search(e.get_text())) + entry.connect("search-changed", _on_changed) + return page diff --git a/src/taninux/gui/pages/update.py b/src/taninux/gui/pages/update.py new file mode 100644 index 0000000..d6faf36 --- /dev/null +++ b/src/taninux/gui/pages/update.py @@ -0,0 +1,195 @@ +"""Updates — Zähler (pacman/AUR/flatpak) + eine gemeinsame Paketliste. + +Alles auf einmal oder einzeln aktualisieren. pacman läuft privilegiert über +den Helper, flatpak/AUR als User. Quellen werden async geladen. +""" +from __future__ import annotations + +import threading + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") +from gi.repository import Adw, GLib, Gtk # noqa: E402 + +from taninux.core import panel +from taninux.gui.console import LogConsole, log_expander +from taninux.gui.process import ProcessRunner +from taninux.update.sources import aur, flatpak, pacman +from taninux.update.types import UpdateItem + + +def _one_argv(item: UpdateItem): + if item.source == "pacman": + return ["pacman", "-S", "--noconfirm", item.name], True + if item.source == "flatpak": + return ["flatpak", "update", "--assumeyes", item.name], False + if item.source == "aur": + return [item.extra.get("helper", "paru"), "-S", "--noconfirm", item.name], False + return None + + +def _all_queue(items: list[UpdateItem]): + """(argv, privileged)-Queue für „Update all".""" + out = [] + if any(i.source == "pacman" for i in items): + out.append((["pacman", "-Syu", "--noconfirm"], True)) + if any(i.source == "flatpak" for i in items): + out.append((["flatpak", "update", "--assumeyes"], False)) + aur_items = [i for i in items if i.source == "aur"] + if aur_items: + helper = aur_items[0].extra.get("helper", "paru") + out.append(([helper, "-Sua", "--noconfirm"], False)) + return out + + +def build() -> Gtk.Widget: + page = Adw.PreferencesPage() + + console = LogConsole() + runner = ProcessRunner() + runner.connect("line", lambda _r, line: console.append_line(line)) + + items: list[UpdateItem] = [] + pending: list = [] + + g_sum = Adw.PreferencesGroup(title="Updates") + refresh = Gtk.Button(icon_name="view-refresh-symbolic") + refresh.add_css_class("flat") + refresh.set_valign(Gtk.Align.CENTER) + g_sum.set_header_suffix(refresh) + + def _count_row(name: str): + row = Adw.ActionRow(title=name) + lbl = Gtk.Label(label="…") + lbl.add_css_class("numeric") + lbl.add_css_class("dim-label") + lbl.set_valign(Gtk.Align.CENTER) + row.add_suffix(lbl) + return row, lbl + + pac_row, pac_lbl = _count_row("Pacman") + aur_row, aur_lbl = _count_row("AUR") + fp_row, fp_lbl = _count_row("Flatpak") + for r in (pac_row, aur_row, fp_row): + g_sum.add(r) + + all_row = Adw.ActionRow(title="Update all") + all_btn = Gtk.Button(label="Update") + all_btn.add_css_class("suggested-action") + all_btn.add_css_class("pill") + all_btn.set_valign(Gtk.Align.CENTER) + all_btn.set_sensitive(False) + all_row.add_suffix(all_btn) + g_sum.add(all_row) + page.add(g_sum) + + g_list = Adw.PreferencesGroup(title="Packages") + page.add(g_list) + list_rows: list[Gtk.Widget] = [] + + page.add(log_expander(console)) + + def _start_next() -> None: + if pending: + argv, priv = pending.pop(0) + runner.run(argv, privileged=priv) + + def _update_one(item: UpdateItem) -> None: + a = _one_argv(item) + if not a: + return + console.clear() + runner.run(a[0], privileged=a[1]) + + def _update_all(_b: Gtk.Button) -> None: + pending.clear() + pending.extend(_all_queue(items)) + if not pending: + return + console.clear() + _start_next() + + all_btn.connect("clicked", _update_all) + + def _clear_list() -> None: + for r in list_rows: + g_list.remove(r) + list_rows.clear() + + def _render() -> None: + counts = {"pacman": 0, "aur": 0, "flatpak": 0} + for i in items: + if i.source in counts: + counts[i.source] += 1 + pac_lbl.set_label(str(counts["pacman"])) + aur_lbl.set_label(str(counts["aur"]) if panel.include_aur() else "off") + fp_lbl.set_label(str(counts["flatpak"])) + total = sum(counts.values()) + all_btn.set_sensitive(total > 0) + all_row.set_title(f"Update all ({total})" if total else "Update all") + + _clear_list() + if not items: + row = Adw.ActionRow(title="Everything up to date") + g_list.add(row) + list_rows.append(row) + return + order = {"pacman": 0, "aur": 1, "flatpak": 2} + for item in sorted(items, key=lambda i: (order.get(i.source, 9), i.name)): + row = Adw.ActionRow( + title=item.name, + subtitle=f"{item.current_version} → {item.new_version}", + ) + tag = Gtk.Label(label=item.source) + tag.add_css_class("dim-label") + tag.add_css_class("caption") + tag.set_valign(Gtk.Align.CENTER) + row.add_prefix(tag) + btn = Gtk.Button(label="Update") + btn.add_css_class("flat") + btn.set_valign(Gtk.Align.CENTER) + btn.connect("clicked", lambda _b, it=item: _update_one(it)) + row.add_suffix(btn) + g_list.add(row) + list_rows.append(row) + + def _load() -> None: + pac_lbl.set_label("…") + aur_lbl.set_label("…") + fp_lbl.set_label("…") + + fetchers = [pacman.fetch, flatpak.fetch] + if panel.include_aur(): + fetchers.insert(1, aur.fetch) + + def worker() -> None: + found: list[UpdateItem] = [] + for fetch in fetchers: + try: + found += fetch() + except Exception: # noqa: BLE001 + pass + + def deliver() -> bool: + items.clear() + items.extend(found) + _render() + return False + + GLib.idle_add(deliver) + + threading.Thread(target=worker, daemon=True).start() + + def on_done(_r: ProcessRunner, code: int) -> None: + console.append_line("✔ done" if code == 0 else f"✘ exit {code}") + if pending: + _start_next() + else: + _load() + + runner.connect("done", on_done) + refresh.connect("clicked", lambda _b: _load()) + GLib.idle_add(lambda: (_load(), False)[1]) + return page diff --git a/src/taninux/gui/pages/users.py b/src/taninux/gui/pages/users.py new file mode 100644 index 0000000..ae8e90e --- /dev/null +++ b/src/taninux/gui/pages/users.py @@ -0,0 +1,253 @@ +"""Users — eigenes Konto (Info + Passwort) und weitere Benutzer verwalten.""" +from __future__ import annotations + +import re +import threading +from pathlib import Path + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") +gi.require_version("GdkPixbuf", "2.0") +from gi.repository import Adw, Gdk, GdkPixbuf, Gio, GLib, Gtk # noqa: E402 + +from taninux.core import account, avatar +from taninux.gui import confirm +from taninux.gui.process import ProcessRunner + +_NAME_RE = re.compile(r"^[a-z_][a-z0-9_-]*$") + + +def build() -> Gtk.Widget: + page = Adw.PreferencesPage() + me = account.current() + + runner = ProcessRunner() + queue: list = [] + others = Adw.PreferencesGroup(title="Other users") + rows: list[Gtk.Widget] = [] + + # -- eigenes Konto -------------------------------------------------- + g_info = Adw.PreferencesGroup(title="Account") + + av = Adw.Avatar(size=64, text=me.fullname, show_initials=True) + _load_avatar(av) + pic_row = Adw.ActionRow(title="Profile picture", subtitle="Click to change") + pic_row.add_prefix(av) + pic_row.set_activatable(True) + pic_row.connect("activated", lambda _r: _pick_avatar(page, av)) + g_info.add(pic_row) + + g_info.add(_row("Name", me.fullname)) + g_info.add(_row("Username", me.username)) + g_info.add(_row("Role", "Administrator" if me.admin else "Standard")) + page.add(g_info) + + page.add(_password_group()) + + # -- weitere Benutzer ---------------------------------------------- + add_btn = Gtk.Button(icon_name="list-add-symbolic") + add_btn.add_css_class("flat") + add_btn.set_valign(Gtk.Align.CENTER) + add_btn.set_tooltip_text("Add user") + others.set_header_suffix(add_btn) + page.add(others) + + def _run_queue() -> None: + if queue: + runner.run(queue.pop(0), privileged=True) + + def _start(cmds: list) -> None: + queue.clear() + queue.extend(cmds) + _run_queue() + + def _render() -> None: + for r in rows: + others.remove(r) + rows.clear() + rest = [u for u in account.list_users() if u.username != me.username] + if not rest: + row = Adw.ActionRow(title="No other users") + others.add(row) + rows.append(row) + return + for u in rest: + row = Adw.ActionRow( + title=u.fullname, + subtitle=f"{u.username} · {'Administrator' if u.admin else 'Standard'}", + ) + rm = Gtk.Button(icon_name="user-trash-symbolic") + rm.add_css_class("flat") + rm.set_valign(Gtk.Align.CENTER) + rm.connect("clicked", lambda _b, name=u.username: confirm.confirm( + page, f"Remove {name}?", + "Their account and home folder are deleted. This cannot be undone.", + lambda n=name: _start([account.delete_user_argv(n)]))) + row.add_suffix(rm) + others.add(row) + rows.append(row) + + add_btn.connect("clicked", lambda _b: _add_dialog(page, _start)) + + def on_done(_r: ProcessRunner, code: int) -> None: + if queue: + _run_queue() + else: + _render() + + runner.connect("done", on_done) + GLib.idle_add(lambda: (_render(), False)[1]) + return page + + +def _add_dialog(parent: Gtk.Widget, start) -> None: + dialog = Adw.Dialog() + dialog.set_title("Add user") + dialog.set_content_width(420) + + page = Adw.PreferencesPage() + tv = Adw.ToolbarView() + tv.add_top_bar(Adw.HeaderBar()) + tv.set_content(page) + dialog.set_child(tv) + + group = Adw.PreferencesGroup() + full = Adw.EntryRow(title="Full name") + user = Adw.EntryRow(title="Username") + admin = Adw.SwitchRow(title="Administrator", + subtitle="Can install software and change system settings") + group.add(full) + group.add(user) + group.add(admin) + page.add(group) + + status = Gtk.Label(xalign=0.0, wrap=True) + status.add_css_class("dim-label") + create = Gtk.Button(label="Create user") + create.add_css_class("suggested-action") + create.add_css_class("pill") + create.set_halign(Gtk.Align.START) + create.set_margin_top(6) + + # Username aus Vollname vorschlagen + def _suggest(_e) -> None: + if not user.get_text(): + user.set_text(re.sub(r"[^a-z0-9_-]", "", full.get_text().lower().split(" ")[0])) + full.connect("changed", _suggest) + + def on_create(_b) -> None: + name = user.get_text().strip() + if not _NAME_RE.match(name): + status.set_label("Username must be lowercase letters, digits, - or _.") + return + start(account.add_user_cmds(name, full.get_text().strip(), admin.get_active())) + dialog.close() + + create.connect("clicked", on_create) + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) + box.set_margin_top(6) + box.append(create) + box.append(status) + group.add(box) + + dialog.present(parent) + + +def _password_group() -> Adw.PreferencesGroup: + g_pw = Adw.PreferencesGroup(title="Password") + cur = Adw.PasswordEntryRow(title="Current password") + new = Adw.PasswordEntryRow(title="New password") + confirm_row = Adw.PasswordEntryRow(title="Confirm new password") + g_pw.add(cur) + g_pw.add(new) + g_pw.add(confirm_row) + + btn = Gtk.Button(label="Change password") + btn.add_css_class("suggested-action") + btn.add_css_class("pill") + btn.set_halign(Gtk.Align.START) + status = Gtk.Label(xalign=0.0, wrap=True) + status.add_css_class("dim-label") + + def on_click(_b) -> None: + c, n, cf = cur.get_text(), new.get_text(), confirm_row.get_text() + if not c or not n: + status.set_label("Enter your current and a new password.") + return + if n != cf: + status.set_label("New passwords don't match.") + return + btn.set_sensitive(False) + status.set_label("Changing…") + + def worker() -> None: + ok, msg = account.change_password(c, n) + + def done() -> bool: + status.set_label(("✔ " if ok else "✘ ") + msg) + btn.set_sensitive(True) + if ok: + for e in (cur, new, confirm_row): + e.set_text("") + return False + + GLib.idle_add(done) + + threading.Thread(target=worker, daemon=True).start() + + btn.connect("clicked", on_click) + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) + box.set_margin_top(6) + box.append(btn) + box.append(status) + g_pw.add(box) + return g_pw + + +def _load_avatar(av: Adw.Avatar) -> None: + path = avatar.current_path() + if path: + try: + av.set_custom_image(Gdk.Texture.new_from_filename(path)) + except GLib.Error: + pass + + +def _pick_avatar(parent: Gtk.Widget, av: Adw.Avatar) -> None: + dialog = Gtk.FileDialog(title="Choose a picture") + filt = Gtk.FileFilter() + filt.set_name("Images") + for p in ("*.png", "*.jpg", "*.jpeg", "*.webp", "*.gif"): + filt.add_pattern(p) + store = Gio.ListStore.new(Gtk.FileFilter) + store.append(filt) + dialog.set_filters(store) + + def done(d, res) -> None: + try: + f = d.open_finish(res) + except GLib.Error: + return + try: + pb = GdkPixbuf.Pixbuf.new_from_file_at_scale(f.get_path(), 256, 256, True) + out = Path.home() / ".cache" / "taninux" + out.mkdir(parents=True, exist_ok=True) + dst = out / "avatar.png" + pb.savev(str(dst), "png", [], []) + except (GLib.Error, OSError): + return + avatar.set_own(str(dst)) + _load_avatar(av) + + dialog.open(parent.get_root(), None, done) + + +def _row(title: str, value: str) -> Adw.ActionRow: + row = Adw.ActionRow(title=title) + lbl = Gtk.Label(label=value, xalign=1.0) + lbl.add_css_class("dim-label") + lbl.set_selectable(True) + row.add_suffix(lbl) + return row diff --git a/src/taninux/gui/pages/vpn.py b/src/taninux/gui/pages/vpn.py new file mode 100644 index 0000000..48e9107 --- /dev/null +++ b/src/taninux/gui/pages/vpn.py @@ -0,0 +1,130 @@ +"""VPN — NetworkManager-VPNs verbinden/importieren + DNS für die aktive +Verbindung. Alles über nmcli (user-level, polkit-backed).""" +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") +from gi.repository import Adw, Gio, GLib, Gtk # noqa: E402 + +from taninux.core import network +from taninux.gui.process import ProcessRunner + + +def build() -> Gtk.Widget: + page = Adw.PreferencesPage() + + if not network.available(): + g = Adw.PreferencesGroup(title="VPN") + g.add(Adw.ActionRow(title="NetworkManager is not running")) + page.add(g) + return page + + runner = ProcessRunner() + dns_runner = ProcessRunner() + dns_queue: list = [] + groups: list[Adw.PreferencesGroup] = [] + + # ---- VPN ---- + def _vpn_group() -> Adw.PreferencesGroup: + g = Adw.PreferencesGroup(title="VPN") + add_btn = Gtk.Button(icon_name="list-add-symbolic") + add_btn.add_css_class("flat") + add_btn.set_valign(Gtk.Align.CENTER) + add_btn.set_tooltip_text("Import .ovpn or WireGuard .conf") + add_btn.connect("clicked", lambda _b: _import()) + g.set_header_suffix(add_btn) + + vpns = network.vpn_connections() + if not vpns: + g.add(Adw.ActionRow(title="No VPN configured", + subtitle="Import a .ovpn or WireGuard .conf file")) + return g + for v in vpns: + row = Adw.ActionRow(title=v.name, subtitle=v.vtype) + sw = Gtk.Switch() + sw.set_active(v.active) + sw.set_valign(Gtk.Align.CENTER) + sw.connect("state-set", lambda _s, on, n=v.name: ( + runner.run(network.vpn_up_argv(n) if on else network.vpn_down_argv(n)), + False)[1]) + row.add_suffix(sw) + g.add(row) + return g + + def _import() -> None: + dialog = Gtk.FileDialog(title="Import VPN config") + filt = Gtk.FileFilter() + filt.set_name("VPN configs") + filt.add_pattern("*.ovpn") + filt.add_pattern("*.conf") + store = Gio.ListStore.new(Gtk.FileFilter) + store.append(filt) + dialog.set_filters(store) + + def done(d, res) -> None: + try: + f = d.open_finish(res) + except GLib.Error: + return + argv = network.vpn_import_argv(f.get_path()) + if argv: + runner.run(argv) + + dialog.open(page.get_root(), None, done) + + # ---- DNS ---- + def _dns_group() -> Adw.PreferencesGroup: + g = Adw.PreferencesGroup(title="DNS") + conn = network.primary_connection() + if not conn: + g.add(Adw.ActionRow(title="No active connection")) + return g + names = list(network.DNS_PRESETS.keys()) + ["Custom"] + row = Adw.ComboRow(title="DNS servers", subtitle=conn) + row.set_model(Gtk.StringList.new(names)) + cur = network.get_dns(conn) + sel = next((i for i, (_, v) in enumerate(network.DNS_PRESETS.items()) if v == cur), None) + if cur and sel is None: + sel = len(names) - 1 # Custom + row.set_selected(sel if sel is not None else 0) + g.add(row) + + custom = Adw.EntryRow(title="Custom servers") + custom.set_text(cur) + custom.set_visible(names[row.get_selected()] == "Custom") + g.add(custom) + + def apply(servers: str) -> None: + dns_queue.clear() + dns_queue.extend(network.set_dns_cmds(conn, servers)) + _dns_next() + + def on_sel(r, _p) -> None: + choice = names[r.get_selected()] + custom.set_visible(choice == "Custom") + if choice != "Custom": + apply(network.DNS_PRESETS[choice]) + + row.connect("notify::selected", on_sel) + custom.connect("apply", lambda e: apply(e.get_text().strip())) + return g + + def _dns_next() -> None: + if dns_queue: + dns_runner.run(dns_queue.pop(0)) + + dns_runner.connect("done", lambda _r, _c: _dns_next()) + + def _render() -> None: + for g in groups: + page.remove(g) + groups.clear() + for g in (_vpn_group(), _dns_group()): + page.add(g) + groups.append(g) + + runner.connect("done", lambda _r, _c: _render()) + GLib.idle_add(lambda: (_render(), False)[1]) + return page diff --git a/src/taninux/gui/pages/wallpaper.py b/src/taninux/gui/pages/wallpaper.py new file mode 100644 index 0000000..1106c06 --- /dev/null +++ b/src/taninux/gui/pages/wallpaper.py @@ -0,0 +1,312 @@ +"""Wallpaper — Thumbnail-Grid, Klick setzt das Bild via waypaper (Backend swaybg). + +RAM-KRITISCH: Eine frühere Version hat ~30 GB belegt, weil Bilder voll geladen +wurden. Deshalb gilt hier strikt: + * Thumbnails NUR skaliert laden: `Pixbuf.new_from_file_at_scale(p, W, H, True)`. + NIE `Pixbuf.new_from_file` oder `Gtk.Picture.set_filename` (laden voll!). + * Aus dem skalierten Pixbuf eine `Gdk.Texture` machen, Pixbuf NICHT festhalten. + * CAP: max. die ersten 60 Bilder rendern (sortiert), Rest als „+N more"-Hinweis. + * Laden ASYNC in EINEM Worker-Thread: pro Bild Pixbuf im Thread skalieren, + dann via GLib.idle_add die Karte bauen + einhängen — graduell, nichts blockt. + +Diese Funktionalität lebt jetzt in `wallpaper_group()` und wird in die +Appearance-Seite eingefaltet. `build()` bleibt als dünner Wrapper bestehen. +""" +from __future__ import annotations + +import threading + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") +gi.require_version("GdkPixbuf", "2.0") +from gi.repository import Adw, Gdk, GdkPixbuf, Gio, GLib, Gtk # noqa: E402 + +from taninux.core import wallpaper +from taninux.gui.console import RunnerCard +from taninux.gui.process import ProcessRunner + +# RAM-Grenzen: kleine Thumbnails, harter Cap auf die Anzahl gerenderter Bilder. +THUMB_W = 220 +THUMB_H = 124 +MAX_THUMBS = 60 + +# Höhenbegrenzung des Grids als Row — verhindert, dass die Gruppe die ganze +# Appearance-Seite sprengt; der Grid scrollt intern. +GRID_MIN_H = 300 +GRID_MAX_H = 360 + + +def wallpaper_group() -> Adw.PreferencesGroup: + """Komplette Wallpaper-UI als eine PreferencesGroup (zum Einhängen in + Appearance). Enthält die Steuerzeile als Rows und den RAM-sicheren + Thumbnail-Grid in einem höhenbegrenzten, intern scrollenden Container.""" + if not wallpaper.available(): + return _missing_group() + + group = Adw.PreferencesGroup( + title="Wallpaper", + description="Click a thumbnail to set it.", + ) + + runner = ProcessRunner() + mons = wallpaper.monitors() + + # -- Steuerzeile: Monitor-Combo als ComboRow --------------------------- + monitor_row = Adw.ComboRow(title="Monitor") + monitor_row.set_model(Gtk.StringList.new(mons)) + monitor_row.set_selected(0) + group.add(monitor_row) + + # -- Steuerzeile: Aktionen als ActionRow mit Suffix-Buttons ------------ + actions_row = Adw.ActionRow(title="Wallpapers") + random_btn = Gtk.Button(label="Random") + random_btn.add_css_class("pill") + random_btn.set_valign(Gtk.Align.CENTER) + open_btn = Gtk.Button(label="Choose folder") + open_btn.add_css_class("pill") + open_btn.set_valign(Gtk.Align.CENTER) + refresh_btn = Gtk.Button(icon_name="view-refresh-symbolic") + refresh_btn.add_css_class("flat") + refresh_btn.set_valign(Gtk.Align.CENTER) + refresh_btn.set_tooltip_text("Reload thumbnails") + for b in (random_btn, open_btn, refresh_btn): + actions_row.add_suffix(b) + group.add(actions_row) + + # -- Status-/Spinner-Zeile --------------------------------------------- + status = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + status.set_halign(Gtk.Align.START) + status.set_margin_top(6) + status.set_margin_bottom(6) + spinner = Adw.Spinner() + spinner.set_valign(Gtk.Align.CENTER) + status_label = Gtk.Label(xalign=0) + status_label.add_css_class("dim-label") + status.append(spinner) + status.append(status_label) + + # -- Grid in höhenbegrenzter ScrolledWindow ---------------------------- + flow = Gtk.FlowBox() + flow.set_selection_mode(Gtk.SelectionMode.NONE) + flow.set_homogeneous(True) + flow.set_min_children_per_line(2) + flow.set_max_children_per_line(4) + flow.set_column_spacing(12) + flow.set_row_spacing(12) + flow.set_margin_top(6) + flow.set_margin_bottom(6) + flow.set_valign(Gtk.Align.START) + + scroller = Gtk.ScrolledWindow(hexpand=True) + scroller.set_min_content_height(GRID_MIN_H) + scroller.set_max_content_height(GRID_MAX_H) + scroller.set_propagate_natural_height(False) + scroller.set_child(flow) + + # Status + Grid zusammen in einer Row der Gruppe. + grid_holder = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) + grid_holder.set_margin_top(6) + grid_holder.append(status) + grid_holder.append(scroller) + group.add(grid_holder) + + # -- Aktionen ---------------------------------------------------------- + def _open_folder(_b: Gtk.Button) -> None: + from pathlib import Path + dialog = Gtk.FileDialog(title="Choose wallpaper folder") + cur = str(wallpaper.wallpaper_dir()) + try: + if Path(cur).is_dir(): + dialog.set_initial_folder(Gio.File.new_for_path(cur)) + except GLib.Error: + pass + + def done(d, res) -> None: + try: + f = d.select_folder_finish(res) + except GLib.Error: + return + if f and f.get_path(): + wallpaper.set_folder(f.get_path()) + _load() + + dialog.select_folder(_b.get_root(), None, done) + + def _random(_b: Gtk.Button) -> None: + runner.run(wallpaper.random_argv()) + + open_btn.connect("clicked", _open_folder) + random_btn.connect("clicked", _random) + + # Veränderlicher Zustand fürs Markieren der aktuellen Karte. + state = {"current": wallpaper.current(), "cards": {}} # path-str -> card + + def _selected_monitor() -> str: + return mons[monitor_row.get_selected()] + + def _set(path) -> None: + runner.run(wallpaper.set_argv(path, _selected_monitor())) + + def _mark_current() -> None: + cur = state["current"] + for p, card in state["cards"].items(): + if p == cur: + card.add_css_class("accent") + else: + card.remove_css_class("accent") + + def on_done(_r: ProcessRunner, code: int) -> None: + if code == 0: + # Leichtgewichtiger Reload des aktuellen Pfads (kein Bildladen). + state["current"] = wallpaper.current() + _mark_current() + + runner.connect("done", on_done) + + # -- ASYNC Thumbnail-Laden --------------------------------------------- + # Worker erzeugt skalierte Pixbufs für die ersten MAX_THUMBS Pfade; pro Bild + # baut idle_add die Karte (Picture aus Texture) und hängt sie ein. Der + # Pixbuf bleibt lokal -> wird nach Texture-Erzeugung freigegeben. + load_token = {"id": 0} + + def _clear_flow() -> None: + child = flow.get_first_child() + while child is not None: + nxt = child.get_next_sibling() + flow.remove(child) + child = nxt + state["cards"].clear() + + def _make_card(path, texture) -> Gtk.Widget: + card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) + card.add_css_class("card") + card.set_overflow(Gtk.Overflow.HIDDEN) + + pic = Gtk.Picture() + pic.set_content_fit(Gtk.ContentFit.COVER) + pic.set_size_request(THUMB_W, THUMB_H) + pic.set_paintable(texture) + card.append(pic) + + name = Gtk.Label(label=path.name, xalign=0) + name.set_ellipsize(3) # Pango.EllipsizeMode.END + name.add_css_class("caption") + name.set_margin_start(6) + name.set_margin_end(6) + name.set_margin_bottom(4) + card.append(name) + + # Klick = setzen. GestureClick auf der Karte. + click = Gtk.GestureClick() + click.connect("released", lambda *_a, p=path: _set(p)) + card.add_controller(click) + card.set_cursor(Gdk.Cursor.new_from_name("pointer", None)) + return card + + def _add_card(path, texture, token: int) -> bool: + if token != load_token["id"]: + return False # veralteter Ladevorgang + card = _make_card(path, texture) + flow.append(card) + state["cards"][str(path)] = card + if str(path) == state["current"]: + card.add_css_class("accent") + return False + + def _add_more(extra: int, token: int) -> bool: + if token != load_token["id"]: + return False + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) + box.set_valign(Gtk.Align.CENTER) + box.set_halign(Gtk.Align.CENTER) + box.set_size_request(THUMB_W, THUMB_H) + lbl = Gtk.Label(label=f"… +{extra} more") + lbl.add_css_class("dim-label") + more_btn = Gtk.Button(label="Choose folder") + more_btn.add_css_class("pill") + more_btn.connect("clicked", _open_folder) + box.append(lbl) + box.append(more_btn) + flow.append(box) + return False + + def _finish(token: int) -> bool: + if token != load_token["id"]: + return False + spinner.set_visible(False) + return False + + def _worker(items, extra: int, token: int) -> None: + for path in items: + if token != load_token["id"]: + return + try: + # NUR skaliert laden — niemals voll! + pb = GdkPixbuf.Pixbuf.new_from_file_at_scale( + str(path), THUMB_W, THUMB_H, True + ) + except (GLib.Error, OSError): + continue + texture = Gdk.Texture.new_for_pixbuf(pb) + pb = None # Pixbuf freigeben, nur die Texture lebt weiter + GLib.idle_add(_add_card, path, texture, token) + if extra > 0: + GLib.idle_add(_add_more, extra, token) + GLib.idle_add(_finish, token) + + def _load() -> None: + load_token["id"] += 1 + token = load_token["id"] + _clear_flow() + state["current"] = wallpaper.current() + all_paths = wallpaper.list_wallpapers() + total = len(all_paths) + if total == 0: + spinner.set_visible(False) + status_label.set_text( + f"No images in {wallpaper.wallpaper_dir()}. " + "Use Open folder to add wallpapers." + ) + return + items = all_paths[:MAX_THUMBS] + extra = max(0, total - MAX_THUMBS) + spinner.set_visible(True) + shown = len(items) + status_label.set_text( + f"{shown} of {total} wallpapers" + if extra else f"{total} wallpapers" + ) + threading.Thread( + target=_worker, args=(items, extra, token), daemon=True + ).start() + + refresh_btn.connect("clicked", lambda _b: _load()) + GLib.idle_add(lambda: (_load(), False)[1]) + + return group + + +def build() -> Gtk.Widget: + """Dünner Wrapper: eigene PreferencesPage, die nur die wallpaper_group() + enthält. Bleibt erhalten, falls noch jemand build() aufruft.""" + page = Adw.PreferencesPage() + page.add(wallpaper_group()) + return page + + +def _missing_group() -> Adw.PreferencesGroup: + """Gruppe mit Install-Hinweis + RunnerCard, falls waypaper fehlt.""" + group = Adw.PreferencesGroup( + title="Wallpaper", + description="waypaper is not installed. Install it to manage wallpapers.", + ) + row = Adw.ActionRow() + row.set_child(RunnerCard( + "Install waypaper", + ["pacman", "-S", "--needed", "--noconfirm", "waypaper"], + privileged=True, + )) + group.add(row) + return group diff --git a/src/taninux/gui/polkit/ch.gabrielevarano.Taninux.policy b/src/taninux/gui/polkit/ch.gabrielevarano.Taninux.policy new file mode 100644 index 0000000..c1fc9cc --- /dev/null +++ b/src/taninux/gui/polkit/ch.gabrielevarano.Taninux.policy @@ -0,0 +1,31 @@ + + + + + TANINUX + https://gabrielevarano.ch + + + TANINUX-Systemwartung ausführen + Authentifizierung nötig, um Systempakete zu verwalten + + auth_admin + auth_admin + auth_admin_keep + + /usr/lib/taninux/taninux-helper + true + + diff --git a/src/taninux/gui/process.py b/src/taninux/gui/process.py new file mode 100644 index 0000000..78e314b --- /dev/null +++ b/src/taninux/gui/process.py @@ -0,0 +1,105 @@ +"""Async Subprocess-Runner fürs GTK-Frontend. + +Das GTK-Pendant zu `taninux.core.runner` / `streaming` (die an die Textual-App +hängen). Hier läuft alles non-blocking über `Gio.Subprocess`: stdout wird +zeilenweise im GLib-Mainloop gelesen, ohne den UI-Thread zu blockieren. + +Privilegierte Befehle (pacman -S/-R/-Syu, bootctl set-default) laufen NICHT mehr +direkt als `pkexec pacman …` (= beliebiges root), sondern werden durch den +dedizierten `taninux-helper` geschleust: `pkexec `. Der Helper +prüft das argv gegen eine feste Allowlist und lehnt alles andere ab. + +Damit das ohne Terminal-Passwortprompt funktioniert, braucht es einen laufenden +polkit-Auth-Agent **und** idealerweise die mitgelieferte Policy (siehe +gui/polkit/). Ohne Policy fragt pkexec generisch nach dem Passwort eines Admins. +""" +from __future__ import annotations + +import os + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gio, GLib, GObject # noqa: E402 + +# Pfad zum root-Helper. Bevorzugt der Systempfad (so zeigt auch die +# polkit-Policy via exec.path dorthin). Fallback: der Helper im Repo, damit +# das Frontend ohne Installation (Dev / uneditierte Quelle) funktioniert. +_SYSTEM_HELPER = "/usr/lib/taninux/taninux-helper" +_REPO_HELPER = os.path.join(os.path.dirname(__file__), "helper", "taninux-helper") + + +def _helper_path() -> str: + return _SYSTEM_HELPER if os.path.exists(_SYSTEM_HELPER) else _REPO_HELPER + + +class ProcessRunner(GObject.Object): + """Führt einen Befehl async aus und emittiert Zeilen + Exit-Code. + + Signals: + line(str): eine Zeile stdout/stderr (ohne Newline) + done(int): Exit-Code, wenn der Prozess endet (-1 = Start fehlgeschlagen) + """ + + __gsignals__ = { + "line": (GObject.SignalFlags.RUN_FIRST, None, (str,)), + "done": (GObject.SignalFlags.RUN_FIRST, None, (int,)), + } + + def __init__(self) -> None: + super().__init__() + self._proc: Gio.Subprocess | None = None + + @property + def running(self) -> bool: + return self._proc is not None + + def run(self, argv: list[str], *, privileged: bool = False) -> None: + if self.running: + self.emit("line", "⚠ a process is already running") + return + + # privileged: argv durch pkexec + Helper schleusen (Helper validiert + # gegen Allowlist). Sonst direkt ausführen. + if privileged: + real_argv = ["pkexec", _helper_path(), *argv] + else: + real_argv = list(argv) + self.emit("line", f"$ {' '.join(real_argv)}") + + flags = Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_MERGE + try: + self._proc = Gio.Subprocess.new(real_argv, flags) + except GLib.Error as err: + self.emit("line", f"✘ failed to start process: {err.message}") + self.emit("done", -1) + return + + stdout = self._proc.get_stdout_pipe() + reader = Gio.DataInputStream.new(stdout) + self._read_next(reader) + self._proc.wait_async(None, self._on_wait) + + # -- intern ----------------------------------------------------------- + + def _read_next(self, reader: Gio.DataInputStream) -> None: + reader.read_line_async(GLib.PRIORITY_DEFAULT, None, self._on_line, None) + + def _on_line(self, reader: Gio.DataInputStream, result, _user) -> None: + try: + raw, _length = reader.read_line_finish_utf8(result) + except GLib.Error: + return + if raw is None: # EOF + return + self.emit("line", raw) + self._read_next(reader) + + def _on_wait(self, proc: Gio.Subprocess, result) -> None: + try: + proc.wait_finish(result) + code = proc.get_exit_status() + except GLib.Error: + code = -1 + self._proc = None + self.emit("done", code) diff --git a/src/taninux/gui/style.py b/src/taninux/gui/style.py new file mode 100644 index 0000000..7f49cb0 --- /dev/null +++ b/src/taninux/gui/style.py @@ -0,0 +1,271 @@ +"""Theming: Fuji-Akzente + Light/Dark — über CSS-Recoloring. + +libadwaita ignoriert klassische GTK-Themes bewusst; Theming läuft daher über +das Überschreiben der benannten Adwaita-Farben (`@define-color accent_bg_color …`) +per CSS-Provider auf USER-Priorität. Die Akzent-Hues kommen 1:1 aus +`taninux.core.theme`, damit GTK-App und TUI dieselbe Palette teilen. +""" +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") +from gi.repository import Adw, Gdk, Gtk # noqa: E402 + +# Akzent-Daten leben GTK-frei in gui.accents (geteilt mit config + eww-Consumer). +from taninux.core import theme as T # noqa: F401 (CSS nutzt @window_bg_color etc.) +from taninux.gui.accents import ( # noqa: F401 (re-export für settings.py) + DEFAULT_ACCENT, + FUJI_ACCENTS, + Accent, + by_key as accent, +) + +# Mapping Schema-Key → Adwaita ColorScheme +_SCHEMES = { + "system": Adw.ColorScheme.DEFAULT, + "light": Adw.ColorScheme.FORCE_LIGHT, + "dark": Adw.ColorScheme.FORCE_DARK, +} + +_provider: Gtk.CssProvider | None = None + + +def _contrast_fg(hex_color: str) -> str: + """Lesbarer Vordergrund (dunkel/weiß) für Text auf der Akzentfläche.""" + h = hex_color.lstrip("#") + r, g, b = (int(h[i:i + 2], 16) / 255 for i in (0, 2, 4)) + lum = 0.2126 * r + 0.7152 * g + 0.0588 * b + return T.YORU if lum > 0.6 else "#ffffff" + + +def _build_css(acc: Accent) -> bytes: + fg = _contrast_fg(acc.hex) + # Akzent-Override + Swatch-Styling (Swatches sind statisch, aber billig + # mitzubauen). Swatch = runder Farbknopf, aktiver bekommt einen Ring. + swatches = "\n".join( + f".swatch-{a.key} {{ background-color: {a.hex}; }}" + for a in FUJI_ACCENTS + ) + return f""" +@define-color accent_bg_color {acc.hex}; +@define-color accent_fg_color {fg}; +@define-color accent_color {acc.hex}; + +/* pill controls — all buttons & dropdowns are stadium pills */ +button, menubutton > button, dropdown, dropdown > button, +combobox button, splitbutton > button, entry, spinbutton, +.pill {{ border-radius: 999px; }} + +.fuji-swatch {{ + min-width: 26px; + min-height: 26px; + padding: 0; + border-radius: 50%; + box-shadow: inset 0 0 0 1px alpha(currentColor, 0.15); +}} +.fuji-swatch:checked {{ + box-shadow: inset 0 0 0 2px @window_bg_color, 0 0 0 2px @accent_bg_color; +}} +{swatches} +""".encode() + + +def apply(color_scheme: str, accent_key: str) -> None: + """Setzt Color-Scheme (StyleManager) + Akzent (CSS-Provider).""" + global _provider + + mgr = Adw.StyleManager.get_default() + mgr.set_color_scheme(_SCHEMES.get(color_scheme, Adw.ColorScheme.DEFAULT)) + + if _provider is None: + _provider = Gtk.CssProvider() + display = Gdk.Display.get_default() + if display is not None: + Gtk.StyleContext.add_provider_for_display( + display, _provider, Gtk.STYLE_PROVIDER_PRIORITY_USER + ) + _provider.load_from_data(_build_css(accent(accent_key))) + + +def apply_persisted() -> None: + """Beim Start aufrufen — lädt die gespeicherte Präferenz.""" + from taninux.gui import config + + cfg = config.load() + apply(cfg["color_scheme"], cfg["accent"]) + + +# -------------------------------------------------------------------------- +# Global theme (system-wide GTK4 + GTK3) — full dynamic Fuji recolour. +# -------------------------------------------------------------------------- +# Writes the picked Fuji accent + monochrome light/dark SURFACES to the global +# GTK4 (libadwaita) and GTK3 user CSS, and sets gsettings so EVERY app follows +# (not just this one). Dark base = #0f0f0f, light base = #f0f0f0; the accent is +# the chosen Fuji hue. All buttons & dropdowns are pills. GTK4 rides on the +# existing `@import "libadwaita.css"`; GTK3 rides on adw-gtk3. + +# Monochrome surfaces derived from the two base values (#0f0f0f / #f0f0f0). +_DARK_SURF = dict(bg="#0f0f0f", bg_alt="#191919", bg_hi="#262626", view="#141414", + card="#191919", headerbar="#0f0f0f", fg="#f0f0f0", + border="rgba(240,240,240,0.10)") +_LIGHT_SURF = dict(bg="#f0f0f0", bg_alt="#e4e4e4", bg_hi="#d6d6d6", view="#fafafa", + card="#ffffff", headerbar="#e8e8e8", fg="#0f0f0f", + border="rgba(15,15,15,0.12)") + +_PILLS_GTK4 = ( + "button, menubutton > button, dropdown, dropdown > button, combobox button,\n" + "splitbutton > button, entry, spinbutton, .pill { border-radius: 999px; }\n" + ".card, popover > contents, .osd { border-radius: 14px; }\n" +) +_PILLS_GTK3 = ( + "button, combobox button, combobox, entry, spinbutton,\n" + "notebook > header > tabs > tab { border-radius: 999px; }\n" +) + + +def _config_base() -> Path: + return Path(os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")) + + +def _gtk4_config_dir() -> Path: + return _config_base() / "gtk-4.0" + + +def _resolve_dark(color_scheme: str) -> bool: + """True if the global theme should be dark. 'system' → read gsettings.""" + if color_scheme == "dark": + return True + if color_scheme == "light": + return False + try: + out = subprocess.run( + ["gsettings", "get", "org.gnome.desktop.interface", "color-scheme"], + capture_output=True, text=True, timeout=5, check=False, + ).stdout + except (FileNotFoundError, subprocess.TimeoutExpired): + return True + return "prefer-light" not in out + + +def _gtk4_full(acc: Accent, dark: bool) -> str: + p = _DARK_SURF if dark else _LIGHT_SURF + fg = _contrast_fg(acc.hex) + return f"""/* TANINUX-managed — global GTK4/libadwaita Fuji theme. Do not edit by hand. + {'dark' if dark else 'light'} · accent {acc.label} ({acc.hex}) */ +@define-color accent_color {acc.hex}; +@define-color accent_bg_color {acc.hex}; +@define-color accent_fg_color {fg}; +@define-color window_bg_color {p['bg']}; +@define-color window_fg_color {p['fg']}; +@define-color view_bg_color {p['view']}; +@define-color view_fg_color {p['fg']}; +@define-color headerbar_bg_color {p['headerbar']}; +@define-color headerbar_fg_color {p['fg']}; +@define-color headerbar_backdrop_color {p['bg']}; +@define-color headerbar_border_color {p['fg']}; +@define-color card_bg_color {p['card']}; +@define-color card_fg_color {p['fg']}; +@define-color dialog_bg_color {p['view']}; +@define-color dialog_fg_color {p['fg']}; +@define-color popover_bg_color {p['bg_alt']}; +@define-color popover_fg_color {p['fg']}; +@define-color sidebar_bg_color {p['bg_alt']}; +@define-color sidebar_fg_color {p['fg']}; +{_PILLS_GTK4}""" + + +def _gtk3_full(acc: Accent, dark: bool) -> str: + p = _DARK_SURF if dark else _LIGHT_SURF + fg = _contrast_fg(acc.hex) + return f"""/* TANINUX-managed — global GTK3 Fuji theme (rides adw-gtk3). Do not edit. */ +@define-color accent_color {acc.hex}; +@define-color accent_bg_color {acc.hex}; +@define-color accent_fg_color {fg}; +@define-color window_bg_color {p['bg']}; +@define-color window_fg_color {p['fg']}; +@define-color view_bg_color {p['view']}; +@define-color headerbar_bg_color {p['headerbar']}; +@define-color card_bg_color {p['card']}; +@define-color popover_bg_color {p['bg_alt']}; +@define-color theme_bg_color {p['bg']}; +@define-color theme_fg_color {p['fg']}; +@define-color theme_base_color {p['view']}; +@define-color theme_text_color {p['fg']}; +@define-color theme_selected_bg_color {acc.hex}; +@define-color theme_selected_fg_color {fg}; +@define-color borders {p['border']}; +{_PILLS_GTK3}/* The per-widget accent (sliders, tab underline, switches) is driven by the + GNOME accent-color gsetting (adw-gtk3 honours it and wins on specificity); + write_global_theme maps the Fuji hue to the nearest preset. */ +""" + + +# Fuji accent → nearest GNOME accent-color preset (for GTK3/adw-gtk3, which +# follows the gsetting; GTK4/libadwaita uses the exact Fuji hex from our css). +_ACCENT_PRESET = { + "ume": "purple", "shinkai": "blue", "take": "green", + "chikyu": "yellow", "beni": "red", +} + + +def _set_gsettings(dark: bool, system: bool, accent_key: str) -> None: + pairs = [ + ("color-scheme", "default" if system else ("prefer-dark" if dark else "prefer-light")), + ("gtk-theme", "adw-gtk3-dark" if dark else "adw-gtk3"), + ("accent-color", _ACCENT_PRESET.get(accent_key, "purple")), + ] + for key, val in pairs: + try: + subprocess.run( + ["gsettings", "set", "org.gnome.desktop.interface", key, val], + check=False, timeout=5, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + pass + + +def write_global_theme(color_scheme: str, accent_key: str) -> None: + """Write the full Fuji theme (GTK4 + GTK3) globally + sync gsettings. + + Drives EVERY app: surfaces follow light/dark (#f0f0f0 / #0f0f0f), the accent + is the chosen Fuji hue, all buttons & dropdowns are pills. + """ + acc = accent(accent_key) + dark = _resolve_dark(color_scheme) + + # GTK4 → ~/.config/gtk-4.0/libadwaita.css (imported by the adw-gtk3 gtk.css). + try: + g4 = _gtk4_config_dir() + g4.mkdir(parents=True, exist_ok=True) + (g4 / "libadwaita.css").write_text(_gtk4_full(acc, dark)) + tweaks = g4 / "libadwaita-tweaks.css" + if not tweaks.exists(): # silence the adw-gtk3 @import for it + tweaks.write_text("/* TANINUX (intentionally empty) */\n") + except OSError: + pass + + # GTK3 → ~/.config/gtk-3.0/gtk.css (overrides on top of adw-gtk3). + try: + g3 = _config_base() / "gtk-3.0" + g3.mkdir(parents=True, exist_ok=True) + dst = g3 / "gtk.css" + backup = g3 / "gtk.css.pre-tanin" + if dst.exists() and not dst.is_symlink() and not backup.exists(): + backup.write_text(dst.read_text()) # keep a real pre-existing file once + dst.write_text(_gtk3_full(acc, dark)) + except OSError: + pass + + _set_gsettings(dark, color_scheme == "system", accent_key) + + +def write_global_accent(accent_key: str) -> None: + """Back-compat shim — now writes the full theme for the saved scheme.""" + from taninux.gui import config + write_global_theme(config.load()["color_scheme"], accent_key) diff --git a/src/taninux/gui/window.py b/src/taninux/gui/window.py new file mode 100644 index 0000000..9a2554c --- /dev/null +++ b/src/taninux/gui/window.py @@ -0,0 +1,144 @@ +"""Hauptfenster: Sidebar (Pillar-Liste) + Content-Stack. + +Adw.NavigationSplitView gibt das responsive Sidebar/Content-Layout, das man +von GNOME-Settings kennt — auf schmalen Fenstern klappt es automatisch zu +einer Drill-Down-Navigation um. +""" +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") +from gi.repository import Adw, Gtk # noqa: E402 + +from taninux.gui.pages import SECTIONS + + +class MainWindow(Adw.ApplicationWindow): + def __init__(self, app: Adw.Application, sections=None, title: str = "TANINUX") -> None: + super().__init__(application=app, title=title) + self.set_default_size(960, 680) + + self._sections = sections if sections is not None else SECTIONS + self._pages = [p for s in self._sections for p in s.pages] + self._app_title = title + self._stack = Gtk.Stack() + self._stack.set_transition_type(Gtk.StackTransitionType.CROSSFADE) + self._title = Adw.WindowTitle(title=title) + self._rows: dict[str, Gtk.ListBoxRow] = {} + self._query = "" + + split = Adw.NavigationSplitView() + split.set_sidebar(self._build_sidebar()) + split.set_content(self._build_content()) + self.set_content(split) + + # lazy: Seiten erst beim ersten Anzeigen bauen; Startseite vorwählen + self._built: set[str] = set() + if self._pages: + self._listbox.select_row(self._rows[self._pages[0].key]) + + # -- Sidebar ------------------------------------------------------- + + def _build_sidebar(self) -> Adw.NavigationPage: + self._listbox = Gtk.ListBox() + self._listbox.add_css_class("navigation-sidebar") + self._listbox.set_selection_mode(Gtk.SelectionMode.SINGLE) + self._listbox.connect("row-selected", self._on_row_selected) + self._listbox.set_filter_func(self._filter_row) + + for section in self._sections: + if section.title: + self._listbox.append(self._header_row(section.title)) + for page in section.pages: + row = Gtk.ListBoxRow() + row._page = page # type: ignore[attr-defined] + box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + box.set_margin_top(10) + box.set_margin_bottom(10) + box.set_margin_start(8) + box.set_margin_end(8) + box.append(Gtk.Image.new_from_icon_name(page.icon)) + box.append(Gtk.Label(label=page.label, xalign=0.0)) + row.set_child(box) + self._rows[page.key] = row + self._listbox.append(row) + + scroller = Gtk.ScrolledWindow(vexpand=True) + scroller.set_child(self._listbox) + + search = Gtk.SearchEntry() + search.set_placeholder_text("Search settings") + search.set_margin_top(6) + search.set_margin_bottom(6) + search.set_margin_start(6) + search.set_margin_end(6) + search.connect("search-changed", self._on_search) + + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + box.append(search) + box.append(scroller) + + toolbar = Adw.ToolbarView() + toolbar.add_top_bar(Adw.HeaderBar()) + toolbar.set_content(box) + + nav = Adw.NavigationPage(title=self._app_title, child=toolbar) + nav.set_tag("sidebar") + return nav + + def _header_row(self, title: str) -> Gtk.ListBoxRow: + """Nicht-wählbare Sektions-Überschrift.""" + row = Gtk.ListBoxRow() + row.set_selectable(False) + row.set_activatable(False) + label = Gtk.Label(label=title, xalign=0.0) + label.add_css_class("dim-label") + label.add_css_class("caption-heading") + label.set_margin_top(14) + label.set_margin_bottom(2) + label.set_margin_start(12) + row.set_child(label) + return row + + # -- Content ------------------------------------------------------- + + def _build_content(self) -> Adw.NavigationPage: + toolbar = Adw.ToolbarView() + header = Adw.HeaderBar() + header.set_title_widget(self._title) + toolbar.add_top_bar(header) + toolbar.set_content(self._stack) + nav = Adw.NavigationPage(title="Content", child=toolbar) + nav.set_tag("content") + return nav + + # -- Suche --------------------------------------------------------- + + def _on_search(self, entry: Gtk.SearchEntry) -> None: + self._query = entry.get_text().strip().lower() + self._listbox.invalidate_filter() + + def _filter_row(self, row: Gtk.ListBoxRow) -> bool: + page = getattr(row, "_page", None) + if page is None: # Sektions-Header + return not self._query + if not self._query: + return True + hay = (page.label + " " + " ".join(page.keywords)).lower() + return self._query in hay + + # -- Navigation ---------------------------------------------------- + + def _on_row_selected(self, _box: Gtk.ListBox, row: Gtk.ListBoxRow | None) -> None: + page = getattr(row, "_page", None) if row is not None else None + if page is not None: + self._select(page) + + def _select(self, page) -> None: + if page.key not in self._built: + self._stack.add_named(page.build(), page.key) + self._built.add(page.key) + self._stack.set_visible_child_name(page.key) + self._title.set_title(page.label) diff --git a/src/taninux/health/__init__.py b/src/taninux/health/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/taninux/health/pillar.py b/src/taninux/health/pillar.py new file mode 100644 index 0000000..750df06 --- /dev/null +++ b/src/taninux/health/pillar.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from taninux.health.screen import HealthView +from taninux.pillars.base import Pillar + + +HEALTH = Pillar( + key="h", + label="health", + tagline="diagnose · perf · drivers", + icon="◈", + factory=HealthView, +) diff --git a/src/taninux/health/screen.py b/src/taninux/health/screen.py new file mode 100644 index 0000000..3faf472 --- /dev/null +++ b/src/taninux/health/screen.py @@ -0,0 +1,248 @@ +"""Health-Pillar — Live-Dashboard mit System-Diagnose-Sektionen.""" +from __future__ import annotations + +from textual import work +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import VerticalScroll +from textual.widgets import Static + +from taninux.core import sysinfo +from taninux.core import theme as T + + +def _human_size(n: int) -> str: + f = float(n) + for unit in ("B", "K", "M", "G", "T"): + if f < 1024 or unit == "T": + return f"{f:.0f}{unit}" if unit == "B" else f"{f:.1f}{unit}" + f /= 1024 + return f"{f:.1f}T" + + +def _human_duration(seconds: float) -> str: + s = int(seconds) + if s < 60: + return f"{s}s" + if s < 3600: + return f"{s // 60}m {s % 60}s" + h, rem = divmod(s, 3600) + if h < 24: + return f"{h}h {rem // 60}m" + d, h = divmod(h, 24) + return f"{d}d {h}h" + + +def _status_glyph(ok: bool, warn: bool = False) -> str: + if warn: + return f"[{T.CHIKYU}]◐[/]" + return f"[{T.TAKE}]●[/]" if ok else f"[{T.RED}]●[/]" + + +class HealthView(VerticalScroll): + """Live system health dashboard. Auto-refresh alle 10s.""" + + DEFAULT_CSS = f""" + HealthView {{ + background: {T.YORU}; + padding: 1 2; + scrollbar-color: {T.BASE2} {T.YORU}; + scrollbar-background: {T.YORU}; + scrollbar-corner-color: {T.YORU}; + scrollbar-size-vertical: 1; + }} + HealthView Static {{ + background: {T.YORU}; + margin-bottom: 1; + }} + """ + + BINDINGS = [ + Binding("r", "refresh", "refresh"), + ] + + def compose(self) -> ComposeResult: + yield Static("", id="h-system", markup=True) + yield Static("", id="h-perf", markup=True) + yield Static("", id="h-gpu", markup=True) + yield Static("", id="h-storage", markup=True) + yield Static("", id="h-net", markup=True) + yield Static("", id="h-services", markup=True) + yield Static("", id="h-logs", markup=True) + + def on_mount(self) -> None: + self._refresh_worker() + self.set_interval(10.0, self._refresh_worker) + + def action_refresh(self) -> None: + self._refresh_worker() + + @work(thread=True, exclusive=True, group="health-refresh") + def _refresh_worker(self) -> None: + # subprocess-heavy calls passieren im worker, render dann auf UI thread + self.app.call_from_thread(self._refresh_all) + + # ---- section builders ---- + + def _section(self, title: str) -> str: + return f"[{T.UME}]{title}[/]" + + def _refresh_all(self) -> None: + self._render_system() + self._render_perf() + self._render_gpu() + self._render_storage() + self._render_net() + self._render_services() + self._render_logs() + + def _render_system(self) -> None: + boot = sysinfo.boot_time_seconds() + up = sysinfo.uptime_seconds() + lines = [ + self._section("◈ system"), + f" [{T.SHINKAI}]host[/] [{T.YUKI}]{sysinfo.hostname()}[/]", + f" [{T.SHINKAI}]distro[/] [{T.YUKI}]{sysinfo.distro_pretty()}[/]", + f" [{T.SHINKAI}]kernel[/] [{T.YUKI}]{sysinfo.kernel_release()}[/]", + f" [{T.SHINKAI}]uptime[/] [{T.YUKI}]{_human_duration(up)}[/]", + ] + if boot is not None: + color = T.TAKE if boot < 30 else (T.CHIKYU if boot < 60 else T.RED) + lines.append(f" [{T.SHINKAI}]boot[/] [{color}]{boot:.1f}s[/]") + self.query_one("#h-system", Static).update("\n".join(lines)) + + def _render_perf(self) -> None: + mem = sysinfo.memory() + la = sysinfo.load_avg() + ncpu = sysinfo.cpu_count() + total = mem.get("MemTotal", 0) + avail = mem.get("MemAvailable", 0) + used = total - avail + used_pct = (used * 100 / total) if total else 0 + swap_total = mem.get("SwapTotal", 0) + swap_free = mem.get("SwapFree", 0) + swap_used = swap_total - swap_free + swap_pct = (swap_used * 100 / swap_total) if swap_total else 0 + + load_color = T.TAKE if la[0] < ncpu * 0.7 else (T.CHIKYU if la[0] < ncpu else T.RED) + mem_color = T.TAKE if used_pct < 60 else (T.CHIKYU if used_pct < 85 else T.RED) + swap_color = T.TAKE if swap_pct < 20 else (T.CHIKYU if swap_pct < 60 else T.RED) + + lines = [ + self._section("⊙ perf"), + f" [{T.SHINKAI}]load[/] " + f"[{load_color}]{la[0]:.2f}[/] " + f"[{T.BASE5}]{la[1]:.2f}[/] " + f"[{T.BASE5}]{la[2]:.2f}[/] " + f"[{T.BASE4}]({ncpu} cpus)[/]", + f" [{T.SHINKAI}]memory[/] " + f"[{mem_color}]{_human_size(used)}[/] [{T.BASE5}]/[/] " + f"[{T.YUKI}]{_human_size(total)}[/] " + f"[{T.BASE4}]({used_pct:.0f}%)[/]", + ] + if swap_total: + lines.append( + f" [{T.SHINKAI}]swap[/] " + f"[{swap_color}]{_human_size(swap_used)}[/] [{T.BASE5}]/[/] " + f"[{T.YUKI}]{_human_size(swap_total)}[/] " + f"[{T.BASE4}]({swap_pct:.0f}%)[/]" + ) + else: + lines.append(f" [{T.SHINKAI}]swap[/] [{T.BASE5}]nicht konfiguriert[/]") + self.query_one("#h-perf", Static).update("\n".join(lines)) + + def _render_gpu(self) -> None: + gpus = sysinfo.gpus() + lines = [self._section("▣ gpu")] + if not gpus: + lines.append(f" [{T.BASE5}](keine gpu erkannt)[/]") + else: + for g in gpus: + vendor_color = { + "nvidia": T.TAKE, + "amd": T.RED, + "intel": T.SHINKAI, + }.get(g.vendor, T.BASE4) + driver_part = ( + f"[{T.SHINKAI}]driver[/] [{T.CHIKYU}]{g.driver}[/]" + if g.driver else f"[{T.BASE4}](kein driver geladen)[/]" + ) + lines.append( + f" [{vendor_color}]{g.vendor:7s}[/] " + f"[{T.YUKI}]{g.model[:60]}[/]" + ) + lines.append(f" {driver_part}") + lines.append(f" [{T.SHINKAI}]audio[/] [{T.YUKI}]{sysinfo.audio_server()}[/]") + self.query_one("#h-gpu", Static).update("\n".join(lines)) + + def _render_storage(self) -> None: + disks = sysinfo.disks() + lines = [self._section("◇ storage")] + if not disks: + lines.append(f" [{T.BASE5}](keine disks)[/]") + else: + for d in disks: + kind = "hdd" if d.rotational else "ssd" + kind_color = T.CHIKYU if d.rotational else T.SHINKAI + lines.append( + f" [{kind_color}]{kind}[/] " + f"[{T.YUKI}]/dev/{d.name}[/] " + f"[{T.CHIKYU}]{_human_size(d.size_bytes)}[/] " + f"[{T.BASE5}]{d.model}[/]" + ) + self.query_one("#h-storage", Static).update("\n".join(lines)) + + def _render_net(self) -> None: + ifaces = sysinfo.network_interfaces() + lines = [self._section("⇆ netzwerk")] + if not ifaces: + lines.append(f" [{T.BASE5}](keine interfaces)[/]") + else: + for iface in ifaces: + if iface.name == "lo": + continue + state_color = { + "UP": T.TAKE, + "DOWN": T.BASE3, + "UNKNOWN": T.BASE4, + }.get(iface.state, T.BASE4) + addrs = " ".join(iface.addrs) if iface.addrs else "—" + lines.append( + f" [{state_color}]●[/] " + f"[{T.YUKI}]{iface.name:12s}[/] " + f"[{T.BASE5}]{iface.state:8s}[/] " + f"[{T.BASE4}]{addrs}[/]" + ) + self.query_one("#h-net", Static).update("\n".join(lines)) + + def _render_services(self) -> None: + failed = sysinfo.systemctl_failed() + lines = [self._section("◉ services")] + if not failed: + lines.append( + f" [{T.TAKE}]●[/] [{T.YUKI}]0[/] [{T.BASE5}]failed units[/]" + ) + else: + lines.append( + f" [{T.RED}]●[/] [{T.YUKI}]{len(failed)}[/] [{T.BASE5}]failed units[/]" + ) + for unit in failed[:10]: + lines.append(f" [{T.RED}]{unit}[/]") + if len(failed) > 10: + lines.append(f" [{T.BASE4}]… +{len(failed) - 10} mehr[/]") + self.query_one("#h-services", Static).update("\n".join(lines)) + + def _render_logs(self) -> None: + today = sysinfo.journal_errors_today() + boot = sysinfo.journal_errors_boot() + def label(n: int, txt: str) -> str: + if n < 0: + return f" [{T.BASE3}]?[/] [{T.BASE5}]{txt} (nicht verfügbar)[/]" + color = T.TAKE if n == 0 else (T.CHIKYU if n < 20 else T.RED) + return f" [{color}]●[/] [{T.YUKI}]{n}[/] [{T.BASE5}]{txt}[/]" + lines = [ + self._section("✎ logs"), + label(today, "errors heute (journalctl -p 3)"), + label(boot, "errors seit boot (journalctl -p 3 -b)"), + ] + self.query_one("#h-logs", Static).update("\n".join(lines)) diff --git a/src/taninux/kernel/__init__.py b/src/taninux/kernel/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/taninux/kernel/pillar.py b/src/taninux/kernel/pillar.py new file mode 100644 index 0000000..623c343 --- /dev/null +++ b/src/taninux/kernel/pillar.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from taninux.kernel.screen import KernelView +from taninux.pillars.base import Pillar + + +KERNEL = Pillar( + key="k", + label="kernel", + tagline="liste · wechsel · prune", + icon="⊙", + factory=KernelView, +) diff --git a/src/taninux/kernel/screen.py b/src/taninux/kernel/screen.py new file mode 100644 index 0000000..d157678 --- /dev/null +++ b/src/taninux/kernel/screen.py @@ -0,0 +1,506 @@ +"""Kernel-Pillar — Liste verfügbarer Kernels, Status, Install/Switch, Boot-Default.""" +from __future__ import annotations + +import subprocess +from dataclasses import dataclass +from pathlib import Path + +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import VerticalScroll +from textual.widgets import DataTable, Static + +from taninux.core import bootloader, pacman as pac, sudo, sysinfo +from taninux.core import theme as T +from taninux.core.confirm import ConfirmModal +from taninux.core.runner import Command, run_interactive +from taninux.core.streaming import RunnerScreen, StreamCommand +from taninux.core.sudo_modal import SudoModal + + +# Standard Arch-Kernel-Varianten +KERNEL_VARIANTS = [ + ("linux", "stable, latest mainline"), + ("linux-lts", "long-term support — stabilster"), + ("linux-zen", "performance + interactivity tweaks (gut für gaming/desktop)"), + ("linux-hardened", "security-fokus, härteren default-configs"), + ("linux-rt", "real-time scheduler (audio, robotics)"), + ("linux-rt-lts", "real-time + LTS"), +] + + +@dataclass +class KernelInfo: + name: str # 'linux-zen' + description: str + installed: bool + headers_installed: bool + version: str | None + is_running: bool + boot_entry: Path | None # systemd-boot .conf entry, falls match + is_default_boot: bool # ist der konfigurierte default für nächsten boot + + +def _installed_with_versions() -> dict[str, str]: + try: + r = subprocess.run( + ["pacman", "-Q"], + capture_output=True, text=True, check=False, timeout=10, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return {} + out: dict[str, str] = {} + for line in r.stdout.splitlines(): + parts = line.split() + if len(parts) >= 2: + out[parts[0]] = parts[1] + return out + + +def _is_running(variant: str, running: str) -> bool: + if variant == "linux": + return not any(s in running for s in ("zen", "lts", "hardened", "rt")) + suffix = variant.removeprefix("linux-") + return suffix in running + + +def list_kernels() -> tuple[list[KernelInfo], bootloader.BootloaderInfo]: + installed = _installed_with_versions() + running = sysinfo.kernel_release() + bl = bootloader.detect() + out: list[KernelInfo] = [] + for name, desc in KERNEL_VARIANTS: + ver = installed.get(name) + headers_ver = installed.get(f"{name}-headers") + entry = bootloader.entry_for_kernel(bl, name) + out.append(KernelInfo( + name=name, + description=desc, + installed=ver is not None, + headers_installed=headers_ver is not None, + version=ver, + is_running=ver is not None and _is_running(name, running), + boot_entry=entry, + is_default_boot=entry is not None and bootloader.is_default(bl, entry), + )) + return out, bl + + +class KernelView(VerticalScroll): + DEFAULT_CSS = f""" + KernelView {{ + background: {T.YORU}; + padding: 1 2; + scrollbar-color: {T.BASE2} {T.YORU}; + scrollbar-background: {T.YORU}; + scrollbar-corner-color: {T.YORU}; + scrollbar-size-vertical: 1; + }} + KernelView Static {{ + background: {T.YORU}; + margin-bottom: 1; + }} + KernelView #k-table {{ + height: auto; + background: {T.YORU}; + color: {T.FG_ALT}; + border: none; + overflow-x: hidden; + }} + KernelView #k-table:focus {{ + border: none; + }} + KernelView DataTable > .datatable--header {{ + background: {T.YORU}; + color: {T.SHINKAI}; + }} + KernelView DataTable > .datatable--cursor {{ + background: {T.BASE1}; + color: {T.YUKI}; + }} + KernelView DataTable > .datatable--even-row {{ + background: {T.YORU}; + }} + KernelView DataTable > .datatable--odd-row {{ + background: {T.YORU}; + }} + """ + + BINDINGS = [ + Binding("r", "rescan", "rescan"), + Binding("i", "install", "install +headers"), + Binding("H", "install_headers", "headers only", show=False), + Binding("b", "set_default", "boot-default"), + Binding("x", "remove", "entfernen"), + Binding("j", "cursor_down", "down", show=False), + Binding("k", "cursor_up", "up", show=False), + ] + + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + self.kernels: list[KernelInfo] = [] + self.bl: bootloader.BootloaderInfo | None = None + + def compose(self) -> ComposeResult: + yield Static("", id="k-intro", markup=True) + yield DataTable(id="k-table", cursor_type="row", zebra_stripes=False) + yield Static("", id="k-detail", markup=True) + yield Static("", id="k-flash", markup=True) + + def on_mount(self) -> None: + t = self.query_one("#k-table", DataTable) + t.add_column("", width=2, key="glyph") + t.add_column("kernel", width=18, key="name") + t.add_column("status", width=12, key="status") + t.add_column("version", width=18, key="version") + t.add_column("hdr", width=4, key="headers") + t.add_column("boot", width=10, key="boot") + t.add_column("beschreibung", key="desc") + self.action_rescan() + + def action_rescan(self) -> None: + pac.invalidate() + self.kernels, self.bl = list_kernels() + self._refresh_view() + + # ---- install / install-headers / remove ---- + + def action_install(self) -> None: + cur = self._current() + if cur is None: + return + if cur.installed and cur.headers_installed: + self._flash("kernel + headers bereits installiert", color=T.BASE4) + return + pkgs = [] + if not cur.installed: + pkgs.append(cur.name) + if not cur.headers_installed: + pkgs.append(f"{cur.name}-headers") + body = ( + f"installieren?\n\n" + f" pakete: {', '.join(pkgs)}\n" + f" beschreibung: {cur.description}\n\n" + f"nach dem install:\n" + f" · mkinitcpio läuft automatisch via pacman-hook\n" + f" · bootloader-config wird {self._bootloader_hint()}" + ) + self.app.push_screen( + ConfirmModal(f"install {' + '.join(pkgs)}?", body, danger=False), + lambda ok: self._after_install(ok, pkgs, cur), + ) + + def action_install_headers(self) -> None: + cur = self._current() + if cur is None or not cur.installed: + self._flash("kernel nicht installiert", color=T.BASE4) + return + if cur.headers_installed: + self._flash("headers schon installiert", color=T.BASE4) + return + pkg = f"{cur.name}-headers" + self.app.push_screen( + ConfirmModal( + f"install {pkg}?", + f"nur headers für {cur.name} installieren?", + danger=False, + ), + lambda ok: self._after_install(ok, [pkg], cur), + ) + + def action_remove(self) -> None: + cur = self._current() + if cur is None or not cur.installed: + self._flash("nicht installiert", color=T.BASE4) + return + if cur.is_running: + self._flash( + "kannst den laufenden kernel nicht entfernen — wechsle erst per reboot", + color=T.RED, + ) + return + if cur.is_default_boot: + self._flash( + "kernel ist als boot-default gesetzt — wechsle erst (`b`)", + color=T.RED, + ) + return + pkgs = [cur.name] + if cur.headers_installed: + pkgs.append(f"{cur.name}-headers") + body = ( + f"entfernen?\n\n" + f" pakete: {', '.join(pkgs)}\n\n" + f" · sicherstellen dass ein anderer kernel installiert + bootbar ist\n" + f" · bootloader-config wird vom pacman-hook regeneriert" + ) + self.app.push_screen( + ConfirmModal(f"remove {cur.name}?", body, danger=True), + lambda ok: self._after_remove(ok, pkgs, cur), + ) + + # ---- boot default ---- + + def action_set_default(self) -> None: + cur = self._current() + if cur is None: + return + if self.bl is None or self.bl.kind == "unknown": + self._flash("bootloader unbekannt — siehe detail-pane", color=T.CHIKYU) + return + if not cur.installed: + self._flash("kernel nicht installiert — kann nicht booten", color=T.BASE4) + return + if cur.boot_entry is None: + if self.bl.kind == "systemd-boot": + self._flash( + f"kein boot-entry für {cur.name} gefunden in /boot/loader/entries/", + color=T.CHIKYU, + ) + else: + self._flash( + f"GRUB: manuell `sudo grub-set-default …` — siehe detail-pane", + color=T.CHIKYU, + ) + return + if cur.is_default_boot: + self._flash(f"{cur.name} ist schon boot-default", color=T.BASE4) + return + + entry_name = cur.boot_entry.name + body = ( + f"boot-default ändern?\n\n" + f" bootloader: {self.bl.kind}\n" + f" entry: {entry_name}\n" + f" kernel: {cur.name} ({cur.version})\n\n" + f" wird beim nächsten reboot gebootet." + ) + self.app.push_screen( + ConfirmModal(f"set boot-default → {cur.name}?", body, danger=False), + lambda ok: self._after_set_default(ok, cur, entry_name), + ) + + def _after_set_default(self, ok: bool | None, k: KernelInfo, entry_name: str) -> None: + if not ok: + self._flash("abgebrochen") + return + cmds = [StreamCommand( + label=f"set-default → {entry_name}", + argv=["sudo", "-n", "bootctl", "set-default", entry_name], + )] + self._run_with_sudo( + cmds, + on_done=lambda codes: self._after_set_default_done(codes, k, entry_name), + ) + + def _after_set_default_done(self, codes, k: KernelInfo, entry_name: str) -> None: + success = codes and codes[0] == 0 + self._flash( + f"boot-default → {k.name} ({entry_name})" if success + else "set-default fehlgeschlagen", + color=T.TAKE if success else T.RED, + ) + self.action_rescan() + + # ---- install/remove follow-up ---- + + def _after_install(self, ok: bool | None, pkgs: list[str], k: KernelInfo) -> None: + if not ok: + self._flash("abgebrochen") + return + helper = "paru" if sysinfo.has_binary("paru") else ( + "yay" if sysinfo.has_binary("yay") else None + ) + if helper: + argv = [ + helper, "-S", "--needed", + "--noconfirm", "--sudoloop", + "--skipreview", "--removemake", "--cleanafter", + *pkgs, + ] + else: + argv = ["sudo", "-n", "pacman", "-S", "--needed", "--noconfirm", *pkgs] + cmds = [StreamCommand(label=f"install {' + '.join(pkgs)}", argv=argv)] + self._run_with_sudo( + cmds, + on_done=lambda codes: self._after_install_done(codes, pkgs, k), + ) + + def _after_install_done(self, codes, pkgs, k: KernelInfo) -> None: + success = codes and codes[0] == 0 + if success: + msg = f"{', '.join(pkgs)} installiert" + if self.bl and self.bl.kind == "systemd-boot": + msg += " · systemd-boot regeneriert via hook" + elif self.bl and self.bl.kind == "grub": + msg += " · prüfe: sudo grub-mkconfig -o /boot/grub/grub.cfg" + self._flash(msg, color=T.TAKE) + else: + self._flash("install fehlgeschlagen — siehe runner-log", color=T.RED) + pac.invalidate() + self.action_rescan() + + def _after_remove(self, ok: bool | None, pkgs: list[str], k: KernelInfo) -> None: + if not ok: + self._flash("abgebrochen") + return + cmds = [StreamCommand( + label=f"remove {' + '.join(pkgs)}", + argv=["sudo", "-n", "pacman", "-Rns", "--noconfirm", *pkgs], + )] + self._run_with_sudo( + cmds, + on_done=lambda codes: self._after_remove_done(codes, k), + ) + + def _after_remove_done(self, codes, k: KernelInfo) -> None: + success = codes and codes[0] == 0 + pac.invalidate() + self._flash( + f"{k.name} entfernt" if success else f"remove fehlgeschlagen", + color=T.TAKE if success else T.RED, + ) + self.action_rescan() + + # ---- shared sudo + runner helper ---- + + def _run_with_sudo(self, cmds: list[StreamCommand], on_done) -> None: + """Stellt sicher dass sudo gecached ist, dann launched RunnerScreen.""" + needs_sudo = any( + c.argv[0] == "sudo" or c.argv[0] in ("paru", "yay") + for c in cmds + ) + if needs_sudo and not sudo.is_cached(): + self.app.push_screen( + SudoModal(), + lambda pw: self._sudo_then_run(pw, cmds, on_done), + ) + else: + self.app.push_screen(RunnerScreen(cmds), on_done) + + def _sudo_then_run(self, pw, cmds, on_done) -> None: + if not pw: + self._flash("kein passwort — abgebrochen") + return + if not sudo.cache(pw): + self._flash("falsches passwort", color=T.RED) + return + self.app.push_screen(RunnerScreen(cmds), on_done) + + # ---- nav ---- + + def action_cursor_down(self) -> None: + self.query_one("#k-table", DataTable).action_cursor_down() + + def action_cursor_up(self) -> None: + self.query_one("#k-table", DataTable).action_cursor_up() + + def _current(self) -> KernelInfo | None: + t = self.query_one("#k-table", DataTable) + r = t.cursor_row + if r is None or r < 0 or r >= len(self.kernels): + return None + return self.kernels[r] + + def _flash(self, msg: str, color: str | None = None) -> None: + color = color or T.BASE4 + self.query_one("#k-flash", Static).update(f"[{color}]{msg}[/]") + + def _bootloader_hint(self) -> str: + if self.bl is None or self.bl.kind == "unknown": + return "manuell regeneriert" + if self.bl.kind == "systemd-boot": + return "automatisch via pacman-hook regeneriert" + return "evt. via `sudo grub-mkconfig -o /boot/grub/grub.cfg` regeneriert" + + # ---- render ---- + + def _refresh_view(self) -> None: + running = sysinfo.kernel_release() + bl_kind = self.bl.kind if self.bl else "unknown" + bl_default = self.bl.default_entry if self.bl and self.bl.default_entry else "—" + + intro = ( + f"[{T.UME}]⊙ kernel[/]\n" + f"[{T.SHINKAI}]aktuell laufend[/] [{T.YUKI}]{running}[/]\n" + f"[{T.SHINKAI}]bootloader[/] [{T.YUKI}]{bl_kind}[/] " + f"[{T.BASE5}]default:[/] [{T.YUKI}]{bl_default}[/]\n" + f"[{T.BASE5}]`i` install +headers · `H` headers only · `b` boot-default · `x` entfernen[/]" + ) + self.query_one("#k-intro", Static).update(intro) + + t = self.query_one("#k-table", DataTable) + t.clear() + for k in self.kernels: + if k.is_running: + glyph = f"[{T.UME}]▲[/]" + status_str = f"[{T.UME}]laufend[/]" + elif k.installed: + glyph = f"[{T.TAKE}]●[/]" + status_str = f"[{T.TAKE}]installiert[/]" + else: + glyph = f"[{T.BASE3}]○[/]" + status_str = f"[{T.BASE4}]nicht inst.[/]" + ver_styled = f"[{T.CHIKYU}]{k.version}[/]" if k.version else f"[{T.BASE3}]—[/]" + + if not k.installed: + hdr = f"[{T.BASE3}]—[/]" + elif k.headers_installed: + hdr = f"[{T.TAKE}]✓[/]" + else: + hdr = f"[{T.CHIKYU}]✗[/]" + + if k.is_default_boot: + boot = f"[{T.UME}]default[/]" + elif k.boot_entry is not None: + boot = f"[{T.BASE5}]bootbar[/]" + elif k.installed and self.bl and self.bl.kind != "unknown": + boot = f"[{T.CHIKYU}]?[/]" + else: + boot = f"[{T.BASE3}]—[/]" + + t.add_row( + glyph, + f"[{T.YUKI}]{k.name}[/]", + status_str, + ver_styled, + hdr, + boot, + f"[{T.BASE5}]{k.description}[/]", + ) + + installed_count = sum(1 for k in self.kernels if k.installed) + with_headers = sum(1 for k in self.kernels if k.installed and k.headers_installed) + without_headers = installed_count - with_headers + + detail_lines = [ + f"[{T.SHINKAI}]übersicht[/]", + f" installiert: [{T.YUKI}]{installed_count}[/]", + f" headers ok: [{T.TAKE}]{with_headers}[/]" + + (f" · fehlt: [{T.CHIKYU}]{without_headers}[/]" if without_headers else ""), + ] + if self.bl and self.bl.kind == "systemd-boot": + detail_lines += [ + "", + f"[{T.SHINKAI}]systemd-boot[/]", + f" config: [{T.BASE5}]/boot/loader/loader.conf[/]", + f" default: [{T.YUKI}]{bl_default}[/]", + f" switch: [{T.CHIKYU}]sudo bootctl set-default [/]", + f" [{T.BASE5}](oder hier mit `b` auf die zeile)[/]", + ] + elif self.bl and self.bl.kind == "grub": + detail_lines += [ + "", + f"[{T.SHINKAI}]GRUB[/]", + f" config: [{T.BASE5}]/etc/default/grub + /boot/grub/grub.cfg[/]", + f" default: [{T.YUKI}]{bl_default}[/]", + f" switch: [{T.CHIKYU}]edit /etc/default/grub (GRUB_DEFAULT=…)[/]", + f" [{T.CHIKYU}]sudo grub-mkconfig -o /boot/grub/grub.cfg[/]", + ] + else: + detail_lines += [ + "", + f"[{T.SHINKAI}]bootloader[/]", + f" [{T.BASE5}]nicht erkannt — kein /boot/loader/loader.conf oder /boot/grub/grub.cfg[/]", + ] + self.query_one("#k-detail", Static).update("\n".join(detail_lines)) diff --git a/src/taninux/maintain/__init__.py b/src/taninux/maintain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/taninux/maintain/pillar.py b/src/taninux/maintain/pillar.py new file mode 100644 index 0000000..ca7e26b --- /dev/null +++ b/src/taninux/maintain/pillar.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from taninux.maintain.screen import MaintainView +from taninux.pillars.base import Pillar + + +MAINTAIN = Pillar( + key="m", + label="maintain", + tagline="scan & clean", + icon="▣", + factory=MaintainView, +) diff --git a/src/taninux/maintain/scanners/__init__.py b/src/taninux/maintain/scanners/__init__.py new file mode 100644 index 0000000..86cb98a --- /dev/null +++ b/src/taninux/maintain/scanners/__init__.py @@ -0,0 +1,113 @@ +"""Scanner-Registry: jeder Eintrag wird zu einem Tab in der TUI.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Callable + +from taninux.maintain.scanners.aur import AurCacheScanner +from taninux.maintain.scanners.base import Scanner +from taninux.maintain.scanners.dotfolders import DotfoldersScanner +from taninux.maintain.scanners.flatpak import FlatpakDataScanner, FlatpakRuntimesScanner +from taninux.maintain.scanners.pacman import ( + PacmanCacheScanner, + PacmanOrphansScanner, + PacnewScanner, +) +from taninux.maintain.scanners.system import ( + SystemCoredumpScanner, + SystemJournalScanner, + SystemKernelsScanner, +) + + +@dataclass(frozen=True) +class ScannerSpec: + id: str + label: str + factory: Callable[[], Scanner] + # Welche TUI-Aktionen sind im jeweiligen Tab sinnvoll? + # - 'trash': → move-to-trash der User-File (dotfolders) + # - 'view': → nur anzeigen + Hinweis-Command im Detail-Pane + actions: str = "view" + advice: str = "" + + +SCANNERS: list[ScannerSpec] = [ + ScannerSpec( + id="dotfolders", + label="dotfolders", + factory=DotfoldersScanner, + actions="trash", + advice="", + ), + ScannerSpec( + id="orphans", + label="orphans", + factory=PacmanOrphansScanner, + actions="view", + advice="entfernen mit: sudo pacman -Rns {pkg}", + ), + ScannerSpec( + id="pacnew", + label="pacnew", + factory=PacnewScanner, + actions="view", + advice="review/merge mit: sudo pacdiff (oder: sudo $EDITOR {path})", + ), + ScannerSpec( + id="cache", + label="cache", + factory=PacmanCacheScanner, + actions="view", + advice="cleanup mit: sudo paccache -ruk0 (entfernt uninstalled + alte Versionen)", + ), + ScannerSpec( + id="flatpak-runtimes", + label="fp-runtimes", + factory=FlatpakRuntimesScanner, + actions="view", + advice="cleanup mit: flatpak uninstall --unused (löst Extension-Ketten korrekt auf)", + ), + ScannerSpec( + id="flatpak-data", + label="fp-data", + factory=FlatpakDataScanner, + actions="trash", + advice="", + ), + ScannerSpec( + id="aur-cache", + label="aur-cache", + factory=AurCacheScanner, + actions="trash", + advice="", + ), + ScannerSpec( + id="journal", + label="journal", + factory=SystemJournalScanner, + actions="view", + advice="vacuum: sudo journalctl --vacuum-time=2weeks (oder --vacuum-size=200M)", + ), + ScannerSpec( + id="coredumps", + label="coredumps", + factory=SystemCoredumpScanner, + actions="view", + advice="cleanup: sudo coredumpctl --since=2weeksago delete (oder: sudo rm /var/lib/systemd/coredump/*)", + ), + ScannerSpec( + id="kernels", + label="kernels", + factory=SystemKernelsScanner, + actions="view", + advice="entfernen: sudo rm -r {path} (vorsicht: nur wenn das System nicht mehr von diesem Kernel bootet!)", + ), +] + + +def by_id(scanner_id: str) -> ScannerSpec | None: + for s in SCANNERS: + if s.id == scanner_id: + return s + return None diff --git a/src/taninux/maintain/scanners/_util.py b/src/taninux/maintain/scanners/_util.py new file mode 100644 index 0000000..9f9aaa7 --- /dev/null +++ b/src/taninux/maintain/scanners/_util.py @@ -0,0 +1,36 @@ +"""Geteilte Helfer für Scanner-Module.""" +from __future__ import annotations + +import os +import time +from pathlib import Path + + +def dir_size(path: Path) -> int: + """Rekursive Grösse in Bytes. Symlinks werden nicht gefolgt.""" + if path.is_symlink(): + return 0 + if path.is_file(): + try: + return path.stat(follow_symlinks=False).st_size + except OSError: + return 0 + if not path.is_dir(): + return 0 + total = 0 + for root, _dirs, files in os.walk(path, followlinks=False, onerror=lambda _e: None): + for f in files: + try: + total += os.stat(os.path.join(root, f), follow_symlinks=False).st_size + except OSError: + continue + return total + + +def last_access_days(path: Path) -> int | None: + """Tage seit letzter Modifikation (mtime). None wenn nicht lesbar.""" + try: + mtime = path.stat().st_mtime + return int((time.time() - mtime) / 86400) + except OSError: + return None diff --git a/src/taninux/maintain/scanners/aur.py b/src/taninux/maintain/scanners/aur.py new file mode 100644 index 0000000..5def4f2 --- /dev/null +++ b/src/taninux/maintain/scanners/aur.py @@ -0,0 +1,86 @@ +"""AUR-Scanner: paru clone-cache (~/.cache/paru/clone//). + +Die installierten AUR-Pakete tauchen via `pacman -Qq` auf (AUR ist nur +ein Build-System, die Pakete landen ganz normal in der lokalen DB). +Ein Clone in `~/.cache/paru/` ohne entsprechendes installiertes Paket +ist also verwaist. +""" +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + +from taninux.maintain.scanners._util import dir_size, last_access_days +from taninux.maintain.types import Category, Classification, Finding + + +PARU_CACHE = Path.home() / ".cache" / "paru" + + +def _pacman_installed() -> set[str]: + try: + r = subprocess.run( + ["pacman", "-Qq"], + capture_output=True, text=True, check=False, timeout=10, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return set() + return {l.strip() for l in r.stdout.splitlines() if l.strip()} + + +class AurCacheScanner: + name = "aur-cache" + + def scan(self) -> list[Finding]: + clone_root = PARU_CACHE / "clone" + if not clone_root.is_dir(): + return [] + installed = _pacman_installed() + + findings: list[Finding] = [] + try: + entries = list(os.scandir(clone_root)) + except (OSError, PermissionError): + return [] + + for entry in entries: + try: + if not entry.is_dir(follow_symlinks=False): + continue + except OSError: + continue + pkg = entry.name + path = Path(entry.path) + size = dir_size(path) + days = last_access_days(path) + is_installed = pkg in installed + + if not is_installed: + cls = Classification.SAFE + reason = f"AUR-Paket {pkg} nicht installiert — Clone verwaist" + elif days is not None and days > 180: + cls = Classification.LIKELY_ORPHAN + reason = f"AUR-Clone von {pkg} seit {days}d unverändert" + else: + cls = Classification.ACTIVE + reason = f"AUR-Clone von {pkg} — installiert, möglicher Rebuild-Source" + + # Active wird in der Übersicht meist nicht gesized + if cls == Classification.ACTIVE: + size_to_show = 0 + else: + size_to_show = size + + findings.append(Finding( + path=path, + category=Category.AUR, + classification=cls, + size_bytes=size_to_show, + last_access_days=days, + reason=reason, + matched_package=pkg, + extra={"kind": "paru-clone", "pkg": pkg}, + )) + findings.sort(key=lambda f: f.sort_key) + return findings diff --git a/src/taninux/maintain/scanners/base.py b/src/taninux/maintain/scanners/base.py new file mode 100644 index 0000000..5c03648 --- /dev/null +++ b/src/taninux/maintain/scanners/base.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from typing import Protocol + +from taninux.maintain.types import Finding + + +class Scanner(Protocol): + name: str + + def scan(self) -> list[Finding]: ... diff --git a/src/taninux/maintain/scanners/dotfolders.py b/src/taninux/maintain/scanners/dotfolders.py new file mode 100644 index 0000000..01cb7f1 --- /dev/null +++ b/src/taninux/maintain/scanners/dotfolders.py @@ -0,0 +1,140 @@ +"""Scannt XDG-Dirs nach Foldern ohne klaren Paket-Owner.""" +from __future__ import annotations + +import os +import time +from pathlib import Path + +from taninux.core.package_index import build_index +from taninux.core.state import State +from taninux.maintain.types import Category, Classification, Finding + +# XDG-Dirs, die wir scannen. Reihenfolge = Anzeige-Reihenfolge. +XDG_DIRS = [ + Path.home() / ".config", + Path.home() / ".local" / "share", + Path.home() / ".cache", + Path.home() / ".local" / "state", +] + +# Schwellwerte (Tage) +SAFE_DAYS = 90 +LIKELY_DAYS = 30 + +# Folders die nie als Findings erscheinen sollen (system-managed) +HARD_EXCLUDE = { + "systemd", "dconf", "pulse", "pipewire", "wireplumber", + "fontconfig", "gtk-3.0", "gtk-4.0", "Trash", "mimeapps.list", + "user-dirs.dirs", "user-dirs.locale", +} + +# Pfade die andere Scanner besser kennen — hier ausblenden um Duplikate zu vermeiden +OWNED_BY_OTHER_SCANNERS = { + Path.home() / ".cache" / "paru", # aur-cache + Path.home() / ".cache" / "yay", # aur-cache (fallback) + Path.home() / ".var", # flatpak-data (theoretisch — XDG-Dirs treffen das nicht, aber sicherheitshalber) +} + + +def _dir_size(path: Path) -> int: + """Rekursive Grösse in Bytes. Symlinks werden nicht gefolgt.""" + total = 0 + try: + for entry in os.scandir(path): + try: + if entry.is_symlink(): + continue + if entry.is_file(follow_symlinks=False): + total += entry.stat(follow_symlinks=False).st_size + elif entry.is_dir(follow_symlinks=False): + total += _dir_size(Path(entry.path)) + except (OSError, PermissionError): + continue + except (OSError, PermissionError): + pass + return total + + +def _last_access_days(path: Path) -> int | None: + """Tage seit letzter Modifikation (mtime). None wenn nicht lesbar.""" + try: + mtime = path.stat().st_mtime + return int((time.time() - mtime) / 86400) + except OSError: + return None + + +def _classify(matched_pkg: str | None, days: int | None) -> tuple[Classification, str]: + if matched_pkg: + return Classification.ACTIVE, f"Paket installiert: {matched_pkg}" + if days is None: + return Classification.UNKNOWN, "Zugriffszeit unbekannt" + if days >= SAFE_DAYS: + return Classification.SAFE, f"Kein Paket, {days} Tage inaktiv" + if days >= LIKELY_DAYS: + return Classification.LIKELY_ORPHAN, f"Kein Paket, {days} Tage inaktiv" + return Classification.UNKNOWN, f"Kein Paket, aber nur {days} Tage inaktiv" + + +class DotfoldersScanner: + name = "dotfolders" + + def __init__(self, state: State | None = None) -> None: + self.state = state if state is not None else State.load() + + def scan(self) -> list[Finding]: + idx = build_index() + findings: list[Finding] = [] + + for base in XDG_DIRS: + if not base.is_dir(): + continue + try: + entries = list(os.scandir(base)) + except (OSError, PermissionError): + continue + + for entry in entries: + name = entry.name + if name.startswith(".") or name in HARD_EXCLUDE: + continue + path = Path(entry.path) + if path in OWNED_BY_OTHER_SCANNERS: + continue + if self.state.is_ignored(path): + continue + + matched = idx.matches(name) + days = _last_access_days(path) + + if self.state.is_whitelisted(path): + classification = Classification.WHITELISTED + reason = "Auf der Whitelist" + else: + classification, reason = _classify(matched, days) + + # Size nur für Cleanup-Kandidaten berechnen — ACTIVE/WHITELISTED + # können riesig sein (Steam-Library!) und das Tool ist nicht + # dafür da, installierte Apps zu vermessen. + if classification in (Classification.ACTIVE, Classification.WHITELISTED): + size = 0 + elif path.is_dir(): + size = _dir_size(path) + else: + try: + size = path.stat().st_size + except OSError: + size = 0 + + findings.append(Finding( + path=path, + category=Category.DOTFOLDERS, + classification=classification, + size_bytes=size, + last_access_days=days, + reason=reason, + matched_package=matched, + )) + + findings.sort(key=lambda f: f.sort_key) + return findings diff --git a/src/taninux/maintain/scanners/flatpak.py b/src/taninux/maintain/scanners/flatpak.py new file mode 100644 index 0000000..16f01f2 --- /dev/null +++ b/src/taninux/maintain/scanners/flatpak.py @@ -0,0 +1,167 @@ +"""Flatpak-Scanner: unused runtimes + leftover ~/.var/app data.""" +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + +from taninux.maintain.scanners._util import dir_size, last_access_days +from taninux.maintain.scanners.pacman import _parse_size # gleiche Logik +from taninux.maintain.types import Category, Classification, Finding + + +def _flatpak_available() -> bool: + try: + subprocess.run( + ["flatpak", "--version"], + capture_output=True, check=False, timeout=5, + ) + return True + except (FileNotFoundError, subprocess.TimeoutExpired): + return False + + +def _installed_app_ids() -> set[str]: + try: + r = subprocess.run( + ["flatpak", "list", "--app", "--columns=application"], + capture_output=True, text=True, check=False, timeout=10, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return set() + return { + line.strip() + for line in r.stdout.splitlines() + if line.strip() and line.strip() != "Application ID" + } + + +def _app_runtime_refs() -> set[str]: + """Welche Runtime-Refs werden von Apps benötigt? + + Refs haben Format `//`.""" + try: + r = subprocess.run( + ["flatpak", "list", "--app", "--columns=runtime"], + capture_output=True, text=True, check=False, timeout=10, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return set() + refs: set[str] = set() + for line in r.stdout.splitlines(): + s = line.strip() + if not s or s == "Runtime": + continue + # Manche Outputs prefixen mit "runtime/" — abschneiden + if s.startswith("runtime/"): + s = s[len("runtime/"):] + refs.add(s) + return refs + + +# ----------------------------------------------------------------- runtimes + + +class FlatpakRuntimesScanner: + """Installierte Runtimes ohne direkten App-Bezug. + + Heuristik (nicht so präzise wie `flatpak uninstall --unused`, das auch + Extension-Ketten berücksichtigt) — Detail-Pane verweist auf den + kanonischen Befehl.""" + name = "flatpak-runtimes" + + def scan(self) -> list[Finding]: + if not _flatpak_available(): + return [] + + try: + r = subprocess.run( + ["flatpak", "list", "--runtime", + "--columns=application,branch,arch,size"], + capture_output=True, text=True, check=False, timeout=10, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return [] + + used = _app_runtime_refs() + # used_app_ids enthält die Basis-IDs der genutzten Runtimes, ohne arch/branch + used_app_ids = {ref.split("/", 1)[0] for ref in used} + + findings: list[Finding] = [] + for line in r.stdout.splitlines(): + s = line.strip() + if not s or s.startswith("Application"): + continue + parts = s.split("\t") + if len(parts) < 3: + continue + app_id, branch, arch = parts[0], parts[1], parts[2] + size_str = parts[3] if len(parts) > 3 else "" + ref = f"{app_id}/{arch}/{branch}" + # Direkt-Match auf vollständigen Ref + if ref in used: + continue + # Substring-Match auf Application-ID, fängt Extensions ein + # (z.B. wenn org.freedesktop.Platform.GL.nvidia-... existiert + # weil org.freedesktop.Platform genutzt wird) + if any(app_id.startswith(u) for u in used_app_ids): + continue + findings.append(Finding( + path=Path(f"flatpak://{ref}"), + category=Category.FLATPAK, + classification=Classification.LIKELY_ORPHAN, + size_bytes=_parse_size(size_str) if size_str else 0, + last_access_days=None, + reason="Runtime hat keinen direkten App-Bezug", + matched_package=app_id, + extra={"kind": "runtime", "ref": ref, "branch": branch, "arch": arch}, + )) + findings.sort(key=lambda f: -f.size_bytes) + return findings + + +# ----------------------------------------------------------------- leftover data + + +class FlatpakDataScanner: + """`~/.var/app//` für Flatpaks die nicht (mehr) installiert sind.""" + name = "flatpak-data" + + def scan(self) -> list[Finding]: + var_app = Path.home() / ".var" / "app" + if not var_app.is_dir(): + return [] + if not _flatpak_available(): + return [] + + installed = _installed_app_ids() + findings: list[Finding] = [] + try: + entries = list(os.scandir(var_app)) + except (OSError, PermissionError): + return [] + + for entry in entries: + try: + if not entry.is_dir(follow_symlinks=False): + continue + except OSError: + continue + app_id = entry.name + if app_id in installed: + continue + path = Path(entry.path) + size = dir_size(path) + days = last_access_days(path) + findings.append(Finding( + path=path, + category=Category.FLATPAK, + classification=Classification.SAFE, + size_bytes=size, + last_access_days=days, + reason=f"Flatpak {app_id} nicht installiert — Daten verwaist", + matched_package=app_id, + extra={"kind": "leftover-data", "app_id": app_id}, + )) + findings.sort(key=lambda f: f.sort_key) + return findings diff --git a/src/taninux/maintain/scanners/pacman.py b/src/taninux/maintain/scanners/pacman.py new file mode 100644 index 0000000..ec51530 --- /dev/null +++ b/src/taninux/maintain/scanners/pacman.py @@ -0,0 +1,218 @@ +"""Pacman-Scanner: orphans, .pacnew/.pacsave Configs, /var/cache/pacman/pkg.""" +from __future__ import annotations + +import os +import re +import subprocess +from pathlib import Path + +from taninux.maintain.types import Category, Classification, Finding + + +_SIZE_RE = re.compile(r"([\d.]+)\s*(\w+)") +_SIZE_UNITS = { + "B": 1, "KiB": 1024, "MiB": 1024**2, "GiB": 1024**3, "TiB": 1024**4, + "K": 1024, "M": 1024**2, "G": 1024**3, "T": 1024**4, + "KB": 1000, "MB": 1000**2, "GB": 1000**3, "TB": 1000**4, +} + + +def _parse_size(s: str) -> int: + m = _SIZE_RE.search(s) + if not m: + return 0 + val = float(m.group(1)) + unit = m.group(2) + return int(val * _SIZE_UNITS.get(unit, 1)) + + +# ----------------------------------------------------------------- orphans + + +class PacmanOrphansScanner: + """`pacman -Qdt` — als Dependency installiert, hat aber keinen Owner mehr.""" + name = "orphans" + + def scan(self) -> list[Finding]: + try: + r = subprocess.run( + ["pacman", "-Qdtq"], + capture_output=True, text=True, check=False, timeout=10, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return [] + pkgs = [p.strip() for p in r.stdout.splitlines() if p.strip()] + if not pkgs: + return [] + + sizes = self._installed_sizes(pkgs) + findings: list[Finding] = [] + for pkg in pkgs: + findings.append(Finding( + path=Path(f"pkg://{pkg}"), + category=Category.PACMAN, + classification=Classification.LIKELY_ORPHAN, + size_bytes=sizes.get(pkg, 0), + last_access_days=None, + reason="Als Dependency installiert, hat keinen Owner mehr", + matched_package=pkg, + extra={"kind": "orphan", "pkg": pkg}, + )) + findings.sort(key=lambda f: -f.size_bytes) + return findings + + @staticmethod + def _installed_sizes(pkgs: list[str]) -> dict[str, int]: + try: + r = subprocess.run( + ["pacman", "-Qi", *pkgs], + capture_output=True, text=True, check=False, timeout=30, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return {} + sizes: dict[str, int] = {} + current: str | None = None + for line in r.stdout.splitlines(): + if line.startswith("Name") and ":" in line: + current = line.split(":", 1)[1].strip() + elif current and line.startswith("Installed Size") and ":" in line: + sizes[current] = _parse_size(line.split(":", 1)[1]) + return sizes + + +# ----------------------------------------------------------------- pacnew/pacsave + + +class PacnewScanner: + """`.pacnew` (neue Default-Configs vom Update) + `.pacsave` (Backups bei Removal).""" + name = "pacnew" + + def scan(self) -> list[Finding]: + findings: list[Finding] = [] + for root in (Path("/etc"), Path("/usr/share")): + if not root.is_dir(): + continue + for dirpath, _dirs, files in os.walk( + root, followlinks=False, onerror=lambda _e: None + ): + for fn in files: + if not (fn.endswith(".pacnew") or fn.endswith(".pacsave")): + continue + p = Path(dirpath) / fn + try: + size = p.stat(follow_symlinks=False).st_size + except OSError: + continue + kind = "pacnew" if fn.endswith(".pacnew") else "pacsave" + reason = ( + "Neue Default-Config vom Update — mit pacdiff mergen" + if kind == "pacnew" + else "Backup der alten Config nach Paket-Entfernung" + ) + findings.append(Finding( + path=p, + category=Category.PACMAN, + classification=Classification.UNKNOWN, + size_bytes=size, + last_access_days=None, + reason=reason, + extra={"kind": kind}, + )) + findings.sort(key=lambda f: str(f.path)) + return findings + + +# ----------------------------------------------------------------- pacman cache + + +class PacmanCacheScanner: + """`/var/cache/pacman/pkg/` — ältere Paket-Versionen + Pakete die nicht mehr installiert sind.""" + name = "cache" + + def scan(self) -> list[Finding]: + cache_dir = Path("/var/cache/pacman/pkg") + if not cache_dir.is_dir(): + return [] + + installed = self._installed_packages() + + # Files nach Paket-Name gruppieren + groups: dict[str, list[tuple[Path, int, float]]] = {} + try: + for entry in os.scandir(cache_dir): + if not entry.is_file(follow_symlinks=False): + continue + fn = entry.name + if not any(fn.endswith(ext) for ext in ( + ".pkg.tar.zst", ".pkg.tar.xz", ".pkg.tar.gz" + )): + continue + if fn.endswith(".sig"): + continue + pkg_name = self._extract_pkg_name(fn) + if not pkg_name: + continue + try: + st = entry.stat(follow_symlinks=False) + except OSError: + continue + groups.setdefault(pkg_name, []).append( + (Path(entry.path), st.st_size, st.st_mtime) + ) + except (OSError, PermissionError): + return [] + + findings: list[Finding] = [] + for pkg, items in groups.items(): + items.sort(key=lambda t: t[2], reverse=True) # newest first + is_installed = pkg in installed + for i, (path, size, _mtime) in enumerate(items): + # Neueste Version eines installierten Pakets → behalten als Reinstall-Quelle + if i == 0 and is_installed: + continue + if not is_installed: + cls = Classification.SAFE + reason = f"Paket {pkg} nicht installiert" + else: + cls = Classification.LIKELY_ORPHAN + reason = f"Ältere Version von {pkg} (neuere im Cache)" + findings.append(Finding( + path=path, + category=Category.PACMAN, + classification=cls, + size_bytes=size, + last_access_days=None, + reason=reason, + matched_package=pkg, + extra={"kind": "cache", "pkg": pkg}, + )) + findings.sort(key=lambda f: f.sort_key) + return findings + + @staticmethod + def _installed_packages() -> set[str]: + try: + r = subprocess.run( + ["pacman", "-Qq"], + capture_output=True, text=True, check=False, timeout=10, + ) + return {l.strip() for l in r.stdout.splitlines() if l.strip()} + except (FileNotFoundError, subprocess.TimeoutExpired): + return set() + + @staticmethod + def _extract_pkg_name(filename: str) -> str | None: + """`foo-bar-1.2.3-1-x86_64.pkg.tar.zst` → `foo-bar`""" + for ext in (".pkg.tar.zst", ".pkg.tar.xz", ".pkg.tar.gz"): + if filename.endswith(ext): + stem = filename[:-len(ext)] + break + else: + return None + # strip -, -, - + try: + stem = stem.rsplit("-", 1)[0] # arch + stem = stem.rsplit("-", 1)[0] # pkgrel + return stem.rsplit("-", 1)[0] # pkgver + except IndexError: + return None diff --git a/src/taninux/maintain/scanners/system.py b/src/taninux/maintain/scanners/system.py new file mode 100644 index 0000000..af5dd6a --- /dev/null +++ b/src/taninux/maintain/scanners/system.py @@ -0,0 +1,229 @@ +"""System-wide Scanner: journal, coredumps, alte Kernel-Module.""" +from __future__ import annotations + +import json +import re +import subprocess +import time +from datetime import datetime, timezone +from pathlib import Path + +from taninux.maintain.scanners._util import dir_size +from taninux.maintain.types import Category, Classification, Finding + + +_SIZE_RE = re.compile(r"([\d.]+)\s*([KMGT])(?:iB)?", re.IGNORECASE) +_UNITS = {"K": 1024, "M": 1024**2, "G": 1024**3, "T": 1024**4} + + +def _parse_size_with_unit(s: str) -> int: + m = _SIZE_RE.search(s) + if not m: + return 0 + return int(float(m.group(1)) * _UNITS.get(m.group(2).upper(), 1)) + + +# ----------------------------------------------------------------- journal + + +class SystemJournalScanner: + """Gesamtgrösse des systemd-journal via `journalctl --disk-usage`.""" + name = "journal" + + def scan(self) -> list[Finding]: + try: + r = subprocess.run( + ["journalctl", "--disk-usage"], + capture_output=True, text=True, check=False, timeout=10, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return [] + if r.returncode != 0: + return [] + + size = _parse_size_with_unit(r.stdout) + if size <= 0: + return [] + + if size > 2 * 1024**3: + cls = Classification.SAFE + reason = f"Journal nimmt {size/1024**3:.1f} GB ein — Vacuum dringend empfohlen" + elif size > 512 * 1024**2: + cls = Classification.LIKELY_ORPHAN + reason = f"Journal nimmt {size/1024**2:.0f} MB ein — Vacuum sinnvoll" + else: + cls = Classification.UNKNOWN + reason = f"Journal nimmt {size/1024**2:.0f} MB ein — unkritisch" + + return [Finding( + path=Path("journal://system"), + category=Category.SYSTEM, + classification=cls, + size_bytes=size, + last_access_days=None, + reason=reason, + matched_package="systemd-journal", + extra={"kind": "journal"}, + )] + + +# ----------------------------------------------------------------- coredumps + + +class SystemCoredumpScanner: + """Crash-Dumps via `coredumpctl`. Nutzt JSON-Output wenn verfügbar.""" + name = "coredumps" + + def scan(self) -> list[Finding]: + entries = self._list_via_json() + if entries is None: + entries = self._list_via_text() + if not entries: + return [] + + now = time.time() + findings: list[Finding] = [] + for e in entries: + ts = e.get("timestamp") + age_days = int((now - ts) / 86400) if ts else None + exe_name = Path(e.get("exe", "")).name or "?" + findings.append(Finding( + path=Path(f"coredump://{e.get('pid', '?')}/{exe_name}"), + category=Category.SYSTEM, + classification=Classification.LIKELY_ORPHAN, + size_bytes=e.get("size", 0), + last_access_days=age_days, + reason=f"Coredump von {exe_name} (sig {e.get('signal', '?')})", + matched_package=exe_name, + extra={"kind": "coredump", **e}, + )) + findings.sort(key=lambda f: -(f.size_bytes or 0)) + return findings + + def _list_via_json(self) -> list[dict] | None: + try: + r = subprocess.run( + ["coredumpctl", "--json=short", "list"], + capture_output=True, text=True, check=False, timeout=10, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return None + if r.returncode != 0 or not r.stdout.strip(): + return None + try: + data = json.loads(r.stdout) + except json.JSONDecodeError: + return None + if not isinstance(data, list): + return None + entries: list[dict] = [] + for item in data: + # timestamp in microseconds since epoch + ts_us = item.get("time") + ts = ts_us / 1_000_000 if isinstance(ts_us, (int, float)) else None + # coredumpctl reports null/missing size when the corefile is gone + size_raw = item.get("size") + size = int(size_raw) if isinstance(size_raw, (int, float)) else 0 + entries.append({ + "pid": item.get("pid"), + "uid": item.get("uid"), + "signal": item.get("signal_name") or item.get("signal"), + "exe": item.get("exe", ""), + "size": size, + "timestamp": ts, + }) + return entries + + def _list_via_text(self) -> list[dict]: + """Fallback: parse `coredumpctl list` text output.""" + try: + r = subprocess.run( + ["coredumpctl", "list", "--no-pager"], + capture_output=True, text=True, check=False, timeout=10, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return [] + if r.returncode not in (0, 1): # exit 1 = no dumps + return [] + entries: list[dict] = [] + # Header looks like: "TIME PID UID GID SIG COREFILE EXE SIZE" + for line in r.stdout.splitlines()[1:]: # skip header + parts = line.split() + if len(parts) < 7: + continue + try: + pid = int(parts[4]) + size = _parse_size_with_unit(parts[-1]) + exe = parts[-2] + sig = parts[7] if len(parts) > 7 else "?" + except (ValueError, IndexError): + continue + entries.append({ + "pid": pid, + "uid": None, + "signal": sig, + "exe": exe, + "size": size, + "timestamp": None, + }) + return entries + + +# ----------------------------------------------------------------- kernel modules + + +class SystemKernelsScanner: + """`/usr/lib/modules//` ohne pacman-Owner = orphaned Kernel-Module.""" + name = "kernels" + + def scan(self) -> list[Finding]: + modules_root = Path("/usr/lib/modules") + if not modules_root.is_dir(): + return [] + + kernel_dirs: list[Path] = [] + try: + for entry in modules_root.iterdir(): + if entry.is_symlink(): + continue + if entry.is_dir(): + kernel_dirs.append(entry) + except (OSError, PermissionError): + return [] + if not kernel_dirs: + return [] + + try: + r = subprocess.run( + ["pacman", "-Qo", *[str(d) for d in kernel_dirs]], + capture_output=True, text=True, check=False, timeout=30, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return [] + + # pacman -Qo mit mehreren Args: stdout hat "X is owned by Y", + # stderr hat "error: No package owns Z" für Orphans. + owned: set[str] = set() + for line in r.stdout.splitlines(): + m = re.match(r"^(.+?)\s+is owned by ", line) + if m: + owned.add(m.group(1).rstrip("/")) + + findings: list[Finding] = [] + for d in kernel_dirs: + key = str(d).rstrip("/") + if key in owned: + continue + size = dir_size(d) + findings.append(Finding( + path=d, + category=Category.SYSTEM, + classification=Classification.SAFE, + size_bytes=size, + last_access_days=None, + reason="Kernel-Modul ohne Pacman-Eigentümer — Kernel-Paket weg, Module verwaist", + matched_package=None, + extra={"kind": "old-kernel-modules", "version": d.name}, + )) + findings.sort(key=lambda f: -f.size_bytes) + return findings diff --git a/src/taninux/maintain/screen.py b/src/taninux/maintain/screen.py new file mode 100644 index 0000000..933c71c --- /dev/null +++ b/src/taninux/maintain/screen.py @@ -0,0 +1,565 @@ +"""Maintain-Pillar View — der bisherige archmaint-Code, jetzt als embeddable Widget.""" +from __future__ import annotations + +from collections import Counter +from pathlib import Path + +from textual import work +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Horizontal, Vertical +from textual.widgets import DataTable, Static, Tab, Tabs + +from taninux.core import theme as T +from taninux.core.confirm import ConfirmModal +from taninux.core.state import State +from taninux.core.trash import move_to_trash +from taninux.maintain.scanners import SCANNERS, ScannerSpec, by_id +from taninux.maintain.scanners.dotfolders import DotfoldersScanner +from taninux.maintain.types import ( + CLASS_COLOR, + CLASS_LABEL, + CLASSIFICATION_ORDER, + Classification, + Finding, + glyph, +) + + +def _human_size(n: int) -> str: + f = float(n) + for unit in ("B", "K", "M", "G", "T"): + if f < 1024 or unit == "T": + return f"{f:.0f}{unit}" if unit == "B" else f"{f:.1f}{unit}" + f /= 1024 + return f"{f:.1f}T" + + +def _format_days(d: int | None) -> str: + if d is None: + return "—" + if d == 0: + return "heute" + if d < 30: + return f"{d}d" + if d < 365: + return f"{d // 30}mo" + return f"{d // 365}y" + + +def _display_path(f: Finding) -> str: + s = str(f.path) + if s.startswith("pkg://"): + return s[6:] + home = str(Path.home()) + return "~" + s[len(home):] if s.startswith(home) else s + + +class MaintainView(Vertical): + """Maintain-Pillar als Widget — Tabs + Tabelle + Detail-Pane.""" + + DEFAULT_CSS = f""" + MaintainView {{ + background: {T.YORU}; + color: {T.YUKI}; + layout: vertical; + }} + + MaintainView Tabs {{ + background: {T.YORU}; + height: 3; + }} + MaintainView Tabs > #tabs-list {{ + background: {T.YORU}; + }} + MaintainView Tab {{ + background: {T.YORU}; + color: {T.BASE4}; + padding: 0 2; + }} + MaintainView Tab.-active {{ + color: {T.UME}; + background: {T.YORU}; + }} + MaintainView Underline {{ + background: {T.YORU}; + color: {T.BASE2}; + }} + MaintainView Underline > .underline--bar {{ + background: {T.YORU}; + color: {T.UME}; + }} + + MaintainView #m-stats {{ + height: 1; + padding: 0 2 0 2; + background: {T.YORU}; + color: {T.BASE5}; + }} + MaintainView #m-legend {{ + height: 1; + padding: 1 2 1 2; + background: {T.YORU}; + color: {T.BASE4}; + }} + MaintainView #m-main {{ + height: 1fr; + background: {T.YORU}; + }} + MaintainView #m-table {{ + width: 2fr; + background: {T.YORU}; + color: {T.FG_ALT}; + border: none; + padding-right: 2; + overflow-x: hidden; + scrollbar-size-vertical: 1; + scrollbar-color: {T.BASE2} {T.YORU}; + scrollbar-color-hover: {T.BASE3} {T.YORU}; + scrollbar-color-active: {T.BASE3} {T.YORU}; + scrollbar-background: {T.YORU}; + scrollbar-background-hover: {T.YORU}; + scrollbar-background-active: {T.YORU}; + scrollbar-corner-color: {T.YORU}; + }} + MaintainView #m-table:focus {{ + border: none; + }} + MaintainView DataTable > .datatable--header {{ + background: {T.YORU}; + color: {T.SHINKAI}; + }} + MaintainView DataTable > .datatable--cursor {{ + background: {T.BASE1}; + color: {T.YUKI}; + }} + MaintainView DataTable > .datatable--hover {{ + background: {T.YORU}; + }} + MaintainView DataTable > .datatable--even-row {{ + background: {T.YORU}; + }} + MaintainView DataTable > .datatable--odd-row {{ + background: {T.YORU}; + }} + MaintainView #m-detail {{ + width: 1fr; + padding: 1 2; + background: {T.YORU}; + color: {T.FG_ALT}; + }} + MaintainView #m-flash {{ + height: 1; + padding: 0 2; + background: {T.YORU}; + color: {T.BASE4}; + }} + """ + + BINDINGS = [ + Binding("space", "toggle_select", "select"), + Binding("d", "delete", "trash"), + Binding("w", "whitelist", "behalten"), + Binding("i", "ignore", "ignorieren"), + Binding("o", "open", "öffnen"), + Binding("r", "rescan", "rescan"), + Binding("R", "rescan_all", "rescan all", show=False), + Binding("j", "cursor_down", "down", show=False), + Binding("k", "cursor_up", "up", show=False), + Binding("g", "cursor_top", "top", show=False), + Binding("G", "cursor_bottom", "bottom", show=False), + Binding("ctrl+1", "select_tab('dotfolders')", "", show=False), + Binding("ctrl+2", "select_tab('orphans')", "", show=False), + Binding("ctrl+3", "select_tab('pacnew')", "", show=False), + Binding("ctrl+4", "select_tab('cache')", "", show=False), + Binding("ctrl+5", "select_tab('flatpak-runtimes')", "", show=False), + Binding("ctrl+6", "select_tab('flatpak-data')", "", show=False), + Binding("ctrl+7", "select_tab('aur-cache')", "", show=False), + Binding("ctrl+8", "select_tab('journal')", "", show=False), + Binding("ctrl+9", "select_tab('coredumps')", "", show=False), + Binding("ctrl+0", "select_tab('kernels')", "", show=False), + ] + + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + self.state = State.load() + self.current_tab: str = SCANNERS[0].id + self.findings_by_tab: dict[str, list[Finding]] = {} + self.selected: set[Path] = set() + + # ---- composition ---- + + def compose(self) -> ComposeResult: + yield Tabs(*[Tab(s.label, id=s.id) for s in SCANNERS], id="tabs") + yield Static("", id="m-stats", markup=True) + yield Static("", id="m-legend", markup=True) + with Horizontal(id="m-main"): + yield DataTable(id="m-table", cursor_type="row", zebra_stripes=False) + yield Static("", id="m-detail", markup=True) + yield Static("", id="m-flash", markup=True) + + def on_mount(self) -> None: + table = self.query_one("#m-table", DataTable) + table.add_column(" ", width=2, key="sel") + table.add_column("", width=2, key="icon") + table.add_column("pfad", key="path") + table.add_column("grösse", key="size") + table.add_column("idle", width=7, key="idle") + table.add_column("status", key="status") + + # ---- properties ---- + + @property + def findings(self) -> list[Finding]: + return self.findings_by_tab.get(self.current_tab, []) + + @property + def spec(self) -> ScannerSpec: + s = by_id(self.current_tab) + assert s is not None + return s + + def _current_finding(self) -> Finding | None: + table = self.query_one("#m-table", DataTable) + row = table.cursor_row + if row is None or row < 0 or row >= len(self.findings): + return None + return self.findings[row] + + def _targets(self) -> list[Finding]: + if self.selected: + return [f for f in self.findings if f.path in self.selected] + cur = self._current_finding() + return [cur] if cur else [] + + def _flash(self, msg: str, color: str | None = None) -> None: + color = color or T.BASE4 + self.query_one("#m-flash", Static).update(f"[{color}]{msg}[/]") + + def _make_scanner(self, spec: ScannerSpec): + if spec.id == "dotfolders": + return DotfoldersScanner(self.state) + return spec.factory() + + # ---- actions ---- + + def action_select_tab(self, tab_id: str) -> None: + self.query_one(Tabs).active = tab_id + + def action_rescan(self) -> None: + spec = self.spec + self._flash(f"scanne {spec.label}…") + if spec.id == "dotfolders": + self.state = State.load() + self._rescan_worker(spec.id) + + @work(thread=True, exclusive=True, group="maintain-scan") + def _rescan_worker(self, tab_id: str) -> None: + spec = by_id(tab_id) + if spec is None: + return + findings = self._make_scanner(spec).scan() + self.app.call_from_thread(self._after_rescan, tab_id, findings) + + def _after_rescan(self, tab_id: str, findings: list[Finding]) -> None: + self.findings_by_tab[tab_id] = findings + if tab_id == self.current_tab: + existing = {f.path for f in findings} + self.selected &= existing + self._refresh_view() + self._flash(f"{len(findings)} einträge in {self.spec.label}") + + def action_rescan_all(self) -> None: + self._flash("scanne alle kategorien…") + self.state = State.load() + self.selected.clear() + self._rescan_all_worker() + + @work(thread=True, exclusive=True, group="maintain-scan-all") + def _rescan_all_worker(self) -> None: + for spec in SCANNERS: + findings = self._make_scanner(spec).scan() + self.app.call_from_thread(self._after_rescan_all_one, spec.id, findings) + self.app.call_from_thread(self._flash, "alle kategorien gescannt") + + def _after_rescan_all_one(self, tab_id: str, findings: list[Finding]) -> None: + self.findings_by_tab[tab_id] = findings + if tab_id == self.current_tab: + self._refresh_view() + + def action_toggle_select(self) -> None: + cur = self._current_finding() + if cur is None: + return + if cur.path in self.selected: + self.selected.discard(cur.path) + else: + self.selected.add(cur.path) + self._render_keep_cursor() + self._render_summary() + + def action_whitelist(self) -> None: + if self.spec.actions != "trash": + self._flash(f"whitelist nicht verfügbar in {self.spec.label}", color=T.BASE4) + return + targets = self._targets() + if not targets: + return + for f in targets: + self.state.whitelist_add(f.path) + self.selected.clear() + self._flash(f"{len(targets)} auf whitelist gesetzt", color=T.SHINKAI) + self.action_rescan() + + def action_ignore(self) -> None: + if self.spec.actions != "trash": + self._flash(f"ignore nicht verfügbar in {self.spec.label}", color=T.BASE4) + return + targets = self._targets() + if not targets: + return + for f in targets: + self.state.ignore_add(f.path) + self.selected.clear() + self._flash(f"{len(targets)} ignoriert", color=T.BASE5) + self.action_rescan() + + def action_delete(self) -> None: + if self.spec.actions != "trash": + self._flash( + f"löschen in {self.spec.label} braucht sudo — siehe detail-pane", + color=T.CHIKYU, + ) + return + targets = self._targets() + if not targets: + return + total = sum(f.size_bytes for f in targets) + body = ( + f"{len(targets)} einträge ({_human_size(total)}) in den trash verschieben?\n" + f" ↩ wiederherstellbar via: taninux trash list / restore" + ) + self.app.push_screen( + ConfirmModal("löschen?", body, danger=True), + self._after_delete_confirm, + ) + + def _after_delete_confirm(self, confirmed: bool | None) -> None: + if not confirmed: + self._flash("abgebrochen") + return + targets = self._targets() + moved = 0 + bytes_moved = 0 + errors: list[str] = [] + for f in targets: + try: + entry = move_to_trash(f.path) + moved += 1 + bytes_moved += entry.size_bytes + except Exception as e: # noqa: BLE001 + errors.append(f"{f.path.name}: {e}") + self.selected.clear() + msg = f"{moved} einträge in trash ({_human_size(bytes_moved)})" + if errors: + msg += f" · {len(errors)} fehler" + self._flash(msg, color=T.TAKE if not errors else T.RED) + self.action_rescan() + + def action_open(self) -> None: + cur = self._current_finding() + if cur is None: + return + if str(cur.path).startswith("pkg://"): + self._flash("paket — kein pfad zum öffnen", color=T.BASE4) + return + import subprocess + try: + subprocess.Popen( + ["xdg-open", str(cur.path)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + self._flash(f"öffne {_display_path(cur)}") + except FileNotFoundError: + self._flash("xdg-open nicht verfügbar", color=T.RED) + + def action_cursor_down(self) -> None: + self.query_one("#m-table", DataTable).action_cursor_down() + + def action_cursor_up(self) -> None: + self.query_one("#m-table", DataTable).action_cursor_up() + + def action_cursor_top(self) -> None: + table = self.query_one("#m-table", DataTable) + if table.row_count: + table.move_cursor(row=0) + + def action_cursor_bottom(self) -> None: + table = self.query_one("#m-table", DataTable) + if table.row_count: + table.move_cursor(row=table.row_count - 1) + + # ---- render ---- + + def _refresh_view(self) -> None: + self._render_table() + self._render_summary() + + def _render_keep_cursor(self) -> None: + table = self.query_one("#m-table", DataTable) + row = table.cursor_row + self._render_table() + if row is not None and 0 <= row < len(self.findings): + table.move_cursor(row=row) + + def _render_table(self) -> None: + table = self.query_one("#m-table", DataTable) + table.clear() + for f in self.findings: + color = CLASS_COLOR[f.classification] + label = CLASS_LABEL[f.classification] + sel_glyph = ( + f"[{T.UME}]■[/]" if f.path in self.selected + else f"[{T.BASE3}]·[/]" + ) + path = _display_path(f) + if "/" in path: + head, _, tail = path.rpartition("/") + path_styled = f"[{T.BASE4}]{head}/[/][{T.YUKI}]{tail}[/]" + else: + path_styled = f"[{T.YUKI}]{path}[/]" + size_str = _human_size(f.size_bytes) if f.size_bytes else "—" + size_styled = ( + f"[{T.CHIKYU}]{size_str}[/]" if f.size_bytes + else f"[{T.BASE3}]—[/]" + ) + idle_styled = f"[{T.BASE5}]{_format_days(f.last_access_days)}[/]" + status_styled = f"[{color}]{label}[/]" + table.add_row( + sel_glyph, + glyph(f.classification), + path_styled, + size_styled, + idle_styled, + status_styled, + ) + + if self.findings: + self._render_detail(self.findings[0]) + else: + self._render_empty() + + def _render_empty(self) -> None: + self.query_one("#m-detail", Static).update( + f"[{T.BASE4}]keine einträge in {self.spec.label}[/]\n\n" + f"[{T.BASE5}]drücke r zum rescan, oder ctrl+1…0 für anderen scanner[/]" + ) + + def _render_summary(self) -> None: + by_class: Counter[Classification] = Counter(f.classification for f in self.findings) + total_reclaim = sum( + f.size_bytes for f in self.findings + if f.classification in (Classification.SAFE, Classification.LIKELY_ORPHAN) + ) + if self.selected: + sel_bytes = sum( + f.size_bytes for f in self.findings if f.path in self.selected + ) + stats = ( + f"[{T.UME}]{len(self.selected)} ausgewählt[/]" + f" [{T.BASE3}]·[/] " + f"[{T.CHIKYU}]{_human_size(sel_bytes)}[/]" + f" [{T.BASE3}]·[/] " + f"[{T.BASE5}]{len(self.findings)} {self.spec.label} insgesamt[/]" + ) + else: + stats = ( + f"[{T.YUKI}]{len(self.findings)}[/] [{T.BASE5}]{self.spec.label}[/]" + f" [{T.BASE3}]·[/] " + f"[{T.TAKE}]{_human_size(total_reclaim)}[/] [{T.BASE5}]potentiell wegwerfbar[/]" + ) + self.query_one("#m-stats", Static).update(stats) + + legend_parts = [] + for cls in CLASSIFICATION_ORDER: + count = by_class.get(cls, 0) + if count == 0: + continue + legend_parts.append( + f"{glyph(cls)} [{T.BASE5}]{CLASS_LABEL[cls]}[/] " + f"[{T.BASE4}]{count}[/]" + ) + self.query_one("#m-legend", Static).update( + f" [{T.BASE3}]│[/] ".join(legend_parts) + ) + + def _render_detail(self, f: Finding) -> None: + color = CLASS_COLOR[f.classification] + label = CLASS_LABEL[f.classification] + gly = glyph(f.classification) + path_display = _display_path(f) + is_pkg = str(f.path).startswith("pkg://") + + lines = [ + f"{gly} [{color}]{label.upper()}[/]", + "", + ] + if is_pkg: + lines += [ + f"[{T.SHINKAI}]paket[/]", + f" [{T.YUKI}]{path_display}[/]", + ] + else: + lines += [ + f"[{T.SHINKAI}]pfad[/]", + f" [{T.YUKI}]{f.path}[/]", + ] + lines += [ + "", + f"[{T.SHINKAI}]grösse[/] [{T.CHIKYU}]{_human_size(f.size_bytes)}[/]", + ] + if f.last_access_days is not None: + lines.append( + f"[{T.SHINKAI}]idle[/] [{T.BASE5}]{_format_days(f.last_access_days)}[/]" + ) + lines.append( + f"[{T.SHINKAI}]kategorie[/] [{T.BASE5}]{self.spec.label}[/]" + ) + if f.matched_package and not is_pkg: + lines.append(f"[{T.SHINKAI}]paket[/] [{T.UME}]{f.matched_package}[/]") + lines += [ + "", + f"[{T.SHINKAI}]grund[/]", + f" [{T.BASE5}]{f.reason}[/]", + ] + if self.spec.advice: + advice = self.spec.advice.format( + pkg=f.matched_package or "", + path=str(f.path), + ) + lines += [ + "", + f"[{T.SHINKAI}]hinweis[/]", + f" [{T.CHIKYU}]{advice}[/]", + ] + if f.path in self.selected: + lines += ["", f"[{T.UME}]■ ausgewählt[/]"] + self.query_one("#m-detail", Static).update("\n".join(lines)) + + # ---- events ---- + + def on_tabs_tab_activated(self, event: Tabs.TabActivated) -> None: + if event.tab is None: + return + self.current_tab = event.tab.id + self.selected.clear() + if self.current_tab not in self.findings_by_tab: + self.action_rescan() + else: + self._refresh_view() + + def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None: + if event.cursor_row is None or event.cursor_row >= len(self.findings): + return + self._render_detail(self.findings[event.cursor_row]) diff --git a/src/taninux/maintain/types.py b/src/taninux/maintain/types.py new file mode 100644 index 0000000..b3a240f --- /dev/null +++ b/src/taninux/maintain/types.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from pathlib import Path + +from taninux.core import theme as T + + +class Category(str, Enum): + DOTFOLDERS = "dotfolders" + PACMAN = "pacman" + FLATPAK = "flatpak" + AUR = "aur" + SYSTEM = "system" + + +class Classification(str, Enum): + ACTIVE = "active" + WHITELISTED = "whitelisted" + UNKNOWN = "unknown" + LIKELY_ORPHAN = "likely" + SAFE = "safe" + + +CLASSIFICATION_ORDER = [ + Classification.SAFE, + Classification.LIKELY_ORPHAN, + Classification.UNKNOWN, + Classification.WHITELISTED, + Classification.ACTIVE, +] + +CLASS_COLOR = { + Classification.SAFE: T.TAKE, + Classification.LIKELY_ORPHAN: T.CHIKYU, + Classification.UNKNOWN: T.BASE4, + Classification.WHITELISTED: T.SHINKAI, + Classification.ACTIVE: T.UME, +} + +CLASS_GLYPH = { + Classification.SAFE: "●", + Classification.LIKELY_ORPHAN: "◐", + Classification.UNKNOWN: "○", + Classification.WHITELISTED: "◆", + Classification.ACTIVE: "▲", +} + +CLASS_LABEL = { + Classification.SAFE: "sicher", + Classification.LIKELY_ORPHAN: "vermutlich", + Classification.UNKNOWN: "unklar", + Classification.WHITELISTED: "behalten", + Classification.ACTIVE: "aktiv", +} + + +def glyph(cls: Classification) -> str: + return f"[{CLASS_COLOR[cls]}]{CLASS_GLYPH[cls]}[/]" + + +@dataclass +class Finding: + path: Path + category: Category + classification: Classification + size_bytes: int + last_access_days: int | None + reason: str + matched_package: str | None = None + extra: dict[str, str] = field(default_factory=dict) + + @property + def sort_key(self) -> tuple[int, int]: + return (CLASSIFICATION_ORDER.index(self.classification), -self.size_bytes) diff --git a/src/taninux/overview/__init__.py b/src/taninux/overview/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/taninux/overview/pillar.py b/src/taninux/overview/pillar.py new file mode 100644 index 0000000..375b7d3 --- /dev/null +++ b/src/taninux/overview/pillar.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from taninux.overview.screen import OverviewView +from taninux.pillars.base import Pillar + + +OVERVIEW = Pillar( + key="o", + label="overview", + tagline="OS at-a-glance", + icon="◉", + factory=OverviewView, +) diff --git a/src/taninux/overview/screen.py b/src/taninux/overview/screen.py new file mode 100644 index 0000000..fa2bf0d --- /dev/null +++ b/src/taninux/overview/screen.py @@ -0,0 +1,351 @@ +"""Overview/Dashboard — Landing-View mit OS at-a-glance.""" +from __future__ import annotations + +import os +import subprocess +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path + +from textual import work +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import VerticalScroll +from textual.widgets import Static + +from taninux.core import pacman as pac +from taninux.core import sysinfo +from taninux.core import theme as T +from taninux.core.paths import snapshots_dir, trash_dir + + +def _human_size(n: int) -> str: + f = float(n) + for unit in ("B", "K", "M", "G", "T"): + if f < 1024 or unit == "T": + return f"{f:.0f}{unit}" if unit == "B" else f"{f:.1f}{unit}" + f /= 1024 + return f"{f:.1f}T" + + +def _human_duration(seconds: float) -> str: + s = int(seconds) + if s < 60: + return f"{s}s" + if s < 3600: + return f"{s // 60}m" + h, rem = divmod(s, 3600) + if h < 24: + return f"{h}h {rem // 60}m" + d, h = divmod(h, 24) + return f"{d}d {h}h" + + +@dataclass +class PackageStats: + total: int + explicit: int + aur: int + deps: int + orphans: int + + +def _pacman_count(args: list[str]) -> int: + try: + r = subprocess.run( + ["pacman", *args], + capture_output=True, text=True, check=False, timeout=10, + ) + return len([l for l in r.stdout.splitlines() if l.strip()]) + except (FileNotFoundError, subprocess.TimeoutExpired): + return 0 + + +def _package_stats() -> PackageStats: + return PackageStats( + total=_pacman_count(["-Qq"]), + explicit=_pacman_count(["-Qenq"]), + aur=_pacman_count(["-Qmq"]), + deps=_pacman_count(["-Qdq"]), + orphans=_pacman_count(["-Qdtq"]), + ) + + +def _trash_stats() -> tuple[int, int]: + """(entries, total_bytes)""" + try: + entries = list(trash_dir().iterdir()) + except OSError: + return (0, 0) + count = 0 + total_size = 0 + for e in entries: + if not e.is_dir(): + continue + count += 1 + mf = e / "manifest.json" + if mf.exists(): + try: + import json + data = json.loads(mf.read_text()) + total_size += int(data.get("size_bytes", 0)) + except (OSError, ValueError): + pass + return (count, total_size) + + +def _last_snapshot() -> str | None: + try: + snaps = sorted(snapshots_dir().glob("*.json")) + except OSError: + return None + return snaps[-1].stem if snaps else None + + +def _flatpak_count() -> tuple[int, int]: + """(installed apps, installed runtimes)""" + try: + r1 = subprocess.run( + ["flatpak", "list", "--app", "--columns=application"], + capture_output=True, text=True, check=False, timeout=5, + ) + r2 = subprocess.run( + ["flatpak", "list", "--runtime", "--columns=application"], + capture_output=True, text=True, check=False, timeout=5, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return (0, 0) + apps = len([l for l in r1.stdout.splitlines() if l.strip() and l.strip() != "Application ID"]) + rts = len([l for l in r2.stdout.splitlines() if l.strip() and l.strip() != "Application ID"]) + return (apps, rts) + + +class OverviewView(VerticalScroll): + """Landing-Dashboard: OS at-a-glance + quick metrics.""" + + DEFAULT_CSS = f""" + OverviewView {{ + background: {T.YORU}; + padding: 1 2; + scrollbar-color: {T.BASE2} {T.YORU}; + scrollbar-background: {T.YORU}; + scrollbar-corner-color: {T.YORU}; + scrollbar-size-vertical: 1; + }} + OverviewView Static {{ + background: {T.YORU}; + margin-bottom: 1; + }} + """ + + BINDINGS = [ + Binding("r", "refresh", "refresh"), + ] + + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + + def compose(self) -> ComposeResult: + yield Static("", id="o-banner", markup=True) + yield Static("", id="o-system", markup=True) + yield Static("", id="o-pkgs", markup=True) + yield Static("", id="o-updates", markup=True) + yield Static("", id="o-maintain", markup=True) + yield Static("", id="o-flatpak", markup=True) + yield Static("", id="o-recent", markup=True) + yield Static("", id="o-hints", markup=True) + + def on_mount(self) -> None: + self._refresh_fast() + self._refresh_slow() + self._refresh_maintain() + + def action_refresh(self) -> None: + pac.invalidate() + self._refresh_fast() + self._refresh_slow() + self._refresh_maintain() + + # ---- fast (synchronous, <100ms) ---- + + def _refresh_fast(self) -> None: + now = datetime.now() + date_str = now.strftime("%A · %d. %B %Y · %H:%M") + banner = ( + f"[{T.UME}]▲ TANINUX[/] " + f"[{T.BASE4}]·[/] " + f"[{T.SHINKAI}]{date_str}[/]" + ) + self.query_one("#o-banner", Static).update(banner) + + # System + up = sysinfo.uptime_seconds() + boot = sysinfo.boot_time_seconds() + gpus_list = sysinfo.gpus() + gpu_str = " · ".join( + f"[{T.YUKI}]{g.vendor}[/] [{T.BASE5}]({g.driver or '—'})[/]" + for g in gpus_list + ) or f"[{T.BASE5}]?[/]" + + lines = [ + f"[{T.UME}]◈ system[/]", + f" [{T.SHINKAI}]distro[/] [{T.YUKI}]{sysinfo.distro_pretty()}[/]", + f" [{T.SHINKAI}]kernel[/] [{T.YUKI}]{sysinfo.kernel_release()}[/]", + f" [{T.SHINKAI}]uptime[/] [{T.YUKI}]{_human_duration(up)}[/]" + + (f" [{T.BASE4}](boot {boot:.1f}s)[/]" if boot else ""), + f" [{T.SHINKAI}]gpu[/] {gpu_str}", + f" [{T.SHINKAI}]audio[/] [{T.YUKI}]{sysinfo.audio_server()}[/]", + ] + self.query_one("#o-system", Static).update("\n".join(lines)) + + # Packages + stats = _package_stats() + lines = [ + f"[{T.UME}]◆ pakete[/]", + f" [{T.SHINKAI}]total[/] [{T.YUKI}]{stats.total}[/]", + f" [{T.SHINKAI}]explicit[/] [{T.YUKI}]{stats.explicit}[/] [{T.BASE5}](native)[/] " + f"[{T.YUKI}]{stats.aur}[/] [{T.BASE5}](AUR)[/]", + f" [{T.SHINKAI}]deps[/] [{T.BASE5}]{stats.deps}[/]", + ] + if stats.orphans: + lines.append( + f" [{T.SHINKAI}]orphans[/] [{T.CHIKYU}]{stats.orphans}[/] " + f"[{T.BASE4}](pacman -Qdt — siehe maintain/orphans)[/]" + ) + else: + lines.append(f" [{T.SHINKAI}]orphans[/] [{T.TAKE}]0[/]") + self.query_one("#o-pkgs", Static).update("\n".join(lines)) + + # Flatpak + fp_apps, fp_runtimes = _flatpak_count() + if fp_apps or fp_runtimes: + lines = [ + f"[{T.UME}]● flatpak[/]", + f" [{T.SHINKAI}]apps[/] [{T.YUKI}]{fp_apps}[/] " + f"[{T.SHINKAI}]runtimes[/] [{T.YUKI}]{fp_runtimes}[/]", + ] + else: + lines = [ + f"[{T.UME}]● flatpak[/]", + f" [{T.BASE5}](nicht installiert / leer)[/]", + ] + self.query_one("#o-flatpak", Static).update("\n".join(lines)) + + # Recent + trash_count, trash_bytes = _trash_stats() + last_snap = _last_snapshot() + lines = [f"[{T.UME}]✎ recent[/]"] + if trash_count: + lines.append( + f" [{T.SHINKAI}]trash[/] [{T.CHIKYU}]{trash_count} einträge[/] " + f"[{T.BASE5}]({_human_size(trash_bytes)})[/] " + f"[{T.BASE4}]via: taninux trash list[/]" + ) + else: + lines.append(f" [{T.SHINKAI}]trash[/] [{T.BASE5}]leer[/]") + if last_snap: + lines.append(f" [{T.SHINKAI}]snapshot[/] [{T.YUKI}]{last_snap}[/] " + f"[{T.BASE4}](taninux snapshot diff)[/]") + else: + lines.append(f" [{T.SHINKAI}]snapshot[/] [{T.BASE5}]keine — speichere:[/] " + f"[{T.BASE4}]taninux snapshot save[/]") + self.query_one("#o-recent", Static).update("\n".join(lines)) + + # Hints + hints = ( + f"[{T.BASE4}]quick-keys:[/] " + f"[{T.BASE5}]m[/] maintain " + f"[{T.BASE5}]u[/] update " + f"[{T.BASE5}]b[/] bundles " + f"[{T.BASE5}]s[/] suggest " + f"[{T.BASE5}]f[/] flatpak " + f"[{T.BASE5}]k[/] kernel " + f"[{T.BASE5}]h[/] health" + ) + self.query_one("#o-hints", Static).update(hints) + + # Updates: zeige Placeholder bis worker fertig + self.query_one("#o-updates", Static).update( + f"[{T.UME}]↻ updates[/]\n [{T.BASE5}]scanne pacman/AUR/flatpak/fwupd…[/]" + ) + + # ---- slow (background) ---- + + @work(thread=True, exclusive=True, group="overview-updates") + def _refresh_slow(self) -> None: + from taninux.update.sources import fetch_all + items = fetch_all() + self.app.call_from_thread(self._render_updates, items) + + def _render_updates(self, items) -> None: + from collections import Counter + counts = Counter(i.source for i in items) + total = len(items) + if not items: + content = ( + f"[{T.UME}]↻ updates[/]\n" + f" [{T.TAKE}]✓ system ist aktuell[/]" + ) + else: + total_size = sum(i.size_bytes for i in items) + size_str = f" [{T.BASE4}]({_human_size(total_size)})[/]" if total_size else "" + parts = [] + for src in ("pacman", "aur", "flatpak", "fwupd"): + n = counts.get(src, 0) + if n: + color = T.UME if src == "pacman" else (T.CHIKYU if src == "aur" else (T.TAKE if src == "flatpak" else T.SHINKAI)) + parts.append(f"[{color}]{src} {n}[/]") + content = ( + f"[{T.UME}]↻ updates[/]\n" + f" [{T.SHINKAI}]pending[/] [{T.YUKI}]{total}[/]{size_str}\n" + f" [{T.SHINKAI}]quellen[/] " + " · ".join(parts) + "\n" + f" [{T.BASE4}]drücke u[/] [{T.BASE5}]→[/] [{T.BASE4}]e zum ausführen[/]" + ) + try: + self.query_one("#o-updates", Static).update(content) + except Exception: + pass + + @work(thread=True, exclusive=True, group="overview-maintain") + def _refresh_maintain(self) -> None: + # Schnelle Variante: nur cache + journal scannen (die sind <100ms) + from taninux.maintain.scanners.pacman import PacmanCacheScanner + from taninux.maintain.scanners.system import SystemJournalScanner + cache_findings = PacmanCacheScanner().scan() + journal_findings = SystemJournalScanner().scan() + cache_bytes = sum( + f.size_bytes for f in cache_findings + if f.classification.value in ("safe", "likely") + ) + journal_bytes = sum(f.size_bytes for f in journal_findings) + self.app.call_from_thread(self._render_maintain, cache_bytes, journal_bytes) + + def _render_maintain(self, cache_bytes: int, journal_bytes: int) -> None: + if cache_bytes or journal_bytes: + lines = [ + f"[{T.UME}]▣ maintain — quick wins[/]", + ] + if cache_bytes: + lines.append( + f" [{T.SHINKAI}]pacman cache[/] " + f"[{T.CHIKYU}]{_human_size(cache_bytes)}[/] " + f"[{T.BASE4}](sudo paccache -ruk0)[/]" + ) + if journal_bytes: + lines.append( + f" [{T.SHINKAI}]journal[/] " + f"[{T.CHIKYU}]{_human_size(journal_bytes)}[/] " + f"[{T.BASE4}](sudo journalctl --vacuum-time=2weeks)[/]" + ) + lines.append( + f" [{T.BASE4}]für full-scan: drücke m[/]" + ) + else: + lines = [ + f"[{T.UME}]▣ maintain — quick wins[/]", + f" [{T.TAKE}]✓ nichts auffälliges (full-scan via m)[/]", + ] + try: + self.query_one("#o-maintain", Static).update("\n".join(lines)) + except Exception: + pass diff --git a/src/taninux/packages/__init__.py b/src/taninux/packages/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/taninux/packages/pillar.py b/src/taninux/packages/pillar.py new file mode 100644 index 0000000..2192f5a --- /dev/null +++ b/src/taninux/packages/pillar.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from taninux.packages.screen import PackagesView +from taninux.pillars.base import Pillar + + +PACKAGES = Pillar( + key="p", + label="packages", + tagline="pacman übersicht", + icon="◆", + factory=PackagesView, +) diff --git a/src/taninux/packages/screen.py b/src/taninux/packages/screen.py new file mode 100644 index 0000000..6526842 --- /dev/null +++ b/src/taninux/packages/screen.py @@ -0,0 +1,324 @@ +"""Packages-Pillar — Übersicht aller installierten Pakete mit Filter-Tabs.""" +from __future__ import annotations + +from textual import work +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Vertical +from textual.widgets import DataTable, Static, Tab, Tabs + +from taninux.core import pacman as pac, pkg, sudo, sysinfo +from taninux.core import theme as T +from taninux.core.confirm import ConfirmModal +from taninux.core.pkg import Package +from taninux.core.streaming import RunnerScreen, StreamCommand +from taninux.core.sudo_modal import SudoModal + + +TABS = [ + ("all", "alle"), + ("explicit", "explicit"), + ("deps", "deps"), + ("orphans", "orphans"), + ("aur", "AUR"), +] + + +REPO_COLOR = { + "core": T.UME, + "extra": T.SHINKAI, + "multilib": T.CHIKYU, + "aur": T.CHIKYU, + "local": T.BASE4, +} + + +def _human_size(n: int) -> str: + f = float(n) + for unit in ("B", "K", "M", "G", "T"): + if f < 1024 or unit == "T": + return f"{f:.0f}{unit}" if unit == "B" else f"{f:.1f}{unit}" + f /= 1024 + return f"{f:.1f}T" + + +class PackagesView(Vertical): + """Pacman-Pakete: tabs für alle/explicit/deps/orphans/AUR.""" + + DEFAULT_CSS = f""" + PackagesView {{ + background: {T.YORU}; + color: {T.YUKI}; + layout: vertical; + }} + PackagesView Tabs {{ + background: {T.YORU}; + height: 3; + }} + PackagesView Tab {{ + background: {T.YORU}; + color: {T.BASE4}; + padding: 0 2; + }} + PackagesView Tab.-active {{ + color: {T.UME}; + }} + PackagesView Underline {{ + background: {T.YORU}; + color: {T.BASE2}; + }} + PackagesView Underline > .underline--bar {{ + background: {T.YORU}; + color: {T.UME}; + }} + PackagesView #p-stats {{ + height: 1; + padding: 0 2; + background: {T.YORU}; + color: {T.BASE5}; + }} + PackagesView #p-table {{ + height: 1fr; + background: {T.YORU}; + color: {T.FG_ALT}; + border: none; + overflow-x: hidden; + scrollbar-size-vertical: 1; + scrollbar-color: {T.BASE2} {T.YORU}; + scrollbar-background: {T.YORU}; + scrollbar-corner-color: {T.YORU}; + }} + PackagesView #p-table:focus {{ + border: none; + }} + PackagesView DataTable > .datatable--header {{ + background: {T.YORU}; + color: {T.SHINKAI}; + }} + PackagesView DataTable > .datatable--cursor {{ + background: {T.BASE1}; + color: {T.YUKI}; + }} + PackagesView DataTable > .datatable--hover {{ + background: {T.YORU}; + }} + PackagesView DataTable > .datatable--even-row {{ + background: {T.YORU}; + }} + PackagesView DataTable > .datatable--odd-row {{ + background: {T.YORU}; + }} + PackagesView #p-flash {{ + height: 1; + padding: 0 2; + background: {T.YORU}; + color: {T.BASE4}; + }} + """ + + BINDINGS = [ + Binding("r", "rescan", "rescan"), + Binding("x", "remove", "entfernen"), + Binding("X", "remove_orphans", "alle orphans"), + Binding("j", "cursor_down", "down", show=False), + Binding("k", "cursor_up", "up", show=False), + Binding("g", "cursor_top", "top", show=False), + Binding("G", "cursor_bottom", "bottom", show=False), + ] + + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + self.all_packages: list[Package] = [] + self.current_tab = "all" + self.filtered: list[Package] = [] + + def compose(self) -> ComposeResult: + yield Tabs(*[Tab(label, id=tab_id) for tab_id, label in TABS], id="p-tabs") + yield Static("", id="p-stats", markup=True) + yield DataTable(id="p-table", cursor_type="row", zebra_stripes=False) + yield Static("", id="p-flash", markup=True) + + def on_mount(self) -> None: + t = self.query_one("#p-table", DataTable) + t.add_column("name", width=30, key="name") + t.add_column("version", width=20, key="version") + t.add_column("grösse", width=10, key="size") + t.add_column("reason", width=10, key="reason") + t.add_column("repo", width=10, key="repo") + t.add_column("beschreibung", key="desc") + self.action_rescan() + + # ---- actions ---- + + def action_rescan(self) -> None: + self._flash("scanne pakete…") + self._scan_worker() + + @work(thread=True, exclusive=True, group="packages-scan") + def _scan_worker(self) -> None: + pkgs = pkg.all_installed() + self.app.call_from_thread(self._after_scan, pkgs) + + def _after_scan(self, pkgs: list[Package]) -> None: + self.all_packages = pkgs + self._apply_filter() + self._flash(f"{len(pkgs)} pakete installiert") + + def action_remove(self) -> None: + cur = self._current() + if cur is None: + return + body = ( + f"paket entfernen?\n\n" + f" {cur.name} {cur.version}\n" + f" {_human_size(cur.size_bytes)} reason: {cur.reason} repo: {cur.repo}\n\n" + f"command: sudo pacman -Rns {cur.name}" + ) + self.app.push_screen( + ConfirmModal(f"remove {cur.name}?", body, danger=True), + lambda ok: self._after_remove(ok, [cur.name]), + ) + + def action_remove_orphans(self) -> None: + orphans = [p for p in self.all_packages if p.is_orphan] + if not orphans: + self._flash("keine orphans", color=T.TAKE) + return + total = sum(p.size_bytes for p in orphans) + body = ( + f"alle orphans entfernen?\n\n" + f" {len(orphans)} pakete {_human_size(total)}\n\n" + f"command: sudo pacman -Rns $(pacman -Qdtq)" + ) + self.app.push_screen( + ConfirmModal( + f"remove {len(orphans)} orphans?", + body, danger=True, + ), + lambda ok: self._after_remove(ok, [p.name for p in orphans]), + ) + + def _after_remove(self, ok: bool | None, names: list[str]) -> None: + if not ok or not names: + self._flash("abgebrochen") + return + cmds = [StreamCommand( + label=f"remove {len(names)} paket{'e' if len(names) > 1 else ''}", + argv=["sudo", "-n", "pacman", "-Rns", "--noconfirm", *names], + )] + if not sudo.is_cached(): + self.app.push_screen( + SudoModal(), + lambda pw: self._sudo_then_run(pw, cmds), + ) + else: + self.app.push_screen(RunnerScreen(cmds), self._after_runner) + + def _sudo_then_run(self, pw, cmds): + if not pw: + self._flash("kein passwort — abgebrochen") + return + if not sudo.cache(pw): + self._flash("falsches passwort", color=T.RED) + return + self.app.push_screen(RunnerScreen(cmds), self._after_runner) + + def _after_runner(self, codes): + pac.invalidate() + if not codes: + self._flash("kein ergebnis") + else: + ok = sum(1 for c in codes if c == 0) + self._flash( + f"{ok}/{len(codes)} ok — rescan…", + color=T.TAKE if ok == len(codes) else T.CHIKYU, + ) + self.action_rescan() + + def action_cursor_down(self) -> None: + self.query_one("#p-table", DataTable).action_cursor_down() + + def action_cursor_up(self) -> None: + self.query_one("#p-table", DataTable).action_cursor_up() + + def action_cursor_top(self) -> None: + t = self.query_one("#p-table", DataTable) + if t.row_count: + t.move_cursor(row=0) + + def action_cursor_bottom(self) -> None: + t = self.query_one("#p-table", DataTable) + if t.row_count: + t.move_cursor(row=t.row_count - 1) + + # ---- helpers ---- + + def _current(self) -> Package | None: + t = self.query_one("#p-table", DataTable) + r = t.cursor_row + if r is None or r < 0 or r >= len(self.filtered): + return None + return self.filtered[r] + + def _flash(self, msg: str, color: str | None = None) -> None: + color = color or T.BASE4 + self.query_one("#p-flash", Static).update(f"[{color}]{msg}[/]") + + def _apply_filter(self) -> None: + all_pkgs = self.all_packages + tab = self.current_tab + if tab == "explicit": + f = [p for p in all_pkgs if p.reason == "explicit"] + elif tab == "deps": + f = [p for p in all_pkgs if p.reason == "dependency"] + elif tab == "orphans": + f = [p for p in all_pkgs if p.is_orphan] + elif tab == "aur": + f = [p for p in all_pkgs if p.repo == "aur"] + else: + f = list(all_pkgs) + # Sort by size desc + f.sort(key=lambda p: -p.size_bytes) + self.filtered = f + self._refresh_view() + + def _refresh_view(self) -> None: + t = self.query_one("#p-table", DataTable) + t.clear() + for p in self.filtered: + repo_color = REPO_COLOR.get(p.repo, T.BASE4) + reason_color = T.UME if p.reason == "explicit" else T.BASE5 + size_styled = ( + f"[{T.CHIKYU}]{_human_size(p.size_bytes)}[/]" + if p.size_bytes else f"[{T.BASE3}]—[/]" + ) + t.add_row( + f"[{T.YUKI}]{p.name}[/]", + f"[{T.BASE5}]{p.version}[/]", + size_styled, + f"[{reason_color}]{p.reason}[/]", + f"[{repo_color}]{p.repo}[/]", + f"[{T.BASE5}]{p.desc[:80]}[/]", + ) + + total_size = sum(p.size_bytes for p in self.filtered) + all_size = sum(p.size_bytes for p in self.all_packages) + stats_parts = [ + f"[{T.YUKI}]{len(self.filtered)}[/] [{T.BASE5}]pakete[/]", + f"[{T.CHIKYU}]{_human_size(total_size)}[/]", + ] + if self.current_tab != "all": + stats_parts.append( + f"[{T.BASE4}](von {len(self.all_packages)} · {_human_size(all_size)} total)[/]" + ) + self.query_one("#p-stats", Static).update( + " " + f" [{T.BASE3}]·[/] ".join(stats_parts) + ) + + # ---- events ---- + + def on_tabs_tab_activated(self, event: Tabs.TabActivated) -> None: + if event.tab is None: + return + self.current_tab = event.tab.id + self._apply_filter() diff --git a/src/taninux/pillars/__init__.py b/src/taninux/pillars/__init__.py new file mode 100644 index 0000000..0917fac --- /dev/null +++ b/src/taninux/pillars/__init__.py @@ -0,0 +1,33 @@ +"""Pillar-Registry: alle Pillars in fester Reihenfolge.""" +from __future__ import annotations + +from taninux.bundles.pillar import BUNDLES +from taninux.flatpak.pillar import FLATPAK +from taninux.health.pillar import HEALTH +from taninux.kernel.pillar import KERNEL +from taninux.maintain.pillar import MAINTAIN +from taninux.overview.pillar import OVERVIEW +from taninux.packages.pillar import PACKAGES +from taninux.pillars.base import Pillar +from taninux.suggest.pillar import SUGGEST +from taninux.update.pillar import UPDATE + + +PILLARS: list[Pillar] = [ + OVERVIEW, + PACKAGES, + MAINTAIN, + UPDATE, + BUNDLES, + SUGGEST, + FLATPAK, + KERNEL, + HEALTH, +] + + +def by_key(key: str) -> Pillar | None: + for p in PILLARS: + if p.key == key: + return p + return None diff --git a/src/taninux/pillars/base.py b/src/taninux/pillars/base.py new file mode 100644 index 0000000..1752a73 --- /dev/null +++ b/src/taninux/pillars/base.py @@ -0,0 +1,23 @@ +"""Pillar-Abstraktion: jeder Pillar (maintain/update/kernel/suggest/health) +ist eine eigenständige Funktions-Säule der TANINUX-TUI.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Callable + +from textual.widget import Widget + + +@dataclass(frozen=True) +class Pillar: + """Metadaten + Factory für ein Pillar. + + Der eigentliche Content (Widget-Tree) wird via `factory()` erzeugt und + in den ContentSwitcher der Top-Level-App eingehängt. Jeder Pillar erhält + automatisch eine ID = `key` für das Switching. + """ + key: str # Single-char nav key: 'm', 'u', 'k', 's', 'h' + label: str # Kurz-Label für Sidebar: 'maintain' + tagline: str # Einzeiler für Sidebar-Description + icon: str # Geometrisches Glyph: '▣', '↻', '⊙', '✦', '◈' + factory: Callable[[], Widget] diff --git a/src/taninux/suggest/__init__.py b/src/taninux/suggest/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/taninux/suggest/pillar.py b/src/taninux/suggest/pillar.py new file mode 100644 index 0000000..98abd59 --- /dev/null +++ b/src/taninux/suggest/pillar.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from taninux.pillars.base import Pillar +from taninux.suggest.screen import SuggestView + + +SUGGEST = Pillar( + key="s", + label="suggest", + tagline="system-aware empfehlungen", + icon="✦", + factory=SuggestView, +) diff --git a/src/taninux/suggest/rules.py b/src/taninux/suggest/rules.py new file mode 100644 index 0000000..a77dd64 --- /dev/null +++ b/src/taninux/suggest/rules.py @@ -0,0 +1,160 @@ +"""Empfehlungs-Regeln: system-zustand → konkrete Pakete.""" +from __future__ import annotations + +from dataclasses import dataclass + +from taninux.core import sysinfo + + +@dataclass +class Suggestion: + category: str # 'gpu', 'dev', 'shell', 'audio' + reason: str # human-readable description (why), EN + packages: list[str] # what would be installed + priority: int = 1 # 1 high, 2 medium, 3 nice-to-have + title: str = "" # short title (what), EN + + +# ---- GPU ---- + +GPU_DRIVERS = { + "nvidia": { + "primary": ["nvidia", "nvidia-utils", "nvidia-settings"], + "open": ["nvidia-open", "nvidia-utils"], + "extras": ["cuda", "nvtop"], + }, + "amd": { + "primary": ["mesa", "vulkan-radeon", "libva-mesa-driver"], + "extras": ["radeontop", "lact"], + }, + "intel": { + "primary": ["mesa", "vulkan-intel", "intel-media-driver"], + "extras": ["intel-gpu-tools"], + }, +} + + +def suggest_gpu(installed: set[str]) -> list[Suggestion]: + out: list[Suggestion] = [] + for gpu in sysinfo.gpus(): + drivers = GPU_DRIVERS.get(gpu.vendor) + if not drivers: + continue + missing_primary = [p for p in drivers["primary"] if p not in installed] + if missing_primary: + out.append(Suggestion( + category="gpu", + title=f"{gpu.vendor.upper()} graphics drivers", + reason=f"{gpu.vendor.upper()} GPU detected ({gpu.model[:50]}) — primary drivers are missing", + packages=missing_primary, + priority=1, + )) + missing_extra = [p for p in drivers.get("extras", []) if p not in installed] + if missing_extra and not missing_primary: + out.append(Suggestion( + category="gpu", + title=f"{gpu.vendor.upper()} GPU extras", + reason="Optional tools for your GPU (monitoring, tuning)", + packages=missing_extra, + priority=3, + )) + return out + + +# ---- dev environments ---- + +DEV_ECOSYSTEMS = { + "rust": { + "trigger_binary": "cargo", + "trigger_pkg": "rust", + "missing": [ + ("rust-analyzer", "LSP für editor-integration"), + ("sccache", "compile cache, beschleunigt rebuilds"), + ("cargo-audit", "security-audit für deps"), + ], + }, + "go": { + "trigger_binary": "go", + "trigger_pkg": "go", + "missing": [ + ("gopls", "LSP für editor-integration"), + ("delve", "debugger"), + ], + }, + "node": { + "trigger_binary": "node", + "trigger_pkg": "nodejs", + "missing": [ + ("typescript", "TS compiler"), + ("pnpm", "schnellerer package-manager"), + ], + }, + "python": { + "trigger_binary": "python", + "trigger_pkg": "python", + "missing": [ + ("uv", "schneller dependency-manager"), + ("ruff", "schneller linter/formatter"), + ("ipython", "besseres REPL"), + ], + }, +} + + +def suggest_dev(installed: set[str]) -> list[Suggestion]: + out: list[Suggestion] = [] + for eco_name, eco in DEV_ECOSYSTEMS.items(): + trigger = eco["trigger_pkg"] in installed or sysinfo.has_binary(eco["trigger_binary"]) + if not trigger: + continue + missing = [ + (pkg, why) for pkg, why in eco["missing"] if pkg not in installed + ] + if missing: + out.append(Suggestion( + category="dev", + title=f"{eco_name.capitalize()} tooling", + reason=f"{eco_name} stack detected — complementary tools are missing", + packages=[p for p, _ in missing], + priority=2, + )) + return out + + +# ---- modern shell tooling ---- + +MODERN_CLI = [ + ("bat", "syntax-highlighted cat-ersatz"), + ("eza", "modernes ls mit icons + git-status"), + ("fd", "user-friendly find-ersatz"), + ("ripgrep", "schnelles grep"), + ("fzf", "fuzzy finder"), + ("zoxide", "smarter cd"), + ("dust", "rust-basiertes du"), + ("bottom", "moderner top/htop"), + ("starship", "shell-prompt"), +] + + +def suggest_shell(installed: set[str]) -> list[Suggestion]: + missing = [pkg for pkg, _ in MODERN_CLI if pkg not in installed] + if not missing: + return [] + return [Suggestion( + category="shell", + title="Modern CLI tools", + reason=f"{len(missing)} quality-of-life terminal tools are missing", + packages=missing, + priority=3, + )] + + +# ---- aggregate ---- + + +def all_suggestions(installed: set[str]) -> list[Suggestion]: + return [ + *suggest_gpu(installed), + *suggest_dev(installed), + *suggest_shell(installed), + ] diff --git a/src/taninux/suggest/screen.py b/src/taninux/suggest/screen.py new file mode 100644 index 0000000..dd40b5b --- /dev/null +++ b/src/taninux/suggest/screen.py @@ -0,0 +1,176 @@ +"""Suggest-Pillar — system-aware Paket-Empfehlungen.""" +from __future__ import annotations + +import subprocess + +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import VerticalScroll +from textual.widgets import DataTable, Static + +from taninux.core import pacman as pac +from taninux.core import theme as T +from taninux.core.confirm import ConfirmModal +from taninux.core.runner import Command, run_interactive +from taninux.suggest.rules import Suggestion, all_suggestions + + +CATEGORY_GLYPH = { + "gpu": ("▣", T.UME), + "dev": ("◆", T.SHINKAI), + "shell": ("●", T.TAKE), + "audio": ("◐", T.CHIKYU), +} + + +class SuggestView(VerticalScroll): + DEFAULT_CSS = f""" + SuggestView {{ + background: {T.YORU}; + padding: 1 2; + scrollbar-color: {T.BASE2} {T.YORU}; + scrollbar-background: {T.YORU}; + scrollbar-corner-color: {T.YORU}; + scrollbar-size-vertical: 1; + }} + SuggestView Static {{ + background: {T.YORU}; + margin-bottom: 1; + }} + SuggestView #s-table {{ + height: auto; + background: {T.YORU}; + color: {T.FG_ALT}; + border: none; + overflow-x: hidden; + }} + SuggestView #s-table:focus {{ + border: none; + }} + SuggestView DataTable > .datatable--header {{ + background: {T.YORU}; + color: {T.SHINKAI}; + }} + SuggestView DataTable > .datatable--cursor {{ + background: {T.BASE1}; + color: {T.YUKI}; + }} + SuggestView DataTable > .datatable--even-row {{ + background: {T.YORU}; + }} + SuggestView DataTable > .datatable--odd-row {{ + background: {T.YORU}; + }} + """ + + BINDINGS = [ + Binding("r", "rescan", "rescan"), + Binding("i", "install", "installieren"), + Binding("j", "cursor_down", "down", show=False), + Binding("k", "cursor_up", "up", show=False), + ] + + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + self.suggestions: list[Suggestion] = [] + + def compose(self) -> ComposeResult: + yield Static("", id="s-intro", markup=True) + yield DataTable(id="s-table", cursor_type="row", zebra_stripes=False) + yield Static("", id="s-flash", markup=True) + + def on_mount(self) -> None: + table = self.query_one("#s-table", DataTable) + table.add_column("", width=2, key="glyph") + table.add_column("kat", width=8, key="cat") + table.add_column("anlass", key="reason") + table.add_column("pakete", key="pkgs") + table.add_column("pri", width=4, key="pri") + self.action_rescan() + + def action_rescan(self) -> None: + self._flash("scanne …") + pac.invalidate() + installed = set(pac.installed_packages()) + self.suggestions = sorted( + all_suggestions(installed), + key=lambda s: (s.priority, s.category), + ) + self._refresh_view() + self._flash(f"{len(self.suggestions)} empfehlungen") + + def action_install(self) -> None: + cur = self._current() + if cur is None: + self._flash("nichts ausgewählt", color=T.BASE4) + return + body = ( + f"folgende pakete via paru -S installieren?\n\n" + f" {', '.join(cur.packages)}\n\n" + f"grund: {cur.reason}" + ) + self.app.push_screen( + ConfirmModal("installieren?", body, danger=False), + lambda ok: self._after_install(ok, cur), + ) + + def _after_install(self, confirmed: bool | None, s: Suggestion) -> None: + if not confirmed: + self._flash("abgebrochen") + return + from taninux.core import sysinfo + helper = "paru" if sysinfo.has_binary("paru") else ( + "yay" if sysinfo.has_binary("yay") else "sudo pacman" + ) + argv = helper.split() + ["-S", *s.packages] + codes = run_interactive( + self.app, + [Command(label=f"install {len(s.packages)} pakete", argv=argv)], + ) + ok = codes and codes[0] == 0 + pac.invalidate() + self._flash( + "installation ok — rescan…" if ok else "installation fehlgeschlagen", + color=T.TAKE if ok else T.RED, + ) + self.action_rescan() + + def action_cursor_down(self) -> None: + self.query_one("#s-table", DataTable).action_cursor_down() + + def action_cursor_up(self) -> None: + self.query_one("#s-table", DataTable).action_cursor_up() + + def _current(self) -> Suggestion | None: + t = self.query_one("#s-table", DataTable) + r = t.cursor_row + if r is None or r < 0 or r >= len(self.suggestions): + return None + return self.suggestions[r] + + def _flash(self, msg: str, color: str | None = None) -> None: + color = color or T.BASE4 + self.query_one("#s-flash", Static).update(f"[{color}]{msg}[/]") + + def _refresh_view(self) -> None: + intro = ( + f"[{T.UME}]✦ suggest[/]\n" + f"[{T.BASE5}]system-aware empfehlungen — `i` installiert die ausgewählte zeile via paru[/]" + ) + self.query_one("#s-intro", Static).update(intro) + + table = self.query_one("#s-table", DataTable) + table.clear() + for s in self.suggestions: + glyph, color = CATEGORY_GLYPH.get(s.category, ("·", T.BASE4)) + pri_color = {1: T.RED, 2: T.CHIKYU, 3: T.BASE5}.get(s.priority, T.BASE5) + pri_label = {1: "hoch", 2: "mittel", 3: "nice"}.get(s.priority, str(s.priority)) + pkg_count = len(s.packages) + pkgs_str = ", ".join(s.packages[:4]) + (f" +{pkg_count - 4}" if pkg_count > 4 else "") + table.add_row( + f"[{color}]{glyph}[/]", + f"[{color}]{s.category}[/]", + f"[{T.YUKI}]{s.reason}[/]", + f"[{T.BASE5}]{pkgs_str}[/]", + f"[{pri_color}]{pri_label}[/]", + ) diff --git a/src/taninux/update/__init__.py b/src/taninux/update/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/taninux/update/pillar.py b/src/taninux/update/pillar.py new file mode 100644 index 0000000..bbaa067 --- /dev/null +++ b/src/taninux/update/pillar.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from taninux.pillars.base import Pillar +from taninux.update.screen import UpdateView + + +UPDATE = Pillar( + key="u", + label="update", + tagline="pacman · AUR · flatpak · firmware", + icon="↻", + factory=UpdateView, +) diff --git a/src/taninux/update/screen.py b/src/taninux/update/screen.py new file mode 100644 index 0000000..cf1f9db --- /dev/null +++ b/src/taninux/update/screen.py @@ -0,0 +1,426 @@ +"""Update-Pillar View — listet pending updates aus allen Quellen.""" +from __future__ import annotations + +from collections import Counter + +from textual import work +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Vertical +from textual.widgets import DataTable, Static + +from taninux.core import sudo, theme as T +from taninux.core.confirm import ConfirmModal +from taninux.core.runner import Command, run_interactive +from taninux.core.streaming import RunnerScreen, StreamCommand +from taninux.core.sudo_modal import SudoModal +from taninux.update.sources import fetch_all +from taninux.update.types import UpdateItem + + +SOURCE_COLOR = { + "pacman": T.UME, # violet — system core + "aur": T.CHIKYU, # yellow — community + "flatpak": T.TAKE, # green — sandboxed + "fwupd": T.SHINKAI, # blue — firmware +} +SOURCE_GLYPH = { + "pacman": "▲", + "aur": "◆", + "flatpak": "●", + "fwupd": "◈", +} + + +def _human_size(n: int) -> str: + f = float(n) + for unit in ("B", "K", "M", "G", "T"): + if f < 1024 or unit == "T": + return f"{f:.0f}{unit}" if unit == "B" else f"{f:.1f}{unit}" + f /= 1024 + return f"{f:.1f}T" + + +class UpdateView(Vertical): + """Cross-source updater: pacman + AUR + flatpak + fwupd in einer View.""" + + DEFAULT_CSS = f""" + UpdateView {{ + background: {T.YORU}; + color: {T.YUKI}; + layout: vertical; + }} + UpdateView #u-stats {{ + height: 1; + padding: 1 2 0 2; + background: {T.YORU}; + color: {T.BASE5}; + }} + UpdateView #u-counts {{ + height: 1; + padding: 1 2 1 2; + background: {T.YORU}; + color: {T.BASE4}; + }} + UpdateView #u-table {{ + height: 1fr; + background: {T.YORU}; + color: {T.FG_ALT}; + border: none; + overflow-x: hidden; + scrollbar-size-vertical: 1; + scrollbar-color: {T.BASE2} {T.YORU}; + scrollbar-background: {T.YORU}; + scrollbar-corner-color: {T.YORU}; + }} + UpdateView #u-table:focus {{ + border: none; + }} + UpdateView DataTable > .datatable--header {{ + background: {T.YORU}; + color: {T.SHINKAI}; + }} + UpdateView DataTable > .datatable--cursor {{ + background: {T.BASE1}; + color: {T.YUKI}; + }} + UpdateView DataTable > .datatable--hover {{ + background: {T.YORU}; + }} + UpdateView DataTable > .datatable--even-row {{ + background: {T.YORU}; + }} + UpdateView DataTable > .datatable--odd-row {{ + background: {T.YORU}; + }} + UpdateView #u-advice {{ + height: auto; + padding: 1 2; + background: {T.YORU}; + color: {T.BASE4}; + }} + UpdateView #u-flash {{ + height: 1; + padding: 0 2; + background: {T.YORU}; + color: {T.BASE4}; + }} + """ + + BINDINGS = [ + Binding("e", "execute", "ausführen (TUI)"), + Binding("E", "execute_external", "ausführen (extern)", show=False), + Binding("r", "rescan", "rescan"), + Binding("j", "cursor_down", "down", show=False), + Binding("k", "cursor_up", "up", show=False), + Binding("g", "cursor_top", "top", show=False), + Binding("G", "cursor_bottom", "bottom", show=False), + ] + + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + self.items: list[UpdateItem] = [] + self._loaded = False + + def compose(self) -> ComposeResult: + yield Static("", id="u-stats", markup=True) + yield Static("", id="u-counts", markup=True) + yield DataTable(id="u-table", cursor_type="row", zebra_stripes=False) + yield Static("", id="u-advice", markup=True) + yield Static("", id="u-flash", markup=True) + + def on_mount(self) -> None: + table = self.query_one("#u-table", DataTable) + table.add_column("", width=2, key="glyph") + table.add_column("quelle", width=9, key="source") + table.add_column("paket", key="name") + table.add_column("aktuell", key="current") + table.add_column("→", width=1, key="arrow") + table.add_column("neu", key="new") + table.add_column("grösse", key="size") + self.action_rescan() + + # ---- actions ---- + + def action_rescan(self) -> None: + self._flash("scanne updates… (kann 30+ sek dauern)") + self._rescan_worker() + + @work(thread=True, exclusive=True, group="update-scan") + def _rescan_worker(self) -> None: + items = fetch_all() + self.app.call_from_thread(self._after_rescan, items) + + def _after_rescan(self, items: list[UpdateItem]) -> None: + self.items = items + self._loaded = True + self._refresh_view() + self._flash(f"{len(items)} updates verfügbar") + + def action_execute(self) -> None: + """Update INSIDE the TUI: sudo-prompt → streaming modal mit --noconfirm.""" + if not self.items: + self._flash("nichts zu tun — system aktuell", color=T.TAKE) + return + cmds = self._build_stream_commands() + if not cmds: + return + + body = ( + "die folgenden befehle werden im TUI ausgeführt (live output):\n\n" + + "\n".join(f" · {c.label}\n $ {' '.join(c.argv)}" for c in cmds) + + "\n\nhinweis: --noconfirm ist aktiv, alle prompts werden auto-bestätigt.\n" + + "für interaktiven modus mit prompts: Shift+E (suspendiert TUI)." + ) + self.app.push_screen( + ConfirmModal("updates im TUI ausführen?", body, danger=False), + lambda ok: self._after_execute_confirm(ok, cmds), + ) + + def action_execute_external(self) -> None: + """Klassischer suspend-modus für interaktive updates mit prompts.""" + if not self.items: + self._flash("nichts zu tun — system aktuell", color=T.TAKE) + return + cmds = self._build_commands() + if not cmds: + return + body = ( + "TUI wird suspendiert, befehle laufen interaktiv im terminal:\n\n" + + "\n".join(f" · {c.label}\n $ {' '.join(c.argv)}" for c in cmds) + ) + self.app.push_screen( + ConfirmModal("extern interaktiv ausführen?", body, danger=False), + lambda ok: self._after_external_confirm(ok, cmds), + ) + + def _build_commands(self) -> list[Command]: + """Interaktive Variante (mit prompts).""" + counts = Counter(i.source for i in self.items) + cmds: list[Command] = [] + if counts.get("pacman") or counts.get("aur"): + from taninux.core import sysinfo + helper = "paru" if sysinfo.has_binary("paru") else ( + "yay" if sysinfo.has_binary("yay") else None + ) + if helper: + cmds.append(Command( + label=f"system + AUR ({counts.get('pacman', 0)} pacman, {counts.get('aur', 0)} AUR)", + argv=[helper, "-Syu"], + )) + elif counts.get("pacman"): + cmds.append(Command( + label=f"system ({counts.get('pacman', 0)} pakete)", + argv=["sudo", "pacman", "-Syu"], + )) + if counts.get("flatpak"): + cmds.append(Command( + label=f"flatpak ({counts.get('flatpak')} apps)", + argv=["flatpak", "update", "-y"], + )) + if counts.get("fwupd"): + cmds.append(Command( + label=f"firmware ({counts.get('fwupd')} geräte)", + argv=["sudo", "fwupdmgr", "update"], + )) + return cmds + + def _build_stream_commands(self) -> list[StreamCommand]: + """Streaming-Variante mit aggressiven unattended-flags. + + paru flags: + --noconfirm keine [Y/n] prompts + --sudoloop hält sudo-cache am leben während build + --skipreview keine PKGBUILD-review prompts + --removemake cleanup nach build + --cleanafter src-dir cleanup + --batchinstall alle AUR pakete in einem pacman call + """ + counts = Counter(i.source for i in self.items) + out: list[StreamCommand] = [] + if counts.get("pacman") or counts.get("aur"): + from taninux.core import sysinfo + helper = "paru" if sysinfo.has_binary("paru") else ( + "yay" if sysinfo.has_binary("yay") else None + ) + if helper: + out.append(StreamCommand( + label=f"system + AUR via {helper}", + argv=[ + helper, "-Syu", + "--noconfirm", "--sudoloop", + "--skipreview", "--removemake", "--cleanafter", + "--batchinstall", + ], + )) + elif counts.get("pacman"): + out.append(StreamCommand( + label="system via pacman", + argv=["sudo", "-n", "pacman", "-Syu", "--noconfirm"], + )) + if counts.get("flatpak"): + out.append(StreamCommand( + label=f"flatpak ({counts.get('flatpak')} apps)", + argv=["flatpak", "update", "--noninteractive"], + )) + if counts.get("fwupd"): + out.append(StreamCommand( + label=f"firmware ({counts.get('fwupd')} geräte)", + argv=["sudo", "-n", "fwupdmgr", "update", + "--assume-yes", "--no-reboot-check"], + )) + return out + + def _after_execute_confirm(self, confirmed: bool | None, + cmds: list[StreamCommand]) -> None: + if not confirmed: + self._flash("abgebrochen") + return + # Brauchen wir sudo? + needs_sudo = any( + c.argv[0] == "sudo" or c.argv[0] in ("paru", "yay") + for c in cmds + ) + if needs_sudo and not sudo.is_cached(): + self.app.push_screen( + SudoModal(), + lambda pw: self._after_sudo_prompt(pw, cmds), + ) + else: + self._launch_runner(cmds) + + def _after_sudo_prompt(self, pw: str | None, + cmds: list[StreamCommand]) -> None: + if not pw: + self._flash("kein passwort — abgebrochen") + return + if not sudo.cache(pw): + self._flash("falsches passwort", color=T.RED) + return + self._launch_runner(cmds) + + def _launch_runner(self, cmds: list[StreamCommand]) -> None: + self.app.push_screen(RunnerScreen(cmds), self._after_runner) + + def _after_runner(self, codes: list[int] | None) -> None: + from taninux.core import pacman as pac + pac.invalidate() + if not codes: + self._flash("kein ergebnis") + else: + ok = sum(1 for c in codes if c == 0) + self._flash( + f"{ok}/{len(codes)} befehle ok — rescan…", + color=T.TAKE if ok == len(codes) else T.CHIKYU, + ) + self.action_rescan() + + def _after_external_confirm(self, confirmed: bool | None, + cmds: list[Command]) -> None: + if not confirmed: + self._flash("abgebrochen") + return + codes = run_interactive(self.app, cmds) + from taninux.core import pacman as pac + pac.invalidate() + ok = sum(1 for c in codes if c == 0) + self._flash( + f"{ok}/{len(codes)} befehle ok — rescan…", + color=T.TAKE if ok == len(codes) else T.CHIKYU, + ) + self.action_rescan() + + def action_cursor_down(self) -> None: + self.query_one("#u-table", DataTable).action_cursor_down() + + def action_cursor_up(self) -> None: + self.query_one("#u-table", DataTable).action_cursor_up() + + def action_cursor_top(self) -> None: + t = self.query_one("#u-table", DataTable) + if t.row_count: + t.move_cursor(row=0) + + def action_cursor_bottom(self) -> None: + t = self.query_one("#u-table", DataTable) + if t.row_count: + t.move_cursor(row=t.row_count - 1) + + # ---- helpers ---- + + def _flash(self, msg: str, color: str | None = None) -> None: + color = color or T.BASE4 + self.query_one("#u-flash", Static).update(f"[{color}]{msg}[/]") + + # ---- render ---- + + def _refresh_view(self) -> None: + table = self.query_one("#u-table", DataTable) + table.clear() + + # sort: pacman, aur, flatpak, fwupd + order = {"pacman": 0, "aur": 1, "flatpak": 2, "fwupd": 3} + items = sorted(self.items, key=lambda i: (order.get(i.source, 9), i.name)) + + for i in items: + c = SOURCE_COLOR.get(i.source, T.BASE5) + g = SOURCE_GLYPH.get(i.source, "·") + size = _human_size(i.size_bytes) if i.size_bytes else "—" + table.add_row( + f"[{c}]{g}[/]", + f"[{c}]{i.source}[/]", + f"[{T.YUKI}]{i.name}[/]", + f"[{T.BASE4}]{i.current_version}[/]", + f"[{T.BASE3}]→[/]", + f"[{T.TAKE}]{i.new_version}[/]", + f"[{T.CHIKYU}]{size}[/]" if i.size_bytes else f"[{T.BASE3}]—[/]", + ) + + # stats + total_size = sum(i.size_bytes for i in self.items) + if not self.items: + stats = f"[{T.TAKE}]system ist aktuell[/] [{T.BASE3}]·[/] [{T.BASE5}]keine pending updates[/]" + else: + stats = ( + f"[{T.YUKI}]{len(self.items)}[/] [{T.BASE5}]updates verfügbar[/]" + + (f" [{T.BASE3}]·[/] [{T.CHIKYU}]{_human_size(total_size)}[/] [{T.BASE5}]download[/]" + if total_size else "") + ) + self.query_one("#u-stats", Static).update(stats) + + # source counts legend + by_source = Counter(i.source for i in self.items) + parts = [] + for src in ("pacman", "aur", "flatpak", "fwupd"): + n = by_source.get(src, 0) + c = SOURCE_COLOR[src] + g = SOURCE_GLYPH[src] + count_color = T.BASE4 if n == 0 else T.BASE5 + parts.append(f"[{c}]{g}[/] [{count_color}]{src} {n}[/]") + self.query_one("#u-counts", Static).update(f" [{T.BASE3}]│[/] ".join(parts)) + + # advice — concrete commands + if not self.items: + self.query_one("#u-advice", Static).update("") + return + action_lines = [ + f"[{T.SHINKAI}]aktionen[/]", + f" [{T.UME}]e[/] [{T.YUKI}]alle {len(self.items)} updates im TUI installieren[/] " + f"[{T.BASE5}](streaming, --noconfirm)[/]", + f" [{T.BASE5}]E[/] [{T.BASE4}]extern interaktiv (suspend → terminal)[/]", + f" [{T.BASE5}]r[/] [{T.BASE4}]rescan[/]", + "", + f"[{T.SHINKAI}]oder shell[/]", + ] + sh_cmds: list[str] = [] + if by_source.get("pacman") or by_source.get("aur"): + sh_cmds.append("paru -Syu") + elif by_source.get("pacman"): + sh_cmds.append("sudo pacman -Syu") + if by_source.get("flatpak"): + sh_cmds.append("flatpak update -y") + if by_source.get("fwupd"): + sh_cmds.append("sudo fwupdmgr update") + for c in sh_cmds: + action_lines.append(f" [{T.CHIKYU}]{c}[/]") + self.query_one("#u-advice", Static).update("\n".join(action_lines)) diff --git a/src/taninux/update/sources/__init__.py b/src/taninux/update/sources/__init__.py new file mode 100644 index 0000000..ac35925 --- /dev/null +++ b/src/taninux/update/sources/__init__.py @@ -0,0 +1,23 @@ +"""Update-Sources Registry.""" +from __future__ import annotations + +from taninux.update.sources import aur, flatpak, fwupd, pacman +from taninux.update.types import UpdateItem + + +SOURCES = { + "pacman": pacman.fetch, + "aur": aur.fetch, + "flatpak": flatpak.fetch, + "fwupd": fwupd.fetch, +} + + +def fetch_all() -> list[UpdateItem]: + items: list[UpdateItem] = [] + for fn in SOURCES.values(): + try: + items += fn() + except Exception: # noqa: BLE001 + pass + return items diff --git a/src/taninux/update/sources/aur.py b/src/taninux/update/sources/aur.py new file mode 100644 index 0000000..70f24e9 --- /dev/null +++ b/src/taninux/update/sources/aur.py @@ -0,0 +1,48 @@ +"""AUR: pending updates via `paru -Qua` (oder yay als Fallback).""" +from __future__ import annotations + +import re +import shutil +import subprocess + +from taninux.update.types import UpdateItem + +_PARU_LINE = re.compile(r"^(\S+)\s+(\S+)\s+->\s+(\S+)") + + +def _helper() -> str | None: + for h in ("paru", "yay"): + if shutil.which(h): + return h + return None + + +def fetch() -> list[UpdateItem]: + helper = _helper() + if not helper: + return [] + try: + r = subprocess.run( + [helper, "-Qua"], + capture_output=True, text=True, check=False, timeout=60, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return [] + if r.returncode not in (0, 1): + return [] + items: list[UpdateItem] = [] + for line in r.stdout.splitlines(): + line = line.strip() + if not line or line.startswith(":"): # paru status lines beginnen mit :: + continue + m = _PARU_LINE.match(line) + if m: + items.append(UpdateItem( + source="aur", + name=m.group(1), + current_version=m.group(2), + new_version=m.group(3), + size_bytes=0, + extra={"helper": helper}, + )) + return items diff --git a/src/taninux/update/sources/flatpak.py b/src/taninux/update/sources/flatpak.py new file mode 100644 index 0000000..c23abb5 --- /dev/null +++ b/src/taninux/update/sources/flatpak.py @@ -0,0 +1,66 @@ +"""Flatpak: pending updates via `flatpak remote-ls --updates`.""" +from __future__ import annotations + +import subprocess + +from taninux.update.types import UpdateItem + + +def _installed_versions() -> dict[str, str]: + try: + r = subprocess.run( + ["flatpak", "list", "--columns=application,version"], + capture_output=True, text=True, check=False, timeout=10, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return {} + versions: dict[str, str] = {} + for line in r.stdout.splitlines(): + s = line.strip() + if not s or s.startswith("Application"): + continue + parts = s.split("\t") + if len(parts) >= 2: + versions[parts[0]] = parts[1] + return versions + + +def fetch() -> list[UpdateItem]: + try: + r = subprocess.run( + ["flatpak", "remote-ls", "--updates", + "--columns=application,version,download-size"], + capture_output=True, text=True, check=False, timeout=60, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return [] + if r.returncode != 0: + return [] + + current = _installed_versions() + items: list[UpdateItem] = [] + for line in r.stdout.splitlines(): + s = line.strip() + if not s or s.startswith("Application"): + continue + parts = s.split("\t") + if len(parts) < 2: + continue + app, new_ver = parts[0], parts[1] + size = 0 + if len(parts) >= 3: + # download-size column is human-readable like "12.3 MB" + size_str = parts[2] + try: + from taninux.maintain.scanners.pacman import _parse_size + size = _parse_size(size_str) + except Exception: + size = 0 + items.append(UpdateItem( + source="flatpak", + name=app, + current_version=current.get(app, "?"), + new_version=new_ver, + size_bytes=size, + )) + return items diff --git a/src/taninux/update/sources/fwupd.py b/src/taninux/update/sources/fwupd.py new file mode 100644 index 0000000..33c2c04 --- /dev/null +++ b/src/taninux/update/sources/fwupd.py @@ -0,0 +1,38 @@ +"""Firmware: pending updates via `fwupdmgr get-updates --json`.""" +from __future__ import annotations + +import json +import subprocess + +from taninux.update.types import UpdateItem + + +def fetch() -> list[UpdateItem]: + try: + r = subprocess.run( + ["fwupdmgr", "get-updates", "--json"], + capture_output=True, text=True, check=False, timeout=30, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return [] + if r.returncode != 0 or not r.stdout.strip(): + return [] + try: + data = json.loads(r.stdout) + except json.JSONDecodeError: + return [] + items: list[UpdateItem] = [] + for dev in data.get("Devices", []): + releases = dev.get("Releases", []) + if not releases: + continue + rel = releases[0] + items.append(UpdateItem( + source="fwupd", + name=dev.get("Name", "?"), + current_version=dev.get("Version", "?"), + new_version=rel.get("Version", "?"), + size_bytes=int(rel.get("Size", 0) or 0), + extra={"device_id": dev.get("DeviceId", "")}, + )) + return items diff --git a/src/taninux/update/sources/pacman.py b/src/taninux/update/sources/pacman.py new file mode 100644 index 0000000..d352fad --- /dev/null +++ b/src/taninux/update/sources/pacman.py @@ -0,0 +1,34 @@ +"""Pacman: pending updates via `checkupdates` (aus pacman-contrib). + +`checkupdates` synct in ein temporäres DB ohne sudo zu brauchen und +listet was bei `pacman -Syu` aktualisiert würde.""" +from __future__ import annotations + +import subprocess + +from taninux.update.types import UpdateItem + + +def fetch() -> list[UpdateItem]: + try: + r = subprocess.run( + ["checkupdates"], + capture_output=True, text=True, check=False, timeout=60, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return [] + # checkupdates exitet 2 wenn keine Updates pending — kein Fehler + if r.returncode not in (0, 2): + return [] + items: list[UpdateItem] = [] + for line in r.stdout.splitlines(): + parts = line.split() + if len(parts) >= 4 and parts[2] == "->": + items.append(UpdateItem( + source="pacman", + name=parts[0], + current_version=parts[1], + new_version=parts[3], + size_bytes=0, + )) + return items diff --git a/src/taninux/update/types.py b/src/taninux/update/types.py new file mode 100644 index 0000000..6a810b4 --- /dev/null +++ b/src/taninux/update/types.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass +class UpdateItem: + """Ein verfügbares Update aus einer Quelle (pacman/AUR/flatpak/fwupd).""" + source: str # 'pacman', 'aur', 'flatpak', 'fwupd' + name: str # Paket- oder Geräte-Name + current_version: str # aktuelle Version + new_version: str # Ziel-Version + size_bytes: int = 0 # Download-Grösse falls bekannt + extra: dict[str, str] = field(default_factory=dict)