From fe46fad5c2e3af372c7e312bf3d02bdecd6be991 Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Wed, 12 Jun 2019 16:52:53 -0300 Subject: [PATCH 001/123] Init --- .gitignore | 4 + LICENSE | 683 +------------------------------ app_qt.py | 20 + core/__init__.py | 1 + core/controller.py | 22 + core/flatpak.py | 55 +++ core/model.py | 75 ++++ core/resource.py | 6 + core/updater.py | 16 + requirements.txt | 2 + resources/img/flathub_logo.svg | 179 ++++++++ resources/img/refresh.svg | 125 ++++++ resources/img/refresh_orange.svg | 129 ++++++ resources/img/toggle_off.svg | 69 ++++ resources/img/toggle_on.svg | 69 ++++ resources/img/update.svg | 69 ++++ resources/img/update_green.svg | 72 ++++ view/__init__.py | 0 view/qt/__init__.py | 0 view/qt/systray.py | 29 ++ view/qt/window.py | 316 ++++++++++++++ 21 files changed, 1273 insertions(+), 668 deletions(-) create mode 100755 .gitignore create mode 100755 app_qt.py create mode 100755 core/__init__.py create mode 100755 core/controller.py create mode 100755 core/flatpak.py create mode 100755 core/model.py create mode 100755 core/resource.py create mode 100755 core/updater.py create mode 100755 requirements.txt create mode 100755 resources/img/flathub_logo.svg create mode 100755 resources/img/refresh.svg create mode 100755 resources/img/refresh_orange.svg create mode 100755 resources/img/toggle_off.svg create mode 100755 resources/img/toggle_on.svg create mode 100755 resources/img/update.svg create mode 100755 resources/img/update_green.svg create mode 100755 view/__init__.py create mode 100755 view/qt/__init__.py create mode 100755 view/qt/systray.py create mode 100755 view/qt/window.py diff --git a/.gitignore b/.gitignore new file mode 100755 index 00000000..a9a43ef4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +env +*.pyc +.idea +__pycache__ diff --git a/LICENSE b/LICENSE index f288702d..f1bc9a28 100644 --- a/LICENSE +++ b/LICENSE @@ -1,674 +1,21 @@ - GNU GENERAL PUBLIC LICENSE - Version 3, 29 June 2007 + Copyright (c) 2017-2019 Philip Rebohle - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. + zlib/libpng license - Preamble +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. - The GNU General Public License is a free, copyleft license for -software and other kinds of works. +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: - 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. +– The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would be + appreciated but is not required. - 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. +– Altered source versions must be plainly marked as such, and must not + be misrepresented as being the original software. - 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 -. +– This notice may not be removed or altered from any source distribution. \ No newline at end of file diff --git a/app_qt.py b/app_qt.py new file mode 100755 index 00000000..ab4389d7 --- /dev/null +++ b/app_qt.py @@ -0,0 +1,20 @@ +#!env/bin/python +import sys + +from PyQt5.QtWidgets import QApplication + +from core.controller import FlatpakController +from core.model import FlatpakManager +from view.qt.window import MainWindow + +app = QApplication(sys.argv) +app.setStyle('Fusion') +manager = FlatpakManager() +controller = FlatpakController(manager) +window_main = MainWindow(controller) +window_main.refresh() + +# tray_icon = SystemTrayIcon(window_main) +window_main.show() +# tray_icon.show() +sys.exit(app.exec_()) diff --git a/core/__init__.py b/core/__init__.py new file mode 100755 index 00000000..b794fd40 --- /dev/null +++ b/core/__init__.py @@ -0,0 +1 @@ +__version__ = '0.1.0' diff --git a/core/controller.py b/core/controller.py new file mode 100755 index 00000000..cf78c298 --- /dev/null +++ b/core/controller.py @@ -0,0 +1,22 @@ +from typing import List + +from core.model import FlatpakManager + + +class FlatpakController: + + def __init__(self, model: FlatpakManager): + self.model = model + + def refresh(self) -> List[dict]: + return self.model.read_installed() + + def update(self, package_refs: List[str]) -> List[dict]: + return self.model.update_packages(package_refs) + + def check_installed(self) -> bool: + version = self.model.get_version() + return False if version is None else version + + def get_version(self) -> str: + return self.model.get_version() diff --git a/core/flatpak.py b/core/flatpak.py new file mode 100755 index 00000000..bd11b39f --- /dev/null +++ b/core/flatpak.py @@ -0,0 +1,55 @@ +import subprocess +from typing import List + + +def _package_str_to_json(line: str, version: str) -> dict: + + app_array = line.split('\t') + + if version >= '1.4.0': + app = {'name': app_array[0], 'id': app_array[1], 'version': app_array[2], 'branch': app_array[3], 'runtime': 'runtime' in app_array[4]} + # TODO not working yet + + elif '1.0' <= version < '1.1': + app = {'ref': app_array[0], 'options': app_array[1]} + + ref_data = app['ref'].split('/') + app['id'] = ref_data[0] + app['arch'] = ref_data[1] + app['branch'] = ref_data[2] + app['name'] = ref_data[0].split('.')[-1] + app['runtime'] = 'runtime' in app['options'] + else: + raise Exception('Unsupported version') + + return app + + +def _run_cmd(cmd: str) -> str: + res = subprocess.run(cmd, shell=True, stdout=subprocess.PIPE) + return res.stdout.decode() if res.returncode == 0 else None + + +def get_version(): + res = _run_cmd('flatpak --version') + return res.split(' ')[1].strip() if res else None + + +def list_installed() -> List[str]: + apps_str = _run_cmd('flatpak list') + + if apps_str: + version = get_version() + app_lines = apps_str.split('\n') + return [_package_str_to_json(line, version) for line in app_lines if line] + + return None + + +def check_update(ref: dict) -> bool: + res = _run_cmd('flatpak update ' + ref) + return 'Updating in system' in res + + +def update(ref: str): + return bool(_run_cmd('flatpak update -y ' + ref)) diff --git a/core/model.py b/core/model.py new file mode 100755 index 00000000..82b704cc --- /dev/null +++ b/core/model.py @@ -0,0 +1,75 @@ +from typing import List + +import requests +from PyQt5.QtCore import QUrl +from PyQt5.QtGui import QPixmap, QIcon +from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkRequest + +from core import flatpak + +__FLATHUB_URL__ = 'https://flathub.org' +__FLATHUB_API_URL__ = __FLATHUB_URL__ + '/api/v1' + + +class FlatpakManager: + + def __init__(self): + self.packages = [] + self.packages_db = {} + self.http_session = requests.Session() + self._load_database() + + def _load_database(self): + + res = self.http_session.get(__FLATHUB_API_URL__ + '/apps') + + if res.status_code == 200: + for app in res.json(): + self.packages_db[app['flatpakAppId']] = app + + def get_version(self): + return flatpak.get_version() + + def read_installed(self) -> List[dict]: + + packages = flatpak.list_installed() + + if packages: + packages.sort(key=lambda p: p['name'].lower()) + + for pak in packages: + + if self.packages_db: + package_data = self.packages_db.get(pak['id'], None) + else: + package_data = None + + if not package_data: + pak['latest_release'] = None + pak['icon'] = None + else: + pak['latest_release'] = package_data['currentReleaseVersion'] + pak['icon'] = __FLATHUB_URL__ + package_data['iconDesktopUrl'] + + pak['update'] = flatpak.check_update(pak['ref']) + + self.packages = packages + return [*self.packages] + + def update_packages(self, refs: List[str]) -> List[dict]: + + if self.packages: + + for ref in refs: + package_found = [pak for pak in self.packages if pak['ref'] == ref] + + if package_found: + package_found = package_found[0] + updated = flatpak.update(ref) + + if updated: + package_found['update'] = not updated + + return [*self.packages] + + return [] diff --git a/core/resource.py b/core/resource.py new file mode 100755 index 00000000..b8927afb --- /dev/null +++ b/core/resource.py @@ -0,0 +1,6 @@ +import os + + +def get_path(resource_path): + cur_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + return cur_dir + '/resources/' + resource_path diff --git a/core/updater.py b/core/updater.py new file mode 100755 index 00000000..e6736b52 --- /dev/null +++ b/core/updater.py @@ -0,0 +1,16 @@ +from model import flatpak + + +def list_packages(): + packages = flatpak.list_installed() + + if packages: + packages = [pak for pak in packages if not pak['runtime']] + + if packages: + packages.sort(key=lambda p: p['name'].lower()) + + for pak in packages: + pak['update'] = flatpak.check_update(pak['ref']) + + return packages diff --git a/requirements.txt b/requirements.txt new file mode 100755 index 00000000..1519cd20 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +pyqt5 +requests \ No newline at end of file diff --git a/resources/img/flathub_logo.svg b/resources/img/flathub_logo.svg new file mode 100755 index 00000000..bbd82f4b --- /dev/null +++ b/resources/img/flathub_logo.svg @@ -0,0 +1,179 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/img/refresh.svg b/resources/img/refresh.svg new file mode 100755 index 00000000..8b784986 --- /dev/null +++ b/resources/img/refresh.svg @@ -0,0 +1,125 @@ + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/resources/img/refresh_orange.svg b/resources/img/refresh_orange.svg new file mode 100755 index 00000000..61757101 --- /dev/null +++ b/resources/img/refresh_orange.svg @@ -0,0 +1,129 @@ + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/resources/img/toggle_off.svg b/resources/img/toggle_off.svg new file mode 100755 index 00000000..f50edee5 --- /dev/null +++ b/resources/img/toggle_off.svg @@ -0,0 +1,69 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + diff --git a/resources/img/toggle_on.svg b/resources/img/toggle_on.svg new file mode 100755 index 00000000..5c341548 --- /dev/null +++ b/resources/img/toggle_on.svg @@ -0,0 +1,69 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + diff --git a/resources/img/update.svg b/resources/img/update.svg new file mode 100755 index 00000000..3dfbb01c --- /dev/null +++ b/resources/img/update.svg @@ -0,0 +1,69 @@ + + + + + + image/svg+xml + + + + + + + + + + + + diff --git a/resources/img/update_green.svg b/resources/img/update_green.svg new file mode 100755 index 00000000..85063166 --- /dev/null +++ b/resources/img/update_green.svg @@ -0,0 +1,72 @@ + + + + + + image/svg+xml + + + + + + + + + + + + diff --git a/view/__init__.py b/view/__init__.py new file mode 100755 index 00000000..e69de29b diff --git a/view/qt/__init__.py b/view/qt/__init__.py new file mode 100755 index 00000000..e69de29b diff --git a/view/qt/systray.py b/view/qt/systray.py new file mode 100755 index 00000000..4ec445ca --- /dev/null +++ b/view/qt/systray.py @@ -0,0 +1,29 @@ +import sys + +from PyQt5.QtGui import QIcon +from PyQt5.QtWidgets import QSystemTrayIcon, QMenu, QApplication, QWidget + +from core import resource + + +# to update +#apps = flatpak.list_installed() +#print(apps) + + +class SystemTrayIcon(QSystemTrayIcon): + + def __init__(self, parent=None): + QSystemTrayIcon.__init__(self, QIcon(resource.get_path('img/flathub_logo.svg')), parent) + self.menu = QMenu(parent) + exitAction = self.menu.addAction("Exit") + self.setContextMenu(self.menu) + + +if __name__ == '__main__': + app = QApplication(sys.argv) + w = QWidget() + trayIcon = SystemTrayIcon(w) + trayIcon.show() + + sys.exit(app.exec_()) diff --git a/view/qt/window.py b/view/qt/window.py new file mode 100755 index 00000000..04776e4e --- /dev/null +++ b/view/qt/window.py @@ -0,0 +1,316 @@ +import operator +from functools import reduce + +from PyQt5 import QtCore +from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkRequest + +from core import resource, __version__ +from typing import List + +from PyQt5.QtCore import Qt, QThread, pyqtSignal, QUrl +from PyQt5.QtGui import QIcon, QColor, QPixmap +from PyQt5.QtWidgets import QWidget, QVBoxLayout, QApplication, QTableWidget, \ + QTableWidgetItem, QTableView, QCheckBox, QHeaderView, QToolButton, QToolBar, \ + QSizePolicy, QLabel, QMessageBox + +from core.controller import FlatpakController + + +class UpdateToggleButton(QToolButton): + + def __init__(self, model: dict, root: QWidget, checked: bool = True): + super(UpdateToggleButton, self).__init__() + self.model = model + self.root = root + self.setCheckable(True) + self.clicked.connect(self.change_state) + self.icon_on = QIcon(resource.get_path('img/toggle_on.svg')) + self.icon_off = QIcon(resource.get_path('img/toggle_off.svg')) + self.setIcon(self.icon_on) + self.setStyleSheet('border: 0px;') + + if not checked: + self.click() + + def change_state(self, not_checked: bool): + self.model['update_checked'] = not not_checked + self.setIcon(self.icon_on if not not_checked else self.icon_off) + self.root.change_update_button_state() + + +class MainWindow(QWidget): + + __COLUMNS__ = ['Package', 'Branch', 'Arch', 'Ref', 'Latest Release', 'Update ?'] + __BASE_HEIGHT__ = 400 + + def __init__(self, controller: FlatpakController): + super(MainWindow, self).__init__() + self.controller = controller + self.icon_cache = {} + + self.network_man = QNetworkAccessManager() + self.network_man.finished.connect(self._load_icon) + + self.icon_flathub = QIcon(resource.get_path('img/flathub_logo.svg')) + self._check_flatpak_installed() + self.resize(MainWindow.__BASE_HEIGHT__, MainWindow.__BASE_HEIGHT__) + self.setWindowTitle('flatman ({})'.format(__version__)) + self.setWindowIcon(self.icon_flathub) + + self.layout = QVBoxLayout() + self.setLayout(self.layout) + + self.checkbox_only_apps = QCheckBox() + self.checkbox_only_apps.setText('Only apps') + self.checkbox_only_apps.setChecked(True) + self.checkbox_only_apps.stateChanged.connect(self.filter_only_apps) + + toolbar = QToolBar() + toolbar.addWidget(self.checkbox_only_apps) + + spacer_1 = QWidget() + spacer_1.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + toolbar.addWidget(spacer_1) + + self.label_status = QLabel() + self.label_status.setText('') + toolbar.addWidget(self.label_status) + + spacer_2 = QWidget() + spacer_2.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + toolbar.addWidget(spacer_2) + + self.bt_refresh = QToolButton() + self.bt_refresh.setIcon(QIcon(resource.get_path('img/refresh_orange.svg'))) + self.bt_refresh.clicked.connect(lambda: self.refresh(threaded=True)) + toolbar.addWidget(self.bt_refresh) + + self.bt_update = QToolButton() + self.bt_update.setIcon(QIcon(resource.get_path('img/update_green.svg'))) + self.bt_update.setEnabled(False) + self.bt_update.clicked.connect(self.update_selected) + toolbar.addWidget(self.bt_update) + + self.layout.addWidget(toolbar) + + self.table_apps = QTableWidget() + self.table_apps.setColumnCount(len(MainWindow.__COLUMNS__)) + self.table_apps.setFocusPolicy(Qt.NoFocus) + self.table_apps.setShowGrid(False) + self.table_apps.verticalHeader().setVisible(False) + self.table_apps.setSelectionBehavior(QTableView.SelectRows) + self.table_apps.setHorizontalHeaderLabels(MainWindow.__COLUMNS__) + self.table_apps.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + + header_horizontal = self.table_apps.horizontalHeader() + for i in range(0, len(MainWindow.__COLUMNS__)): + header_horizontal.setSectionResizeMode(i, QHeaderView.ResizeToContents) + + self.layout.addWidget(self.table_apps) + + self.apps = [] + self.centralize() + + self.update_thread = UpdateSelectedPackages(self.controller) + self.update_thread.signal.connect(self._finish_update_selected) + + self.refresh_thread = RefreshPackages(self.controller) + self.refresh_thread.signal.connect(self._finish_refresh) + + self.layout.addWidget(QLabel('flatpak: ' + self.controller.get_version()), alignment=Qt.AlignRight) + + def _load_icon(self, http_response): + icon_url = http_response.url().toString() + pixmap = QPixmap() + pixmap.loadFromData(http_response.readAll()) + icon = QIcon(pixmap) + self.icon_cache[icon_url] = icon + + for idx, app in enumerate(self.apps): + if app['model']['icon'] == icon_url: + self.table_apps.item(idx, 0).setIcon(icon) + self.resize_and_center() + break + + def _check_flatpak_installed(self): + + if not self.controller.check_installed(): + error_msg = QMessageBox() + error_msg.setIcon(QMessageBox.Critical) + error_msg.setText('flatpak seems not to be installed. Exiting...') + error_msg.setWindowTitle('Error') + error_msg.setWindowIcon(self.icon_flathub) + error_msg.exec_() + exit(1) + + def refresh(self, threaded: bool = False): + + self._check_flatpak_installed() + + self._begin_action('Refreshing...') + + if threaded: + self.refresh_thread.start() + else: + apps = self.controller.refresh() + self.update_packages(apps) + self.finish_action() + + def _finish_refresh(self): + self.update_packages(self.refresh_thread.apps) + self.finish_action() + + def filter_only_apps(self, only_apps: int): + + if self.apps: + show_only_apps = True if only_apps == 2 else False + + for idx, app in enumerate(self.apps): + hidden = show_only_apps and app['model']['runtime'] + self.table_apps.setRowHidden(idx, hidden) + app['visible'] = not hidden + + def change_update_button_state(self): + + enable_bt_update = False + + for app in self.apps: + if app['visible'] and app['update_checked']: + enable_bt_update = True + break + + self.bt_update.setEnabled(enable_bt_update) + + def centralize(self): + geo = self.frameGeometry() + screen = QApplication.desktop().screenNumber(QApplication.desktop().cursor().pos()) + center_point = QApplication.desktop().screenGeometry(screen).center() + geo.moveCenter(center_point) + self.move(geo.topLeft()) + + def update_packages(self, apps: List[dict]): + self._check_flatpak_installed() + + self.table_apps.setEnabled(True) + self.apps = [] + + self.table_apps.setRowCount(len(apps) if apps else 0) + + if apps: + for idx, app in enumerate(apps): + + col_name = QTableWidgetItem() + col_name.setText(app['name']) + col_name.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) + + if not app['icon']: + col_name.setIcon(self.icon_flathub) + else: + cached_icon = self.icon_cache.get(app['icon']) + + if cached_icon: + col_name.setIcon(cached_icon) + else: + col_name.setIcon(self.icon_flathub) + self.network_man.get(QNetworkRequest(QUrl(app['icon']))) + + self.table_apps.setItem(idx, 0, col_name) + + col_version = QTableWidgetItem() + col_version.setText(app['branch']) + col_version.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) + self.table_apps.setItem(idx, 1, col_version) + + col_arch = QTableWidgetItem() + col_arch.setText(app['arch']) + col_arch.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) + self.table_apps.setItem(idx, 2, col_arch) + + col_package = QTableWidgetItem() + col_package.setText(app['ref']) + col_package.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) + self.table_apps.setItem(idx, 3, col_package) + + col_release = QTableWidgetItem() + col_release.setText(app['latest_release']) + col_release.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) + self.table_apps.setItem(idx, 4, col_release) + + if app['update']: + col_release.setForeground(QColor('yellow')) + + app_model = {'model': app, 'update_checked': app['update'], 'visible': True} + + if app['update']: + col_update = UpdateToggleButton(app_model, self, app['update']) + self.table_apps.setCellWidget(idx, 5, col_update) + else: + self.table_apps.setCellWidget(idx, 5, None) + + self.apps.append(app_model) + + self.change_update_button_state() + self.filter_only_apps(2 if self.checkbox_only_apps.isChecked() else 0) + self.resize_and_center() + + def resize_and_center(self): + new_width = reduce(operator.add, [self.table_apps.columnWidth(i) for i in range(len(MainWindow.__COLUMNS__))]) * 1.03 + self.resize(new_width, self.height()) + self.centralize() + + def update_selected(self): + if self.apps: + to_update = [pak['model']['ref'] for pak in self.apps if pak['visible'] and pak['update_checked']] + + if to_update: + self._begin_action('Updating...') + self.update_thread.refs_to_update = to_update + self.update_thread.start() + + def _finish_update_selected(self): + self.update_packages(self.update_thread.updated_apps) + self.finish_action() + + def _begin_action(self, action_label: str): + self.label_status.setText(action_label) + self.bt_update.setEnabled(False) + self.bt_refresh.setEnabled(False) + self.checkbox_only_apps.setEnabled(False) + self.table_apps.setEnabled(False) + + def finish_action(self): + self.bt_refresh.setEnabled(True) + self.checkbox_only_apps.setEnabled(True) + self.table_apps.setEnabled(True) + self.label_status.setText('') + + +# Threaded actions + +class UpdateSelectedPackages(QThread): + + signal = pyqtSignal() + + def __init__(self, controller: FlatpakController): + super(UpdateSelectedPackages, self).__init__() + self.controller = controller + self.refs_to_update = [] + self.updated_apps = None + + def run(self): + self.updated_apps = self.controller.update(self.refs_to_update) + self.signal.emit() + + +class RefreshPackages(QThread): + + signal = pyqtSignal() + + def __init__(self, controller: FlatpakController): + super(RefreshPackages, self).__init__() + self.controller = controller + self.apps = None + + def run(self): + self.apps = self.controller.refresh() + self.signal.emit() From 7b03ccba2eb18eebc2f7b14f61bb54c89f341901 Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Wed, 12 Jun 2019 18:13:37 -0300 Subject: [PATCH 002/123] supporting flatpak 1.4 --- core/flatpak.py | 39 +++++++++++++++++++++++++++++++-------- core/model.py | 17 +++++++++++------ view/qt/window.py | 35 +++++++++++++++++++++++------------ 3 files changed, 65 insertions(+), 26 deletions(-) diff --git a/core/flatpak.py b/core/flatpak.py index bd11b39f..c1d1d882 100755 --- a/core/flatpak.py +++ b/core/flatpak.py @@ -1,3 +1,4 @@ +import re import subprocess from typing import List @@ -7,9 +8,10 @@ def _package_str_to_json(line: str, version: str) -> dict: app_array = line.split('\t') if version >= '1.4.0': - app = {'name': app_array[0], 'id': app_array[1], 'version': app_array[2], 'branch': app_array[3], 'runtime': 'runtime' in app_array[4]} - # TODO not working yet - + app = {'name': app_array[0], + 'id': app_array[1], + 'version': app_array[2], + 'branch': app_array[3]} elif '1.0' <= version < '1.1': app = {'ref': app_array[0], 'options': app_array[1]} @@ -18,16 +20,29 @@ def _package_str_to_json(line: str, version: str) -> dict: app['arch'] = ref_data[1] app['branch'] = ref_data[2] app['name'] = ref_data[0].split('.')[-1] - app['runtime'] = 'runtime' in app['options'] + app['version'] = None else: raise Exception('Unsupported version') + info = re.findall('\w+:\s.+', get_info(app['id'])) + fields_to_get = ['origin', 'arch', 'ref'] + + for field in info: + field_val = field.split(':') + field_name = field_val[0].lower() + + if field_name in fields_to_get: + app[field_name] = field_val[1].strip() + + if field_name == 'ref': + app['runtime'] = app['ref'].startswith('runtime/') + return app -def _run_cmd(cmd: str) -> str: +def _run_cmd(cmd: str, expected_code: int = 0) -> str: res = subprocess.run(cmd, shell=True, stdout=subprocess.PIPE) - return res.stdout.decode() if res.returncode == 0 else None + return res.stdout.decode() if res.returncode == expected_code else None def get_version(): @@ -35,6 +50,10 @@ def get_version(): return res.split(' ')[1].strip() if res else None +def get_info(app_id: str): + return _run_cmd('flatpak info ' + app_id) + + def list_installed() -> List[str]: apps_str = _run_cmd('flatpak list') @@ -46,10 +65,14 @@ def list_installed() -> List[str]: return None -def check_update(ref: dict) -> bool: - res = _run_cmd('flatpak update ' + ref) +def check_update(pak_id: dict) -> bool: + res = _run_cmd('flatpak update ' + pak_id) return 'Updating in system' in res def update(ref: str): return bool(_run_cmd('flatpak update -y ' + ref)) + + +def list_updates(): + return _run_cmd('flatpak update', expected_code=1) diff --git a/core/model.py b/core/model.py index 82b704cc..de67285a 100755 --- a/core/model.py +++ b/core/model.py @@ -37,21 +37,26 @@ class FlatpakManager: if packages: packages.sort(key=lambda p: p['name'].lower()) + available_updates = flatpak.list_updates() + for pak in packages: if self.packages_db: - package_data = self.packages_db.get(pak['id'], None) + pak_data = self.packages_db.get(pak['id'], None) else: - package_data = None + pak_data = None - if not package_data: + if not pak_data: pak['latest_release'] = None pak['icon'] = None else: - pak['latest_release'] = package_data['currentReleaseVersion'] - pak['icon'] = __FLATHUB_URL__ + package_data['iconDesktopUrl'] + pak['latest_release'] = pak_data['currentReleaseVersion'] + pak['icon'] = pak_data['iconDesktopUrl'] - pak['update'] = flatpak.check_update(pak['ref']) + if pak['icon'].startswith('/'): + pak['icon'] = __FLATHUB_URL__ + pak['icon'] + + pak['update'] = pak['id'] in available_updates self.packages = packages return [*self.packages] diff --git a/view/qt/window.py b/view/qt/window.py index 04776e4e..10ee7235 100755 --- a/view/qt/window.py +++ b/view/qt/window.py @@ -40,7 +40,7 @@ class UpdateToggleButton(QToolButton): class MainWindow(QWidget): - __COLUMNS__ = ['Package', 'Branch', 'Arch', 'Ref', 'Latest Release', 'Update ?'] + __COLUMNS__ = ['Package', 'Version', 'Branch', 'Arch', 'Ref', 'Latest Release', 'Origin', 'Update ?'] __BASE_HEIGHT__ = 400 def __init__(self, controller: FlatpakController): @@ -170,6 +170,8 @@ class MainWindow(QWidget): self.table_apps.setRowHidden(idx, hidden) app['visible'] = not hidden + self.change_update_button_state() + def change_update_button_state(self): enable_bt_update = False @@ -217,35 +219,44 @@ class MainWindow(QWidget): self.table_apps.setItem(idx, 0, col_name) col_version = QTableWidgetItem() - col_version.setText(app['branch']) + col_version.setText(app['version']) col_version.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) self.table_apps.setItem(idx, 1, col_version) + col_branch = QTableWidgetItem() + col_branch.setText(app['branch']) + col_branch.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) + self.table_apps.setItem(idx, 2, col_branch) + col_arch = QTableWidgetItem() col_arch.setText(app['arch']) col_arch.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) - self.table_apps.setItem(idx, 2, col_arch) + self.table_apps.setItem(idx, 3, col_arch) col_package = QTableWidgetItem() col_package.setText(app['ref']) col_package.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) - self.table_apps.setItem(idx, 3, col_package) + self.table_apps.setItem(idx, 4, col_package) col_release = QTableWidgetItem() col_release.setText(app['latest_release']) col_release.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) - self.table_apps.setItem(idx, 4, col_release) + self.table_apps.setItem(idx, 5, col_release) if app['update']: col_release.setForeground(QColor('yellow')) - app_model = {'model': app, 'update_checked': app['update'], 'visible': True} + col_origin = QTableWidgetItem() + col_origin.setText(app['origin']) + col_origin.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) + self.table_apps.setItem(idx, 6, col_origin) - if app['update']: - col_update = UpdateToggleButton(app_model, self, app['update']) - self.table_apps.setCellWidget(idx, 5, col_update) - else: - self.table_apps.setCellWidget(idx, 5, None) + app_model = {'model': app, + 'update_checked': app['update'], + 'visible': not app['runtime'] or not self.checkbox_only_apps.isChecked()} + + col_update = UpdateToggleButton(app_model, self, app['update']) if app['update'] else None + self.table_apps.setCellWidget(idx, 7, col_update) self.apps.append(app_model) @@ -254,7 +265,7 @@ class MainWindow(QWidget): self.resize_and_center() def resize_and_center(self): - new_width = reduce(operator.add, [self.table_apps.columnWidth(i) for i in range(len(MainWindow.__COLUMNS__))]) * 1.03 + new_width = reduce(operator.add, [self.table_apps.columnWidth(i) for i in range(len(MainWindow.__COLUMNS__))]) * 1.05 self.resize(new_width, self.height()) self.centralize() From c0193a66bf9a8726bcd2000e4bc6aee1dc0d7835 Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Wed, 12 Jun 2019 18:35:07 -0300 Subject: [PATCH 003/123] Fix to work with flatpak 1.0 --- core/flatpak.py | 6 +++--- core/model.py | 4 ++-- view/qt/window.py | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/core/flatpak.py b/core/flatpak.py index c1d1d882..d50acbea 100755 --- a/core/flatpak.py +++ b/core/flatpak.py @@ -40,9 +40,9 @@ def _package_str_to_json(line: str, version: str) -> dict: return app -def _run_cmd(cmd: str, expected_code: int = 0) -> str: +def _run_cmd(cmd: str, expected_code: int = 0, ignore_return_code: bool = False) -> str: res = subprocess.run(cmd, shell=True, stdout=subprocess.PIPE) - return res.stdout.decode() if res.returncode == expected_code else None + return res.stdout.decode() if ignore_return_code or res.returncode == expected_code else None def get_version(): @@ -75,4 +75,4 @@ def update(ref: str): def list_updates(): - return _run_cmd('flatpak update', expected_code=1) + return _run_cmd('flatpak update', ignore_return_code=True) diff --git a/core/model.py b/core/model.py index de67285a..71747001 100755 --- a/core/model.py +++ b/core/model.py @@ -47,10 +47,10 @@ class FlatpakManager: pak_data = None if not pak_data: - pak['latest_release'] = None + pak['latest_version'] = None pak['icon'] = None else: - pak['latest_release'] = pak_data['currentReleaseVersion'] + pak['latest_version'] = pak_data['currentReleaseVersion'] pak['icon'] = pak_data['iconDesktopUrl'] if pak['icon'].startswith('/'): diff --git a/view/qt/window.py b/view/qt/window.py index 10ee7235..55959838 100755 --- a/view/qt/window.py +++ b/view/qt/window.py @@ -40,7 +40,7 @@ class UpdateToggleButton(QToolButton): class MainWindow(QWidget): - __COLUMNS__ = ['Package', 'Version', 'Branch', 'Arch', 'Ref', 'Latest Release', 'Origin', 'Update ?'] + __COLUMNS__ = ['Package', 'Version', 'Branch', 'Arch', 'Ref', 'Latest Version', 'Origin', 'Update ?'] __BASE_HEIGHT__ = 400 def __init__(self, controller: FlatpakController): @@ -239,7 +239,7 @@ class MainWindow(QWidget): self.table_apps.setItem(idx, 4, col_package) col_release = QTableWidgetItem() - col_release.setText(app['latest_release']) + col_release.setText(app['latest_version']) col_release.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) self.table_apps.setItem(idx, 5, col_release) From 6d58041f0a969b05bc3c7cac68f538344d62731f Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Thu, 13 Jun 2019 11:50:29 -0300 Subject: [PATCH 004/123] TrayIcon integrated with ManageWindow --- app.py | 17 ++++ app_qt.py | 20 ---- resources/img/flathub_logo.svg | 10 +- resources/img/update_logo.svg | 179 +++++++++++++++++++++++++++++++++ view/qt/systray.py | 73 +++++++++++--- view/qt/window.py | 135 ++++++++++++++++--------- 6 files changed, 351 insertions(+), 83 deletions(-) create mode 100644 app.py delete mode 100755 app_qt.py create mode 100644 resources/img/update_logo.svg diff --git a/app.py b/app.py new file mode 100644 index 00000000..0f0b6820 --- /dev/null +++ b/app.py @@ -0,0 +1,17 @@ +#!env/bin/python +import sys + +from PyQt5.QtWidgets import QApplication, QWidget + +from core.controller import FlatpakController +from core.model import FlatpakManager +from view.qt.systray import TrayIcon + +app = QApplication(sys.argv) +manager = FlatpakManager() +controller = FlatpakController(manager) +hidden_widget = QWidget() +trayIcon = TrayIcon(parent=hidden_widget, controller=controller, check_interval=30) +trayIcon.show() + +sys.exit(app.exec_()) diff --git a/app_qt.py b/app_qt.py deleted file mode 100755 index ab4389d7..00000000 --- a/app_qt.py +++ /dev/null @@ -1,20 +0,0 @@ -#!env/bin/python -import sys - -from PyQt5.QtWidgets import QApplication - -from core.controller import FlatpakController -from core.model import FlatpakManager -from view.qt.window import MainWindow - -app = QApplication(sys.argv) -app.setStyle('Fusion') -manager = FlatpakManager() -controller = FlatpakController(manager) -window_main = MainWindow(controller) -window_main.refresh() - -# tray_icon = SystemTrayIcon(window_main) -window_main.show() -# tray_icon.show() -sys.exit(app.exec_()) diff --git a/resources/img/flathub_logo.svg b/resources/img/flathub_logo.svg index bbd82f4b..2c72e02e 100755 --- a/resources/img/flathub_logo.svg +++ b/resources/img/flathub_logo.svg @@ -14,7 +14,7 @@ viewBox="0 0 11.495958 11.042967" version="1.1" id="svg3871" - inkscape:version="0.92.3 (2405546, 2018-03-11)" + inkscape:version="0.92.4 5da689c313, 2019-01-14" sodipodi:docname="flathub_logo.svg"> @@ -27,15 +27,15 @@ inkscape:pageshadow="2" inkscape:zoom="0.35" inkscape:cx="354.2246" - inkscape:cy="117.98097" + inkscape:cy="117.98098" inkscape:document-units="px" inkscape:current-layer="layer1" showgrid="false" showborder="false" - inkscape:window-width="1366" - inkscape:window-height="739" + inkscape:window-width="1360" + inkscape:window-height="707" inkscape:window-x="0" - inkscape:window-y="260" + inkscape:window-y="0" inkscape:window-maximized="1" fit-margin-bottom="-0.2" fit-margin-top="0" diff --git a/resources/img/update_logo.svg b/resources/img/update_logo.svg new file mode 100644 index 00000000..fdfd0c3a --- /dev/null +++ b/resources/img/update_logo.svg @@ -0,0 +1,179 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/view/qt/systray.py b/view/qt/systray.py index 4ec445ca..739b6bbf 100755 --- a/view/qt/systray.py +++ b/view/qt/systray.py @@ -1,29 +1,76 @@ import sys +import time +from PyQt5.QtCore import QThread, pyqtSignal, QCoreApplication from PyQt5.QtGui import QIcon -from PyQt5.QtWidgets import QSystemTrayIcon, QMenu, QApplication, QWidget +from PyQt5.QtWidgets import QSystemTrayIcon, QMenu from core import resource # to update -#apps = flatpak.list_installed() -#print(apps) +from core.controller import FlatpakController +from view.qt.window import ManageWindow -class SystemTrayIcon(QSystemTrayIcon): +class UpdateCheck(QThread): - def __init__(self, parent=None): - QSystemTrayIcon.__init__(self, QIcon(resource.get_path('img/flathub_logo.svg')), parent) + signal = pyqtSignal(int) + + def __init__(self, check_interval: int, controller: FlatpakController, parent=None): + super(UpdateCheck, self).__init__(parent) + self.controller = controller + self.check_interval = check_interval + + def run(self): + + while True: + + apps = self.controller.refresh() + + updates = len([app for app in apps if app['update']]) + + self.signal.emit(updates) + + time.sleep(self.check_interval) + + +class TrayIcon(QSystemTrayIcon): + + def __init__(self, controller: FlatpakController, check_interval: int = 60, parent=None): + self.icon_default = QIcon(resource.get_path('img/flathub_logo.svg')) + self.icon_update = QIcon(resource.get_path('img/update_logo.svg')) + QSystemTrayIcon.__init__(self, self.icon_default, parent) self.menu = QMenu(parent) - exitAction = self.menu.addAction("Exit") + + self.action_manage = self.menu.addAction("Manage applications") + self.action_manage.triggered.connect(self.show_manage_window) + + self.action_exit = self.menu.addAction("Exit") + self.action_exit.triggered.connect(lambda: QCoreApplication.exit()) self.setContextMenu(self.menu) + self.controller = controller + self.check_thread = UpdateCheck(check_interval=check_interval, controller=self.controller) + self.check_thread.signal.connect(self.notify_update) + self.check_thread.start() + self.manage_window = ManageWindow(controller=controller, tray_icon=self) + def notify_update(self, updates: int): + if updates > 0: + if self.icon() != self.icon_update: + self.setIcon(self.icon_update) + self.setToolTip('Updates found: {}'.format(updates)) -if __name__ == '__main__': - app = QApplication(sys.argv) - w = QWidget() - trayIcon = SystemTrayIcon(w) - trayIcon.show() + if self.manage_window: + self.manage_window.refresh() + else: + self.setIcon(self.icon_default) + self.setToolTip(None) - sys.exit(app.exec_()) + def show_manage_window(self): + + if not self.manage_window: + self.manage_window = ManageWindow(controller=self.controller) + + self.manage_window.refresh() + self.manage_window.show() diff --git a/view/qt/window.py b/view/qt/window.py index 55959838..9e2527bb 100755 --- a/view/qt/window.py +++ b/view/qt/window.py @@ -1,18 +1,16 @@ import operator from functools import reduce - -from PyQt5 import QtCore -from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkRequest - -from core import resource, __version__ +from threading import Lock from typing import List from PyQt5.QtCore import Qt, QThread, pyqtSignal, QUrl from PyQt5.QtGui import QIcon, QColor, QPixmap +from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkRequest from PyQt5.QtWidgets import QWidget, QVBoxLayout, QApplication, QTableWidget, \ QTableWidgetItem, QTableView, QCheckBox, QHeaderView, QToolButton, QToolBar, \ QSizePolicy, QLabel, QMessageBox +from core import resource, __version__ from core.controller import FlatpakController @@ -38,23 +36,24 @@ class UpdateToggleButton(QToolButton): self.root.change_update_button_state() -class MainWindow(QWidget): +class ManageWindow(QWidget): - __COLUMNS__ = ['Package', 'Version', 'Branch', 'Arch', 'Ref', 'Latest Version', 'Origin', 'Update ?'] + __COLUMNS__ = ['Package', 'Version', 'Latest Version', 'Branch', 'Arch', 'Ref', 'Origin', 'Update ?'] __BASE_HEIGHT__ = 400 - def __init__(self, controller: FlatpakController): - super(MainWindow, self).__init__() + def __init__(self, controller: FlatpakController, tray_icon = None): + super(ManageWindow, self).__init__() self.controller = controller self.icon_cache = {} + self.tray_icon = tray_icon self.network_man = QNetworkAccessManager() self.network_man.finished.connect(self._load_icon) self.icon_flathub = QIcon(resource.get_path('img/flathub_logo.svg')) self._check_flatpak_installed() - self.resize(MainWindow.__BASE_HEIGHT__, MainWindow.__BASE_HEIGHT__) - self.setWindowTitle('flatman ({})'.format(__version__)) + self.resize(ManageWindow.__BASE_HEIGHT__, ManageWindow.__BASE_HEIGHT__) + self.setWindowTitle('fpakman ({})'.format(__version__)) self.setWindowIcon(self.icon_flathub) self.layout = QVBoxLayout() @@ -94,17 +93,17 @@ class MainWindow(QWidget): self.layout.addWidget(toolbar) self.table_apps = QTableWidget() - self.table_apps.setColumnCount(len(MainWindow.__COLUMNS__)) + self.table_apps.setColumnCount(len(ManageWindow.__COLUMNS__)) self.table_apps.setFocusPolicy(Qt.NoFocus) self.table_apps.setShowGrid(False) self.table_apps.verticalHeader().setVisible(False) self.table_apps.setSelectionBehavior(QTableView.SelectRows) - self.table_apps.setHorizontalHeaderLabels(MainWindow.__COLUMNS__) + self.table_apps.setHorizontalHeaderLabels(ManageWindow.__COLUMNS__) self.table_apps.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) header_horizontal = self.table_apps.horizontalHeader() - for i in range(0, len(MainWindow.__COLUMNS__)): - header_horizontal.setSectionResizeMode(i, QHeaderView.ResizeToContents) + for i in range(0, len(ManageWindow.__COLUMNS__)): + header_horizontal.setSectionResizeMode(i, QHeaderView.Stretch + QHeaderView.AdjustToContents) self.layout.addWidget(self.table_apps) @@ -117,7 +116,24 @@ class MainWindow(QWidget): self.refresh_thread = RefreshPackages(self.controller) self.refresh_thread.signal.connect(self._finish_refresh) - self.layout.addWidget(QLabel('flatpak: ' + self.controller.get_version()), alignment=Qt.AlignRight) + self.toolbar_bottom = QToolBar() + self.label_updates = QLabel('') + self.toolbar_bottom.addWidget(self.label_updates) + spacer = QWidget() + spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + self.toolbar_bottom.addWidget(spacer) + + self.toolbar_bottom.addWidget(QLabel('flatpak: ' + self.controller.get_version())) + self.layout.addWidget(self.toolbar_bottom) + + self.thread_lock = Lock() + self.working = False # restrict the number of threaded actions + + def closeEvent(self, event): + + if self.tray_icon: + event.ignore() + self.hide() def _load_icon(self, http_response): icon_url = http_response.url().toString() @@ -143,22 +159,37 @@ class MainWindow(QWidget): error_msg.exec_() exit(1) - def refresh(self, threaded: bool = False): + def _acquire_lock(self): - self._check_flatpak_installed() + self.thread_lock.acquire() - self._begin_action('Refreshing...') + if not self.working: + self.working = True - if threaded: + self.thread_lock.release() + return self.working + + def _release_lock(self): + + self.thread_lock.acquire() + + if self.working: + self.working = False + + self.thread_lock.release() + + def refresh(self): + + if self._acquire_lock(): + self._check_flatpak_installed() + self._begin_action('Refreshing...') self.refresh_thread.start() - else: - apps = self.controller.refresh() - self.update_packages(apps) - self.finish_action() def _finish_refresh(self): + self.update_packages(self.refresh_thread.apps) self.finish_action() + self._release_lock() def filter_only_apps(self, only_apps: int): @@ -170,12 +201,19 @@ class MainWindow(QWidget): self.table_apps.setRowHidden(idx, hidden) app['visible'] = not hidden - self.change_update_button_state() + self.change_update_state() - def change_update_button_state(self): + def change_update_state(self): enable_bt_update = False + updates = len([app for app in self.apps if app['model']['update']]) + + if updates > 0: + self.label_updates.setText('Updates: {}'.format(updates)) + else: + self.label_updates.setText('') + for app in self.apps: if app['visible'] and app['update_checked']: enable_bt_update = True @@ -223,28 +261,28 @@ class MainWindow(QWidget): col_version.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) self.table_apps.setItem(idx, 1, col_version) + col_release = QTableWidgetItem() + col_release.setText(app['latest_version']) + col_release.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) + self.table_apps.setItem(idx, 2, col_release) + + if app['update']: + col_release.setForeground(QColor('orange')) + col_branch = QTableWidgetItem() col_branch.setText(app['branch']) col_branch.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) - self.table_apps.setItem(idx, 2, col_branch) + self.table_apps.setItem(idx, 3, col_branch) col_arch = QTableWidgetItem() col_arch.setText(app['arch']) col_arch.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) - self.table_apps.setItem(idx, 3, col_arch) + self.table_apps.setItem(idx, 4, col_arch) col_package = QTableWidgetItem() col_package.setText(app['ref']) col_package.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) - self.table_apps.setItem(idx, 4, col_package) - - col_release = QTableWidgetItem() - col_release.setText(app['latest_version']) - col_release.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) - self.table_apps.setItem(idx, 5, col_release) - - if app['update']: - col_release.setForeground(QColor('yellow')) + self.table_apps.setItem(idx, 5, col_package) col_origin = QTableWidgetItem() col_origin.setText(app['origin']) @@ -260,28 +298,35 @@ class MainWindow(QWidget): self.apps.append(app_model) - self.change_update_button_state() + self.change_update_state() self.filter_only_apps(2 if self.checkbox_only_apps.isChecked() else 0) self.resize_and_center() def resize_and_center(self): - new_width = reduce(operator.add, [self.table_apps.columnWidth(i) for i in range(len(MainWindow.__COLUMNS__))]) * 1.05 + new_width = reduce(operator.add, [self.table_apps.columnWidth(i) for i in range(len(ManageWindow.__COLUMNS__))]) * 1.05 self.resize(new_width, self.height()) self.centralize() def update_selected(self): - if self.apps: - to_update = [pak['model']['ref'] for pak in self.apps if pak['visible'] and pak['update_checked']] - if to_update: - self._begin_action('Updating...') - self.update_thread.refs_to_update = to_update - self.update_thread.start() + if self._acquire_lock(): + if self.apps: + to_update = [pak['model']['ref'] for pak in self.apps if pak['visible'] and pak['update_checked']] + + if to_update: + self._begin_action('Updating...') + self.update_thread.refs_to_update = to_update + self.update_thread.start() def _finish_update_selected(self): self.update_packages(self.update_thread.updated_apps) self.finish_action() + if self.tray_icon and self.update_thread.updated_apps: + self.tray_icon.notify_update(len([app for app in self.update_thread.updated_apps if app['update']])) + + self._release_lock() + def _begin_action(self, action_label: str): self.label_status.setText(action_label) self.bt_update.setEnabled(False) From bf1175626add2ca453cb7816c01b61d8d138168c Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Thu, 13 Jun 2019 12:14:58 -0300 Subject: [PATCH 005/123] Refactoring --- core/controller.py | 2 +- core/flatpak.py | 17 +- core/model.py | 50 ++--- core/updater.py | 16 -- resources/img/flathub_25.svg | 179 ++++++++++++++++++ .../img/{flathub_logo.svg => flathub_45.svg} | 0 view/qt/systray.py | 20 +- view/qt/window.py | 67 ++++--- 8 files changed, 258 insertions(+), 93 deletions(-) delete mode 100755 core/updater.py create mode 100644 resources/img/flathub_25.svg rename resources/img/{flathub_logo.svg => flathub_45.svg} (100%) diff --git a/core/controller.py b/core/controller.py index cf78c298..fe028d80 100755 --- a/core/controller.py +++ b/core/controller.py @@ -12,7 +12,7 @@ class FlatpakController: return self.model.read_installed() def update(self, package_refs: List[str]) -> List[dict]: - return self.model.update_packages(package_refs) + return self.model.update_apps(package_refs) def check_installed(self) -> bool: version = self.model.get_version() diff --git a/core/flatpak.py b/core/flatpak.py index d50acbea..89b4a6f5 100755 --- a/core/flatpak.py +++ b/core/flatpak.py @@ -3,7 +3,7 @@ import subprocess from typing import List -def _package_str_to_json(line: str, version: str) -> dict: +def app_str_to_json(line: str, version: str) -> dict: app_array = line.split('\t') @@ -24,7 +24,7 @@ def _package_str_to_json(line: str, version: str) -> dict: else: raise Exception('Unsupported version') - info = re.findall('\w+:\s.+', get_info(app['id'])) + info = re.findall('\w+:\s.+', get_app_info(app['id'])) fields_to_get = ['origin', 'arch', 'ref'] for field in info: @@ -50,29 +50,24 @@ def get_version(): return res.split(' ')[1].strip() if res else None -def get_info(app_id: str): +def get_app_info(app_id: str): return _run_cmd('flatpak info ' + app_id) -def list_installed() -> List[str]: +def list_installed() -> List[dict]: apps_str = _run_cmd('flatpak list') if apps_str: version = get_version() app_lines = apps_str.split('\n') - return [_package_str_to_json(line, version) for line in app_lines if line] + return [app_str_to_json(line, version) for line in app_lines if line] return None -def check_update(pak_id: dict) -> bool: - res = _run_cmd('flatpak update ' + pak_id) - return 'Updating in system' in res - - def update(ref: str): return bool(_run_cmd('flatpak update -y ' + ref)) -def list_updates(): +def list_updates_as_str(): return _run_cmd('flatpak update', ignore_return_code=True) diff --git a/core/model.py b/core/model.py index 71747001..505ba6f9 100755 --- a/core/model.py +++ b/core/model.py @@ -14,8 +14,8 @@ __FLATHUB_API_URL__ = __FLATHUB_URL__ + '/api/v1' class FlatpakManager: def __init__(self): - self.packages = [] - self.packages_db = {} + self.apps = [] + self.apps_db = {} self.http_session = requests.Session() self._load_database() @@ -25,48 +25,48 @@ class FlatpakManager: if res.status_code == 200: for app in res.json(): - self.packages_db[app['flatpakAppId']] = app + self.apps_db[app['flatpakAppId']] = app def get_version(self): return flatpak.get_version() def read_installed(self) -> List[dict]: - packages = flatpak.list_installed() + installed = flatpak.list_installed() - if packages: - packages.sort(key=lambda p: p['name'].lower()) + if installed: + installed.sort(key=lambda p: p['name'].lower()) - available_updates = flatpak.list_updates() + available_updates = flatpak.list_updates_as_str() - for pak in packages: + for app in installed: - if self.packages_db: - pak_data = self.packages_db.get(pak['id'], None) + if self.apps_db: + app_data = self.apps_db.get(app['id'], None) else: - pak_data = None + app_data = None - if not pak_data: - pak['latest_version'] = None - pak['icon'] = None + if not app_data: + app['latest_version'] = None + app['icon'] = None else: - pak['latest_version'] = pak_data['currentReleaseVersion'] - pak['icon'] = pak_data['iconDesktopUrl'] + app['latest_version'] = app_data['currentReleaseVersion'] + app['icon'] = app_data['iconMobileUrl'] - if pak['icon'].startswith('/'): - pak['icon'] = __FLATHUB_URL__ + pak['icon'] + if app['icon'].startswith('/'): + app['icon'] = __FLATHUB_URL__ + app['icon'] - pak['update'] = pak['id'] in available_updates + app['update'] = app['id'] in available_updates - self.packages = packages - return [*self.packages] + self.apps = installed + return [*self.apps] - def update_packages(self, refs: List[str]) -> List[dict]: + def update_apps(self, refs: List[str]) -> List[dict]: - if self.packages: + if self.apps: for ref in refs: - package_found = [pak for pak in self.packages if pak['ref'] == ref] + package_found = [app for app in self.apps if app['ref'] == ref] if package_found: package_found = package_found[0] @@ -75,6 +75,6 @@ class FlatpakManager: if updated: package_found['update'] = not updated - return [*self.packages] + return [*self.apps] return [] diff --git a/core/updater.py b/core/updater.py deleted file mode 100755 index e6736b52..00000000 --- a/core/updater.py +++ /dev/null @@ -1,16 +0,0 @@ -from model import flatpak - - -def list_packages(): - packages = flatpak.list_installed() - - if packages: - packages = [pak for pak in packages if not pak['runtime']] - - if packages: - packages.sort(key=lambda p: p['name'].lower()) - - for pak in packages: - pak['update'] = flatpak.check_update(pak['ref']) - - return packages diff --git a/resources/img/flathub_25.svg b/resources/img/flathub_25.svg new file mode 100644 index 00000000..15e46ff5 --- /dev/null +++ b/resources/img/flathub_25.svg @@ -0,0 +1,179 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/img/flathub_logo.svg b/resources/img/flathub_45.svg similarity index 100% rename from resources/img/flathub_logo.svg rename to resources/img/flathub_45.svg diff --git a/view/qt/systray.py b/view/qt/systray.py index 739b6bbf..8c438d51 100755 --- a/view/qt/systray.py +++ b/view/qt/systray.py @@ -1,4 +1,3 @@ -import sys import time from PyQt5.QtCore import QThread, pyqtSignal, QCoreApplication @@ -38,24 +37,25 @@ class UpdateCheck(QThread): class TrayIcon(QSystemTrayIcon): def __init__(self, controller: FlatpakController, check_interval: int = 60, parent=None): - self.icon_default = QIcon(resource.get_path('img/flathub_logo.svg')) + self.controller = controller + + self.icon_default = QIcon(resource.get_path('img/flathub_45.svg')) self.icon_update = QIcon(resource.get_path('img/update_logo.svg')) QSystemTrayIcon.__init__(self, self.icon_default, parent) - self.menu = QMenu(parent) + self.menu = QMenu(parent) self.action_manage = self.menu.addAction("Manage applications") self.action_manage.triggered.connect(self.show_manage_window) - self.action_exit = self.menu.addAction("Exit") self.action_exit.triggered.connect(lambda: QCoreApplication.exit()) self.setContextMenu(self.menu) - self.controller = controller - self.check_thread = UpdateCheck(check_interval=check_interval, controller=self.controller) - self.check_thread.signal.connect(self.notify_update) - self.check_thread.start() - self.manage_window = ManageWindow(controller=controller, tray_icon=self) - def notify_update(self, updates: int): + self.manage_window = ManageWindow(controller=controller, tray_icon=self) + self.check_thread = UpdateCheck(check_interval=check_interval, controller=self.controller) + self.check_thread.signal.connect(self.notify_updates) + self.check_thread.start() + + def notify_updates(self, updates: int): if updates > 0: if self.icon() != self.icon_update: self.setIcon(self.icon_update) diff --git a/view/qt/window.py b/view/qt/window.py index 9e2527bb..fa06bab4 100755 --- a/view/qt/window.py +++ b/view/qt/window.py @@ -38,7 +38,7 @@ class UpdateToggleButton(QToolButton): class ManageWindow(QWidget): - __COLUMNS__ = ['Package', 'Version', 'Latest Version', 'Branch', 'Arch', 'Ref', 'Origin', 'Update ?'] + __COLUMNS__ = ['Name', 'Version', 'Latest Version', 'Branch', 'Arch', 'Ref', 'Origin', 'Update ?'] __BASE_HEIGHT__ = 400 def __init__(self, controller: FlatpakController, tray_icon = None): @@ -46,11 +46,15 @@ class ManageWindow(QWidget): self.controller = controller self.icon_cache = {} self.tray_icon = tray_icon + self.thread_lock = Lock() + self.working = False # restrict the number of threaded actions + self.apps = [] + self.label_flatpak = None self.network_man = QNetworkAccessManager() self.network_man.finished.connect(self._load_icon) - self.icon_flathub = QIcon(resource.get_path('img/flathub_logo.svg')) + self.icon_flathub = QIcon(resource.get_path('img/flathub_45.svg')) self._check_flatpak_installed() self.resize(ManageWindow.__BASE_HEIGHT__, ManageWindow.__BASE_HEIGHT__) self.setWindowTitle('fpakman ({})'.format(__version__)) @@ -67,17 +71,17 @@ class ManageWindow(QWidget): toolbar = QToolBar() toolbar.addWidget(self.checkbox_only_apps) - spacer_1 = QWidget() - spacer_1.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) - toolbar.addWidget(spacer_1) + spacer = QWidget() + spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + toolbar.addWidget(spacer) self.label_status = QLabel() self.label_status.setText('') toolbar.addWidget(self.label_status) - spacer_2 = QWidget() - spacer_2.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) - toolbar.addWidget(spacer_2) + spacer = QWidget() + spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + toolbar.addWidget(spacer) self.bt_refresh = QToolButton() self.bt_refresh.setIcon(QIcon(resource.get_path('img/refresh_orange.svg'))) @@ -107,14 +111,11 @@ class ManageWindow(QWidget): self.layout.addWidget(self.table_apps) - self.apps = [] - self.centralize() + self.thread_update = UpdateSelectedApps(self.controller) + self.thread_update.signal.connect(self._finish_update_selected) - self.update_thread = UpdateSelectedPackages(self.controller) - self.update_thread.signal.connect(self._finish_update_selected) - - self.refresh_thread = RefreshPackages(self.controller) - self.refresh_thread.signal.connect(self._finish_refresh) + self.thread_refresh = RefreshApps(self.controller) + self.thread_refresh.signal.connect(self._finish_refresh) self.toolbar_bottom = QToolBar() self.label_updates = QLabel('') @@ -123,11 +124,11 @@ class ManageWindow(QWidget): spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self.toolbar_bottom.addWidget(spacer) - self.toolbar_bottom.addWidget(QLabel('flatpak: ' + self.controller.get_version())) + self.label_flatpak = QLabel(self._get_flatpak_label()) + self.toolbar_bottom.addWidget(self.label_flatpak) self.layout.addWidget(self.toolbar_bottom) - self.thread_lock = Lock() - self.working = False # restrict the number of threaded actions + self.centralize() def closeEvent(self, event): @@ -159,6 +160,12 @@ class ManageWindow(QWidget): error_msg.exec_() exit(1) + if self.label_flatpak: + self.label_flatpak.setText(self._get_flatpak_label()) + + def _get_flatpak_label(self): + return 'flatpak: ' + self.controller.get_version() + def _acquire_lock(self): self.thread_lock.acquire() @@ -183,11 +190,11 @@ class ManageWindow(QWidget): if self._acquire_lock(): self._check_flatpak_installed() self._begin_action('Refreshing...') - self.refresh_thread.start() + self.thread_refresh.start() def _finish_refresh(self): - self.update_packages(self.refresh_thread.apps) + self.update_apps(self.thread_refresh.apps) self.finish_action() self._release_lock() @@ -228,7 +235,7 @@ class ManageWindow(QWidget): geo.moveCenter(center_point) self.move(geo.topLeft()) - def update_packages(self, apps: List[dict]): + def update_apps(self, apps: List[dict]): self._check_flatpak_installed() self.table_apps.setEnabled(True) @@ -315,15 +322,15 @@ class ManageWindow(QWidget): if to_update: self._begin_action('Updating...') - self.update_thread.refs_to_update = to_update - self.update_thread.start() + self.thread_update.refs_to_update = to_update + self.thread_update.start() def _finish_update_selected(self): - self.update_packages(self.update_thread.updated_apps) + self.update_apps(self.thread_update.updated_apps) self.finish_action() - if self.tray_icon and self.update_thread.updated_apps: - self.tray_icon.notify_update(len([app for app in self.update_thread.updated_apps if app['update']])) + if self.tray_icon and self.thread_update.updated_apps: + self.tray_icon.notify_updates(len([app for app in self.thread_update.updated_apps if app['update']])) self._release_lock() @@ -343,12 +350,12 @@ class ManageWindow(QWidget): # Threaded actions -class UpdateSelectedPackages(QThread): +class UpdateSelectedApps(QThread): signal = pyqtSignal() def __init__(self, controller: FlatpakController): - super(UpdateSelectedPackages, self).__init__() + super(UpdateSelectedApps, self).__init__() self.controller = controller self.refs_to_update = [] self.updated_apps = None @@ -358,12 +365,12 @@ class UpdateSelectedPackages(QThread): self.signal.emit() -class RefreshPackages(QThread): +class RefreshApps(QThread): signal = pyqtSignal() def __init__(self, controller: FlatpakController): - super(RefreshPackages, self).__init__() + super(RefreshApps, self).__init__() self.controller = controller self.apps = None From d5142662eb5688f7038259901703e9e9c044193b Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Thu, 13 Jun 2019 14:56:03 -0300 Subject: [PATCH 006/123] Installer script --- README.md | 10 +++++ app.py | 0 core/flatpak.py | 18 +++----- core/system.py | 6 +++ run.sh | 2 + sandbox_installer.py | 101 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 126 insertions(+), 11 deletions(-) create mode 100644 README.md mode change 100644 => 100755 app.py create mode 100644 core/system.py create mode 100755 run.sh create mode 100755 sandbox_installer.py diff --git a/README.md b/README.md new file mode 100644 index 00000000..62b911d8 --- /dev/null +++ b/README.md @@ -0,0 +1,10 @@ +## fpakman +Graphical interface for Flatpak application management. It has also a tray icon to let the user known when new updates are available. +Wriiten in Python for QT5. + +### Roadmap +- Test installer for Ubuntu +- Load the database in a thread when the application starts. It will prevent the delay of showing the icon. +- Locales +- System notification whe new updates +- Search and install applications. \ No newline at end of file diff --git a/app.py b/app.py old mode 100644 new mode 100755 diff --git a/core/flatpak.py b/core/flatpak.py index 89b4a6f5..fb049174 100755 --- a/core/flatpak.py +++ b/core/flatpak.py @@ -1,7 +1,8 @@ import re -import subprocess from typing import List +from core import system + def app_str_to_json(line: str, version: str) -> dict: @@ -40,22 +41,17 @@ def app_str_to_json(line: str, version: str) -> dict: return app -def _run_cmd(cmd: str, expected_code: int = 0, ignore_return_code: bool = False) -> str: - res = subprocess.run(cmd, shell=True, stdout=subprocess.PIPE) - return res.stdout.decode() if ignore_return_code or res.returncode == expected_code else None - - def get_version(): - res = _run_cmd('flatpak --version') + res = system.run_cmd('flatpak --version') return res.split(' ')[1].strip() if res else None def get_app_info(app_id: str): - return _run_cmd('flatpak info ' + app_id) + return system.run_cmd('flatpak info ' + app_id) def list_installed() -> List[dict]: - apps_str = _run_cmd('flatpak list') + apps_str = system.run_cmd('flatpak list') if apps_str: version = get_version() @@ -66,8 +62,8 @@ def list_installed() -> List[dict]: def update(ref: str): - return bool(_run_cmd('flatpak update -y ' + ref)) + return bool(system.run_cmd('flatpak update -y ' + ref)) def list_updates_as_str(): - return _run_cmd('flatpak update', ignore_return_code=True) + return system.run_cmd('flatpak update', ignore_return_code=True) diff --git a/core/system.py b/core/system.py new file mode 100644 index 00000000..dcd9a138 --- /dev/null +++ b/core/system.py @@ -0,0 +1,6 @@ +import subprocess + + +def run_cmd(cmd: str, expected_code: int = 0, ignore_return_code: bool = False) -> str: + res = subprocess.run(cmd, shell=True, stdout=subprocess.PIPE) + return res.stdout.decode() if ignore_return_code or res.returncode == expected_code else None diff --git a/run.sh b/run.sh new file mode 100755 index 00000000..aea3a644 --- /dev/null +++ b/run.sh @@ -0,0 +1,2 @@ +#!/bin/bash +/home/dafiti/workspace/fpakman/env/bin/python /home/dafiti/workspace/fpakman/app.py \ No newline at end of file diff --git a/sandbox_installer.py b/sandbox_installer.py new file mode 100755 index 00000000..f51adb5b --- /dev/null +++ b/sandbox_installer.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +################################################################ +# It installs the application without compromising you system. # +# libraries. 'qt5' is required to be installed. # +# # +# If you use GTK, install 'libappindicator3' also. # +# # +# EXECUTE THIS SCRIPT INSIDE THE PROJECT FOLDER AS ROOT # +# # +################################################################ +import os +import sys +from shutil import rmtree + +from core import system, flatpak + +if not os.geteuid() == 0: + sys.exit("\nOnly root can run this script\n") + + +link_path = '/usr/local/bin/fpakman' +runner_file = 'run.sh' +env_name = 'env' + + +def log(msg: str): + print('[fpakman] {}'.format(msg)) + + +if os.path.exists(link_path): + log('already installed') + log('Do you wish to uninstall it ? (y/N)') + uninstall = input() + + if uninstall.lower() == 'y': + + try: + os.unlink(link_path) + except: + log("Could not remove the runner syslink '{}'".format(link_path)) + log("Aborting...") + exit(1) + + if os.path.exists('{}/{}'.format(os.getcwd(), env_name)): + try: + rmtree(env_name) + except: + log("Could not remove the virtualenv '{}'".format(env_name)) + log("Aborting") + exit(1) + + if os.path.exists('{}/{}'.format(os.getcwd(), runner_file)): + + try: + os.remove(runner_file) + except: + log("Could not remove the runner file '{}'".format(runner_file)) + log("Aborting...") + exit(1) + + log("Successfully uninstalled") + + else: + log('Aborting...') +else: + if flatpak.get_version is None: + print('flatpak seems not to be installed. Aborting...') + exit(1) + + if not os.path.exists('env'): + log("Creating a new 'virtualenv' as '{}'...".format(env_name)) + res = system.run_cmd('python3 -m venv ' + env_name) + + if res is None: + log("Could create a virtualenv for installation. Check if 'virtualenv' is installed.") + log('Aborting...') + exit(1) + + res = system.run_cmd('env/bin/pip install -r requirements.txt') + + if res: + log("Creating runner as '{}'".format(runner_file)) + with open('run.sh', 'w+') as f: + f.write('#!/bin/bash\n{d}/env/bin/python {d}/app.py'.format(d=os.getcwd())) + + system.run_cmd('chmod +x ' + runner_file) + + log("Creating syslink as '{}'".format(link_path)) + + try: + os.link('{}/{}'.format(os.getcwd(), runner_file), link_path) + except: + log("Could not create the syslink") + log("Aborting...") + exit(1) + + log('Successfully installed') + else: + log('Could not install python requirements to the virtualenv') + log('Aborting...') + exit(1) From 05a33c9d63e67e54cec95dac3dab9352ae5b9b86 Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Thu, 13 Jun 2019 15:20:57 -0300 Subject: [PATCH 007/123] loading flatpak db in a different thread --- README.md | 1 - app.py | 7 +++++++ core/model.py | 24 ++++++++++++++++++------ run.sh | 2 -- view/qt/window.py | 18 +++++++++++++----- 5 files changed, 38 insertions(+), 14 deletions(-) delete mode 100755 run.sh diff --git a/README.md b/README.md index 62b911d8..75b172d2 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,6 @@ Wriiten in Python for QT5. ### Roadmap - Test installer for Ubuntu -- Load the database in a thread when the application starts. It will prevent the delay of showing the icon. - Locales - System notification whe new updates - Search and install applications. \ No newline at end of file diff --git a/app.py b/app.py index 0f0b6820..7e9c272f 100755 --- a/app.py +++ b/app.py @@ -1,4 +1,5 @@ #!env/bin/python +import argparse import sys from PyQt5.QtWidgets import QApplication, QWidget @@ -6,9 +7,15 @@ from PyQt5.QtWidgets import QApplication, QWidget from core.controller import FlatpakController from core.model import FlatpakManager from view.qt.systray import TrayIcon +from core import __version__ + +parser = argparse.ArgumentParser(prog='fpakman', description="GUI for Flatpak applications management") +parser.add_argument('-v', '--version', action='version', version='%(prog)s {}'.format(__version__)) +args = parser.parse_args() app = QApplication(sys.argv) manager = FlatpakManager() +manager.load_database_async() controller = FlatpakController(manager) hidden_widget = QWidget() trayIcon = TrayIcon(parent=hidden_widget, controller=controller, check_interval=30) diff --git a/core/model.py b/core/model.py index 505ba6f9..b8be52b6 100755 --- a/core/model.py +++ b/core/model.py @@ -1,3 +1,4 @@ +from threading import Lock, Thread from typing import List import requests @@ -17,15 +18,23 @@ class FlatpakManager: self.apps = [] self.apps_db = {} self.http_session = requests.Session() - self._load_database() + self.lock_db_read = Lock() - def _load_database(self): + def load_database_async(self): + Thread(target=self.load_database).start() - res = self.http_session.get(__FLATHUB_API_URL__ + '/apps') + def load_database(self): - if res.status_code == 200: - for app in res.json(): - self.apps_db[app['flatpakAppId']] = app + self.lock_db_read.acquire() + + try: + res = self.http_session.get(__FLATHUB_API_URL__ + '/apps') + + if res.status_code == 200: + for app in res.json(): + self.apps_db[app['flatpakAppId']] = app + finally: + self.lock_db_read.release() def get_version(self): return flatpak.get_version() @@ -41,6 +50,9 @@ class FlatpakManager: for app in installed: + if not self.apps_db: + self.load_database() + if self.apps_db: app_data = self.apps_db.get(app['id'], None) else: diff --git a/run.sh b/run.sh deleted file mode 100755 index aea3a644..00000000 --- a/run.sh +++ /dev/null @@ -1,2 +0,0 @@ -#!/bin/bash -/home/dafiti/workspace/fpakman/env/bin/python /home/dafiti/workspace/fpakman/app.py \ No newline at end of file diff --git a/view/qt/window.py b/view/qt/window.py index fa06bab4..261d3852 100755 --- a/view/qt/window.py +++ b/view/qt/window.py @@ -3,8 +3,8 @@ from functools import reduce from threading import Lock from typing import List -from PyQt5.QtCore import Qt, QThread, pyqtSignal, QUrl -from PyQt5.QtGui import QIcon, QColor, QPixmap +from PyQt5.QtCore import Qt, QThread, pyqtSignal, QUrl, QEvent +from PyQt5.QtGui import QIcon, QColor, QPixmap, QWindowStateChangeEvent from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkRequest from PyQt5.QtWidgets import QWidget, QVBoxLayout, QApplication, QTableWidget, \ QTableWidgetItem, QTableView, QCheckBox, QHeaderView, QToolButton, QToolBar, \ @@ -105,9 +105,7 @@ class ManageWindow(QWidget): self.table_apps.setHorizontalHeaderLabels(ManageWindow.__COLUMNS__) self.table_apps.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - header_horizontal = self.table_apps.horizontalHeader() - for i in range(0, len(ManageWindow.__COLUMNS__)): - header_horizontal.setSectionResizeMode(i, QHeaderView.Stretch + QHeaderView.AdjustToContents) + self._change_table_headers_policy() self.layout.addWidget(self.table_apps) @@ -130,6 +128,16 @@ class ManageWindow(QWidget): self.centralize() + def _change_table_headers_policy(self, policy: QHeaderView = QHeaderView.ResizeToContents): + header_horizontal = self.table_apps.horizontalHeader() + for i in range(0, len(ManageWindow.__COLUMNS__)): + header_horizontal.setSectionResizeMode(i, policy) + + def changeEvent(self, e: QEvent): + + if isinstance(e, QWindowStateChangeEvent): + self._change_table_headers_policy(QHeaderView.Stretch if self.isMaximized() else QHeaderView.ResizeToContents) + def closeEvent(self, event): if self.tray_icon: From cef411295c0ce9839d35474de4fa217037e245c5 Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Thu, 13 Jun 2019 16:09:41 -0300 Subject: [PATCH 008/123] System updates notification and env vars --- README.md | 17 +++++++-- app.py | 3 +- core/model.py | 2 +- .../img/{flathub_25.svg => flathub_10.svg} | 16 ++++---- resources/img/update_logo.svg | 38 +++++++++---------- view/qt/systray.py | 14 +++++-- 6 files changed, 54 insertions(+), 36 deletions(-) rename resources/img/{flathub_25.svg => flathub_10.svg} (97%) diff --git a/README.md b/README.md index 75b172d2..e050db9a 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,20 @@ ## fpakman -Graphical interface for Flatpak application management. It has also a tray icon to let the user known when new updates are available. -Wriiten in Python for QT5. +Graphical interface for Flatpak application management. It is a tray icon to let the user known when new updates are available. +It has also a management window allowing the user to see all installed apllications and update them. + +### Technologies: +- Python3 and QT 5. + +## Settings +You can change some application settings via environment variables: +- **FPAKMAN_UPDATE_NOTIFICATION: enable or disable system updates notifications. Use 0 (disable) or 1 (enable, default). +- **FPAKMAN_CHECK_INTERVAL**: define the updates check interval in seconds. Default: 60. + +## Installation script +You can install the application without compromising your system via the provided installation script called 'sandbox_installer.py'. +Type in the terminal: sudo python3 sandbox_installer.py. If you want to uninstall the application, just call the script the same way. ### Roadmap - Test installer for Ubuntu - Locales -- System notification whe new updates - Search and install applications. \ No newline at end of file diff --git a/app.py b/app.py index 7e9c272f..e1d42cab 100755 --- a/app.py +++ b/app.py @@ -1,5 +1,6 @@ #!env/bin/python import argparse +import os import sys from PyQt5.QtWidgets import QApplication, QWidget @@ -18,7 +19,7 @@ manager = FlatpakManager() manager.load_database_async() controller = FlatpakController(manager) hidden_widget = QWidget() -trayIcon = TrayIcon(parent=hidden_widget, controller=controller, check_interval=30) +trayIcon = TrayIcon(parent=hidden_widget, controller=controller, check_interval=int(os.getenv('FPAKMAN_CHECK_INTERVAL', 30))) trayIcon.show() sys.exit(app.exec_()) diff --git a/core/model.py b/core/model.py index b8be52b6..95e55b2f 100755 --- a/core/model.py +++ b/core/model.py @@ -21,7 +21,7 @@ class FlatpakManager: self.lock_db_read = Lock() def load_database_async(self): - Thread(target=self.load_database).start() + Thread(target=self.load_database, daemon=True).start() def load_database(self): diff --git a/resources/img/flathub_25.svg b/resources/img/flathub_10.svg similarity index 97% rename from resources/img/flathub_25.svg rename to resources/img/flathub_10.svg index 15e46ff5..2311083c 100644 --- a/resources/img/flathub_25.svg +++ b/resources/img/flathub_10.svg @@ -9,13 +9,13 @@ xmlns="http://www.w3.org/2000/svg" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" - width="24.856121" - height="24.071226" - viewBox="0 0 6.5765152 6.3688453" + width="9.6111898" + height="8.805685" + viewBox="0 0 2.5429606 2.3298375" version="1.1" id="svg3871" inkscape:version="0.92.4 5da689c313, 2019-01-14" - sodipodi:docname="flathub_25.svg"> + sodipodi:docname="flathub_5.svg"> + transform="translate(-12.162843,-177.89043)"> image/svg+xml - + @@ -62,19 +62,19 @@ + style="fill:#ff2a2a;stroke:#800000;stroke-width:0.77710575"> + style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#ff2a2a;fill-opacity:1;fill-rule:nonzero;stroke:#800000;stroke-width:9.3252697;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" /> @@ -92,7 +92,7 @@ inkscape:connector-curvature="0" id="path10650" d="m 783.00003,824.92983 5e-5,68.99997 66.46803,42.90055 -5e-5,-68.99997 z" - style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#ff2a2a;fill-opacity:1;fill-rule:nonzero;stroke:#aa0000;stroke-width:2.34915853;stroke-linecap:square;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" /> + style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#ff2a2a;fill-opacity:1;fill-rule:nonzero;stroke:#800000;stroke-width:2.34915853;stroke-linecap:square;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" /> + style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#ff2a2a;fill-opacity:1;fill-rule:nonzero;stroke:#800000;stroke-width:2.4608953;stroke-linecap:square;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" /> + style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#ff2a2a;fill-opacity:1;fill-rule:nonzero;stroke:#800000;stroke-width:2.4608953;stroke-linecap:square;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" /> + style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#ff2a2a;fill-opacity:1;fill-rule:nonzero;stroke:#800000;stroke-width:2.34915853;stroke-linecap:square;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" /> + style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#ff2a2a;fill-opacity:1;fill-rule:nonzero;stroke:#800000;stroke-width:2.4608953;stroke-linecap:square;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" /> @@ -172,7 +172,7 @@ inkscape:connector-curvature="0" id="path10670" d="m 783.00003,824.92983 5e-5,68.99997 66.46803,42.90055 -5e-5,-68.99997 z" - style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#ff2a2a;fill-opacity:1;fill-rule:nonzero;stroke:#aa0000;stroke-width:2.34915853;stroke-linecap:square;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" /> + style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#ff2a2a;fill-opacity:1;fill-rule:nonzero;stroke:#800000;stroke-width:2.34915853;stroke-linecap:square;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" /> diff --git a/view/qt/systray.py b/view/qt/systray.py index 8c438d51..76ebf5e0 100755 --- a/view/qt/systray.py +++ b/view/qt/systray.py @@ -1,3 +1,4 @@ +import os import time from PyQt5.QtCore import QThread, pyqtSignal, QCoreApplication @@ -6,7 +7,6 @@ from PyQt5.QtWidgets import QSystemTrayIcon, QMenu from core import resource - # to update from core.controller import FlatpakController from view.qt.window import ManageWindow @@ -57,12 +57,18 @@ class TrayIcon(QSystemTrayIcon): def notify_updates(self, updates: int): if updates > 0: - if self.icon() != self.icon_update: + if self.icon().cacheKey() != self.icon_update.cacheKey(): self.setIcon(self.icon_update) - self.setToolTip('Updates found: {}'.format(updates)) - if self.manage_window: + msg = 'Updates found: {}'.format(updates) + self.setToolTip(msg) + + if bool(os.getenv('FPAKMAN_UPDATE_NOTIFICATION', 1)): + os.system("notify-send -i {} '{}'".format(resource.get_path('img/flathub_10.svg'), msg)) + + if self.manage_window and not self.manage_window.isHidden(): self.manage_window.refresh() + else: self.setIcon(self.icon_default) self.setToolTip(None) From 672b4321816d10ccf7136b112979aec530c03e5f Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Thu, 13 Jun 2019 16:15:00 -0300 Subject: [PATCH 009/123] changing refresh icon color --- resources/img/refresh.svg | 26 ++++--- resources/img/refresh_orange.svg | 129 ------------------------------- view/qt/window.py | 4 +- 3 files changed, 17 insertions(+), 142 deletions(-) mode change 100755 => 100644 resources/img/refresh.svg delete mode 100755 resources/img/refresh_orange.svg diff --git a/resources/img/refresh.svg b/resources/img/refresh.svg old mode 100755 new mode 100644 index 8b784986..0158d7d0 --- a/resources/img/refresh.svg +++ b/resources/img/refresh.svg @@ -17,8 +17,8 @@ height="44.999996" viewBox="0 0 44.999999 44.999995" xml:space="preserve" - sodipodi:docname="refresh.svg" - inkscape:version="0.92.3 (2405546, 2018-03-11)">image/svg+xml + transform="matrix(0.09887825,0,0,0.09235884,-1.5881978,0)" + style="fill:#0088aa"> + id="g6" + style="fill:#0088aa"> + inkscape:connector-curvature="0" + style="fill:#0088aa" /> + inkscape:connector-curvature="0" + style="fill:#0088aa" /> - - -image/svg+xml - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/view/qt/window.py b/view/qt/window.py index 261d3852..5f862cf7 100755 --- a/view/qt/window.py +++ b/view/qt/window.py @@ -84,8 +84,8 @@ class ManageWindow(QWidget): toolbar.addWidget(spacer) self.bt_refresh = QToolButton() - self.bt_refresh.setIcon(QIcon(resource.get_path('img/refresh_orange.svg'))) - self.bt_refresh.clicked.connect(lambda: self.refresh(threaded=True)) + self.bt_refresh.setIcon(QIcon(resource.get_path('img/refresh.svg'))) + self.bt_refresh.clicked.connect(self.refresh) toolbar.addWidget(self.bt_refresh) self.bt_update = QToolButton() From 83ddad0d68a2471fdee43683eb66ea1a8893c5c2 Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Thu, 13 Jun 2019 17:17:56 -0300 Subject: [PATCH 010/123] Locale support for en, pt and es --- README.md | 2 +- app.py | 10 ++++++++-- core/resource.py | 8 ++++++-- core/util.py | 35 +++++++++++++++++++++++++++++++++++ resources/locale/en | 17 +++++++++++++++++ resources/locale/es | 17 +++++++++++++++++ resources/locale/pt | 17 +++++++++++++++++ view/qt/systray.py | 11 ++++++----- view/qt/window.py | 32 ++++++++++++++++++++------------ 9 files changed, 127 insertions(+), 22 deletions(-) create mode 100644 core/util.py create mode 100644 resources/locale/en create mode 100644 resources/locale/es create mode 100644 resources/locale/pt diff --git a/README.md b/README.md index e050db9a..491d8d34 100644 --- a/README.md +++ b/README.md @@ -16,5 +16,5 @@ Type in the terminal: sudo python3 sandbox_installer.py. If you want to uninstal ### Roadmap - Test installer for Ubuntu -- Locales +- Show update commands - Search and install applications. \ No newline at end of file diff --git a/app.py b/app.py index e1d42cab..764ce9fe 100755 --- a/app.py +++ b/app.py @@ -8,18 +8,24 @@ from PyQt5.QtWidgets import QApplication, QWidget from core.controller import FlatpakController from core.model import FlatpakManager from view.qt.systray import TrayIcon -from core import __version__ +from core import __version__, resource, util parser = argparse.ArgumentParser(prog='fpakman', description="GUI for Flatpak applications management") parser.add_argument('-v', '--version', action='version', version='%(prog)s {}'.format(__version__)) args = parser.parse_args() +locale_keys = util.get_locale_keys() + app = QApplication(sys.argv) manager = FlatpakManager() manager.load_database_async() controller = FlatpakController(manager) hidden_widget = QWidget() -trayIcon = TrayIcon(parent=hidden_widget, controller=controller, check_interval=int(os.getenv('FPAKMAN_CHECK_INTERVAL', 30))) + +trayIcon = TrayIcon(locale_keys=locale_keys, + parent=hidden_widget, + controller=controller, + check_interval=int(os.getenv('FPAKMAN_CHECK_INTERVAL', 30))) trayIcon.show() sys.exit(app.exec_()) diff --git a/core/resource.py b/core/resource.py index b8927afb..0918e299 100755 --- a/core/resource.py +++ b/core/resource.py @@ -2,5 +2,9 @@ import os def get_path(resource_path): - cur_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - return cur_dir + '/resources/' + resource_path + app_dir = get_app_dir() + return app_dir + '/resources/' + resource_path + + +def get_app_dir(): + return os.path.dirname(os.path.dirname(os.path.abspath(__file__))) diff --git a/core/util.py b/core/util.py new file mode 100644 index 00000000..a12a9b41 --- /dev/null +++ b/core/util.py @@ -0,0 +1,35 @@ +import locale + +from core import resource +import glob + + +def get_locale_keys(): + + current_locale = locale.getdefaultlocale() + locale_path = None + if current_locale: + current_locale = current_locale[0] + + locale_dir = '{}/resources/locale'.format(resource.get_app_dir()) + + for locale_file in glob.glob(locale_dir + '/*'): + name = locale_file.split('/')[-1] + + if current_locale == name or current_locale.startswith(name + '_'): + locale_path = locale_file + break + + if not locale_path: + locale_path = '{}/resources/locale/en'.format(resource.get_app_dir()) + + with open(locale_path, 'r') as f: + locale_keys = f.readlines() + + locale_obj = {} + for line in locale_keys: + if line: + keyval = line.strip().split('=') + locale_obj[keyval[0].strip()] = keyval[1].strip() + + return locale_obj diff --git a/resources/locale/en b/resources/locale/en new file mode 100644 index 00000000..6a3a38ff --- /dev/null +++ b/resources/locale/en @@ -0,0 +1,17 @@ +manage_window.columns.name=Name +manage_window.columns.version=Version +manage_window.columns.latest_version=Latest Version +manage_window.columns.branch=Branch +manage_window.columns.arch=Arch +manage_window.columns.ref=Ref +manage_window.columns.origin=Origin +manage_window.columns.update=Update ? +manage_window.checkbox.only_apps=Apps +manage_window.label.updates=Updates +manage_window.status.refreshing=Refreshing +manage_window.status.updating=Updating +popup.flatpak_not_installed.title=Error +popup.flatpak_not_installed.msg=flatpak seems not to be installed. Exiting +tray.action.manage=Manage applications +tray.action.exit=Exit +notification.new_updates=Updates found \ No newline at end of file diff --git a/resources/locale/es b/resources/locale/es new file mode 100644 index 00000000..801c5b26 --- /dev/null +++ b/resources/locale/es @@ -0,0 +1,17 @@ +manage_window.columns.name=Nombre +manage_window.columns.version=Versión +manage_window.columns.latest_version=Ultima Versión +manage_window.columns.branch=Branch +manage_window.columns.arch=Arch +manage_window.columns.ref=Ref +manage_window.columns.origin=Origen +manage_window.columns.update=¿Actualizar? +manage_window.checkbox.only_apps=Apps +manage_window.label.updates=Actualizaciones +manage_window.status.refreshing=Recargando +manage_window.status.updating=Actualizando +popup.flatpak_not_installed.title=Error +popup.flatpak_not_installed.msg=flatpak no parece estar instalado. Saliendo +tray.action.manage=Administrar aplicativos +tray.action.exit=Salir +notification.new_updates=Actualizaciones encontradas \ No newline at end of file diff --git a/resources/locale/pt b/resources/locale/pt new file mode 100644 index 00000000..5b817152 --- /dev/null +++ b/resources/locale/pt @@ -0,0 +1,17 @@ +manage_window.columns.name=Nome +manage_window.columns.version=Versão +manage_window.columns.latest_version=Última Versão +manage_window.columns.branch=Branch +manage_window.columns.arch=Arch +manage_window.columns.ref=Ref +manage_window.columns.origin=Origem +manage_window.columns.update=Atualizar ? +manage_window.checkbox.only_apps=Apps +manage_window.label.updates=Actualizaciones +manage_window.status.refreshing=Recarregando +manage_window.status.updating=Atualizando +popup.flatpak_not_installed.title=Erro +popup.flatpak_not_installed.msg=flatpak não parece estar instalado. Saindo +tray.action.manage=Gerenciar aplicativos +tray.action.exit=Sair +notification.new_updates=Atualizações encontradas \ No newline at end of file diff --git a/view/qt/systray.py b/view/qt/systray.py index 76ebf5e0..f822ccbf 100755 --- a/view/qt/systray.py +++ b/view/qt/systray.py @@ -36,7 +36,8 @@ class UpdateCheck(QThread): class TrayIcon(QSystemTrayIcon): - def __init__(self, controller: FlatpakController, check_interval: int = 60, parent=None): + def __init__(self, locale_keys: dict, controller: FlatpakController, check_interval: int = 60, parent=None): + self.locale_keys = locale_keys self.controller = controller self.icon_default = QIcon(resource.get_path('img/flathub_45.svg')) @@ -44,13 +45,13 @@ class TrayIcon(QSystemTrayIcon): QSystemTrayIcon.__init__(self, self.icon_default, parent) self.menu = QMenu(parent) - self.action_manage = self.menu.addAction("Manage applications") + self.action_manage = self.menu.addAction(self.locale_keys['tray.action.manage']) self.action_manage.triggered.connect(self.show_manage_window) - self.action_exit = self.menu.addAction("Exit") + self.action_exit = self.menu.addAction(self.locale_keys['tray.action.exit']) self.action_exit.triggered.connect(lambda: QCoreApplication.exit()) self.setContextMenu(self.menu) - self.manage_window = ManageWindow(controller=controller, tray_icon=self) + self.manage_window = ManageWindow(locale_keys=self.locale_keys, controller=controller, tray_icon=self) self.check_thread = UpdateCheck(check_interval=check_interval, controller=self.controller) self.check_thread.signal.connect(self.notify_updates) self.check_thread.start() @@ -60,7 +61,7 @@ class TrayIcon(QSystemTrayIcon): if self.icon().cacheKey() != self.icon_update.cacheKey(): self.setIcon(self.icon_update) - msg = 'Updates found: {}'.format(updates) + msg = '{}: {}'.format(self.locale_keys['notification.new_updates'], updates) self.setToolTip(msg) if bool(os.getenv('FPAKMAN_UPDATE_NOTIFICATION', 1)): diff --git a/view/qt/window.py b/view/qt/window.py index 5f862cf7..ae75deb3 100755 --- a/view/qt/window.py +++ b/view/qt/window.py @@ -38,11 +38,19 @@ class UpdateToggleButton(QToolButton): class ManageWindow(QWidget): - __COLUMNS__ = ['Name', 'Version', 'Latest Version', 'Branch', 'Arch', 'Ref', 'Origin', 'Update ?'] __BASE_HEIGHT__ = 400 - def __init__(self, controller: FlatpakController, tray_icon = None): + def __init__(self, locale_keys: dict, controller: FlatpakController, tray_icon = None): super(ManageWindow, self).__init__() + self.locale_keys = locale_keys + self.column_names = [locale_keys['manage_window.columns.name'], + locale_keys['manage_window.columns.version'], + locale_keys['manage_window.columns.latest_version'], + locale_keys['manage_window.columns.branch'], + locale_keys['manage_window.columns.arch'], + locale_keys['manage_window.columns.ref'], + locale_keys['manage_window.columns.origin'], + locale_keys['manage_window.columns.update']] self.controller = controller self.icon_cache = {} self.tray_icon = tray_icon @@ -64,7 +72,7 @@ class ManageWindow(QWidget): self.setLayout(self.layout) self.checkbox_only_apps = QCheckBox() - self.checkbox_only_apps.setText('Only apps') + self.checkbox_only_apps.setText(self.locale_keys['manage_window.checkbox.only_apps']) self.checkbox_only_apps.setChecked(True) self.checkbox_only_apps.stateChanged.connect(self.filter_only_apps) @@ -97,12 +105,12 @@ class ManageWindow(QWidget): self.layout.addWidget(toolbar) self.table_apps = QTableWidget() - self.table_apps.setColumnCount(len(ManageWindow.__COLUMNS__)) + self.table_apps.setColumnCount(len(self.column_names)) self.table_apps.setFocusPolicy(Qt.NoFocus) self.table_apps.setShowGrid(False) self.table_apps.verticalHeader().setVisible(False) self.table_apps.setSelectionBehavior(QTableView.SelectRows) - self.table_apps.setHorizontalHeaderLabels(ManageWindow.__COLUMNS__) + self.table_apps.setHorizontalHeaderLabels(self.column_names) self.table_apps.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self._change_table_headers_policy() @@ -130,7 +138,7 @@ class ManageWindow(QWidget): def _change_table_headers_policy(self, policy: QHeaderView = QHeaderView.ResizeToContents): header_horizontal = self.table_apps.horizontalHeader() - for i in range(0, len(ManageWindow.__COLUMNS__)): + for i in range(0, len(self.column_names)): header_horizontal.setSectionResizeMode(i, policy) def changeEvent(self, e: QEvent): @@ -162,8 +170,8 @@ class ManageWindow(QWidget): if not self.controller.check_installed(): error_msg = QMessageBox() error_msg.setIcon(QMessageBox.Critical) - error_msg.setText('flatpak seems not to be installed. Exiting...') - error_msg.setWindowTitle('Error') + error_msg.setWindowTitle(self.locale_keys['popup.flatpak_not_installed.title']) + error_msg.setText(self.locale_keys['popup.flatpak_not_installed.msg'] + '...') error_msg.setWindowIcon(self.icon_flathub) error_msg.exec_() exit(1) @@ -197,7 +205,7 @@ class ManageWindow(QWidget): if self._acquire_lock(): self._check_flatpak_installed() - self._begin_action('Refreshing...') + self._begin_action(self.locale_keys['manage_window.status.refreshing'] + '...') self.thread_refresh.start() def _finish_refresh(self): @@ -225,7 +233,7 @@ class ManageWindow(QWidget): updates = len([app for app in self.apps if app['model']['update']]) if updates > 0: - self.label_updates.setText('Updates: {}'.format(updates)) + self.label_updates.setText('{}: {}'.format(self.locale_keys['manage_window.label.updates'], updates)) else: self.label_updates.setText('') @@ -318,7 +326,7 @@ class ManageWindow(QWidget): self.resize_and_center() def resize_and_center(self): - new_width = reduce(operator.add, [self.table_apps.columnWidth(i) for i in range(len(ManageWindow.__COLUMNS__))]) * 1.05 + new_width = reduce(operator.add, [self.table_apps.columnWidth(i) for i in range(len(self.column_names))]) * 1.05 self.resize(new_width, self.height()) self.centralize() @@ -329,7 +337,7 @@ class ManageWindow(QWidget): to_update = [pak['model']['ref'] for pak in self.apps if pak['visible'] and pak['update_checked']] if to_update: - self._begin_action('Updating...') + self._begin_action(self.locale_keys['manage_window.status.updating'] + '...') self.thread_update.refs_to_update = to_update self.thread_update.start() From 2923f654546751353ccd0d0ad20bb4f8d83f6a1c Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Thu, 13 Jun 2019 17:29:35 -0300 Subject: [PATCH 011/123] fixing requirements versions --- requirements.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 1519cd20..9ef4c21f 100755 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ -pyqt5 -requests \ No newline at end of file +pyqt5>=5.12 +requests>=2.22 \ No newline at end of file From 1e32b408039cfe653327d8dc8582ff2af3a39024 Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Thu, 13 Jun 2019 17:38:09 -0300 Subject: [PATCH 012/123] Updating README.md --- README.md | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 491d8d34..6784c034 100644 --- a/README.md +++ b/README.md @@ -2,19 +2,23 @@ Graphical interface for Flatpak application management. It is a tray icon to let the user known when new updates are available. It has also a management window allowing the user to see all installed apllications and update them. -### Technologies: +### Developed with: - Python3 and QT 5. -## Settings -You can change some application settings via environment variables: -- **FPAKMAN_UPDATE_NOTIFICATION: enable or disable system updates notifications. Use 0 (disable) or 1 (enable, default). -- **FPAKMAN_CHECK_INTERVAL**: define the updates check interval in seconds. Default: 60. +### Requirements +- Python >= 3.5 +- qt5 packages +- libappindicator3 (for GTK3 desktop environments) ## Installation script You can install the application without compromising your system via the provided installation script called 'sandbox_installer.py'. Type in the terminal: sudo python3 sandbox_installer.py. If you want to uninstall the application, just call the script the same way. +### Settings +You can change some application settings via environment variables: +- **FPAKMAN_UPDATE_NOTIFICATION: enable or disable system updates notifications. Use 0 (disable) or 1 (enable, default). +- **FPAKMAN_CHECK_INTERVAL**: define the updates check interval in seconds. Default: 60. + ### Roadmap -- Test installer for Ubuntu - Show update commands -- Search and install applications. \ No newline at end of file +- Search and install applications. From 8faa5ff6e57c967f7b42b3aa99ba9d3d734933bd Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Thu, 13 Jun 2019 17:40:38 -0300 Subject: [PATCH 013/123] Fixing error when no applications are installed --- core/flatpak.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/flatpak.py b/core/flatpak.py index fb049174..a51c3bb5 100755 --- a/core/flatpak.py +++ b/core/flatpak.py @@ -58,7 +58,7 @@ def list_installed() -> List[dict]: app_lines = apps_str.split('\n') return [app_str_to_json(line, version) for line in app_lines if line] - return None + return [] def update(ref: str): From 4d9dfd2d6927b2529a9d772ae028b9a31b411465 Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Thu, 13 Jun 2019 18:42:59 -0300 Subject: [PATCH 014/123] support for flatpak 1.2 --- core/flatpak.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/flatpak.py b/core/flatpak.py index a51c3bb5..f817b6c8 100755 --- a/core/flatpak.py +++ b/core/flatpak.py @@ -8,7 +8,7 @@ def app_str_to_json(line: str, version: str) -> dict: app_array = line.split('\t') - if version >= '1.4.0': + if version >= '1.2.0': app = {'name': app_array[0], 'id': app_array[1], 'version': app_array[2], From de5944846cb717cf333b8837c39e48ce9b468eb8 Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Thu, 13 Jun 2019 18:44:42 -0300 Subject: [PATCH 015/123] support for flatpak 1.2 --- core/flatpak.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/flatpak.py b/core/flatpak.py index f817b6c8..2e3dbb86 100755 --- a/core/flatpak.py +++ b/core/flatpak.py @@ -8,12 +8,12 @@ def app_str_to_json(line: str, version: str) -> dict: app_array = line.split('\t') - if version >= '1.2.0': + if version >= '1.3.0': app = {'name': app_array[0], 'id': app_array[1], 'version': app_array[2], 'branch': app_array[3]} - elif '1.0' <= version < '1.1': + elif '1.0' <= version < '1.3': app = {'ref': app_array[0], 'options': app_array[1]} ref_data = app['ref'].split('/') From a968b5a70306b3051a8998c3203959d505de5228 Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Thu, 13 Jun 2019 19:08:18 -0300 Subject: [PATCH 016/123] Support for flatpak 1.2 --- README.md | 1 + core/flatpak.py | 9 ++++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 6784c034..f154c754 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ It has also a management window allowing the user to see all installed apllicati - Python >= 3.5 - qt5 packages - libappindicator3 (for GTK3 desktop environments) +- python3-venv (for Debian based distros -> Ubuntu, Linux Mint, ...) ## Installation script You can install the application without compromising your system via the provided installation script called 'sandbox_installer.py'. diff --git a/core/flatpak.py b/core/flatpak.py index 2e3dbb86..094e6740 100755 --- a/core/flatpak.py +++ b/core/flatpak.py @@ -13,7 +13,7 @@ def app_str_to_json(line: str, version: str) -> dict: 'id': app_array[1], 'version': app_array[2], 'branch': app_array[3]} - elif '1.0' <= version < '1.3': + elif '1.0' <= version < '1.1': app = {'ref': app_array[0], 'options': app_array[1]} ref_data = app['ref'].split('/') @@ -22,6 +22,13 @@ def app_str_to_json(line: str, version: str) -> dict: app['branch'] = ref_data[2] app['name'] = ref_data[0].split('.')[-1] app['version'] = None + elif '1.2' <= version < '1.3': + app = {'name': app_array[0][1].split('.')[-1], + 'id': app_array[1], + 'version': app_array[2], + 'branch': app_array[3], + 'arch': app_array[4], + 'origin': app_array[5]} else: raise Exception('Unsupported version') From 3e64c80a97d5c2c27e96e048aaabcafd764e6777 Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Thu, 13 Jun 2019 21:37:14 -0300 Subject: [PATCH 017/123] Refresh fix --- view/qt/systray.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/view/qt/systray.py b/view/qt/systray.py index f822ccbf..9e22b1d8 100755 --- a/view/qt/systray.py +++ b/view/qt/systray.py @@ -67,7 +67,7 @@ class TrayIcon(QSystemTrayIcon): if bool(os.getenv('FPAKMAN_UPDATE_NOTIFICATION', 1)): os.system("notify-send -i {} '{}'".format(resource.get_path('img/flathub_10.svg'), msg)) - if self.manage_window and not self.manage_window.isHidden(): + if self.manage_window: self.manage_window.refresh() else: From 5f77f4dcb1e6311dae1ab7a12708074a130801b4 Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Fri, 14 Jun 2019 11:24:25 -0300 Subject: [PATCH 018/123] Docs --- CHANGELOG.md | 13 +++++++++ CONTRIBUTING.md | 22 ++++++++++++++ README.md | 46 ++++++++++++++++++++++++------ app.py | 4 +-- sandbox_installer.py => install.py | 43 +++++++--------------------- 5 files changed, 85 insertions(+), 43 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md rename sandbox_installer.py => install.py (70%) diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..d0046e94 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,13 @@ +# Changelog +All notable changes to this project will be documented in this file. + + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). + +## [0.1.0] - 2019-06-14 +### Features + - System tray icon. + - Applications management window. + - Support for the following locales: PT, EN, ES. + - System notification for new updates. + - Update applications. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..178c7035 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,22 @@ +# How can I contribute? +Well, you can... +* Report bugs +* Add improvements +* Fix bugs +* Add new translations or fix the current ones + +# Reporting bugs +The best means of reporting bugs is by following these basic guidelines: + +* First describe in the title of the issue tracker what's gone wrong. +* In the body, explain a basic synopsis of what exactly happens, explain how you got the bug one step at a time. If you're including script output, make sure you run the script with the verbose flag `-v`. +* Explain what you had expected to occur, and what really occurred. +* Optionally, if you want, if you're a programmer, you can try to issue a pull request yourself that fixes the issue. + +# Adding improvements +The way to go here is to ask yourself if the improvement would be useful for more than just a singular person, if it's for a certain use case then sure! + +* In any pull request, explain thoroughly what changes you made +* Explain why you think these changes could be useful +* If it fixes a bug, be sure to link to the issue itself. +* Follow the [PEP 8](https://www.python.org/dev/peps/pep-0008/) code style to keep the code consistent. diff --git a/README.md b/README.md index f154c754..09eff06c 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,46 @@ ## fpakman -Graphical interface for Flatpak application management. It is a tray icon to let the user known when new updates are available. -It has also a management window allowing the user to see all installed apllications and update them. +Graphical user interface for Flatpak application management. It is a tray icon to let the user known when new updates are available. +It has also a management window allowing the user to see all installed applications and update them. ### Developed with: - Python3 and QT 5. ### Requirements -- Python >= 3.5 -- qt5 packages +#### Debian-based distros - libappindicator3 (for GTK3 desktop environments) -- python3-venv (for Debian based distros -> Ubuntu, Linux Mint, ...) +- python3-venv +#### Arch-based distros +- python +- python-requests +- python-pip +- python-pyqt5 -## Installation script -You can install the application without compromising your system via the provided installation script called 'sandbox_installer.py'. -Type in the terminal: sudo python3 sandbox_installer.py. If you want to uninstall the application, just call the script the same way. + +### Installation script +You can install the application without compromising your system via the provided installation script called 'install.py'. +Type in the terminal: +``` +sudo python3 install.py. +``` +If you want to uninstall the application, just call the script the same way. + +To start the application, type in the terminal: +``` +fpakman +``` + +### Manual installation: +Inside the project directory type the following commands: +``` +python3 -m venv env +env/bin/pip install -r requirements.txt +chmod +x app.py +./app.py +``` + +### Autostart +In order to autostart the application, use your Desktop Environment settings to register it as startup script ("fpakman"). +(P.S: the installation script currently does not do that) ### Settings You can change some application settings via environment variables: @@ -21,5 +48,6 @@ You can change some application settings via environment variables: - **FPAKMAN_CHECK_INTERVAL**: define the updates check interval in seconds. Default: 60. ### Roadmap -- Show update commands +- Show updates being applied. - Search and install applications. +- Uninstall applications. diff --git a/app.py b/app.py index 764ce9fe..11e21960 100755 --- a/app.py +++ b/app.py @@ -8,7 +8,7 @@ from PyQt5.QtWidgets import QApplication, QWidget from core.controller import FlatpakController from core.model import FlatpakManager from view.qt.systray import TrayIcon -from core import __version__, resource, util +from core import __version__, util parser = argparse.ArgumentParser(prog='fpakman', description="GUI for Flatpak applications management") parser.add_argument('-v', '--version', action='version', version='%(prog)s {}'.format(__version__)) @@ -25,7 +25,7 @@ hidden_widget = QWidget() trayIcon = TrayIcon(locale_keys=locale_keys, parent=hidden_widget, controller=controller, - check_interval=int(os.getenv('FPAKMAN_CHECK_INTERVAL', 30))) + check_interval=int(os.getenv('FPAKMAN_CHECK_INTERVAL', 60))) trayIcon.show() sys.exit(app.exec_()) diff --git a/sandbox_installer.py b/install.py similarity index 70% rename from sandbox_installer.py rename to install.py index f51adb5b..b2b8ec8e 100755 --- a/sandbox_installer.py +++ b/install.py @@ -1,9 +1,5 @@ #!/usr/bin/env python3 ################################################################ -# It installs the application without compromising you system. # -# libraries. 'qt5' is required to be installed. # -# # -# If you use GTK, install 'libappindicator3' also. # # # # EXECUTE THIS SCRIPT INSIDE THE PROJECT FOLDER AS ROOT # # # @@ -18,8 +14,7 @@ if not os.geteuid() == 0: sys.exit("\nOnly root can run this script\n") -link_path = '/usr/local/bin/fpakman' -runner_file = 'run.sh' +runner_file = '/usr/local/bin/fpakman' env_name = 'env' @@ -27,19 +22,21 @@ def log(msg: str): print('[fpakman] {}'.format(msg)) -if os.path.exists(link_path): +if os.path.exists(runner_file): log('already installed') log('Do you wish to uninstall it ? (y/N)') uninstall = input() if uninstall.lower() == 'y': - try: - os.unlink(link_path) - except: - log("Could not remove the runner syslink '{}'".format(link_path)) - log("Aborting...") - exit(1) + if os.path.exists(runner_file): + + try: + os.remove(runner_file) + except: + log("Could not remove the runner file '{}'".format(runner_file)) + log("Aborting...") + exit(1) if os.path.exists('{}/{}'.format(os.getcwd(), env_name)): try: @@ -49,15 +46,6 @@ if os.path.exists(link_path): log("Aborting") exit(1) - if os.path.exists('{}/{}'.format(os.getcwd(), runner_file)): - - try: - os.remove(runner_file) - except: - log("Could not remove the runner file '{}'".format(runner_file)) - log("Aborting...") - exit(1) - log("Successfully uninstalled") else: @@ -80,20 +68,11 @@ else: if res: log("Creating runner as '{}'".format(runner_file)) - with open('run.sh', 'w+') as f: + with open(runner_file, 'w+') as f: f.write('#!/bin/bash\n{d}/env/bin/python {d}/app.py'.format(d=os.getcwd())) system.run_cmd('chmod +x ' + runner_file) - log("Creating syslink as '{}'".format(link_path)) - - try: - os.link('{}/{}'.format(os.getcwd(), runner_file), link_path) - except: - log("Could not create the syslink") - log("Aborting...") - exit(1) - log('Successfully installed') else: log('Could not install python requirements to the virtualenv') From 3188820bc6b86f79f9d413d701acf3c7ba8c5228 Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Fri, 14 Jun 2019 14:02:44 -0300 Subject: [PATCH 019/123] setup.py --- {view => fpakman}/__init__.py | 0 app.py => fpakman/app.py | 9 ++-- {core => fpakman/core}/__init__.py | 0 {core => fpakman/core}/controller.py | 2 +- {core => fpakman/core}/flatpak.py | 2 +- {core => fpakman/core}/model.py | 2 +- {core => fpakman/core}/resource.py | 0 {core => fpakman/core}/system.py | 0 {core => fpakman/core}/util.py | 2 +- .../resources}/img/flathub_10.svg | 0 .../resources}/img/flathub_45.svg | 0 .../resources}/img/refresh.svg | 0 .../resources}/img/toggle_off.svg | 0 .../resources}/img/toggle_on.svg | 0 .../resources}/img/update.svg | 0 .../resources}/img/update_green.svg | 0 .../resources}/img/update_logo.svg | 0 {resources => fpakman/resources}/locale/en | 0 {resources => fpakman/resources}/locale/es | 0 {resources => fpakman/resources}/locale/pt | 0 {view/qt => fpakman/view}/__init__.py | 0 fpakman/view/qt/__init__.py | 0 {view => fpakman/view}/qt/systray.py | 8 ++- {view => fpakman/view}/qt/window.py | 5 +- install.py | 6 +-- setup.py | 49 +++++++++++++++++++ 26 files changed, 67 insertions(+), 18 deletions(-) rename {view => fpakman}/__init__.py (100%) mode change 100755 => 100644 rename app.py => fpakman/app.py (78%) rename {core => fpakman/core}/__init__.py (100%) rename {core => fpakman/core}/controller.py (92%) rename {core => fpakman/core}/flatpak.py (98%) rename {core => fpakman/core}/model.py (98%) rename {core => fpakman/core}/resource.py (100%) rename {core => fpakman/core}/system.py (100%) rename {core => fpakman/core}/util.py (96%) rename {resources => fpakman/resources}/img/flathub_10.svg (100%) rename {resources => fpakman/resources}/img/flathub_45.svg (100%) rename {resources => fpakman/resources}/img/refresh.svg (100%) rename {resources => fpakman/resources}/img/toggle_off.svg (100%) rename {resources => fpakman/resources}/img/toggle_on.svg (100%) rename {resources => fpakman/resources}/img/update.svg (100%) rename {resources => fpakman/resources}/img/update_green.svg (100%) rename {resources => fpakman/resources}/img/update_logo.svg (100%) rename {resources => fpakman/resources}/locale/en (100%) rename {resources => fpakman/resources}/locale/es (100%) rename {resources => fpakman/resources}/locale/pt (100%) rename {view/qt => fpakman/view}/__init__.py (100%) create mode 100755 fpakman/view/qt/__init__.py rename {view => fpakman/view}/qt/systray.py (95%) rename {view => fpakman/view}/qt/window.py (99%) create mode 100644 setup.py diff --git a/view/__init__.py b/fpakman/__init__.py old mode 100755 new mode 100644 similarity index 100% rename from view/__init__.py rename to fpakman/__init__.py diff --git a/app.py b/fpakman/app.py similarity index 78% rename from app.py rename to fpakman/app.py index 11e21960..cadcefa2 100755 --- a/app.py +++ b/fpakman/app.py @@ -5,10 +5,11 @@ import sys from PyQt5.QtWidgets import QApplication, QWidget -from core.controller import FlatpakController -from core.model import FlatpakManager -from view.qt.systray import TrayIcon -from core import __version__, util +from fpakman.core.controller import FlatpakController +from fpakman.core.model import FlatpakManager +from fpakman.view.qt.systray import TrayIcon +from fpakman.core import __version__ +from fpakman.core import util parser = argparse.ArgumentParser(prog='fpakman', description="GUI for Flatpak applications management") parser.add_argument('-v', '--version', action='version', version='%(prog)s {}'.format(__version__)) diff --git a/core/__init__.py b/fpakman/core/__init__.py similarity index 100% rename from core/__init__.py rename to fpakman/core/__init__.py diff --git a/core/controller.py b/fpakman/core/controller.py similarity index 92% rename from core/controller.py rename to fpakman/core/controller.py index fe028d80..3e43630b 100755 --- a/core/controller.py +++ b/fpakman/core/controller.py @@ -1,6 +1,6 @@ from typing import List -from core.model import FlatpakManager +from fpakman.core.model import FlatpakManager class FlatpakController: diff --git a/core/flatpak.py b/fpakman/core/flatpak.py similarity index 98% rename from core/flatpak.py rename to fpakman/core/flatpak.py index 094e6740..823c146a 100755 --- a/core/flatpak.py +++ b/fpakman/core/flatpak.py @@ -1,7 +1,7 @@ import re from typing import List -from core import system +from fpakman.core import system def app_str_to_json(line: str, version: str) -> dict: diff --git a/core/model.py b/fpakman/core/model.py similarity index 98% rename from core/model.py rename to fpakman/core/model.py index 95e55b2f..115976c6 100755 --- a/core/model.py +++ b/fpakman/core/model.py @@ -6,7 +6,7 @@ from PyQt5.QtCore import QUrl from PyQt5.QtGui import QPixmap, QIcon from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkRequest -from core import flatpak +from fpakman.core import flatpak __FLATHUB_URL__ = 'https://flathub.org' __FLATHUB_API_URL__ = __FLATHUB_URL__ + '/api/v1' diff --git a/core/resource.py b/fpakman/core/resource.py similarity index 100% rename from core/resource.py rename to fpakman/core/resource.py diff --git a/core/system.py b/fpakman/core/system.py similarity index 100% rename from core/system.py rename to fpakman/core/system.py diff --git a/core/util.py b/fpakman/core/util.py similarity index 96% rename from core/util.py rename to fpakman/core/util.py index a12a9b41..f502ceee 100644 --- a/core/util.py +++ b/fpakman/core/util.py @@ -1,6 +1,6 @@ import locale -from core import resource +from fpakman.core import resource import glob diff --git a/resources/img/flathub_10.svg b/fpakman/resources/img/flathub_10.svg similarity index 100% rename from resources/img/flathub_10.svg rename to fpakman/resources/img/flathub_10.svg diff --git a/resources/img/flathub_45.svg b/fpakman/resources/img/flathub_45.svg similarity index 100% rename from resources/img/flathub_45.svg rename to fpakman/resources/img/flathub_45.svg diff --git a/resources/img/refresh.svg b/fpakman/resources/img/refresh.svg similarity index 100% rename from resources/img/refresh.svg rename to fpakman/resources/img/refresh.svg diff --git a/resources/img/toggle_off.svg b/fpakman/resources/img/toggle_off.svg similarity index 100% rename from resources/img/toggle_off.svg rename to fpakman/resources/img/toggle_off.svg diff --git a/resources/img/toggle_on.svg b/fpakman/resources/img/toggle_on.svg similarity index 100% rename from resources/img/toggle_on.svg rename to fpakman/resources/img/toggle_on.svg diff --git a/resources/img/update.svg b/fpakman/resources/img/update.svg similarity index 100% rename from resources/img/update.svg rename to fpakman/resources/img/update.svg diff --git a/resources/img/update_green.svg b/fpakman/resources/img/update_green.svg similarity index 100% rename from resources/img/update_green.svg rename to fpakman/resources/img/update_green.svg diff --git a/resources/img/update_logo.svg b/fpakman/resources/img/update_logo.svg similarity index 100% rename from resources/img/update_logo.svg rename to fpakman/resources/img/update_logo.svg diff --git a/resources/locale/en b/fpakman/resources/locale/en similarity index 100% rename from resources/locale/en rename to fpakman/resources/locale/en diff --git a/resources/locale/es b/fpakman/resources/locale/es similarity index 100% rename from resources/locale/es rename to fpakman/resources/locale/es diff --git a/resources/locale/pt b/fpakman/resources/locale/pt similarity index 100% rename from resources/locale/pt rename to fpakman/resources/locale/pt diff --git a/view/qt/__init__.py b/fpakman/view/__init__.py similarity index 100% rename from view/qt/__init__.py rename to fpakman/view/__init__.py diff --git a/fpakman/view/qt/__init__.py b/fpakman/view/qt/__init__.py new file mode 100755 index 00000000..e69de29b diff --git a/view/qt/systray.py b/fpakman/view/qt/systray.py similarity index 95% rename from view/qt/systray.py rename to fpakman/view/qt/systray.py index 9e22b1d8..f81d6a21 100755 --- a/view/qt/systray.py +++ b/fpakman/view/qt/systray.py @@ -5,11 +5,9 @@ from PyQt5.QtCore import QThread, pyqtSignal, QCoreApplication from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import QSystemTrayIcon, QMenu -from core import resource - -# to update -from core.controller import FlatpakController -from view.qt.window import ManageWindow +from fpakman.core import resource +from fpakman.core.controller import FlatpakController +from fpakman.view.qt.window import ManageWindow class UpdateCheck(QThread): diff --git a/view/qt/window.py b/fpakman/view/qt/window.py similarity index 99% rename from view/qt/window.py rename to fpakman/view/qt/window.py index ae75deb3..337d9efe 100755 --- a/view/qt/window.py +++ b/fpakman/view/qt/window.py @@ -10,8 +10,9 @@ from PyQt5.QtWidgets import QWidget, QVBoxLayout, QApplication, QTableWidget, \ QTableWidgetItem, QTableView, QCheckBox, QHeaderView, QToolButton, QToolBar, \ QSizePolicy, QLabel, QMessageBox -from core import resource, __version__ -from core.controller import FlatpakController +from fpakman.core import __version__ +from fpakman.core import resource +from fpakman.core.controller import FlatpakController class UpdateToggleButton(QToolButton): diff --git a/install.py b/install.py index b2b8ec8e..fae54698 100755 --- a/install.py +++ b/install.py @@ -8,7 +8,7 @@ import os import sys from shutil import rmtree -from core import system, flatpak +from fpakman.core import system, flatpak if not os.geteuid() == 0: sys.exit("\nOnly root can run this script\n") @@ -64,12 +64,12 @@ else: log('Aborting...') exit(1) - res = system.run_cmd('env/bin/pip install -r requirements.txt') + res = system.run_cmd('env/bin/pip install .') if res: log("Creating runner as '{}'".format(runner_file)) with open(runner_file, 'w+') as f: - f.write('#!/bin/bash\n{d}/env/bin/python {d}/app.py'.format(d=os.getcwd())) + f.write('#!/bin/bash\n{d}/env/bin/fpakman'.format(d=os.getcwd())) system.run_cmd('chmod +x ' + runner_file) diff --git a/setup.py b/setup.py new file mode 100644 index 00000000..b4119ba7 --- /dev/null +++ b/setup.py @@ -0,0 +1,49 @@ +import os +from setuptools import setup, find_packages + +DESCRIPTION = ( + "Graphical user interface to manage Flatpak applications." +) + +AUTHOR = "Vinicius Moreira" +AUTHOR_EMAIL = "vinicius_fmoreira@hotmail.com" +URL = "https://github.com/vinifmor/fpakman" + +file_dir = os.path.dirname(os.path.abspath(__file__)) + +with open(file_dir + '/requirements.txt', 'r') as f: + requirements = [line.strip() for line in f.readlines() if line] + + +with open(file_dir + '/fpakman/core/__init__.py', 'r') as f: + exec(f.readlines()[0]) + + +setup( + name="fpakman", + version=eval('__version__'), + description=DESCRIPTION, + long_description=DESCRIPTION, + author=AUTHOR, + author_email=AUTHOR_EMAIL, + python_requires=">=3.5", + url=URL, + packages=find_packages(), + package_data={"fpakman": ["resources/locale/*", "resources/img/*"]}, + install_requires=requirements, + entry_points={ + "console_scripts": [ + "fpakman=fpakman.app" + ] + }, + include_package_data=True, + license="zlib/libpng", + classifiers=[ + 'Topic :: Utilities', + 'Programming Language :: Python', + 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7' + ] +) From 7483f3b0b6957b58925e1787a32d71c16a09c33a Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Fri, 14 Jun 2019 15:43:39 -0300 Subject: [PATCH 020/123] Fix readme --- README.md | 8 ++++---- fpakman/app.py | 1 - 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 09eff06c..aa0c3b4a 100644 --- a/README.md +++ b/README.md @@ -30,12 +30,12 @@ fpakman ``` ### Manual installation: -Inside the project directory type the following commands: +The following script shows how to install the application in a separate python environment in order to not mess up with your +system's python libraries. Inside the project directory type: ``` python3 -m venv env -env/bin/pip install -r requirements.txt -chmod +x app.py -./app.py +env/bin/pip install . +env/bin/fpakman ``` ### Autostart diff --git a/fpakman/app.py b/fpakman/app.py index cadcefa2..81a90d00 100755 --- a/fpakman/app.py +++ b/fpakman/app.py @@ -1,4 +1,3 @@ -#!env/bin/python import argparse import os import sys From f55c5be2a43dc96a4f0f8a60f09ca26c197f1a2b Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Fri, 14 Jun 2019 16:28:04 -0300 Subject: [PATCH 021/123] script to AUR installation --- arch/register_desktop.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 arch/register_desktop.py diff --git a/arch/register_desktop.py b/arch/register_desktop.py new file mode 100644 index 00000000..d057c6ed --- /dev/null +++ b/arch/register_desktop.py @@ -0,0 +1,20 @@ +# used for AUR installation +import subprocess +import sys +from pathlib import Path + +desktop_file = """ +[Desktop Entry] +Type = Application +Name = fpakman +Comment = Manage your Flatpak applications +Exec = /usr/bin/fpakman +Icon = /usr/lib/python{version}/site-packages/fpakman/resources/img/flathub_45.svg +""".format(version="{}.{}".format(sys.version_info.major, sys.version_info.minor)) + +file_path = '{}/.local/share/applications/fpakman.desktop'.format(str(Path.home())) + +with open(file_path, 'w+') as f: + f.write(desktop_file) + +subprocess.run('desktop-file-install {}'.format(file_path), shell=True) From c5ab4716bf851997fa7b240790c08726ff8c7480 Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Fri, 14 Jun 2019 16:46:47 -0300 Subject: [PATCH 022/123] Update README.md --- README.md | 15 +++++++++------ arch/{register_desktop.py => register_app.py} | 0 2 files changed, 9 insertions(+), 6 deletions(-) rename arch/{register_desktop.py => register_app.py} (100%) diff --git a/README.md b/README.md index aa0c3b4a..e7501da5 100644 --- a/README.md +++ b/README.md @@ -15,12 +15,15 @@ It has also a management window allowing the user to see all installed applicati - python-pip - python-pyqt5 +### Distribution +Currently only available for Arch Linux users via **AUR**. Other distros can install it manually (described bellow). + ### Installation script You can install the application without compromising your system via the provided installation script called 'install.py'. Type in the terminal: ``` -sudo python3 install.py. +sudo python3 install.py ``` If you want to uninstall the application, just call the script the same way. @@ -30,7 +33,7 @@ fpakman ``` ### Manual installation: -The following script shows how to install the application in a separate python environment in order to not mess up with your +The following script shows how to install the application in a separate python environment to not mess up with your system's python libraries. Inside the project directory type: ``` python3 -m venv env @@ -44,10 +47,10 @@ In order to autostart the application, use your Desktop Environment settings to ### Settings You can change some application settings via environment variables: -- **FPAKMAN_UPDATE_NOTIFICATION: enable or disable system updates notifications. Use 0 (disable) or 1 (enable, default). +- **FPAKMAN_UPDATE_NOTIFICATION**: enable or disable system updates notifications. Use **0** (disable) or **1** (enable, default). - **FPAKMAN_CHECK_INTERVAL**: define the updates check interval in seconds. Default: 60. ### Roadmap -- Show updates being applied. -- Search and install applications. -- Uninstall applications. +- Show updates being applied +- Search and install applications +- Uninstall applications diff --git a/arch/register_desktop.py b/arch/register_app.py similarity index 100% rename from arch/register_desktop.py rename to arch/register_app.py From 8938aacf9f0b9c609e352f33ae1d1693ec618c07 Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Fri, 14 Jun 2019 17:22:37 -0300 Subject: [PATCH 023/123] Fix --- fpakman/view/qt/window.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fpakman/view/qt/window.py b/fpakman/view/qt/window.py index 337d9efe..edaf0e19 100755 --- a/fpakman/view/qt/window.py +++ b/fpakman/view/qt/window.py @@ -34,7 +34,7 @@ class UpdateToggleButton(QToolButton): def change_state(self, not_checked: bool): self.model['update_checked'] = not not_checked self.setIcon(self.icon_on if not not_checked else self.icon_off) - self.root.change_update_button_state() + self.root.change_update_state() class ManageWindow(QWidget): From 9056b90b134571aedf19249fb247272af88a6ae4 Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Fri, 14 Jun 2019 17:51:49 -0300 Subject: [PATCH 024/123] Fix: resizing after app filter is checked --- fpakman/view/qt/window.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/fpakman/view/qt/window.py b/fpakman/view/qt/window.py index edaf0e19..2c3a1dc4 100755 --- a/fpakman/view/qt/window.py +++ b/fpakman/view/qt/window.py @@ -226,6 +226,9 @@ class ManageWindow(QWidget): app['visible'] = not hidden self.change_update_state() + self._change_table_headers_policy(QHeaderView.Stretch) + self._change_table_headers_policy() + self.resize_and_center() def change_update_state(self): From ef3294a8c19e217f7f2622f34f13d344cf15f0cd Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Mon, 17 Jun 2019 11:47:38 -0300 Subject: [PATCH 025/123] Fix: AUR installation when '.local/share/applications' does not exist --- {arch => aur}/register_app.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) rename {arch => aur}/register_app.py (74%) diff --git a/arch/register_app.py b/aur/register_app.py similarity index 74% rename from arch/register_app.py rename to aur/register_app.py index d057c6ed..ee5a91bb 100644 --- a/arch/register_app.py +++ b/aur/register_app.py @@ -1,4 +1,5 @@ # used for AUR installation +import os import subprocess import sys from pathlib import Path @@ -12,7 +13,12 @@ Exec = /usr/bin/fpakman Icon = /usr/lib/python{version}/site-packages/fpakman/resources/img/flathub_45.svg """.format(version="{}.{}".format(sys.version_info.major, sys.version_info.minor)) -file_path = '{}/.local/share/applications/fpakman.desktop'.format(str(Path.home())) +apps_path = '{}/.local/share/applications'.format(str(Path.home())) + +if not os.path.exists(apps_path): + os.mkdir(apps_path) + +file_path = apps_path + '/fpakman.desktop' with open(file_path, 'w+') as f: f.write(desktop_file) From 30fe0c62fabe52ad7b7c87e1209e7ee2becc9e31 Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Mon, 17 Jun 2019 12:52:25 -0300 Subject: [PATCH 026/123] Working with local resources path --- aur/register_app.py | 15 ++++++++++++--- fpakman/core/resource.py | 13 ++++++++----- fpakman/core/util.py | 5 +++-- 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/aur/register_app.py b/aur/register_app.py index ee5a91bb..e6a5454c 100644 --- a/aur/register_app.py +++ b/aur/register_app.py @@ -1,17 +1,26 @@ # used for AUR installation import os +import shutil import subprocess -import sys from pathlib import Path +share_path = str(Path.home()) + '/.local/share' +local_resources_path = share_path + '/fpakman' + +if os.path.exists(local_resources_path): + shutil.rmtree(local_resources_path) + +os.mkdir(local_resources_path) + desktop_file = """ [Desktop Entry] Type = Application Name = fpakman +Categories=System; Comment = Manage your Flatpak applications Exec = /usr/bin/fpakman -Icon = /usr/lib/python{version}/site-packages/fpakman/resources/img/flathub_45.svg -""".format(version="{}.{}".format(sys.version_info.major, sys.version_info.minor)) +Icon = {resource_path}/resources/img/flathub_45.svg +""".format(resource_path=local_resources_path) apps_path = '{}/.local/share/applications'.format(str(Path.home())) diff --git a/fpakman/core/resource.py b/fpakman/core/resource.py index 0918e299..b4eed7de 100755 --- a/fpakman/core/resource.py +++ b/fpakman/core/resource.py @@ -1,10 +1,13 @@ import os +import getpass + +local_resource_path = '/home/{}/.local/share/fpakman/resources'.format(getpass.getuser()) +app_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) def get_path(resource_path): - app_dir = get_app_dir() - return app_dir + '/resources/' + resource_path - -def get_app_dir(): - return os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + if os.path.exists(local_resource_path): + return local_resource_path + resource_path + else: + return app_dir + '/resources/' + resource_path diff --git a/fpakman/core/util.py b/fpakman/core/util.py index f502ceee..9173d7f2 100644 --- a/fpakman/core/util.py +++ b/fpakman/core/util.py @@ -8,10 +8,11 @@ def get_locale_keys(): current_locale = locale.getdefaultlocale() locale_path = None + if current_locale: current_locale = current_locale[0] - locale_dir = '{}/resources/locale'.format(resource.get_app_dir()) + locale_dir = resource.get_path('/locale') for locale_file in glob.glob(locale_dir + '/*'): name = locale_file.split('/')[-1] @@ -21,7 +22,7 @@ def get_locale_keys(): break if not locale_path: - locale_path = '{}/resources/locale/en'.format(resource.get_app_dir()) + locale_path = resource.get_path('locale/en') with open(locale_path, 'r') as f: locale_keys = f.readlines() From 5cd23a481b0caf3f2d2c60a336c033a97e8c516b Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Mon, 17 Jun 2019 14:10:07 -0300 Subject: [PATCH 027/123] Changing notification size --- fpakman/resources/img/flathub_10.svg | 179 --------------------------- fpakman/view/qt/systray.py | 2 +- 2 files changed, 1 insertion(+), 180 deletions(-) delete mode 100644 fpakman/resources/img/flathub_10.svg diff --git a/fpakman/resources/img/flathub_10.svg b/fpakman/resources/img/flathub_10.svg deleted file mode 100644 index 2311083c..00000000 --- a/fpakman/resources/img/flathub_10.svg +++ /dev/null @@ -1,179 +0,0 @@ - - - - - - - - - - image/svg+xml - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/fpakman/view/qt/systray.py b/fpakman/view/qt/systray.py index f81d6a21..381d2bef 100755 --- a/fpakman/view/qt/systray.py +++ b/fpakman/view/qt/systray.py @@ -63,7 +63,7 @@ class TrayIcon(QSystemTrayIcon): self.setToolTip(msg) if bool(os.getenv('FPAKMAN_UPDATE_NOTIFICATION', 1)): - os.system("notify-send -i {} '{}'".format(resource.get_path('img/flathub_10.svg'), msg)) + os.system("notify-send -i {} '{}'".format(resource.get_path('img/flathub_45.svg'), msg)) if self.manage_window: self.manage_window.refresh() From 2399868168de98e71d3c3cfdf1d5c3d3784aeece Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Mon, 17 Jun 2019 14:19:12 -0300 Subject: [PATCH 028/123] Fix: loading resources from .local/share/fpakman/resources --- fpakman/core/resource.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fpakman/core/resource.py b/fpakman/core/resource.py index b4eed7de..3998b95e 100755 --- a/fpakman/core/resource.py +++ b/fpakman/core/resource.py @@ -8,6 +8,6 @@ app_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) def get_path(resource_path): if os.path.exists(local_resource_path): - return local_resource_path + resource_path + return local_resource_path + '/' + resource_path else: return app_dir + '/resources/' + resource_path From 8f7cdbfa368a5db3589d913d5969df0c364e4466 Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Mon, 17 Jun 2019 14:31:54 -0300 Subject: [PATCH 029/123] Cleaning .desktop file when installing --- aur/register_app.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/aur/register_app.py b/aur/register_app.py index e6a5454c..eeaed2e4 100644 --- a/aur/register_app.py +++ b/aur/register_app.py @@ -22,13 +22,16 @@ Exec = /usr/bin/fpakman Icon = {resource_path}/resources/img/flathub_45.svg """.format(resource_path=local_resources_path) -apps_path = '{}/.local/share/applications'.format(str(Path.home())) +apps_path = share_path + '/applications' if not os.path.exists(apps_path): os.mkdir(apps_path) file_path = apps_path + '/fpakman.desktop' +if os.path.exists(file_path): + os.remove(file_path) + with open(file_path, 'w+') as f: f.write(desktop_file) From 0ce4f4bde7b2f17349e854f7c70a01310b83e0fe Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Mon, 17 Jun 2019 14:50:34 -0300 Subject: [PATCH 030/123] Systray using .png icons --- fpakman/resources/img/flathub_45.png | Bin 0 -> 34235 bytes fpakman/resources/img/update_logo.png | Bin 0 -> 29663 bytes fpakman/view/qt/systray.py | 4 ++-- 3 files changed, 2 insertions(+), 2 deletions(-) create mode 100644 fpakman/resources/img/flathub_45.png create mode 100644 fpakman/resources/img/update_logo.png diff --git a/fpakman/resources/img/flathub_45.png b/fpakman/resources/img/flathub_45.png new file mode 100644 index 0000000000000000000000000000000000000000..5ccd4c97496335612a7248db94bfc89120e970d7 GIT binary patch literal 34235 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 ayL(=?SxdJSVN(JD|Ky~VrOG8t0{#!tiDiKR literal 0 HcmV?d00001 diff --git a/fpakman/resources/img/update_logo.png b/fpakman/resources/img/update_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..498deed62dfaefae2464bd7492fbbabcee28b8fb GIT binary patch literal 29663 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
    &P-48zw8WNe&Z)M#kbvVG~qiY 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 literal 0 HcmV?d00001 diff --git a/fpakman/view/qt/systray.py b/fpakman/view/qt/systray.py index 381d2bef..4a737c83 100755 --- a/fpakman/view/qt/systray.py +++ b/fpakman/view/qt/systray.py @@ -38,8 +38,8 @@ class TrayIcon(QSystemTrayIcon): self.locale_keys = locale_keys self.controller = controller - self.icon_default = QIcon(resource.get_path('img/flathub_45.svg')) - self.icon_update = QIcon(resource.get_path('img/update_logo.svg')) + self.icon_default = QIcon(resource.get_path('img/flathub_45.png')) + self.icon_update = QIcon(resource.get_path('img/update_logo.png')) QSystemTrayIcon.__init__(self, self.icon_default, parent) self.menu = QMenu(parent) From 7ffbbf322416a97c93b8c530195826135dd54d33 Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Mon, 17 Jun 2019 14:55:32 -0300 Subject: [PATCH 031/123] Last commit rollback --- fpakman/resources/img/flathub_45.png | Bin 34235 -> 0 bytes fpakman/resources/img/update_logo.png | Bin 29663 -> 0 bytes fpakman/view/qt/systray.py | 4 ++-- 3 files changed, 2 insertions(+), 2 deletions(-) delete mode 100644 fpakman/resources/img/flathub_45.png delete mode 100644 fpakman/resources/img/update_logo.png diff --git a/fpakman/resources/img/flathub_45.png b/fpakman/resources/img/flathub_45.png deleted file mode 100644 index 5ccd4c97496335612a7248db94bfc89120e970d7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 34235 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 ayL(=?SxdJSVN(JD|Ky~VrOG8t0{#!tiDiKR diff --git a/fpakman/resources/img/update_logo.png b/fpakman/resources/img/update_logo.png deleted file mode 100644 index 498deed62dfaefae2464bd7492fbbabcee28b8fb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 29663 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
      &P-48zw8WNe&Z)M#kbvVG~qiY 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 diff --git a/fpakman/view/qt/systray.py b/fpakman/view/qt/systray.py index 4a737c83..381d2bef 100755 --- a/fpakman/view/qt/systray.py +++ b/fpakman/view/qt/systray.py @@ -38,8 +38,8 @@ class TrayIcon(QSystemTrayIcon): self.locale_keys = locale_keys self.controller = controller - self.icon_default = QIcon(resource.get_path('img/flathub_45.png')) - self.icon_update = QIcon(resource.get_path('img/update_logo.png')) + self.icon_default = QIcon(resource.get_path('img/flathub_45.svg')) + self.icon_update = QIcon(resource.get_path('img/update_logo.svg')) QSystemTrayIcon.__init__(self, self.icon_default, parent) self.menu = QMenu(parent) From 0312e0cd2ed0e4b00f2a262c9dc2607de6e4fd73 Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Mon, 17 Jun 2019 15:31:31 -0300 Subject: [PATCH 032/123] AUR installation script fix --- aur/register_app.py | 1 + 1 file changed, 1 insertion(+) diff --git a/aur/register_app.py b/aur/register_app.py index eeaed2e4..f169bfce 100644 --- a/aur/register_app.py +++ b/aur/register_app.py @@ -11,6 +11,7 @@ if os.path.exists(local_resources_path): shutil.rmtree(local_resources_path) os.mkdir(local_resources_path) +os.mkdir(local_resources_path + '/resources') desktop_file = """ [Desktop Entry] From 6081041d9f98700c11e98c2ee06cb4faa0bba83e Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Mon, 17 Jun 2019 15:38:05 -0300 Subject: [PATCH 033/123] rollback last --- aur/register_app.py | 1 - 1 file changed, 1 deletion(-) diff --git a/aur/register_app.py b/aur/register_app.py index f169bfce..eeaed2e4 100644 --- a/aur/register_app.py +++ b/aur/register_app.py @@ -11,7 +11,6 @@ if os.path.exists(local_resources_path): shutil.rmtree(local_resources_path) os.mkdir(local_resources_path) -os.mkdir(local_resources_path + '/resources') desktop_file = """ [Desktop Entry] From 3e85d14ca8d30dd1795b5b67766c81dfdb6a6a40 Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Mon, 17 Jun 2019 15:45:35 -0300 Subject: [PATCH 034/123] resources fix --- fpakman/core/resource.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/fpakman/core/resource.py b/fpakman/core/resource.py index 3998b95e..efcb482e 100755 --- a/fpakman/core/resource.py +++ b/fpakman/core/resource.py @@ -1,7 +1,7 @@ import os -import getpass +from pathlib import Path -local_resource_path = '/home/{}/.local/share/fpakman/resources'.format(getpass.getuser()) +local_resource_path = '{}/.local/share/fpakman/resources'.format(str(Path.home())) app_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) From f467da2fbf94e9ac274ecee6bba203e917a7e85c Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Mon, 17 Jun 2019 15:54:46 -0300 Subject: [PATCH 035/123] debugging on --- fpakman/core/resource.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/fpakman/core/resource.py b/fpakman/core/resource.py index efcb482e..ea210ab9 100755 --- a/fpakman/core/resource.py +++ b/fpakman/core/resource.py @@ -8,6 +8,9 @@ app_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) def get_path(resource_path): if os.path.exists(local_resource_path): - return local_resource_path + '/' + resource_path + final_path = local_resource_path + '/' + resource_path else: - return app_dir + '/resources/' + resource_path + final_path = app_dir + '/resources/' + resource_path + + print(final_path) + return final_path From 1daf3eb5e218269811b4e6912298d921224177bd Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Mon, 17 Jun 2019 16:03:28 -0300 Subject: [PATCH 036/123] Locale path fix --- fpakman/core/resource.py | 7 ++----- fpakman/core/util.py | 2 +- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/fpakman/core/resource.py b/fpakman/core/resource.py index ea210ab9..efcb482e 100755 --- a/fpakman/core/resource.py +++ b/fpakman/core/resource.py @@ -8,9 +8,6 @@ app_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) def get_path(resource_path): if os.path.exists(local_resource_path): - final_path = local_resource_path + '/' + resource_path + return local_resource_path + '/' + resource_path else: - final_path = app_dir + '/resources/' + resource_path - - print(final_path) - return final_path + return app_dir + '/resources/' + resource_path diff --git a/fpakman/core/util.py b/fpakman/core/util.py index 9173d7f2..f66016ca 100644 --- a/fpakman/core/util.py +++ b/fpakman/core/util.py @@ -12,7 +12,7 @@ def get_locale_keys(): if current_locale: current_locale = current_locale[0] - locale_dir = resource.get_path('/locale') + locale_dir = resource.get_path('locale') for locale_file in glob.glob(locale_dir + '/*'): name = locale_file.split('/')[-1] From ce1eac86f36a6eb2b2a08ee3916cc7dbb19f7b13 Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Mon, 17 Jun 2019 16:39:59 -0300 Subject: [PATCH 037/123] systray context menu fix try --- fpakman/view/qt/systray.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fpakman/view/qt/systray.py b/fpakman/view/qt/systray.py index 381d2bef..a725c801 100755 --- a/fpakman/view/qt/systray.py +++ b/fpakman/view/qt/systray.py @@ -40,7 +40,7 @@ class TrayIcon(QSystemTrayIcon): self.icon_default = QIcon(resource.get_path('img/flathub_45.svg')) self.icon_update = QIcon(resource.get_path('img/update_logo.svg')) - QSystemTrayIcon.__init__(self, self.icon_default, parent) + super(TrayIcon, self).__init__(icon=self.icon_default) self.menu = QMenu(parent) self.action_manage = self.menu.addAction(self.locale_keys['tray.action.manage']) From 99051914ed1c219266b694c0c08881b4c6e17a3e Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Mon, 17 Jun 2019 17:01:02 -0300 Subject: [PATCH 038/123] KDE systray icon fix try --- fpakman/view/qt/systray.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/fpakman/view/qt/systray.py b/fpakman/view/qt/systray.py index a725c801..e9263a55 100755 --- a/fpakman/view/qt/systray.py +++ b/fpakman/view/qt/systray.py @@ -38,9 +38,9 @@ class TrayIcon(QSystemTrayIcon): self.locale_keys = locale_keys self.controller = controller - self.icon_default = QIcon(resource.get_path('img/flathub_45.svg')) - self.icon_update = QIcon(resource.get_path('img/update_logo.svg')) - super(TrayIcon, self).__init__(icon=self.icon_default) + self.icon_default = QIcon.fromTheme(resource.get_path('img/flathub_45.svg')) + self.icon_update = QIcon.fromTheme(resource.get_path('img/update_logo.svg')) + super(TrayIcon, self).__init__(self.icon_default, parent) self.menu = QMenu(parent) self.action_manage = self.menu.addAction(self.locale_keys['tray.action.manage']) From 4c5f2461d4575acbbd491931b0fbf0f99a0edd12 Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Mon, 17 Jun 2019 17:52:31 -0300 Subject: [PATCH 039/123] kde tray icon fix try --- fpakman/app.py | 2 -- fpakman/view/qt/systray.py | 9 +++++---- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/fpakman/app.py b/fpakman/app.py index 81a90d00..a44e43ca 100755 --- a/fpakman/app.py +++ b/fpakman/app.py @@ -20,10 +20,8 @@ app = QApplication(sys.argv) manager = FlatpakManager() manager.load_database_async() controller = FlatpakController(manager) -hidden_widget = QWidget() trayIcon = TrayIcon(locale_keys=locale_keys, - parent=hidden_widget, controller=controller, check_interval=int(os.getenv('FPAKMAN_CHECK_INTERVAL', 60))) trayIcon.show() diff --git a/fpakman/view/qt/systray.py b/fpakman/view/qt/systray.py index e9263a55..bda6c90b 100755 --- a/fpakman/view/qt/systray.py +++ b/fpakman/view/qt/systray.py @@ -34,25 +34,26 @@ class UpdateCheck(QThread): class TrayIcon(QSystemTrayIcon): - def __init__(self, locale_keys: dict, controller: FlatpakController, check_interval: int = 60, parent=None): + def __init__(self, locale_keys: dict, controller: FlatpakController, check_interval: int = 60): + super(TrayIcon, self).__init__() self.locale_keys = locale_keys self.controller = controller self.icon_default = QIcon.fromTheme(resource.get_path('img/flathub_45.svg')) self.icon_update = QIcon.fromTheme(resource.get_path('img/update_logo.svg')) - super(TrayIcon, self).__init__(self.icon_default, parent) + self.setIcon(self.icon_default) - self.menu = QMenu(parent) + self.menu = QMenu() self.action_manage = self.menu.addAction(self.locale_keys['tray.action.manage']) self.action_manage.triggered.connect(self.show_manage_window) self.action_exit = self.menu.addAction(self.locale_keys['tray.action.exit']) self.action_exit.triggered.connect(lambda: QCoreApplication.exit()) - self.setContextMenu(self.menu) self.manage_window = ManageWindow(locale_keys=self.locale_keys, controller=controller, tray_icon=self) self.check_thread = UpdateCheck(check_interval=check_interval, controller=self.controller) self.check_thread.signal.connect(self.notify_updates) self.check_thread.start() + self.setContextMenu(self.menu) def notify_updates(self, updates: int): if updates > 0: From 67aac0e6b1fe6785c0e8e3bc28d2db2c1f443f8f Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Mon, 17 Jun 2019 17:59:17 -0300 Subject: [PATCH 040/123] kde tray icon fix try --- fpakman/app.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/fpakman/app.py b/fpakman/app.py index a44e43ca..05f7d6b0 100755 --- a/fpakman/app.py +++ b/fpakman/app.py @@ -2,12 +2,13 @@ import argparse import os import sys +from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import QApplication, QWidget from fpakman.core.controller import FlatpakController from fpakman.core.model import FlatpakManager from fpakman.view.qt.systray import TrayIcon -from fpakman.core import __version__ +from fpakman.core import __version__, resource from fpakman.core import util parser = argparse.ArgumentParser(prog='fpakman', description="GUI for Flatpak applications management") @@ -17,6 +18,7 @@ args = parser.parse_args() locale_keys = util.get_locale_keys() app = QApplication(sys.argv) +app.setWindowIcon(QIcon(resource.get_path('img/flathub_45.svg'))) manager = FlatpakManager() manager.load_database_async() controller = FlatpakController(manager) From 6289c2856d630a62bf51fa1a6921193b2c64bb8a Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Mon, 17 Jun 2019 18:26:53 -0300 Subject: [PATCH 041/123] Refactoring --- fpakman/app.py | 6 +++--- fpakman/view/qt/systray.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/fpakman/app.py b/fpakman/app.py index 05f7d6b0..6cab3474 100755 --- a/fpakman/app.py +++ b/fpakman/app.py @@ -3,13 +3,13 @@ import os import sys from PyQt5.QtGui import QIcon -from PyQt5.QtWidgets import QApplication, QWidget +from PyQt5.QtWidgets import QApplication +from fpakman.core import __version__, resource +from fpakman.core import util from fpakman.core.controller import FlatpakController from fpakman.core.model import FlatpakManager from fpakman.view.qt.systray import TrayIcon -from fpakman.core import __version__, resource -from fpakman.core import util parser = argparse.ArgumentParser(prog='fpakman', description="GUI for Flatpak applications management") parser.add_argument('-v', '--version', action='version', version='%(prog)s {}'.format(__version__)) diff --git a/fpakman/view/qt/systray.py b/fpakman/view/qt/systray.py index bda6c90b..c97a4834 100755 --- a/fpakman/view/qt/systray.py +++ b/fpakman/view/qt/systray.py @@ -39,8 +39,8 @@ class TrayIcon(QSystemTrayIcon): self.locale_keys = locale_keys self.controller = controller - self.icon_default = QIcon.fromTheme(resource.get_path('img/flathub_45.svg')) - self.icon_update = QIcon.fromTheme(resource.get_path('img/update_logo.svg')) + self.icon_default = QIcon(resource.get_path('img/flathub_45.svg')) + self.icon_update = QIcon(resource.get_path('img/update_logo.svg')) self.setIcon(self.icon_default) self.menu = QMenu() @@ -48,12 +48,12 @@ class TrayIcon(QSystemTrayIcon): self.action_manage.triggered.connect(self.show_manage_window) self.action_exit = self.menu.addAction(self.locale_keys['tray.action.exit']) self.action_exit.triggered.connect(lambda: QCoreApplication.exit()) + self.setContextMenu(self.menu) self.manage_window = ManageWindow(locale_keys=self.locale_keys, controller=controller, tray_icon=self) self.check_thread = UpdateCheck(check_interval=check_interval, controller=self.controller) self.check_thread.signal.connect(self.notify_updates) self.check_thread.start() - self.setContextMenu(self.menu) def notify_updates(self, updates: int): if updates > 0: From 3ffe2ad96f6d4a0dfef40a01029c079f1f3ddc65 Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Tue, 18 Jun 2019 17:01:13 -0300 Subject: [PATCH 042/123] Management panel shows update commands streams --- CHANGELOG.md | 4 +++ README.md | 9 +++++-- aur/register_app.py | 27 +++++++------------- fpakman/core/controller.py | 4 +-- fpakman/core/flatpak.py | 13 ++++++++-- fpakman/core/model.py | 16 ++++++++++++ fpakman/core/resource.py | 8 +----- fpakman/core/system.py | 5 ++++ fpakman/view/qt/window.py | 51 +++++++++++++++++++++++++++----------- 9 files changed, 92 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0046e94..78711cb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [0.2.0] - 2019-06-18 +### Features + - Management panel shows update commands streams + ## [0.1.0] - 2019-06-14 ### Features - System tray icon. diff --git a/README.md b/README.md index 817b6a35..f9895877 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,13 @@ It has also a management window allowing the user to see all installed applicati - python-pyqt5 ### Distribution -Currently available via **PyPi** (sudo pip3 install fpakman) or **AUR** (for Arch Linux users. **fpakman-staging** is just for testing, do not install it.) +**PyPi** +``` +sudo pip3 install fpakman +``` + +**AUR** +As **fpakman** package. There is also a staging version (**fpakman-staging**) but is intended for testing and it may not work properly. ### Manual installation: @@ -38,6 +44,5 @@ You can change some application settings via environment variables: - **FPAKMAN_CHECK_INTERVAL**: define the updates check interval in seconds. Default: 60. ### Roadmap -- Show updates being applied - Search and install applications - Uninstall applications diff --git a/aur/register_app.py b/aur/register_app.py index eeaed2e4..bc1dce71 100644 --- a/aur/register_app.py +++ b/aur/register_app.py @@ -1,38 +1,29 @@ # used for AUR installation import os -import shutil import subprocess +import sys from pathlib import Path -share_path = str(Path.home()) + '/.local/share' -local_resources_path = share_path + '/fpakman' - -if os.path.exists(local_resources_path): - shutil.rmtree(local_resources_path) - -os.mkdir(local_resources_path) - desktop_file = """ [Desktop Entry] Type = Application Name = fpakman -Categories=System; +Categories = System; Comment = Manage your Flatpak applications Exec = /usr/bin/fpakman -Icon = {resource_path}/resources/img/flathub_45.svg -""".format(resource_path=local_resources_path) +Icon = /usr/lib/python{version}/site-packages/fpakman/resources/img/flathub_45.svg +""".format(version="{}.{}".format(sys.version_info.major, sys.version_info.minor)) -apps_path = share_path + '/applications' +apps_path = '{}/.local/share/applications'.format(str(Path.home())) if not os.path.exists(apps_path): os.mkdir(apps_path) -file_path = apps_path + '/fpakman.desktop' - -if os.path.exists(file_path): - os.remove(file_path) +file_path = '{}/fpakman.desktop'.format(apps_path) with open(file_path, 'w+') as f: f.write(desktop_file) -subprocess.run('desktop-file-install {}'.format(file_path), shell=True) +res = subprocess.run('desktop-file-install {}'.format(file_path), shell=True) + +exit(res.returncode) diff --git a/fpakman/core/controller.py b/fpakman/core/controller.py index 3e43630b..84ed8233 100755 --- a/fpakman/core/controller.py +++ b/fpakman/core/controller.py @@ -11,8 +11,8 @@ class FlatpakController: def refresh(self) -> List[dict]: return self.model.read_installed() - def update(self, package_refs: List[str]) -> List[dict]: - return self.model.update_apps(package_refs) + def update(self, app_ref: str): + return self.model.update_app(app_ref) def check_installed(self) -> bool: version = self.model.get_version() diff --git a/fpakman/core/flatpak.py b/fpakman/core/flatpak.py index 823c146a..b9fe8dfd 100755 --- a/fpakman/core/flatpak.py +++ b/fpakman/core/flatpak.py @@ -68,8 +68,17 @@ def list_installed() -> List[dict]: return [] -def update(ref: str): - return bool(system.run_cmd('flatpak update -y ' + ref)) +def update(app_ref: str) -> bool: + return bool(system.run_cmd('flatpak update -y ' + app_ref)) + + +def update_and_stream(app_ref: str): + """ + Updates the app reference and streams Flatpak output, + :param app_ref: + :return: + """ + return system.stream_cmd(['flatpak', 'update', '-y', app_ref]) def list_updates_as_str(): diff --git a/fpakman/core/model.py b/fpakman/core/model.py index 115976c6..8346a4ad 100755 --- a/fpakman/core/model.py +++ b/fpakman/core/model.py @@ -90,3 +90,19 @@ class FlatpakManager: return [*self.apps] return [] + + def update_app(self, ref: str): + + """ + :param ref: + :return: the update command stream + """ + + if self.apps: + + package_found = [app for app in self.apps if app['ref'] == ref] + + if package_found: + return flatpak.update_and_stream(ref) + + return None diff --git a/fpakman/core/resource.py b/fpakman/core/resource.py index efcb482e..e9378c6d 100755 --- a/fpakman/core/resource.py +++ b/fpakman/core/resource.py @@ -1,13 +1,7 @@ import os -from pathlib import Path -local_resource_path = '{}/.local/share/fpakman/resources'.format(str(Path.home())) app_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) def get_path(resource_path): - - if os.path.exists(local_resource_path): - return local_resource_path + '/' + resource_path - else: - return app_dir + '/resources/' + resource_path + return app_dir + '/resources/' + resource_path diff --git a/fpakman/core/system.py b/fpakman/core/system.py index dcd9a138..ddd5edfa 100644 --- a/fpakman/core/system.py +++ b/fpakman/core/system.py @@ -1,6 +1,11 @@ import subprocess +from typing import List def run_cmd(cmd: str, expected_code: int = 0, ignore_return_code: bool = False) -> str: res = subprocess.run(cmd, shell=True, stdout=subprocess.PIPE) return res.stdout.decode() if ignore_return_code or res.returncode == expected_code else None + + +def stream_cmd(cmd: List[str]): + return subprocess.Popen(cmd, stdout=subprocess.PIPE).stdout diff --git a/fpakman/view/qt/window.py b/fpakman/view/qt/window.py index 2c3a1dc4..f197c9b9 100755 --- a/fpakman/view/qt/window.py +++ b/fpakman/view/qt/window.py @@ -8,7 +8,7 @@ from PyQt5.QtGui import QIcon, QColor, QPixmap, QWindowStateChangeEvent from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkRequest from PyQt5.QtWidgets import QWidget, QVBoxLayout, QApplication, QTableWidget, \ QTableWidgetItem, QTableView, QCheckBox, QHeaderView, QToolButton, QToolBar, \ - QSizePolicy, QLabel, QMessageBox + QSizePolicy, QLabel, QMessageBox, QPlainTextEdit from fpakman.core import __version__ from fpakman.core import resource @@ -94,7 +94,7 @@ class ManageWindow(QWidget): self.bt_refresh = QToolButton() self.bt_refresh.setIcon(QIcon(resource.get_path('img/refresh.svg'))) - self.bt_refresh.clicked.connect(self.refresh) + self.bt_refresh.clicked.connect(lambda: self.refresh(clear_output=True)) toolbar.addWidget(self.bt_refresh) self.bt_update = QToolButton() @@ -118,8 +118,16 @@ class ManageWindow(QWidget): self.layout.addWidget(self.table_apps) + self.textarea_output = QPlainTextEdit(self) + self.textarea_output.resize(self.table_apps.size()) + self.textarea_output.setStyleSheet("background: black; color: white;") + self.layout.addWidget(self.textarea_output) + self.textarea_output.setVisible(False) + self.textarea_output.setReadOnly(True) + self.thread_update = UpdateSelectedApps(self.controller) - self.thread_update.signal.connect(self._finish_update_selected) + self.thread_update.signal_output.connect(self._update_action_output) + self.thread_update.signal_finished.connect(self._finish_update_selected) self.thread_refresh = RefreshApps(self.controller) self.thread_refresh.signal.connect(self._finish_refresh) @@ -202,11 +210,16 @@ class ManageWindow(QWidget): self.thread_lock.release() - def refresh(self): + def refresh(self, clear_output: bool = True): if self._acquire_lock(): self._check_flatpak_installed() self._begin_action(self.locale_keys['manage_window.status.refreshing'] + '...') + + if clear_output: + self.textarea_output.clear() + self.textarea_output.hide() + self.thread_refresh.start() def _finish_refresh(self): @@ -248,6 +261,8 @@ class ManageWindow(QWidget): self.bt_update.setEnabled(enable_bt_update) + self.tray_icon.notify_updates(updates) + def centralize(self): geo = self.frameGeometry() screen = QApplication.desktop().screenNumber(QApplication.desktop().cursor().pos()) @@ -338,21 +353,24 @@ class ManageWindow(QWidget): if self._acquire_lock(): if self.apps: + to_update = [pak['model']['ref'] for pak in self.apps if pak['visible'] and pak['update_checked']] if to_update: + self.textarea_output.clear() + self.textarea_output.setVisible(True) + self._begin_action(self.locale_keys['manage_window.status.updating'] + '...') self.thread_update.refs_to_update = to_update self.thread_update.start() def _finish_update_selected(self): - self.update_apps(self.thread_update.updated_apps) self.finish_action() - - if self.tray_icon and self.thread_update.updated_apps: - self.tray_icon.notify_updates(len([app for app in self.thread_update.updated_apps if app['update']])) - self._release_lock() + self.refresh(clear_output=False) + + def _update_action_output(self, output: str): + self.textarea_output.appendPlainText(output) def _begin_action(self, action_label: str): self.label_status.setText(action_label) @@ -369,20 +387,25 @@ class ManageWindow(QWidget): # Threaded actions - class UpdateSelectedApps(QThread): - signal = pyqtSignal() + signal_finished = pyqtSignal() + signal_output = pyqtSignal(str) def __init__(self, controller: FlatpakController): super(UpdateSelectedApps, self).__init__() self.controller = controller self.refs_to_update = [] - self.updated_apps = None def run(self): - self.updated_apps = self.controller.update(self.refs_to_update) - self.signal.emit() + + for app_ref in self.refs_to_update: + for output in self.controller.update(app_ref): + line = output.decode().strip() + if line: + self.signal_output.emit(line) + + self.signal_finished.emit() class RefreshApps(QThread): From 3b70984a360462c5db45f831e883947a5750afb6 Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Tue, 18 Jun 2019 17:10:01 -0300 Subject: [PATCH 043/123] Fix: register_app.py --- aur/register_app.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/aur/register_app.py b/aur/register_app.py index bc1dce71..98516e09 100644 --- a/aur/register_app.py +++ b/aur/register_app.py @@ -1,6 +1,5 @@ # used for AUR installation import os -import subprocess import sys from pathlib import Path @@ -23,7 +22,3 @@ file_path = '{}/fpakman.desktop'.format(apps_path) with open(file_path, 'w+') as f: f.write(desktop_file) - -res = subprocess.run('desktop-file-install {}'.format(file_path), shell=True) - -exit(res.returncode) From 6607e57e496e22aeb289666eb3dff997edec4b07 Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Tue, 18 Jun 2019 17:39:02 -0300 Subject: [PATCH 044/123] version update --- fpakman/__init__.py | 1 + fpakman/core/__init__.py | 2 +- setup.py | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/fpakman/__init__.py b/fpakman/__init__.py index e69de29b..7fd229a3 100644 --- a/fpakman/__init__.py +++ b/fpakman/__init__.py @@ -0,0 +1 @@ +__version__ = '0.2.0' diff --git a/fpakman/core/__init__.py b/fpakman/core/__init__.py index b794fd40..8b137891 100755 --- a/fpakman/core/__init__.py +++ b/fpakman/core/__init__.py @@ -1 +1 @@ -__version__ = '0.1.0' + diff --git a/setup.py b/setup.py index 10344000..2de123dc 100644 --- a/setup.py +++ b/setup.py @@ -15,7 +15,7 @@ with open(file_dir + '/requirements.txt', 'r') as f: requirements = [line.strip() for line in f.readlines() if line] -with open(file_dir + '/fpakman/core/__init__.py', 'r') as f: +with open(file_dir + '/fpakman/__init__.py', 'r') as f: exec(f.readlines()[0]) From 2e5498f6e49b4c208cee7f89410310cb56fdde38 Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Tue, 18 Jun 2019 17:52:09 -0300 Subject: [PATCH 045/123] version fix --- fpakman/app.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/fpakman/app.py b/fpakman/app.py index 6cab3474..0ee4c73c 100755 --- a/fpakman/app.py +++ b/fpakman/app.py @@ -5,7 +5,8 @@ import sys from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import QApplication -from fpakman.core import __version__, resource +from fpakman import __version__ +from fpakman.core import resource from fpakman.core import util from fpakman.core.controller import FlatpakController from fpakman.core.model import FlatpakManager From 94d000e435a0595a86ff31840da9b119b5937bb8 Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Tue, 18 Jun 2019 17:53:51 -0300 Subject: [PATCH 046/123] version fix --- fpakman/view/qt/window.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fpakman/view/qt/window.py b/fpakman/view/qt/window.py index f197c9b9..f804f567 100755 --- a/fpakman/view/qt/window.py +++ b/fpakman/view/qt/window.py @@ -10,7 +10,7 @@ from PyQt5.QtWidgets import QWidget, QVBoxLayout, QApplication, QTableWidget, \ QTableWidgetItem, QTableView, QCheckBox, QHeaderView, QToolButton, QToolBar, \ QSizePolicy, QLabel, QMessageBox, QPlainTextEdit -from fpakman.core import __version__ +from fpakman import __version__ from fpakman.core import resource from fpakman.core.controller import FlatpakController From 4e81563faf861a37b8ca6f00fafe6131f77c9560 Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Tue, 18 Jun 2019 18:02:52 -0300 Subject: [PATCH 047/123] Management panel status label is now orange --- CHANGELOG.md | 1 + fpakman/view/qt/window.py | 1 + 2 files changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78711cb2..de30c3b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [0.2.0] - 2019-06-18 ### Features - Management panel shows update commands streams + - Management panel status label is "orange" now ## [0.1.0] - 2019-06-14 ### Features diff --git a/fpakman/view/qt/window.py b/fpakman/view/qt/window.py index f804f567..978a2ed8 100755 --- a/fpakman/view/qt/window.py +++ b/fpakman/view/qt/window.py @@ -86,6 +86,7 @@ class ManageWindow(QWidget): self.label_status = QLabel() self.label_status.setText('') + self.label_status.setStyleSheet("color: orange") toolbar.addWidget(self.label_status) spacer = QWidget() From 4c55e7a61726aa59a4ac005d6c72381d15d0444e Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Tue, 18 Jun 2019 18:31:28 -0300 Subject: [PATCH 048/123] Fix portuguese translations --- fpakman/resources/locale/pt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fpakman/resources/locale/pt b/fpakman/resources/locale/pt index 5b817152..61db2c38 100644 --- a/fpakman/resources/locale/pt +++ b/fpakman/resources/locale/pt @@ -7,7 +7,7 @@ manage_window.columns.ref=Ref manage_window.columns.origin=Origem manage_window.columns.update=Atualizar ? manage_window.checkbox.only_apps=Apps -manage_window.label.updates=Actualizaciones +manage_window.label.updates=Atualizações manage_window.status.refreshing=Recarregando manage_window.status.updating=Atualizando popup.flatpak_not_installed.title=Erro From 10e69d1c3cdb62a9bd79e8c20bdbf15271d13a4d Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Tue, 18 Jun 2019 18:47:31 -0300 Subject: [PATCH 049/123] Fix: Flatpak 1.2.X application names not showing properly --- CHANGELOG.md | 17 ++++++++++------- fpakman/core/flatpak.py | 2 +- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index de30c3b7..57ce86be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,13 +6,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [0.2.0] - 2019-06-18 ### Features - - Management panel shows update commands streams - - Management panel status label is "orange" now +- Management panel shows update commands streams +- Management panel status label is "orange" now + +### Fixes +- Application name not properly showing for Flatpak 1.2.X ## [0.1.0] - 2019-06-14 ### Features - - System tray icon. - - Applications management window. - - Support for the following locales: PT, EN, ES. - - System notification for new updates. - - Update applications. +- System tray icon. +- Applications management window. +- Support for the following locales: PT, EN, ES. +- System notification for new updates. +- Update applications. diff --git a/fpakman/core/flatpak.py b/fpakman/core/flatpak.py index b9fe8dfd..fa9ca0bf 100755 --- a/fpakman/core/flatpak.py +++ b/fpakman/core/flatpak.py @@ -23,7 +23,7 @@ def app_str_to_json(line: str, version: str) -> dict: app['name'] = ref_data[0].split('.')[-1] app['version'] = None elif '1.2' <= version < '1.3': - app = {'name': app_array[0][1].split('.')[-1], + app = {'name': app_array[1].strip().split('.')[-1], 'id': app_array[1], 'version': app_array[2], 'branch': app_array[3], From 6136d83d31e8cb796cd98a0c218a699039fcbc74 Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Tue, 18 Jun 2019 19:01:23 -0300 Subject: [PATCH 050/123] Fix 'CHANGELOG.md' --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 57ce86be..7b9681db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Management panel status label is "orange" now ### Fixes -- Application name not properly showing for Flatpak 1.2.X +- Application name is not properly showing for Flatpak 1.2.X ## [0.1.0] - 2019-06-14 ### Features From 02cbafb487db8802bab8e153735b1aff9df448ca Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Sat, 22 Jun 2019 16:16:40 -0300 Subject: [PATCH 051/123] Fix: Retrieving information for the same AppId with different branches --- CHANGELOG.md | 4 ++++ fpakman/core/flatpak.py | 6 +++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b9681db..86a6142e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [0.2.1] - 2019-06-22 +### Fixes +- Retrieving information for the same AppId with different branches. + ## [0.2.0] - 2019-06-18 ### Features - Management panel shows update commands streams diff --git a/fpakman/core/flatpak.py b/fpakman/core/flatpak.py index fa9ca0bf..82ea400d 100755 --- a/fpakman/core/flatpak.py +++ b/fpakman/core/flatpak.py @@ -32,7 +32,7 @@ def app_str_to_json(line: str, version: str) -> dict: else: raise Exception('Unsupported version') - info = re.findall('\w+:\s.+', get_app_info(app['id'])) + info = re.findall(r'\w+:\s.+', get_app_info(app['id'], app['branch'])) fields_to_get = ['origin', 'arch', 'ref'] for field in info: @@ -53,8 +53,8 @@ def get_version(): return res.split(' ')[1].strip() if res else None -def get_app_info(app_id: str): - return system.run_cmd('flatpak info ' + app_id) +def get_app_info(app_id: str, branch: str): + return system.run_cmd('flatpak info {} {}'.format(app_id, branch)) def list_installed() -> List[dict]: From 0c201a8ad759aa8b6668c76b42602d1bae257c6f Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Sat, 22 Jun 2019 16:28:43 -0300 Subject: [PATCH 052/123] Upgrading version --- fpakman/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fpakman/__init__.py b/fpakman/__init__.py index 7fd229a3..fc79d63d 100644 --- a/fpakman/__init__.py +++ b/fpakman/__init__.py @@ -1 +1 @@ -__version__ = '0.2.0' +__version__ = '0.2.1' From 477ad435e737f51aa0ce77308d14c4f85a27c70b Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Mon, 24 Jun 2019 12:56:50 -0300 Subject: [PATCH 053/123] Showing the number of apps and runtime updates available --- CHANGELOG.md | 4 +++- fpakman/view/qt/window.py | 16 ++++++++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 86a6142e..c33eca66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,9 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). -## [0.2.1] - 2019-06-22 +## [0.2.1] - 2019-06-24 +### Features +- Showing the number of apps and runtime updates available ### Fixes - Retrieving information for the same AppId with different branches. diff --git a/fpakman/view/qt/window.py b/fpakman/view/qt/window.py index 978a2ed8..ab5bb616 100755 --- a/fpakman/view/qt/window.py +++ b/fpakman/view/qt/window.py @@ -248,10 +248,18 @@ class ManageWindow(QWidget): enable_bt_update = False - updates = len([app for app in self.apps if app['model']['update']]) + app_updates, runtime_updates = 0, 0 - if updates > 0: - self.label_updates.setText('{}: {}'.format(self.locale_keys['manage_window.label.updates'], updates)) + for app in self.apps: + if app['model']['update']: + if app['model']['runtime']: + runtime_updates += 1 + else: + app_updates += 1 + + total_updates = app_updates + runtime_updates + if total_updates > 0: + self.label_updates.setText('{}: {} ( {} apps | {} runtimes )'.format(self.locale_keys['manage_window.label.updates'], total_updates, app_updates, runtime_updates)) else: self.label_updates.setText('') @@ -262,7 +270,7 @@ class ManageWindow(QWidget): self.bt_update.setEnabled(enable_bt_update) - self.tray_icon.notify_updates(updates) + self.tray_icon.notify_updates(total_updates) def centralize(self): geo = self.frameGeometry() From 838b3fe903cf483ba9a45de0743fb0b7d360b790 Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Mon, 24 Jun 2019 13:00:58 -0300 Subject: [PATCH 054/123] CHANGELOG.md fix --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c33eca66..affcdc85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Features - Showing the number of apps and runtime updates available ### Fixes -- Retrieving information for the same AppId with different branches. +- Retrieving information for the same AppId from different branches. ## [0.2.0] - 2019-06-18 ### Features From 009503fa36f9c74b72eed9aca567f4dd62c71b32 Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Mon, 24 Jun 2019 15:08:13 -0300 Subject: [PATCH 055/123] Changing 'register_app.py' to generate the .desktop file to local dir --- aur/{register_app.py => desktop_entry.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename aur/{register_app.py => desktop_entry.py} (92%) diff --git a/aur/register_app.py b/aur/desktop_entry.py similarity index 92% rename from aur/register_app.py rename to aur/desktop_entry.py index 98516e09..e927f86f 100644 --- a/aur/register_app.py +++ b/aur/desktop_entry.py @@ -18,7 +18,7 @@ apps_path = '{}/.local/share/applications'.format(str(Path.home())) if not os.path.exists(apps_path): os.mkdir(apps_path) -file_path = '{}/fpakman.desktop'.format(apps_path) +file_path = 'fpakman.desktop'.format(apps_path) with open(file_path, 'w+') as f: f.write(desktop_file) From fa871f000d7cdfe2d1bed6f1bcefb61ca7bcef7a Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Mon, 24 Jun 2019 16:11:02 -0300 Subject: [PATCH 056/123] cleaning .desktop file from .local --- aur/desktop_entry.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/aur/desktop_entry.py b/aur/desktop_entry.py index e927f86f..6d40717c 100644 --- a/aur/desktop_entry.py +++ b/aur/desktop_entry.py @@ -1,4 +1,4 @@ -# used for AUR installation +# Generates a .desktop file based on the current python version. Used for AUR installation import os import sys from pathlib import Path @@ -13,12 +13,12 @@ Exec = /usr/bin/fpakman Icon = /usr/lib/python{version}/site-packages/fpakman/resources/img/flathub_45.svg """.format(version="{}.{}".format(sys.version_info.major, sys.version_info.minor)) -apps_path = '{}/.local/share/applications'.format(str(Path.home())) - -if not os.path.exists(apps_path): - os.mkdir(apps_path) - -file_path = 'fpakman.desktop'.format(apps_path) - -with open(file_path, 'w+') as f: +with open('fpakman.desktop', 'w+') as f: f.write(desktop_file) + + +# cleaning the old fpakman.desktop entry model -> the following lines will be removed for the next releases +desktop_file_path = '{}/.local/share/applications/fpakman.desktop'.format(str(Path.home())) + +if os.path.exists(desktop_file_path): + os.remove(desktop_file_path) From 59462e3bb27f16ed06f31e90abc6218069a65e98 Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Mon, 24 Jun 2019 16:43:22 -0300 Subject: [PATCH 057/123] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f9895877..1e66ab47 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ ## fpakman -Graphical user interface for Flatpak application management. It is a tray icon to let the user known when new updates are available. -It has also a management window allowing the user to see all installed applications and update them. +Non-official graphical user interface for Flatpak application management. It is a tray icon that let the user known when new updates are available and +an application management panel. ### Developed with: - Python3 and QT 5. From 6c19f76c47d8ed808d4c0308370702aac622254e Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Mon, 24 Jun 2019 17:05:03 -0300 Subject: [PATCH 058/123] Adding tooltips to toolbar buttons --- CHANGELOG.md | 5 +++++ fpakman/resources/locale/en | 4 +++- fpakman/resources/locale/es | 2 ++ fpakman/resources/locale/pt | 2 ++ fpakman/view/qt/window.py | 16 +++++++++------- 5 files changed, 21 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index affcdc85..3f862185 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [0.2.2] +### Improvements +- Adding tooltips to toolbar buttons +- "Update ?" column renamed to "Upgrade ?" + ## [0.2.1] - 2019-06-24 ### Features - Showing the number of apps and runtime updates available diff --git a/fpakman/resources/locale/en b/fpakman/resources/locale/en index 6a3a38ff..15424852 100644 --- a/fpakman/resources/locale/en +++ b/fpakman/resources/locale/en @@ -5,11 +5,13 @@ manage_window.columns.branch=Branch manage_window.columns.arch=Arch manage_window.columns.ref=Ref manage_window.columns.origin=Origin -manage_window.columns.update=Update ? +manage_window.columns.update=Upgrade ? manage_window.checkbox.only_apps=Apps manage_window.label.updates=Updates manage_window.status.refreshing=Refreshing manage_window.status.updating=Updating +manage_window.bt.refresh.tooltip=Reload the data about installed applications +manage_window.bt.upgrade.tooltip=Upgrade all selected applications popup.flatpak_not_installed.title=Error popup.flatpak_not_installed.msg=flatpak seems not to be installed. Exiting tray.action.manage=Manage applications diff --git a/fpakman/resources/locale/es b/fpakman/resources/locale/es index 801c5b26..9c8f43d4 100644 --- a/fpakman/resources/locale/es +++ b/fpakman/resources/locale/es @@ -10,6 +10,8 @@ manage_window.checkbox.only_apps=Apps manage_window.label.updates=Actualizaciones manage_window.status.refreshing=Recargando manage_window.status.updating=Actualizando +manage_window.bt.refresh.tooltip=Recarga los datos sobre los aplicativos instalados +manage_window.bt.upgrade.tooltip=Actualiza todos los aplicativos seleccionados popup.flatpak_not_installed.title=Error popup.flatpak_not_installed.msg=flatpak no parece estar instalado. Saliendo tray.action.manage=Administrar aplicativos diff --git a/fpakman/resources/locale/pt b/fpakman/resources/locale/pt index 61db2c38..957ff0ef 100644 --- a/fpakman/resources/locale/pt +++ b/fpakman/resources/locale/pt @@ -10,6 +10,8 @@ manage_window.checkbox.only_apps=Apps manage_window.label.updates=Atualizações manage_window.status.refreshing=Recarregando manage_window.status.updating=Atualizando +manage_window.bt.refresh.tooltip=Recarrega os dados sobre os aplicativos instalados +manage_window.bt.upgrade.tooltip=Atualiza todos os aplicativos selecionados popup.flatpak_not_installed.title=Erro popup.flatpak_not_installed.msg=flatpak não parece estar instalado. Saindo tray.action.manage=Gerenciar aplicativos diff --git a/fpakman/view/qt/window.py b/fpakman/view/qt/window.py index ab5bb616..1be598c8 100755 --- a/fpakman/view/qt/window.py +++ b/fpakman/view/qt/window.py @@ -94,15 +94,17 @@ class ManageWindow(QWidget): toolbar.addWidget(spacer) self.bt_refresh = QToolButton() + self.bt_refresh.setToolTip(locale_keys['manage_window.bt.refresh.tooltip']) self.bt_refresh.setIcon(QIcon(resource.get_path('img/refresh.svg'))) self.bt_refresh.clicked.connect(lambda: self.refresh(clear_output=True)) toolbar.addWidget(self.bt_refresh) - self.bt_update = QToolButton() - self.bt_update.setIcon(QIcon(resource.get_path('img/update_green.svg'))) - self.bt_update.setEnabled(False) - self.bt_update.clicked.connect(self.update_selected) - toolbar.addWidget(self.bt_update) + self.bt_upgrade = QToolButton() + self.bt_upgrade.setToolTip(locale_keys['manage_window.bt.upgrade.tooltip']) + self.bt_upgrade.setIcon(QIcon(resource.get_path('img/update_green.svg'))) + self.bt_upgrade.setEnabled(False) + self.bt_upgrade.clicked.connect(self.update_selected) + toolbar.addWidget(self.bt_upgrade) self.layout.addWidget(toolbar) @@ -268,7 +270,7 @@ class ManageWindow(QWidget): enable_bt_update = True break - self.bt_update.setEnabled(enable_bt_update) + self.bt_upgrade.setEnabled(enable_bt_update) self.tray_icon.notify_updates(total_updates) @@ -383,7 +385,7 @@ class ManageWindow(QWidget): def _begin_action(self, action_label: str): self.label_status.setText(action_label) - self.bt_update.setEnabled(False) + self.bt_upgrade.setEnabled(False) self.bt_refresh.setEnabled(False) self.checkbox_only_apps.setEnabled(False) self.table_apps.setEnabled(False) From c15f433bac84bc6c2dccafdd82524ef6490c70a6 Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Mon, 24 Jun 2019 17:15:19 -0300 Subject: [PATCH 059/123] Management panel title renamed --- CHANGELOG.md | 1 + fpakman/resources/locale/en | 1 + fpakman/resources/locale/es | 1 + fpakman/resources/locale/pt | 1 + fpakman/view/qt/window.py | 2 +- 5 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f862185..4f405d19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Improvements - Adding tooltips to toolbar buttons - "Update ?" column renamed to "Upgrade ?" +- Management panel title renamed ## [0.2.1] - 2019-06-24 ### Features diff --git a/fpakman/resources/locale/en b/fpakman/resources/locale/en index 15424852..9b424929 100644 --- a/fpakman/resources/locale/en +++ b/fpakman/resources/locale/en @@ -1,3 +1,4 @@ +manage_window.title=Flatpak Application Manager manage_window.columns.name=Name manage_window.columns.version=Version manage_window.columns.latest_version=Latest Version diff --git a/fpakman/resources/locale/es b/fpakman/resources/locale/es index 9c8f43d4..a05f65ac 100644 --- a/fpakman/resources/locale/es +++ b/fpakman/resources/locale/es @@ -1,3 +1,4 @@ +manage_window.title=Administrador de Aplicativos Flatpak manage_window.columns.name=Nombre manage_window.columns.version=Versión manage_window.columns.latest_version=Ultima Versión diff --git a/fpakman/resources/locale/pt b/fpakman/resources/locale/pt index 957ff0ef..36cbf078 100644 --- a/fpakman/resources/locale/pt +++ b/fpakman/resources/locale/pt @@ -1,3 +1,4 @@ +manage_window.title=Gerenciador de Aplicativos Flatpak manage_window.columns.name=Nome manage_window.columns.version=Versão manage_window.columns.latest_version=Última Versão diff --git a/fpakman/view/qt/window.py b/fpakman/view/qt/window.py index 1be598c8..0bd38872 100755 --- a/fpakman/view/qt/window.py +++ b/fpakman/view/qt/window.py @@ -66,7 +66,7 @@ class ManageWindow(QWidget): self.icon_flathub = QIcon(resource.get_path('img/flathub_45.svg')) self._check_flatpak_installed() self.resize(ManageWindow.__BASE_HEIGHT__, ManageWindow.__BASE_HEIGHT__) - self.setWindowTitle('fpakman ({})'.format(__version__)) + self.setWindowTitle('{} ({})'.format(locale_keys['manage_window.title'], __version__)) self.setWindowIcon(self.icon_flathub) self.layout = QVBoxLayout() From 2dc490ff432fdd31de98fdd8c1eb57728fdfddab Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Mon, 24 Jun 2019 17:16:08 -0300 Subject: [PATCH 060/123] Upgrading version to 0.2.2 --- fpakman/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fpakman/__init__.py b/fpakman/__init__.py index fc79d63d..020ed73d 100644 --- a/fpakman/__init__.py +++ b/fpakman/__init__.py @@ -1 +1 @@ -__version__ = '0.2.1' +__version__ = '0.2.2' From e054186d69cd8f582a76ba524eaeb4361e7630ed Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Mon, 24 Jun 2019 18:30:55 -0300 Subject: [PATCH 061/123] README.md fix --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 1e66ab47..c3a78d5b 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ sudo pip3 install fpakman ``` **AUR** -As **fpakman** package. There is also a staging version (**fpakman-staging**) but is intended for testing and it may not work properly. +As **fpakman** package. There is also a staging version (**fpakman-staging**) but is intended for testing and may not work properly. ### Manual installation: @@ -46,3 +46,4 @@ You can change some application settings via environment variables: ### Roadmap - Search and install applications - Uninstall applications +- Downgrade applications \ No newline at end of file From 9d6187b0a0d8c08b130102a3dffc38f280ad1e16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vin=C3=ADcius=20Moreira?= Date: Tue, 25 Jun 2019 11:48:28 -0300 Subject: [PATCH 062/123] Uninstall selected app --- CHANGELOG.md | 5 +- fpakman/__init__.py | 2 +- fpakman/core/controller.py | 3 + fpakman/core/flatpak.py | 9 ++ fpakman/core/model.py | 22 +++- fpakman/resources/img/uninstall.svg | 76 ++++++++++++ fpakman/resources/locale/en | 4 + fpakman/resources/locale/es | 4 + fpakman/resources/locale/pt | 4 + fpakman/view/qt/apps_table.py | 154 ++++++++++++++++++++++++ fpakman/view/qt/window.py | 174 +++++++++------------------- 11 files changed, 330 insertions(+), 127 deletions(-) create mode 100644 fpakman/resources/img/uninstall.svg create mode 100644 fpakman/view/qt/apps_table.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f405d19..65877065 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). -## [0.2.2] +## [0.3.0] +### Features +- Allows app uninstall by right clicking it. + ### Improvements - Adding tooltips to toolbar buttons - "Update ?" column renamed to "Upgrade ?" diff --git a/fpakman/__init__.py b/fpakman/__init__.py index 020ed73d..0404d810 100644 --- a/fpakman/__init__.py +++ b/fpakman/__init__.py @@ -1 +1 @@ -__version__ = '0.2.2' +__version__ = '0.3.0' diff --git a/fpakman/core/controller.py b/fpakman/core/controller.py index 84ed8233..4a05e3a2 100755 --- a/fpakman/core/controller.py +++ b/fpakman/core/controller.py @@ -20,3 +20,6 @@ class FlatpakController: def get_version(self) -> str: return self.model.get_version() + + def uninstall(self, app_ref: str): + return self.model.remove_app(app_ref) diff --git a/fpakman/core/flatpak.py b/fpakman/core/flatpak.py index 82ea400d..b42cdd8c 100755 --- a/fpakman/core/flatpak.py +++ b/fpakman/core/flatpak.py @@ -81,5 +81,14 @@ def update_and_stream(app_ref: str): return system.stream_cmd(['flatpak', 'update', '-y', app_ref]) +def uninstall_and_stream(app_ref: str): + """ + Removes the app by its reference + :param app_ref: + :return: + """ + return system.stream_cmd(['flatpak', 'uninstall', app_ref, '-y']) + + def list_updates_as_str(): return system.run_cmd('flatpak update', ignore_return_code=True) diff --git a/fpakman/core/model.py b/fpakman/core/model.py index 8346a4ad..904b0116 100755 --- a/fpakman/core/model.py +++ b/fpakman/core/model.py @@ -91,6 +91,12 @@ class FlatpakManager: return [] + def _find_by_ref(self, ref: str): + package_found = [app for app in self.apps if app['ref'] == ref] + + if package_found: + return package_found[0] + def update_app(self, ref: str): """ @@ -100,9 +106,19 @@ class FlatpakManager: if self.apps: - package_found = [app for app in self.apps if app['ref'] == ref] + app_found = self._find_by_ref(ref) - if package_found: + if app_found: return flatpak.update_and_stream(ref) - return None + def remove_app(self, ref: str): + """ + :param ref: + :return: the uninstall command stream + """ + if self.apps: + + app_found = self._find_by_ref(ref) + + if app_found: + return flatpak.uninstall_and_stream(ref) diff --git a/fpakman/resources/img/uninstall.svg b/fpakman/resources/img/uninstall.svg new file mode 100644 index 00000000..b09818d1 --- /dev/null +++ b/fpakman/resources/img/uninstall.svg @@ -0,0 +1,76 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + diff --git a/fpakman/resources/locale/en b/fpakman/resources/locale/en index 9b424929..877f1790 100644 --- a/fpakman/resources/locale/en +++ b/fpakman/resources/locale/en @@ -7,10 +7,14 @@ manage_window.columns.arch=Arch manage_window.columns.ref=Ref manage_window.columns.origin=Origin manage_window.columns.update=Upgrade ? +manage_window.apps_table.row.actions.uninstall=Uninstall +manage_window.apps_table.row.actions.uninstall.popup.title=Uninstall +manage_window.apps_table.row.actions.uninstall.popup.body=Remove {} ? manage_window.checkbox.only_apps=Apps manage_window.label.updates=Updates manage_window.status.refreshing=Refreshing manage_window.status.updating=Updating +manage_window.status.uninstalling=Uninstalling manage_window.bt.refresh.tooltip=Reload the data about installed applications manage_window.bt.upgrade.tooltip=Upgrade all selected applications popup.flatpak_not_installed.title=Error diff --git a/fpakman/resources/locale/es b/fpakman/resources/locale/es index a05f65ac..4a3a0f37 100644 --- a/fpakman/resources/locale/es +++ b/fpakman/resources/locale/es @@ -7,10 +7,14 @@ manage_window.columns.arch=Arch manage_window.columns.ref=Ref manage_window.columns.origin=Origen manage_window.columns.update=¿Actualizar? +manage_window.apps_table.row.actions.uninstall=Desinstalar +manage_window.apps_table.row.actions.uninstall.popup.title=Desinstalación +manage_window.apps_table.row.actions.uninstall.popup.body=¿Eliminar {}? manage_window.checkbox.only_apps=Apps manage_window.label.updates=Actualizaciones manage_window.status.refreshing=Recargando manage_window.status.updating=Actualizando +manage_window.status.uninstalling=Desinstalando manage_window.bt.refresh.tooltip=Recarga los datos sobre los aplicativos instalados manage_window.bt.upgrade.tooltip=Actualiza todos los aplicativos seleccionados popup.flatpak_not_installed.title=Error diff --git a/fpakman/resources/locale/pt b/fpakman/resources/locale/pt index 36cbf078..d4a005b7 100644 --- a/fpakman/resources/locale/pt +++ b/fpakman/resources/locale/pt @@ -7,10 +7,14 @@ manage_window.columns.arch=Arch manage_window.columns.ref=Ref manage_window.columns.origin=Origem manage_window.columns.update=Atualizar ? +manage_window.apps_table.row.actions.uninstall=Desinstalar +manage_window.apps_table.row.actions.uninstall.popup.title=Desinstalação +manage_window.apps_table.row.actions.uninstall.popup.body=Rmover {}? manage_window.checkbox.only_apps=Apps manage_window.label.updates=Atualizações manage_window.status.refreshing=Recarregando manage_window.status.updating=Atualizando +manage_window.status.uninstalling=Desinstalando manage_window.bt.refresh.tooltip=Recarrega os dados sobre os aplicativos instalados manage_window.bt.upgrade.tooltip=Atualiza todos os aplicativos selecionados popup.flatpak_not_installed.title=Erro diff --git a/fpakman/view/qt/apps_table.py b/fpakman/view/qt/apps_table.py new file mode 100644 index 00000000..4671b847 --- /dev/null +++ b/fpakman/view/qt/apps_table.py @@ -0,0 +1,154 @@ +from typing import List + +from PyQt5.QtCore import Qt, QUrl +from PyQt5.QtGui import QPixmap, QIcon, QColor, QCursor +from PyQt5.QtNetwork import QNetworkRequest, QNetworkAccessManager +from PyQt5.QtWidgets import QTableWidget, QTableView, QMenu, QAction, QTableWidgetItem, QToolButton, QWidget, \ + QMessageBox, QHeaderView + +from fpakman.core import resource +from fpakman.core.controller import FlatpakController + + +class UpdateToggleButton(QToolButton): + + def __init__(self, model: dict, root: QWidget, checked: bool = True): + super(UpdateToggleButton, self).__init__() + self.model = model + self.root = root + self.setCheckable(True) + self.clicked.connect(self.change_state) + self.icon_on = QIcon(resource.get_path('img/toggle_on.svg')) + self.icon_off = QIcon(resource.get_path('img/toggle_off.svg')) + self.setIcon(self.icon_on) + self.setStyleSheet('border: 0px;') + + if not checked: + self.click() + + def change_state(self, not_checked: bool): + self.model['update_checked'] = not not_checked + self.setIcon(self.icon_on if not not_checked else self.icon_off) + self.root.change_update_state() + + +class AppsTable(QTableWidget): + + def __init__(self, parent: QWidget, controller: FlatpakController, columns: List[str]): + super(AppsTable, self).__init__() + self.parent = parent + self.controller = controller + self.columns = columns + self.setColumnCount(len(columns)) + self.setFocusPolicy(Qt.NoFocus) + self.setShowGrid(False) + self.verticalHeader().setVisible(False) + self.setSelectionBehavior(QTableView.SelectRows) + self.setHorizontalHeaderLabels(columns) + self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.icon_flathub = QIcon(resource.get_path('img/flathub_45.svg')) + self.icon_uninstall = QIcon(resource.get_path('img/uninstall.svg')) + + self.network_man = QNetworkAccessManager() + self.network_man.finished.connect(self._load_icon) + + self.icon_cache = {} + + def contextMenuEvent(self, QContextMenuEvent): # selected row right click event + menu_row = QMenu() + + action_uninstall = QAction(self.parent.locale_keys["manage_window.apps_table.row.actions.uninstall"]) + action_uninstall.setIcon(self.icon_uninstall) + action_uninstall.triggered.connect(self._uninstall_app) + + menu_row.addAction(action_uninstall) + menu_row.adjustSize() + menu_row.popup(QCursor.pos()) + menu_row.exec_() + + def _uninstall_app(self): + selected_app = self.parent.apps[self.currentRow()] + + confirmation = QMessageBox.question(self, self.parent.locale_keys['manage_window.apps_table.row.actions.uninstall.popup.title'], + self.parent.locale_keys['manage_window.apps_table.row.actions.uninstall.popup.body'].format(selected_app['model']['name']), + QMessageBox.Yes | QMessageBox.No, QMessageBox.No) + if confirmation == QMessageBox.Yes: + self.parent.uninstall_app(selected_app['model']['ref']) + + def _load_icon(self, http_response): + icon_url = http_response.url().toString() + pixmap = QPixmap() + pixmap.loadFromData(http_response.readAll()) + icon = QIcon(pixmap) + self.icon_cache[icon_url] = icon + + for idx, app in enumerate(self.parent.apps): + if app['model']['icon'] == icon_url: + self.item(idx, 0).setIcon(icon) + self.parent.resize_and_center() + break + + def update_apps(self, apps: List[dict]): + self.setEnabled(True) + self.setRowCount(len(apps) if apps else 0) + + if apps: + for idx, app in enumerate(apps): + + col_name = QTableWidgetItem() + col_name.setText(app['model']['name']) + col_name.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) + + if not app['model']['icon']: + col_name.setIcon(self.icon_flathub) + else: + cached_icon = self.icon_cache.get(app['model']['icon']) + + if cached_icon: + col_name.setIcon(cached_icon) + else: + col_name.setIcon(self.icon_flathub) + self.network_man.get(QNetworkRequest(QUrl(app['model']['icon']))) + + self.setItem(idx, 0, col_name) + + col_version = QTableWidgetItem() + col_version.setText(app['model']['version']) + col_version.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) + self.setItem(idx, 1, col_version) + + col_release = QTableWidgetItem() + col_release.setText(app['model']['latest_version']) + col_release.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) + self.setItem(idx, 2, col_release) + + if app['model']['update']: + col_release.setForeground(QColor('orange')) + + col_branch = QTableWidgetItem() + col_branch.setText(app['model']['branch']) + col_branch.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) + self.setItem(idx, 3, col_branch) + + col_arch = QTableWidgetItem() + col_arch.setText(app['model']['arch']) + col_arch.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) + self.setItem(idx, 4, col_arch) + + col_package = QTableWidgetItem() + col_package.setText(app['model']['ref']) + col_package.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) + self.setItem(idx, 5, col_package) + + col_origin = QTableWidgetItem() + col_origin.setText(app['model']['origin']) + col_origin.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) + self.setItem(idx, 6, col_origin) + + col_update = UpdateToggleButton(app['model'], self.parent, app['model']['update']) if app['model']['update'] else None + self.setCellWidget(idx, 7, col_update) + + def change_headers_policy(self, policy: QHeaderView = QHeaderView.ResizeToContents): + header_horizontal = self.horizontalHeader() + for i in range(0, len(self.columns)): + header_horizontal.setSectionResizeMode(i, policy) diff --git a/fpakman/view/qt/window.py b/fpakman/view/qt/window.py index 0bd38872..630ab340 100755 --- a/fpakman/view/qt/window.py +++ b/fpakman/view/qt/window.py @@ -3,38 +3,15 @@ from functools import reduce from threading import Lock from typing import List -from PyQt5.QtCore import Qt, QThread, pyqtSignal, QUrl, QEvent -from PyQt5.QtGui import QIcon, QColor, QPixmap, QWindowStateChangeEvent -from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkRequest -from PyQt5.QtWidgets import QWidget, QVBoxLayout, QApplication, QTableWidget, \ - QTableWidgetItem, QTableView, QCheckBox, QHeaderView, QToolButton, QToolBar, \ +from PyQt5.QtCore import QThread, pyqtSignal, QEvent +from PyQt5.QtGui import QIcon, QWindowStateChangeEvent +from PyQt5.QtWidgets import QWidget, QVBoxLayout, QApplication, QCheckBox, QHeaderView, QToolButton, QToolBar, \ QSizePolicy, QLabel, QMessageBox, QPlainTextEdit from fpakman import __version__ from fpakman.core import resource from fpakman.core.controller import FlatpakController - - -class UpdateToggleButton(QToolButton): - - def __init__(self, model: dict, root: QWidget, checked: bool = True): - super(UpdateToggleButton, self).__init__() - self.model = model - self.root = root - self.setCheckable(True) - self.clicked.connect(self.change_state) - self.icon_on = QIcon(resource.get_path('img/toggle_on.svg')) - self.icon_off = QIcon(resource.get_path('img/toggle_off.svg')) - self.setIcon(self.icon_on) - self.setStyleSheet('border: 0px;') - - if not checked: - self.click() - - def change_state(self, not_checked: bool): - self.model['update_checked'] = not not_checked - self.setIcon(self.icon_on if not not_checked else self.icon_off) - self.root.change_update_state() +from fpakman.view.qt.apps_table import AppsTable class ManageWindow(QWidget): @@ -53,16 +30,12 @@ class ManageWindow(QWidget): locale_keys['manage_window.columns.origin'], locale_keys['manage_window.columns.update']] self.controller = controller - self.icon_cache = {} self.tray_icon = tray_icon self.thread_lock = Lock() self.working = False # restrict the number of threaded actions self.apps = [] self.label_flatpak = None - self.network_man = QNetworkAccessManager() - self.network_man.finished.connect(self._load_icon) - self.icon_flathub = QIcon(resource.get_path('img/flathub_45.svg')) self._check_flatpak_installed() self.resize(ManageWindow.__BASE_HEIGHT__, ManageWindow.__BASE_HEIGHT__) @@ -108,16 +81,8 @@ class ManageWindow(QWidget): self.layout.addWidget(toolbar) - self.table_apps = QTableWidget() - self.table_apps.setColumnCount(len(self.column_names)) - self.table_apps.setFocusPolicy(Qt.NoFocus) - self.table_apps.setShowGrid(False) - self.table_apps.verticalHeader().setVisible(False) - self.table_apps.setSelectionBehavior(QTableView.SelectRows) - self.table_apps.setHorizontalHeaderLabels(self.column_names) - self.table_apps.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - - self._change_table_headers_policy() + self.table_apps = AppsTable(self, self.controller, self.column_names) + self.table_apps.change_headers_policy() self.layout.addWidget(self.table_apps) @@ -135,6 +100,10 @@ class ManageWindow(QWidget): self.thread_refresh = RefreshApps(self.controller) self.thread_refresh.signal.connect(self._finish_refresh) + self.thread_uninstall = UninstallApp(self.controller) + self.thread_uninstall.signal_output.connect(self._update_action_output) + self.thread_uninstall.signal_finished.connect(self._finish_uninstall) + self.toolbar_bottom = QToolBar() self.label_updates = QLabel('') self.toolbar_bottom.addWidget(self.label_updates) @@ -148,15 +117,11 @@ class ManageWindow(QWidget): self.centralize() - def _change_table_headers_policy(self, policy: QHeaderView = QHeaderView.ResizeToContents): - header_horizontal = self.table_apps.horizontalHeader() - for i in range(0, len(self.column_names)): - header_horizontal.setSectionResizeMode(i, policy) - def changeEvent(self, e: QEvent): if isinstance(e, QWindowStateChangeEvent): - self._change_table_headers_policy(QHeaderView.Stretch if self.isMaximized() else QHeaderView.ResizeToContents) + policy = QHeaderView.Stretch if self.isMaximized() else QHeaderView.ResizeToContents + self.table_apps.adjust_header_policy(policy) def closeEvent(self, event): @@ -164,19 +129,6 @@ class ManageWindow(QWidget): event.ignore() self.hide() - def _load_icon(self, http_response): - icon_url = http_response.url().toString() - pixmap = QPixmap() - pixmap.loadFromData(http_response.readAll()) - icon = QIcon(pixmap) - self.icon_cache[icon_url] = icon - - for idx, app in enumerate(self.apps): - if app['model']['icon'] == icon_url: - self.table_apps.item(idx, 0).setIcon(icon) - self.resize_and_center() - break - def _check_flatpak_installed(self): if not self.controller.check_installed(): @@ -231,6 +183,22 @@ class ManageWindow(QWidget): self.finish_action() self._release_lock() + def uninstall_app(self, app_ref: str): + self._check_flatpak_installed() + + if self._acquire_lock(): + self.textarea_output.clear() + self.textarea_output.setVisible(True) + self._begin_action(self.locale_keys['manage_window.status.uninstalling'] + '...') + + self.thread_uninstall.app_ref = app_ref + self.thread_uninstall.start() + + def _finish_uninstall(self): + self.finish_action() + self._release_lock() + self.refresh(clear_output=False) + def filter_only_apps(self, only_apps: int): if self.apps: @@ -242,8 +210,8 @@ class ManageWindow(QWidget): app['visible'] = not hidden self.change_update_state() - self._change_table_headers_policy(QHeaderView.Stretch) - self._change_table_headers_policy() + self.table_apps.change_headers_policy(QHeaderView.Stretch) + self.table_apps.change_headers_policy() self.resize_and_center() def change_update_state(self): @@ -284,73 +252,16 @@ class ManageWindow(QWidget): def update_apps(self, apps: List[dict]): self._check_flatpak_installed() - self.table_apps.setEnabled(True) self.apps = [] - - self.table_apps.setRowCount(len(apps) if apps else 0) - if apps: - for idx, app in enumerate(apps): - - col_name = QTableWidgetItem() - col_name.setText(app['name']) - col_name.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) - - if not app['icon']: - col_name.setIcon(self.icon_flathub) - else: - cached_icon = self.icon_cache.get(app['icon']) - - if cached_icon: - col_name.setIcon(cached_icon) - else: - col_name.setIcon(self.icon_flathub) - self.network_man.get(QNetworkRequest(QUrl(app['icon']))) - - self.table_apps.setItem(idx, 0, col_name) - - col_version = QTableWidgetItem() - col_version.setText(app['version']) - col_version.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) - self.table_apps.setItem(idx, 1, col_version) - - col_release = QTableWidgetItem() - col_release.setText(app['latest_version']) - col_release.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) - self.table_apps.setItem(idx, 2, col_release) - - if app['update']: - col_release.setForeground(QColor('orange')) - - col_branch = QTableWidgetItem() - col_branch.setText(app['branch']) - col_branch.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) - self.table_apps.setItem(idx, 3, col_branch) - - col_arch = QTableWidgetItem() - col_arch.setText(app['arch']) - col_arch.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) - self.table_apps.setItem(idx, 4, col_arch) - - col_package = QTableWidgetItem() - col_package.setText(app['ref']) - col_package.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) - self.table_apps.setItem(idx, 5, col_package) - - col_origin = QTableWidgetItem() - col_origin.setText(app['origin']) - col_origin.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) - self.table_apps.setItem(idx, 6, col_origin) - + for app in apps: app_model = {'model': app, 'update_checked': app['update'], 'visible': not app['runtime'] or not self.checkbox_only_apps.isChecked()} - - col_update = UpdateToggleButton(app_model, self, app['update']) if app['update'] else None - self.table_apps.setCellWidget(idx, 7, col_update) - self.apps.append(app_model) + self.table_apps.update_apps(self.apps) + self.change_update_state() self.filter_only_apps(2 if self.checkbox_only_apps.isChecked() else 0) self.resize_and_center() @@ -431,3 +342,22 @@ class RefreshApps(QThread): def run(self): self.apps = self.controller.refresh() self.signal.emit() + + +class UninstallApp(QThread): + signal_finished = pyqtSignal() + signal_output = pyqtSignal(str) + + def __init__(self, controller: FlatpakController): + super(UninstallApp, self).__init__() + self.controller = controller + self.app_ref = None + + def run(self): + if self.app_ref: + for output in self.controller.uninstall(self.app_ref): + line = output.decode().strip() + if line: + self.signal_output.emit(line) + + self.signal_finished.emit() From 8783ca567ae9333069a070777b02edc84f412082 Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Tue, 25 Jun 2019 12:07:17 -0300 Subject: [PATCH 063/123] Showing runtime apps when no app is available --- CHANGELOG.md | 1 + fpakman/view/qt/window.py | 15 +++++++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 65877065..99fe3c08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Adding tooltips to toolbar buttons - "Update ?" column renamed to "Upgrade ?" - Management panel title renamed +- Showing runtime apps when no app is available ## [0.2.1] - 2019-06-24 ### Features diff --git a/fpakman/view/qt/window.py b/fpakman/view/qt/window.py index 630ab340..2f3114c0 100755 --- a/fpakman/view/qt/window.py +++ b/fpakman/view/qt/window.py @@ -121,7 +121,7 @@ class ManageWindow(QWidget): if isinstance(e, QWindowStateChangeEvent): policy = QHeaderView.Stretch if self.isMaximized() else QHeaderView.ResizeToContents - self.table_apps.adjust_header_policy(policy) + self.table_apps.change_headers_policy(policy) def closeEvent(self, event): @@ -253,15 +253,26 @@ class ManageWindow(QWidget): self._check_flatpak_installed() self.apps = [] + + napps = 0 # number of apps (not runtimes) + if apps: for app in apps: app_model = {'model': app, 'update_checked': app['update'], 'visible': not app['runtime'] or not self.checkbox_only_apps.isChecked()} + + napps += 1 if not app['runtime'] else 0 self.apps.append(app_model) - self.table_apps.update_apps(self.apps) + if napps == 0: + self.checkbox_only_apps.setChecked(False) + self.checkbox_only_apps.setCheckable(False) + else: + self.checkbox_only_apps.setCheckable(True) + self.checkbox_only_apps.setChecked(True) + self.table_apps.update_apps(self.apps) self.change_update_state() self.filter_only_apps(2 if self.checkbox_only_apps.isChecked() else 0) self.resize_and_center() From 1fabec43d2b3cacc150a994e4ff59c53718e0cbc Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Tue, 25 Jun 2019 12:18:39 -0300 Subject: [PATCH 064/123] Changing 'Updating' status to 'Upgrading' --- fpakman/resources/locale/en | 2 +- fpakman/resources/locale/es | 2 +- fpakman/resources/locale/pt | 2 +- fpakman/view/qt/window.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/fpakman/resources/locale/en b/fpakman/resources/locale/en index 877f1790..d03599a8 100644 --- a/fpakman/resources/locale/en +++ b/fpakman/resources/locale/en @@ -13,7 +13,7 @@ manage_window.apps_table.row.actions.uninstall.popup.body=Remove {} ? manage_window.checkbox.only_apps=Apps manage_window.label.updates=Updates manage_window.status.refreshing=Refreshing -manage_window.status.updating=Updating +manage_window.status.upgrading=Upgrading manage_window.status.uninstalling=Uninstalling manage_window.bt.refresh.tooltip=Reload the data about installed applications manage_window.bt.upgrade.tooltip=Upgrade all selected applications diff --git a/fpakman/resources/locale/es b/fpakman/resources/locale/es index 4a3a0f37..8b8f44a6 100644 --- a/fpakman/resources/locale/es +++ b/fpakman/resources/locale/es @@ -13,7 +13,7 @@ manage_window.apps_table.row.actions.uninstall.popup.body=¿Eliminar {}? manage_window.checkbox.only_apps=Apps manage_window.label.updates=Actualizaciones manage_window.status.refreshing=Recargando -manage_window.status.updating=Actualizando +manage_window.status.upgrading=Actualizando manage_window.status.uninstalling=Desinstalando manage_window.bt.refresh.tooltip=Recarga los datos sobre los aplicativos instalados manage_window.bt.upgrade.tooltip=Actualiza todos los aplicativos seleccionados diff --git a/fpakman/resources/locale/pt b/fpakman/resources/locale/pt index d4a005b7..4c855930 100644 --- a/fpakman/resources/locale/pt +++ b/fpakman/resources/locale/pt @@ -13,7 +13,7 @@ manage_window.apps_table.row.actions.uninstall.popup.body=Rmover {}? manage_window.checkbox.only_apps=Apps manage_window.label.updates=Atualizações manage_window.status.refreshing=Recarregando -manage_window.status.updating=Atualizando +manage_window.status.upgrading=Atualizando manage_window.status.uninstalling=Desinstalando manage_window.bt.refresh.tooltip=Recarrega os dados sobre os aplicativos instalados manage_window.bt.upgrade.tooltip=Atualiza todos os aplicativos selecionados diff --git a/fpakman/view/qt/window.py b/fpakman/view/qt/window.py index 2f3114c0..258e6af2 100755 --- a/fpakman/view/qt/window.py +++ b/fpakman/view/qt/window.py @@ -293,7 +293,7 @@ class ManageWindow(QWidget): self.textarea_output.clear() self.textarea_output.setVisible(True) - self._begin_action(self.locale_keys['manage_window.status.updating'] + '...') + self._begin_action(self.locale_keys['manage_window.status.upgrading'] + '...') self.thread_update.refs_to_update = to_update self.thread_update.start() From 2073a902dce1da2e469d7c057b8f84149ebe180a Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Tue, 25 Jun 2019 12:24:53 -0300 Subject: [PATCH 065/123] Portuguese translation fix --- fpakman/resources/locale/pt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/fpakman/resources/locale/pt b/fpakman/resources/locale/pt index 4c855930..759936b4 100644 --- a/fpakman/resources/locale/pt +++ b/fpakman/resources/locale/pt @@ -2,14 +2,14 @@ manage_window.title=Gerenciador de Aplicativos Flatpak manage_window.columns.name=Nome manage_window.columns.version=Versão manage_window.columns.latest_version=Última Versão -manage_window.columns.branch=Branch +manage_window.columns.branch=Ramo manage_window.columns.arch=Arch manage_window.columns.ref=Ref manage_window.columns.origin=Origem manage_window.columns.update=Atualizar ? manage_window.apps_table.row.actions.uninstall=Desinstalar manage_window.apps_table.row.actions.uninstall.popup.title=Desinstalação -manage_window.apps_table.row.actions.uninstall.popup.body=Rmover {}? +manage_window.apps_table.row.actions.uninstall.popup.body=Remover {}? manage_window.checkbox.only_apps=Apps manage_window.label.updates=Atualizações manage_window.status.refreshing=Recarregando From 7d106c149b56bca48ad066cdc5a8d4576d98d17f Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Tue, 25 Jun 2019 12:49:32 -0300 Subject: [PATCH 066/123] better uninstall icon --- fpakman/resources/img/uninstall.svg | 38 ++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 11 deletions(-) mode change 100644 => 100755 fpakman/resources/img/uninstall.svg diff --git a/fpakman/resources/img/uninstall.svg b/fpakman/resources/img/uninstall.svg old mode 100644 new mode 100755 index b09818d1..3b421a01 --- a/fpakman/resources/img/uninstall.svg +++ b/fpakman/resources/img/uninstall.svg @@ -7,13 +7,13 @@ xmlns="http://www.w3.org/2000/svg" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" - height="36.000004pt" + height="48.000004" viewBox="-40 0 36.000001 36.000114" - width="36pt" + width="48" version="1.1" id="svg72" - sodipodi:docname="delete.svg" - inkscape:version="0.92.4 5da689c313, 2019-01-14"> + sodipodi:docname="uninstall.svg" + inkscape:version="0.92.3 (2405546, 2018-03-11)"> @@ -37,8 +37,8 @@ guidetolerance="10" inkscape:pageopacity="0" inkscape:pageshadow="2" - inkscape:window-width="1920" - inkscape:window-height="1019" + inkscape:window-width="1366" + inkscape:window-height="739" id="namedview74" showgrid="false" showborder="false" @@ -47,15 +47,16 @@ fit-margin-right="0" fit-margin-bottom="0" inkscape:zoom="5.7611241" - inkscape:cx="58.614066" - inkscape:cy="3.8950274" + inkscape:cx="71.805983" + inkscape:cy="3.8950259" inkscape:window-x="0" - inkscape:window-y="0" + inkscape:window-y="260" inkscape:window-maximized="1" - inkscape:current-layer="svg72" /> + inkscape:current-layer="svg72" + units="px" /> + transform="matrix(0.10380557,0,0,0.08430914,-35.847729,0)"> + + + From f9f47d1429a532e4e62d9f8188857179043be3e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vin=C3=ADcius=20Moreira?= Date: Tue, 25 Jun 2019 18:25:04 -0300 Subject: [PATCH 067/123] Downgrade --- CHANGELOG.md | 3 +- README.md | 1 + fpakman/app.py | 3 +- fpakman/core/flatpak.py | 45 ++++++++++++++--- fpakman/core/model.py | 24 +++++++-- fpakman/core/util.py | 8 ++- fpakman/resources/img/downgrade.svg | 72 +++++++++++++++++++++++++++ fpakman/resources/locale/en | 12 +++++ fpakman/resources/locale/es | 14 +++++- fpakman/resources/locale/pt | 12 +++++ fpakman/view/qt/apps_table.py | 36 +++++++++++--- fpakman/view/qt/dialog.py | 31 ++++++++++++ fpakman/view/qt/root.py | 39 +++++++++++++++ fpakman/view/qt/systray.py | 6 ++- fpakman/view/qt/window.py | 75 ++++++++++++++++++++++++++--- 15 files changed, 349 insertions(+), 32 deletions(-) create mode 100755 fpakman/resources/img/downgrade.svg create mode 100644 fpakman/view/qt/dialog.py create mode 100644 fpakman/view/qt/root.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 99fe3c08..d224c420 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,13 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [0.3.0] ### Features -- Allows app uninstall by right clicking it. +- Allows app uninstall and downgrade by right clicking it. ### Improvements - Adding tooltips to toolbar buttons - "Update ?" column renamed to "Upgrade ?" - Management panel title renamed - Showing runtime apps when no app is available +- Allowing to specify a custom app translation with the environment variable FPAKMAN_LOCALE ## [0.2.1] - 2019-06-24 ### Features diff --git a/README.md b/README.md index c3a78d5b..99b65781 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ In order to autostart the application, use your Desktop Environment settings to You can change some application settings via environment variables: - **FPAKMAN_UPDATE_NOTIFICATION**: enable or disable system updates notifications. Use **0** (disable) or **1** (enable, default). - **FPAKMAN_CHECK_INTERVAL**: define the updates check interval in seconds. Default: 60. +- **FPAKMAN_LOCALE**: define a custom app translation for a given locale key (e.g: 'pt', 'en', 'es', ...). Default: system locale. ### Roadmap - Search and install applications diff --git a/fpakman/app.py b/fpakman/app.py index 0ee4c73c..8700fc90 100755 --- a/fpakman/app.py +++ b/fpakman/app.py @@ -16,7 +16,7 @@ parser = argparse.ArgumentParser(prog='fpakman', description="GUI for Flatpak ap parser.add_argument('-v', '--version', action='version', version='%(prog)s {}'.format(__version__)) args = parser.parse_args() -locale_keys = util.get_locale_keys() +locale_keys = util.get_locale_keys(os.getenv('FPAKMAN_LOCALE', None)) app = QApplication(sys.argv) app.setWindowIcon(QIcon(resource.get_path('img/flathub_45.svg'))) @@ -26,6 +26,7 @@ controller = FlatpakController(manager) trayIcon = TrayIcon(locale_keys=locale_keys, controller=controller, + manager=manager, check_interval=int(os.getenv('FPAKMAN_CHECK_INTERVAL', 60))) trayIcon.show() diff --git a/fpakman/core/flatpak.py b/fpakman/core/flatpak.py index b42cdd8c..9933eea2 100755 --- a/fpakman/core/flatpak.py +++ b/fpakman/core/flatpak.py @@ -1,4 +1,5 @@ import re +import subprocess from typing import List from fpakman.core import system @@ -32,20 +33,33 @@ def app_str_to_json(line: str, version: str) -> dict: else: raise Exception('Unsupported version') - info = re.findall(r'\w+:\s.+', get_app_info(app['id'], app['branch'])) - fields_to_get = ['origin', 'arch', 'ref'] + extra_fields = get_app_info_fields(app['id'], app['branch'], ['origin', 'arch', 'ref', 'commit'], check_runtime=True) + app.update(extra_fields) + + return app + + +def get_app_info_fields(app_id: str, branch: str, fields: List[str], check_runtime: bool = False): + info = re.findall(r'\w+:\s.+', get_app_info(app_id, branch)) + data = {} + fields_to_retrieve = len(fields) + (1 if check_runtime and 'ref' not in fields else 0) for field in info: + + if fields_to_retrieve == 0: + break + field_val = field.split(':') field_name = field_val[0].lower() - if field_name in fields_to_get: - app[field_name] = field_val[1].strip() + if field_name in fields or (check_runtime and field_name == 'ref'): + data[field_name] = field_val[1].strip() + fields_to_retrieve -= 1 - if field_name == 'ref': - app['runtime'] = app['ref'].startswith('runtime/') + if check_runtime and field_name == 'ref': + data['runtime'] = data['ref'].startswith('runtime/') - return app + return data def get_version(): @@ -92,3 +106,20 @@ def uninstall_and_stream(app_ref: str): def list_updates_as_str(): return system.run_cmd('flatpak update', ignore_return_code=True) + + +def downgrade_and_stream(app_ref: str, commit: str, root_password: str): + + pwdin, downgrade_cmd = None, [] + + if root_password is not None: + downgrade_cmd.extend(['sudo', '-S']) + pwdin = subprocess.Popen(['echo', root_password], stdout=subprocess.PIPE).stdout + + downgrade_cmd.extend(['flatpak', 'update', '--commit={}'.format(commit), app_ref, '-y']) + return subprocess.Popen(downgrade_cmd, stdin=pwdin, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL).stdout + + +def get_app_commits(app_ref: str, origin: str) -> List[str]: + log = system.run_cmd('flatpak remote-info --log {} {}'.format(origin, app_ref)) + return re.findall(r'Commit+:\s(.+)', log) diff --git a/fpakman/core/model.py b/fpakman/core/model.py index 904b0116..7b953806 100755 --- a/fpakman/core/model.py +++ b/fpakman/core/model.py @@ -2,9 +2,6 @@ from threading import Lock, Thread from typing import List import requests -from PyQt5.QtCore import QUrl -from PyQt5.QtGui import QPixmap, QIcon -from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkRequest from fpakman.core import flatpak @@ -12,6 +9,10 @@ __FLATHUB_URL__ = 'https://flathub.org' __FLATHUB_API_URL__ = __FLATHUB_URL__ + '/api/v1' +class ImpossibleDowngradeException(Exception): + pass + + class FlatpakManager: def __init__(self): @@ -122,3 +123,20 @@ class FlatpakManager: if app_found: return flatpak.uninstall_and_stream(ref) + + def downgrade_app(self, ref: str, root_password: str): + + if self.apps: + + app_found = self._find_by_ref(ref) + + if app_found: + commits = flatpak.get_app_commits(app_found['ref'], app_found['origin']) + + commit_idx = commits.index(app_found['commit']) + + # downgrade is not possible if the app current commit in the first one: + if commit_idx == len(commits) - 1: + raise ImpossibleDowngradeException() + + return flatpak.downgrade_and_stream(app_found['ref'], commits[commit_idx + 1], root_password) diff --git a/fpakman/core/util.py b/fpakman/core/util.py index f66016ca..4a6afda1 100644 --- a/fpakman/core/util.py +++ b/fpakman/core/util.py @@ -4,11 +4,15 @@ from fpakman.core import resource import glob -def get_locale_keys(): +def get_locale_keys(key: str = None): - current_locale = locale.getdefaultlocale() locale_path = None + if key is None: + current_locale = locale.getdefaultlocale() + else: + current_locale = [key] + if current_locale: current_locale = current_locale[0] diff --git a/fpakman/resources/img/downgrade.svg b/fpakman/resources/img/downgrade.svg new file mode 100755 index 00000000..f1e6a7bd --- /dev/null +++ b/fpakman/resources/img/downgrade.svg @@ -0,0 +1,72 @@ + + + + + + image/svg+xml + + + + + + + + + + + + diff --git a/fpakman/resources/locale/en b/fpakman/resources/locale/en index d03599a8..3c1c96a5 100644 --- a/fpakman/resources/locale/en +++ b/fpakman/resources/locale/en @@ -10,15 +10,27 @@ manage_window.columns.update=Upgrade ? manage_window.apps_table.row.actions.uninstall=Uninstall manage_window.apps_table.row.actions.uninstall.popup.title=Uninstall manage_window.apps_table.row.actions.uninstall.popup.body=Remove {} ? +manage_window.apps_table.row.actions.downgrade=Downgrade +manage_window.apps_table.row.actions.downgrade.popup.body=Do you really want to downgrade {} ? manage_window.checkbox.only_apps=Apps manage_window.label.updates=Updates manage_window.status.refreshing=Refreshing manage_window.status.upgrading=Upgrading manage_window.status.uninstalling=Uninstalling +manage_window.status.downgrading=Downgrading manage_window.bt.refresh.tooltip=Reload the data about installed applications manage_window.bt.upgrade.tooltip=Upgrade all selected applications popup.flatpak_not_installed.title=Error popup.flatpak_not_installed.msg=flatpak seems not to be installed. Exiting +popup.root.title=Requires root privileges +popup.root.password=Password +popup.root.bad_password.title=Error +popup.root.bad_password.body=Wrong password +popup.downgrade.impossible.title=Impossible to downgrade +popup.downgrade.impossible.body=The app is in its first version +popup.button.yes=Yes +popup.button.no=No +popup.button.cancel=Cancel tray.action.manage=Manage applications tray.action.exit=Exit notification.new_updates=Updates found \ No newline at end of file diff --git a/fpakman/resources/locale/es b/fpakman/resources/locale/es index 8b8f44a6..ad3da9ff 100644 --- a/fpakman/resources/locale/es +++ b/fpakman/resources/locale/es @@ -2,7 +2,7 @@ manage_window.title=Administrador de Aplicativos Flatpak manage_window.columns.name=Nombre manage_window.columns.version=Versión manage_window.columns.latest_version=Ultima Versión -manage_window.columns.branch=Branch +manage_window.columns.branch=Rama manage_window.columns.arch=Arch manage_window.columns.ref=Ref manage_window.columns.origin=Origen @@ -10,15 +10,27 @@ manage_window.columns.update=¿Actualizar? manage_window.apps_table.row.actions.uninstall=Desinstalar manage_window.apps_table.row.actions.uninstall.popup.title=Desinstalación manage_window.apps_table.row.actions.uninstall.popup.body=¿Eliminar {}? +manage_window.apps_table.row.actions.downgrade=Revertir versión +manage_window.apps_table.row.actions.downgrade.popup.body=¿Realmente quieres revertir la versión actual de {}? manage_window.checkbox.only_apps=Apps manage_window.label.updates=Actualizaciones manage_window.status.refreshing=Recargando manage_window.status.upgrading=Actualizando manage_window.status.uninstalling=Desinstalando +manage_window.status.downgrading=Revirtiendo manage_window.bt.refresh.tooltip=Recarga los datos sobre los aplicativos instalados manage_window.bt.upgrade.tooltip=Actualiza todos los aplicativos seleccionados popup.flatpak_not_installed.title=Error popup.flatpak_not_installed.msg=flatpak no parece estar instalado. Saliendo +popup.root.title=Requiere privilegios de root +popup.root.password=Contraseña +popup.root.bad_password.title=Error +popup.root.bad_password.body=Contraseña incorrecta +popup.downgrade.impossible.title=Imposible revertir la versión +popup.downgrade.impossible.body=El aplicativo está en su primera versión +popup.button.yes=Sí +popup.button.no=No +popup.button.cancel=Cancelar tray.action.manage=Administrar aplicativos tray.action.exit=Salir notification.new_updates=Actualizaciones encontradas \ No newline at end of file diff --git a/fpakman/resources/locale/pt b/fpakman/resources/locale/pt index 759936b4..d917c4d2 100644 --- a/fpakman/resources/locale/pt +++ b/fpakman/resources/locale/pt @@ -10,15 +10,27 @@ manage_window.columns.update=Atualizar ? manage_window.apps_table.row.actions.uninstall=Desinstalar manage_window.apps_table.row.actions.uninstall.popup.title=Desinstalação manage_window.apps_table.row.actions.uninstall.popup.body=Remover {}? +manage_window.apps_table.row.actions.downgrade=Reverter versão +manage_window.apps_table.row.actions.downgrade.popup.body=Você realmente quer reverter a versão atual de {} ? manage_window.checkbox.only_apps=Apps manage_window.label.updates=Atualizações manage_window.status.refreshing=Recarregando manage_window.status.upgrading=Atualizando manage_window.status.uninstalling=Desinstalando +manage_window.status.downgrading=Revertendo manage_window.bt.refresh.tooltip=Recarrega os dados sobre os aplicativos instalados manage_window.bt.upgrade.tooltip=Atualiza todos os aplicativos selecionados popup.flatpak_not_installed.title=Erro popup.flatpak_not_installed.msg=flatpak não parece estar instalado. Saindo +popup.root.title=Requer privilégios de root +popup.root.password=Senha +popup.root.bad_password.title=Erro +popup.root.bad_password.body=Senha incorreta +popup.downgrade.impossible.title=Impossível reverter a versão +popup.downgrade.impossible.body=O aplicativo está na sua primeira versão +popup.button.yes=Sim +popup.button.no=Não +popup.button.cancel=Cancelar tray.action.manage=Gerenciar aplicativos tray.action.exit=Sair notification.new_updates=Atualizações encontradas \ No newline at end of file diff --git a/fpakman/view/qt/apps_table.py b/fpakman/view/qt/apps_table.py index 4671b847..6478f64e 100644 --- a/fpakman/view/qt/apps_table.py +++ b/fpakman/view/qt/apps_table.py @@ -8,6 +8,7 @@ from PyQt5.QtWidgets import QTableWidget, QTableView, QMenu, QAction, QTableWidg from fpakman.core import resource from fpakman.core.controller import FlatpakController +from fpakman.view.qt import dialog class UpdateToggleButton(QToolButton): @@ -48,6 +49,7 @@ class AppsTable(QTableWidget): self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.icon_flathub = QIcon(resource.get_path('img/flathub_45.svg')) self.icon_uninstall = QIcon(resource.get_path('img/uninstall.svg')) + self.icon_downgrade = QIcon(resource.get_path('img/downgrade.svg')) self.network_man = QNetworkAccessManager() self.network_man.finished.connect(self._load_icon) @@ -60,21 +62,39 @@ class AppsTable(QTableWidget): action_uninstall = QAction(self.parent.locale_keys["manage_window.apps_table.row.actions.uninstall"]) action_uninstall.setIcon(self.icon_uninstall) action_uninstall.triggered.connect(self._uninstall_app) - menu_row.addAction(action_uninstall) + + app = self._get_selected_app() + + if not app['model']['runtime']: # downgrade only allowed for apps + action_downgrade = QAction(self.parent.locale_keys["manage_window.apps_table.row.actions.downgrade"]) + action_downgrade.triggered.connect(self._downgrade_app) + action_downgrade.setIcon(self.icon_downgrade) + menu_row.addAction(action_downgrade) + menu_row.adjustSize() menu_row.popup(QCursor.pos()) menu_row.exec_() - def _uninstall_app(self): - selected_app = self.parent.apps[self.currentRow()] + def _get_selected_app(self): + return self.parent.apps[self.currentRow()] - confirmation = QMessageBox.question(self, self.parent.locale_keys['manage_window.apps_table.row.actions.uninstall.popup.title'], - self.parent.locale_keys['manage_window.apps_table.row.actions.uninstall.popup.body'].format(selected_app['model']['name']), - QMessageBox.Yes | QMessageBox.No, QMessageBox.No) - if confirmation == QMessageBox.Yes: + def _uninstall_app(self): + selected_app = self._get_selected_app() + + if dialog.ask_confirmation(title=self.parent.locale_keys['manage_window.apps_table.row.actions.uninstall.popup.title'], + body=self.parent.locale_keys['manage_window.apps_table.row.actions.uninstall.popup.body'].format(selected_app['model']['name']), + locale_keys=self.parent.locale_keys): self.parent.uninstall_app(selected_app['model']['ref']) + def _downgrade_app(self): + selected_app = self._get_selected_app() + + if dialog.ask_confirmation(title=self.parent.locale_keys['manage_window.apps_table.row.actions.downgrade'], + body=self.parent.locale_keys['manage_window.apps_table.row.actions.downgrade.popup.body'].format(selected_app['model']['name']), + locale_keys=self.parent.locale_keys): + self.parent.downgrade_app(selected_app['model']['ref']) + def _load_icon(self, http_response): icon_url = http_response.url().toString() pixmap = QPixmap() @@ -122,7 +142,7 @@ class AppsTable(QTableWidget): col_release.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) self.setItem(idx, 2, col_release) - if app['model']['update']: + if app['model']['version'] and app['model']['latest_version'] and app['model']['version'] < app['model']['latest_version']: col_release.setForeground(QColor('orange')) col_branch = QTableWidgetItem() diff --git a/fpakman/view/qt/dialog.py b/fpakman/view/qt/dialog.py new file mode 100644 index 00000000..ec9c8210 --- /dev/null +++ b/fpakman/view/qt/dialog.py @@ -0,0 +1,31 @@ +from PyQt5.QtGui import QIcon +from PyQt5.QtWidgets import QMessageBox + +from fpakman.core import resource + + +def show_error(title: str, body: str, icon: QIcon = QIcon(resource.get_path('img/flathub_45.svg'))): + error_msg = QMessageBox() + error_msg.setIcon(QMessageBox.Critical) + error_msg.setWindowTitle(title) + error_msg.setText(body) + + if icon: + error_msg.setWindowIcon(icon) + error_msg.exec_() + + +def ask_confirmation(title: str, body: str, locale_keys: dict, icon: QIcon = QIcon(resource.get_path('img/flathub_45.svg'))): + dialog_confirmation = QMessageBox() + dialog_confirmation.setIcon(QMessageBox.Critical) + dialog_confirmation.setWindowTitle(title) + dialog_confirmation.setText(body) + bt_yes = dialog_confirmation.addButton(locale_keys['popup.button.yes'], QMessageBox.YesRole) + dialog_confirmation.addButton(locale_keys['popup.button.no'], QMessageBox.NoRole) + + if icon: + dialog_confirmation.setWindowIcon(icon) + + dialog_confirmation.exec_() + + return dialog_confirmation.clickedButton() == bt_yes diff --git a/fpakman/view/qt/root.py b/fpakman/view/qt/root.py new file mode 100644 index 00000000..0d68b3ea --- /dev/null +++ b/fpakman/view/qt/root.py @@ -0,0 +1,39 @@ +import os +import subprocess + +from PyQt5.QtWidgets import QInputDialog, QLineEdit + +from fpakman.view.qt.dialog import show_error + + +def is_root(): + return os.getuid() == 0 + + +def ask_root_password(locale_keys: dict): + + dialog_pwd = QInputDialog() + dialog_pwd.setInputMode(QInputDialog.TextInput) + dialog_pwd.setTextEchoMode(QLineEdit.Password) + dialog_pwd.setWindowTitle(locale_keys['popup.root.title']) + dialog_pwd.setLabelText(locale_keys['popup.root.password'] + ':') + dialog_pwd.setCancelButtonText(locale_keys['popup.button.cancel']) + dialog_pwd.resize(400, 200) + + ok = dialog_pwd.exec_() + + if ok: + if not validate_password(dialog_pwd.textValue()): + show_error(title=locale_keys['popup.root.bad_password.title'], + body=locale_keys['popup.root.bad_password.body']) + ok = False + + return dialog_pwd.textValue(), ok + + +def validate_password(password: str) -> bool: + f = os.popen('echo {} | sudo -S whoami'.format(password)) + res = f.read() + f.close() + + return bool(res.strip()) diff --git a/fpakman/view/qt/systray.py b/fpakman/view/qt/systray.py index c97a4834..00ff02ac 100755 --- a/fpakman/view/qt/systray.py +++ b/fpakman/view/qt/systray.py @@ -4,6 +4,7 @@ import time from PyQt5.QtCore import QThread, pyqtSignal, QCoreApplication from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import QSystemTrayIcon, QMenu +from fpakman.core.model import FlatpakManager from fpakman.core import resource from fpakman.core.controller import FlatpakController @@ -34,10 +35,11 @@ class UpdateCheck(QThread): class TrayIcon(QSystemTrayIcon): - def __init__(self, locale_keys: dict, controller: FlatpakController, check_interval: int = 60): + def __init__(self, locale_keys: dict, controller: FlatpakController, manager: FlatpakManager, check_interval: int = 60): super(TrayIcon, self).__init__() self.locale_keys = locale_keys self.controller = controller + self.manager = manager self.icon_default = QIcon(resource.get_path('img/flathub_45.svg')) self.icon_update = QIcon(resource.get_path('img/update_logo.svg')) @@ -50,7 +52,7 @@ class TrayIcon(QSystemTrayIcon): self.action_exit.triggered.connect(lambda: QCoreApplication.exit()) self.setContextMenu(self.menu) - self.manage_window = ManageWindow(locale_keys=self.locale_keys, controller=controller, tray_icon=self) + self.manage_window = ManageWindow(locale_keys=self.locale_keys, controller=controller, manager=self.manager, tray_icon=self) self.check_thread = UpdateCheck(check_interval=check_interval, controller=self.controller) self.check_thread.signal.connect(self.notify_updates) self.check_thread.start() diff --git a/fpakman/view/qt/window.py b/fpakman/view/qt/window.py index 258e6af2..b6b09a27 100755 --- a/fpakman/view/qt/window.py +++ b/fpakman/view/qt/window.py @@ -7,18 +7,21 @@ from PyQt5.QtCore import QThread, pyqtSignal, QEvent from PyQt5.QtGui import QIcon, QWindowStateChangeEvent from PyQt5.QtWidgets import QWidget, QVBoxLayout, QApplication, QCheckBox, QHeaderView, QToolButton, QToolBar, \ QSizePolicy, QLabel, QMessageBox, QPlainTextEdit +from fpakman.core.model import FlatpakManager, ImpossibleDowngradeException from fpakman import __version__ from fpakman.core import resource from fpakman.core.controller import FlatpakController +from fpakman.view.qt import dialog from fpakman.view.qt.apps_table import AppsTable +from fpakman.view.qt.root import is_root, ask_root_password class ManageWindow(QWidget): __BASE_HEIGHT__ = 400 - def __init__(self, locale_keys: dict, controller: FlatpakController, tray_icon = None): + def __init__(self, locale_keys: dict, controller: FlatpakController, manager: FlatpakManager, tray_icon = None): super(ManageWindow, self).__init__() self.locale_keys = locale_keys self.column_names = [locale_keys['manage_window.columns.name'], @@ -30,6 +33,7 @@ class ManageWindow(QWidget): locale_keys['manage_window.columns.origin'], locale_keys['manage_window.columns.update']] self.controller = controller + self.manager = manager self.tray_icon = tray_icon self.thread_lock = Lock() self.working = False # restrict the number of threaded actions @@ -104,6 +108,10 @@ class ManageWindow(QWidget): self.thread_uninstall.signal_output.connect(self._update_action_output) self.thread_uninstall.signal_finished.connect(self._finish_uninstall) + self.thread_downgrade = DowngradeApp(self.manager) + self.thread_downgrade.signal_output.connect(self._update_action_output) + self.thread_downgrade.signal_finished.connect(self._finish_downgrade) + self.toolbar_bottom = QToolBar() self.label_updates = QLabel('') self.toolbar_bottom.addWidget(self.label_updates) @@ -132,12 +140,9 @@ class ManageWindow(QWidget): def _check_flatpak_installed(self): if not self.controller.check_installed(): - error_msg = QMessageBox() - error_msg.setIcon(QMessageBox.Critical) - error_msg.setWindowTitle(self.locale_keys['popup.flatpak_not_installed.title']) - error_msg.setText(self.locale_keys['popup.flatpak_not_installed.msg'] + '...') - error_msg.setWindowIcon(self.icon_flathub) - error_msg.exec_() + dialog.show_error(title=self.locale_keys['popup.flatpak_not_installed.title'], + body=self.locale_keys['popup.flatpak_not_installed.msg'] + '...', + icon=self.icon_flathub) exit(1) if self.label_flatpak: @@ -199,6 +204,11 @@ class ManageWindow(QWidget): self._release_lock() self.refresh(clear_output=False) + def _finish_downgrade(self): + self.finish_action() + self._release_lock() + self.refresh(clear_output=False) + def filter_only_apps(self, only_apps: int): if self.apps: @@ -318,6 +328,29 @@ class ManageWindow(QWidget): self.table_apps.setEnabled(True) self.label_status.setText('') + def downgrade_app(self, app_ref: str): + + self._check_flatpak_installed() + + if self._acquire_lock(): + + pwd = None + + if not is_root(): + pwd, ok = ask_root_password(self.locale_keys) + + if not ok: + self._release_lock() + return + + self.textarea_output.clear() + self.textarea_output.setVisible(True) + self._begin_action(self.locale_keys['manage_window.status.downgrading'] + '...') + + self.thread_downgrade.app_ref = app_ref + self.thread_downgrade.root_password = pwd + self.thread_downgrade.start() + # Threaded actions class UpdateSelectedApps(QThread): @@ -372,3 +405,31 @@ class UninstallApp(QThread): self.signal_output.emit(line) self.signal_finished.emit() + + +class DowngradeApp(QThread): + signal_finished = pyqtSignal() + signal_output = pyqtSignal(str) + + def __init__(self, manager: FlatpakManager): + super(DowngradeApp, self).__init__() + self.manager = manager + self.app_ref = None + self.root_password = None + + def run(self): + if self.app_ref: + try: + for output in self.manager.downgrade_app(self.app_ref, self.root_password): + line = output.decode().strip() + if line: + self.signal_output.emit(line) + + except ImpossibleDowngradeException: + dialog.show_error(title=self.locale_keys['popup.downgrade.impossible.title'], + body=self.locale_keys['popup.downgrade.impossible.body'], + icon=self.icon_flathub) + + self.app_ref = None + self.root_password = None + self.signal_finished.emit() From c2e59ba75768572b31f5b419cddbe1549494a57c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vin=C3=ADcius=20Moreira?= Date: Wed, 26 Jun 2019 15:52:26 -0300 Subject: [PATCH 068/123] Retrieving app information and history --- CHANGELOG.md | 1 + fpakman/core/flatpak.py | 51 +++++-- fpakman/core/system.py | 4 +- fpakman/resources/img/history.svg | 218 ++++++++++++++++++++++++++++++ fpakman/resources/img/info.svg | 154 +++++++++++++++++++++ fpakman/resources/locale/en | 32 ++++- fpakman/resources/locale/es | 36 +++-- fpakman/resources/locale/pt | 34 +++-- fpakman/view/qt/apps_table.py | 40 ++++-- fpakman/view/qt/history.py | 52 +++++++ fpakman/view/qt/info.py | 34 +++++ fpakman/view/qt/window.py | 107 ++++++++++++--- 12 files changed, 694 insertions(+), 69 deletions(-) create mode 100755 fpakman/resources/img/history.svg create mode 100755 fpakman/resources/img/info.svg create mode 100644 fpakman/view/qt/history.py create mode 100644 fpakman/view/qt/info.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d224c420..987cfb0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [0.3.0] ### Features +- Allows to retrieve app information and history by right clicking it. - Allows app uninstall and downgrade by right clicking it. ### Improvements diff --git a/fpakman/core/flatpak.py b/fpakman/core/flatpak.py index 9933eea2..f8ba10be 100755 --- a/fpakman/core/flatpak.py +++ b/fpakman/core/flatpak.py @@ -4,6 +4,8 @@ from typing import List from fpakman.core import system +BASE_CMD = 'flatpak' + def app_str_to_json(line: str, version: str) -> dict: @@ -39,22 +41,24 @@ def app_str_to_json(line: str, version: str) -> dict: return app -def get_app_info_fields(app_id: str, branch: str, fields: List[str], check_runtime: bool = False): +def get_app_info_fields(app_id: str, branch: str, fields: List[str] = [], check_runtime: bool = False): info = re.findall(r'\w+:\s.+', get_app_info(app_id, branch)) data = {} fields_to_retrieve = len(fields) + (1 if check_runtime and 'ref' not in fields else 0) for field in info: - if fields_to_retrieve == 0: + if fields and fields_to_retrieve == 0: break field_val = field.split(':') field_name = field_val[0].lower() - if field_name in fields or (check_runtime and field_name == 'ref'): + if not fields or field_name in fields or (check_runtime and field_name == 'ref'): data[field_name] = field_val[1].strip() - fields_to_retrieve -= 1 + + if fields: + fields_to_retrieve -= 1 if check_runtime and field_name == 'ref': data['runtime'] = data['ref'].startswith('runtime/') @@ -63,16 +67,16 @@ def get_app_info_fields(app_id: str, branch: str, fields: List[str], check_runti def get_version(): - res = system.run_cmd('flatpak --version') + res = system.run_cmd('{} --version'.format(BASE_CMD)) return res.split(' ')[1].strip() if res else None def get_app_info(app_id: str, branch: str): - return system.run_cmd('flatpak info {} {}'.format(app_id, branch)) + return system.run_cmd('{} info {} {}'.format(BASE_CMD, app_id, branch)) def list_installed() -> List[dict]: - apps_str = system.run_cmd('flatpak list') + apps_str = system.run_cmd('{} list'.format(BASE_CMD)) if apps_str: version = get_version() @@ -83,7 +87,7 @@ def list_installed() -> List[dict]: def update(app_ref: str) -> bool: - return bool(system.run_cmd('flatpak update -y ' + app_ref)) + return bool(system.run_cmd('{} update -y {}'.format(BASE_CMD, app_ref))) def update_and_stream(app_ref: str): @@ -92,7 +96,7 @@ def update_and_stream(app_ref: str): :param app_ref: :return: """ - return system.stream_cmd(['flatpak', 'update', '-y', app_ref]) + return system.stream_cmd([BASE_CMD, 'update', '-y', app_ref]) def uninstall_and_stream(app_ref: str): @@ -101,11 +105,11 @@ def uninstall_and_stream(app_ref: str): :param app_ref: :return: """ - return system.stream_cmd(['flatpak', 'uninstall', app_ref, '-y']) + return system.stream_cmd([BASE_CMD, 'uninstall', app_ref, '-y']) def list_updates_as_str(): - return system.run_cmd('flatpak update', ignore_return_code=True) + return system.run_cmd('{} update'.format(BASE_CMD), ignore_return_code=True) def downgrade_and_stream(app_ref: str, commit: str, root_password: str): @@ -114,12 +118,31 @@ def downgrade_and_stream(app_ref: str, commit: str, root_password: str): if root_password is not None: downgrade_cmd.extend(['sudo', '-S']) - pwdin = subprocess.Popen(['echo', root_password], stdout=subprocess.PIPE).stdout + pwdin = system.stream_cmd(['echo', root_password]) - downgrade_cmd.extend(['flatpak', 'update', '--commit={}'.format(commit), app_ref, '-y']) + downgrade_cmd.extend([BASE_CMD, 'update', '--commit={}'.format(commit), app_ref, '-y']) return subprocess.Popen(downgrade_cmd, stdin=pwdin, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL).stdout def get_app_commits(app_ref: str, origin: str) -> List[str]: - log = system.run_cmd('flatpak remote-info --log {} {}'.format(origin, app_ref)) + log = system.run_cmd('{} remote-info --log {} {}'.format(BASE_CMD, origin, app_ref)) return re.findall(r'Commit+:\s(.+)', log) + + +def get_app_commits_data(app_ref: str, origin: str) -> List[dict]: + log = system.run_cmd('{} remote-info --log {} {}'.format(BASE_CMD, origin, app_ref)) + + res = re.findall(r'(Commit|Subject|Date):\s(.+)', log) + + commits = [] + + commit = {} + + for idx, data in enumerate(res): + commit[data[0].strip().lower()] = data[1].strip() + + if (idx + 1) % 3 == 0: + commits.append(commit) + commit = {} + + return commits diff --git a/fpakman/core/system.py b/fpakman/core/system.py index ddd5edfa..a7b1715b 100644 --- a/fpakman/core/system.py +++ b/fpakman/core/system.py @@ -3,9 +3,9 @@ from typing import List def run_cmd(cmd: str, expected_code: int = 0, ignore_return_code: bool = False) -> str: - res = subprocess.run(cmd, shell=True, stdout=subprocess.PIPE) + res = subprocess.run(cmd, shell=True, stdout=subprocess.PIPE, env={'LANG': 'en'}) return res.stdout.decode() if ignore_return_code or res.returncode == expected_code else None def stream_cmd(cmd: List[str]): - return subprocess.Popen(cmd, stdout=subprocess.PIPE).stdout + return subprocess.Popen(cmd, stdout=subprocess.PIPE, env={'LANG': 'en'}).stdout diff --git a/fpakman/resources/img/history.svg b/fpakman/resources/img/history.svg new file mode 100755 index 00000000..e1f00f18 --- /dev/null +++ b/fpakman/resources/img/history.svg @@ -0,0 +1,218 @@ + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/fpakman/resources/img/info.svg b/fpakman/resources/img/info.svg new file mode 100755 index 00000000..7983485e --- /dev/null +++ b/fpakman/resources/img/info.svg @@ -0,0 +1,154 @@ + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/fpakman/resources/locale/en b/fpakman/resources/locale/en index 3c1c96a5..b2a4787b 100644 --- a/fpakman/resources/locale/en +++ b/fpakman/resources/locale/en @@ -1,12 +1,8 @@ manage_window.title=Flatpak Application Manager -manage_window.columns.name=Name -manage_window.columns.version=Version manage_window.columns.latest_version=Latest Version -manage_window.columns.branch=Branch -manage_window.columns.arch=Arch -manage_window.columns.ref=Ref -manage_window.columns.origin=Origin manage_window.columns.update=Upgrade ? +manage_window.apps_table.row.actions.info=Information +manage_window.apps_table.row.actions.history=History manage_window.apps_table.row.actions.uninstall=Uninstall manage_window.apps_table.row.actions.uninstall.popup.title=Uninstall manage_window.apps_table.row.actions.uninstall.popup.body=Remove {} ? @@ -18,6 +14,8 @@ manage_window.status.refreshing=Refreshing manage_window.status.upgrading=Upgrading manage_window.status.uninstalling=Uninstalling manage_window.status.downgrading=Downgrading +manage_window.status.info=Retrieving information +manage_window.status.history=Retrieving history manage_window.bt.refresh.tooltip=Reload the data about installed applications manage_window.bt.upgrade.tooltip=Upgrade all selected applications popup.flatpak_not_installed.title=Error @@ -28,9 +26,29 @@ popup.root.bad_password.title=Error popup.root.bad_password.body=Wrong password popup.downgrade.impossible.title=Impossible to downgrade popup.downgrade.impossible.body=The app is in its first version +popup.history.title=History +popup.history.selected.tooltip=Current version popup.button.yes=Yes popup.button.no=No popup.button.cancel=Cancel tray.action.manage=Manage applications tray.action.exit=Exit -notification.new_updates=Updates found \ No newline at end of file +notification.new_updates=Updates found +flatpak.info.arch=arch +flatpak.info.branch=branch +flatpak.info.collection=collection +flatpak.info.commit=commit +flatpak.info.date=date +flatpak.info.id=id +flatpak.info.installation=installation +flatpak.info.installed=installed +flatpak.info.license=license +flatpak.info.name=name +flatpak.info.origin=origin +flatpak.info.parent=parent +flatpak.info.ref=ref +flatpak.info.runtime=runtime +flatpak.info.sdk=sdk +flatpak.info.subject=subject +flatpak.info.type=type +flatpak.info.version=version \ No newline at end of file diff --git a/fpakman/resources/locale/es b/fpakman/resources/locale/es index ad3da9ff..e4c897fb 100644 --- a/fpakman/resources/locale/es +++ b/fpakman/resources/locale/es @@ -1,23 +1,21 @@ manage_window.title=Administrador de Aplicativos Flatpak -manage_window.columns.name=Nombre -manage_window.columns.version=Versión manage_window.columns.latest_version=Ultima Versión -manage_window.columns.branch=Rama -manage_window.columns.arch=Arch -manage_window.columns.ref=Ref -manage_window.columns.origin=Origen -manage_window.columns.update=¿Actualizar? +manage_window.columns.update=Actualizar ? +manage_window.apps_table.row.actions.info=Información +manage_window.apps_table.row.actions.history=Historia manage_window.apps_table.row.actions.uninstall=Desinstalar manage_window.apps_table.row.actions.uninstall.popup.title=Desinstalación manage_window.apps_table.row.actions.uninstall.popup.body=¿Eliminar {}? manage_window.apps_table.row.actions.downgrade=Revertir versión manage_window.apps_table.row.actions.downgrade.popup.body=¿Realmente quieres revertir la versión actual de {}? -manage_window.checkbox.only_apps=Apps +manage_window.checkbox.only_apps=Aplicativos manage_window.label.updates=Actualizaciones manage_window.status.refreshing=Recargando manage_window.status.upgrading=Actualizando manage_window.status.uninstalling=Desinstalando manage_window.status.downgrading=Revirtiendo +manage_window.status.info=Obteniendo información +manage_window.status.history=Obteniendo la historia manage_window.bt.refresh.tooltip=Recarga los datos sobre los aplicativos instalados manage_window.bt.upgrade.tooltip=Actualiza todos los aplicativos seleccionados popup.flatpak_not_installed.title=Error @@ -28,9 +26,29 @@ popup.root.bad_password.title=Error popup.root.bad_password.body=Contraseña incorrecta popup.downgrade.impossible.title=Imposible revertir la versión popup.downgrade.impossible.body=El aplicativo está en su primera versión +popup.history.title=Historia +popup.history.selected.tooltip=Versión actual popup.button.yes=Sí popup.button.no=No popup.button.cancel=Cancelar tray.action.manage=Administrar aplicativos tray.action.exit=Salir -notification.new_updates=Actualizaciones encontradas \ No newline at end of file +notification.new_updates=Actualizaciones encontradas +flatpak.info.arch=arquitectura +flatpak.info.branch=rama +flatpak.info.collection=colección +flatpak.info.commit=commit +flatpak.info.date=fecha +flatpak.info.id=id +flatpak.info.installation=instalación +flatpak.info.installed=instalado +flatpak.info.license=licencia +flatpak.info.name=nombre +flatpak.info.origin=origen +flatpak.info.parent=padre +flatpak.info.ref=ref +flatpak.info.runtime=runtime +flatpak.info.sdk=sdk +flatpak.info.subject=tema +flatpak.info.type=tipo +flatpak.info.version=versión \ No newline at end of file diff --git a/fpakman/resources/locale/pt b/fpakman/resources/locale/pt index d917c4d2..5775be5e 100644 --- a/fpakman/resources/locale/pt +++ b/fpakman/resources/locale/pt @@ -1,23 +1,21 @@ manage_window.title=Gerenciador de Aplicativos Flatpak -manage_window.columns.name=Nome -manage_window.columns.version=Versão manage_window.columns.latest_version=Última Versão -manage_window.columns.branch=Ramo -manage_window.columns.arch=Arch -manage_window.columns.ref=Ref -manage_window.columns.origin=Origem manage_window.columns.update=Atualizar ? +manage_window.apps_table.row.actions.info=Informação +manage_window.apps_table.row.actions.history=Histórico manage_window.apps_table.row.actions.uninstall=Desinstalar manage_window.apps_table.row.actions.uninstall.popup.title=Desinstalação manage_window.apps_table.row.actions.uninstall.popup.body=Remover {}? manage_window.apps_table.row.actions.downgrade=Reverter versão manage_window.apps_table.row.actions.downgrade.popup.body=Você realmente quer reverter a versão atual de {} ? -manage_window.checkbox.only_apps=Apps +manage_window.checkbox.only_apps=Aplicativos manage_window.label.updates=Atualizações manage_window.status.refreshing=Recarregando manage_window.status.upgrading=Atualizando manage_window.status.uninstalling=Desinstalando manage_window.status.downgrading=Revertendo +manage_window.status.info=Obtendo informação +manage_window.status.history=Obtendo histórico manage_window.bt.refresh.tooltip=Recarrega os dados sobre os aplicativos instalados manage_window.bt.upgrade.tooltip=Atualiza todos os aplicativos selecionados popup.flatpak_not_installed.title=Erro @@ -28,9 +26,29 @@ popup.root.bad_password.title=Erro popup.root.bad_password.body=Senha incorreta popup.downgrade.impossible.title=Impossível reverter a versão popup.downgrade.impossible.body=O aplicativo está na sua primeira versão +popup.history.title=Histórico +popup.history.selected.tooltip=Versão atual popup.button.yes=Sim popup.button.no=Não popup.button.cancel=Cancelar tray.action.manage=Gerenciar aplicativos tray.action.exit=Sair -notification.new_updates=Atualizações encontradas \ No newline at end of file +notification.new_updates=Atualizações encontradas +flatpak.info.arch=arquitetura +flatpak.info.branch=ramo +flatpak.info.collection=coleção +flatpak.info.commit=commit +flatpak.info.date=data +flatpak.info.id=id +flatpak.info.installation=instalação +flatpak.info.installed=instalado +flatpak.info.license=licença +flatpak.info.name=nome +flatpak.info.origin=origem +flatpak.info.parent=pai +flatpak.info.ref=ref +flatpak.info.runtime=runtime +flatpak.info.sdk=sdk +flatpak.info.subject=assunto +flatpak.info.type=tipo +flatpak.info.version=versão \ No newline at end of file diff --git a/fpakman/view/qt/apps_table.py b/fpakman/view/qt/apps_table.py index 6478f64e..00b54437 100644 --- a/fpakman/view/qt/apps_table.py +++ b/fpakman/view/qt/apps_table.py @@ -1,12 +1,13 @@ +import json from typing import List -from PyQt5.QtCore import Qt, QUrl +from PyQt5.QtCore import Qt, QUrl, QThread, pyqtSignal from PyQt5.QtGui import QPixmap, QIcon, QColor, QCursor from PyQt5.QtNetwork import QNetworkRequest, QNetworkAccessManager from PyQt5.QtWidgets import QTableWidget, QTableView, QMenu, QAction, QTableWidgetItem, QToolButton, QWidget, \ - QMessageBox, QHeaderView + QMessageBox, QHeaderView, QDialog, QLineEdit, QVBoxLayout, QPlainTextEdit -from fpakman.core import resource +from fpakman.core import resource, flatpak from fpakman.core.controller import FlatpakController from fpakman.view.qt import dialog @@ -48,8 +49,6 @@ class AppsTable(QTableWidget): self.setHorizontalHeaderLabels(columns) self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.icon_flathub = QIcon(resource.get_path('img/flathub_45.svg')) - self.icon_uninstall = QIcon(resource.get_path('img/uninstall.svg')) - self.icon_downgrade = QIcon(resource.get_path('img/downgrade.svg')) self.network_man = QNetworkAccessManager() self.network_man.finished.connect(self._load_icon) @@ -59,28 +58,41 @@ class AppsTable(QTableWidget): def contextMenuEvent(self, QContextMenuEvent): # selected row right click event menu_row = QMenu() + action_info = QAction(self.parent.locale_keys["manage_window.apps_table.row.actions.info"]) + action_info.setIcon(QIcon(resource.get_path('img/info.svg'))) + action_info.triggered.connect(self._get_app_info) + menu_row.addAction(action_info) + + action_history = QAction(self.parent.locale_keys["manage_window.apps_table.row.actions.history"]) + action_history.setIcon(QIcon(resource.get_path('img/history.svg'))) + action_history.triggered.connect(self._get_app_history) + menu_row.addAction(action_history) + action_uninstall = QAction(self.parent.locale_keys["manage_window.apps_table.row.actions.uninstall"]) - action_uninstall.setIcon(self.icon_uninstall) + action_uninstall.setIcon(QIcon(resource.get_path('img/uninstall.svg'))) action_uninstall.triggered.connect(self._uninstall_app) menu_row.addAction(action_uninstall) - app = self._get_selected_app() + app = self.get_selected_app() if not app['model']['runtime']: # downgrade only allowed for apps action_downgrade = QAction(self.parent.locale_keys["manage_window.apps_table.row.actions.downgrade"]) action_downgrade.triggered.connect(self._downgrade_app) - action_downgrade.setIcon(self.icon_downgrade) + action_downgrade.setIcon(QIcon(resource.get_path('img/downgrade.svg'))) menu_row.addAction(action_downgrade) menu_row.adjustSize() menu_row.popup(QCursor.pos()) menu_row.exec_() - def _get_selected_app(self): + def get_selected_app(self): return self.parent.apps[self.currentRow()] + def get_selected_app_icon(self): + return self.item(self.currentRow(), 0).icon() + def _uninstall_app(self): - selected_app = self._get_selected_app() + selected_app = self.get_selected_app() if dialog.ask_confirmation(title=self.parent.locale_keys['manage_window.apps_table.row.actions.uninstall.popup.title'], body=self.parent.locale_keys['manage_window.apps_table.row.actions.uninstall.popup.body'].format(selected_app['model']['name']), @@ -88,13 +100,19 @@ class AppsTable(QTableWidget): self.parent.uninstall_app(selected_app['model']['ref']) def _downgrade_app(self): - selected_app = self._get_selected_app() + selected_app = self.get_selected_app() if dialog.ask_confirmation(title=self.parent.locale_keys['manage_window.apps_table.row.actions.downgrade'], body=self.parent.locale_keys['manage_window.apps_table.row.actions.downgrade.popup.body'].format(selected_app['model']['name']), locale_keys=self.parent.locale_keys): self.parent.downgrade_app(selected_app['model']['ref']) + def _get_app_info(self): + self.parent.get_app_info(self.get_selected_app()) + + def _get_app_history(self): + self.parent.get_app_history(self.get_selected_app()) + def _load_icon(self, http_response): icon_url = http_response.url().toString() pixmap = QPixmap() diff --git a/fpakman/view/qt/history.py b/fpakman/view/qt/history.py new file mode 100644 index 00000000..5282fe97 --- /dev/null +++ b/fpakman/view/qt/history.py @@ -0,0 +1,52 @@ +import operator +from functools import reduce + +from PyQt5.QtCore import Qt +from PyQt5.QtGui import QIcon +from PyQt5.QtWidgets import QDialog, QVBoxLayout, QTableWidget, QTableWidgetItem, QHeaderView + + +class HistoryDialog(QDialog): + + def __init__(self, app: dict, app_icon: QIcon, locale_keys: dict): + super(HistoryDialog, self).__init__() + + self.setWindowTitle('{} - {} '.format(locale_keys['popup.history.title'], app['model']['name'])) + self.setWindowIcon(app_icon) + + layout = QVBoxLayout() + self.setLayout(layout) + + table_history = QTableWidget() + table_history.setFocusPolicy(Qt.NoFocus) + table_history.setShowGrid(False) + table_history.verticalHeader().setVisible(False) + table_history.setAlternatingRowColors(True) + + table_history.setColumnCount(len(app['commits'][0])) + table_history.setRowCount(len(app['commits'])) + table_history.setHorizontalHeaderLabels([locale_keys['flatpak.info.' + key].capitalize() for key in sorted(app['commits'][0].keys())]) + + for row, commit in enumerate(app['commits']): + + current_app_commit = app['model']['commit'] == commit['commit'] + + for col, key in enumerate(sorted(commit.keys())): + item = QTableWidgetItem() + item.setText(commit[key]) + item.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) + + if current_app_commit: + item.setBackground(Qt.darkYellow) + item.setToolTip(locale_keys['popup.history.selected.tooltip']) + + table_history.setItem(row, col, item) + + layout.addWidget(table_history) + + header_horizontal = table_history.horizontalHeader() + for i in range(0, table_history.columnCount()): + header_horizontal.setSectionResizeMode(i, QHeaderView.Stretch) + + new_width = reduce(operator.add, [table_history.columnWidth(i) for i in range(table_history.columnCount())]) + self.resize(new_width, table_history.height()) diff --git a/fpakman/view/qt/info.py b/fpakman/view/qt/info.py new file mode 100644 index 00000000..db32fb30 --- /dev/null +++ b/fpakman/view/qt/info.py @@ -0,0 +1,34 @@ +from PyQt5.QtGui import QIcon +from PyQt5.QtWidgets import QDialog, QVBoxLayout, QFormLayout, QGroupBox, \ + QLineEdit, QLabel + + +class InfoDialog(QDialog): + + def __init__(self, app: dict, app_icon: QIcon, locale_keys: dict): + super(InfoDialog, self).__init__() + self.setWindowTitle(app['name']) + self.setWindowIcon(app_icon) + layout = QVBoxLayout() + self.setLayout(layout) + + gbox_info_layout = QFormLayout() + gbox_info = QGroupBox() + gbox_info.setLayout(gbox_info_layout) + layout.addWidget(gbox_info) + + for attr in sorted(app.keys()): + + if attr != 'name': + text = QLineEdit() + text.setText(app[attr]) + text.setStyleSheet("width: 400px") + text.setReadOnly(True) + text.setCursorPosition(0) + + label = QLabel("{}: ".format(locale_keys.get('flatpak.info.' + attr, attr)).capitalize()) + label.setStyleSheet("font-weight: bold") + + gbox_info_layout.addRow(label, text) + + self.adjustSize() diff --git a/fpakman/view/qt/window.py b/fpakman/view/qt/window.py index b6b09a27..137d9606 100755 --- a/fpakman/view/qt/window.py +++ b/fpakman/view/qt/window.py @@ -1,19 +1,22 @@ +import json import operator from functools import reduce from threading import Lock from typing import List -from PyQt5.QtCore import QThread, pyqtSignal, QEvent +from PyQt5.QtCore import QThread, pyqtSignal, QEvent, Qt from PyQt5.QtGui import QIcon, QWindowStateChangeEvent from PyQt5.QtWidgets import QWidget, QVBoxLayout, QApplication, QCheckBox, QHeaderView, QToolButton, QToolBar, \ - QSizePolicy, QLabel, QMessageBox, QPlainTextEdit -from fpakman.core.model import FlatpakManager, ImpossibleDowngradeException + QSizePolicy, QLabel, QPlainTextEdit, QDialog, QGroupBox, QFormLayout, QLineEdit, QTableWidget, QTableWidgetItem from fpakman import __version__ -from fpakman.core import resource +from fpakman.core import resource, flatpak from fpakman.core.controller import FlatpakController +from fpakman.core.model import FlatpakManager, ImpossibleDowngradeException from fpakman.view.qt import dialog from fpakman.view.qt.apps_table import AppsTable +from fpakman.view.qt.history import HistoryDialog +from fpakman.view.qt.info import InfoDialog from fpakman.view.qt.root import is_root, ask_root_password @@ -21,17 +24,17 @@ class ManageWindow(QWidget): __BASE_HEIGHT__ = 400 - def __init__(self, locale_keys: dict, controller: FlatpakController, manager: FlatpakManager, tray_icon = None): + def __init__(self, locale_keys: dict, controller: FlatpakController, manager: FlatpakManager, tray_icon=None): super(ManageWindow, self).__init__() self.locale_keys = locale_keys - self.column_names = [locale_keys['manage_window.columns.name'], - locale_keys['manage_window.columns.version'], - locale_keys['manage_window.columns.latest_version'], - locale_keys['manage_window.columns.branch'], - locale_keys['manage_window.columns.arch'], - locale_keys['manage_window.columns.ref'], - locale_keys['manage_window.columns.origin'], - locale_keys['manage_window.columns.update']] + self.column_names = [locale_keys[key].capitalize() for key in ['flatpak.info.name', + 'flatpak.info.version', + 'manage_window.columns.latest_version', + 'flatpak.info.branch', + 'flatpak.info.arch', + 'flatpak.info.ref', + 'flatpak.info.origin', + 'manage_window.columns.update']] self.controller = controller self.manager = manager self.tray_icon = tray_icon @@ -112,6 +115,12 @@ class ManageWindow(QWidget): self.thread_downgrade.signal_output.connect(self._update_action_output) self.thread_downgrade.signal_finished.connect(self._finish_downgrade) + self.thread_get_info = GetAppInfo() + self.thread_get_info.signal_finished.connect(self._finish_get_info) + + self.thread_get_history = GetAppHistory() + self.thread_get_history.signal_finished.connect(self._finish_get_history) + self.toolbar_bottom = QToolBar() self.label_updates = QLabel('') self.toolbar_bottom.addWidget(self.label_updates) @@ -174,7 +183,7 @@ class ManageWindow(QWidget): if self._acquire_lock(): self._check_flatpak_installed() - self._begin_action(self.locale_keys['manage_window.status.refreshing'] + '...') + self._begin_action(self.locale_keys['manage_window.status.refreshing']) if clear_output: self.textarea_output.clear() @@ -194,7 +203,7 @@ class ManageWindow(QWidget): if self._acquire_lock(): self.textarea_output.clear() self.textarea_output.setVisible(True) - self._begin_action(self.locale_keys['manage_window.status.uninstalling'] + '...') + self._begin_action(self.locale_keys['manage_window.status.uninstalling']) self.thread_uninstall.app_ref = app_ref self.thread_uninstall.start() @@ -303,7 +312,7 @@ class ManageWindow(QWidget): self.textarea_output.clear() self.textarea_output.setVisible(True) - self._begin_action(self.locale_keys['manage_window.status.upgrading'] + '...') + self._begin_action(self.locale_keys['manage_window.status.upgrading']) self.thread_update.refs_to_update = to_update self.thread_update.start() @@ -316,7 +325,7 @@ class ManageWindow(QWidget): self.textarea_output.appendPlainText(output) def _begin_action(self, action_label: str): - self.label_status.setText(action_label) + self.label_status.setText(action_label + "...") self.bt_upgrade.setEnabled(False) self.bt_refresh.setEnabled(False) self.checkbox_only_apps.setEnabled(False) @@ -345,12 +354,44 @@ class ManageWindow(QWidget): self.textarea_output.clear() self.textarea_output.setVisible(True) - self._begin_action(self.locale_keys['manage_window.status.downgrading'] + '...') + self._begin_action(self.locale_keys['manage_window.status.downgrading']) self.thread_downgrade.app_ref = app_ref self.thread_downgrade.root_password = pwd self.thread_downgrade.start() + def get_app_info(self, app: dict): + + if self._acquire_lock(): + self.textarea_output.clear() + self.textarea_output.setVisible(False) + self._begin_action(self.locale_keys['manage_window.status.info']) + + self.thread_get_info.app = app + self.thread_get_info.start() + + def get_app_history(self, app: dict): + if self._acquire_lock(): + self.textarea_output.clear() + self.textarea_output.setVisible(False) + self._begin_action(self.locale_keys['manage_window.status.history']) + + self.thread_get_history.app = app + self.thread_get_history.start() + + def _finish_get_info(self, app_info: dict): + self._release_lock() + self.finish_action() + dialog_info = InfoDialog(app_info, self.table_apps.get_selected_app_icon(), self.locale_keys) + dialog_info.exec_() + + def _finish_get_history(self, app: dict): + self._release_lock() + self.finish_action() + + dialog_history = HistoryDialog(app, self.table_apps.get_selected_app_icon(), self.locale_keys) + dialog_history.exec_() + # Threaded actions class UpdateSelectedApps(QThread): @@ -433,3 +474,33 @@ class DowngradeApp(QThread): self.app_ref = None self.root_password = None self.signal_finished.emit() + + +class GetAppInfo(QThread): + signal_finished = pyqtSignal(dict) + + def __init__(self): + super(GetAppInfo, self).__init__() + self.app = None + + def run(self): + if self.app: + app_info = flatpak.get_app_info_fields(self.app['model']['id'], self.app['model']['branch']) + app_info['name'] = self.app['model']['name'] + app_info['type'] = 'runtime' if self.app['model']['runtime'] else 'app' + self.signal_finished.emit(app_info) + self.app = None + + +class GetAppHistory(QThread): + signal_finished = pyqtSignal(dict) + + def __init__(self): + super(GetAppHistory, self).__init__() + self.app = None + + def run(self): + if self.app: + commits = flatpak.get_app_commits_data(self.app['model']['ref'], self.app['model']['origin']) + self.signal_finished.emit({'model': self.app['model'], 'commits': commits}) + self.app = None From 97477c4274fcb28d2f1d0c87de4482ac8b5f7c5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vin=C3=ADcius=20Moreira?= Date: Wed, 26 Jun 2019 18:32:49 -0300 Subject: [PATCH 069/123] Refactoring and improvements (#12) * Removing controller * Refactoring FlatpakManager * Retrieving only installed apps data from the API * Removing wrong password logs --- CHANGELOG.md | 5 +- README.md | 5 +- fpakman/app.py | 9 +-- fpakman/core/controller.py | 105 ++++++++++++++++++++---- fpakman/core/flatpak.py | 9 ++- fpakman/core/model.py | 142 -------------------------------- fpakman/resources/locale/en | 4 +- fpakman/resources/locale/es | 4 +- fpakman/resources/locale/pt | 4 +- fpakman/view/qt/apps_table.py | 13 ++- fpakman/view/qt/dialog.py | 3 +- fpakman/view/qt/root.py | 12 ++- fpakman/view/qt/systray.py | 20 ++--- fpakman/view/qt/thread.py | 115 ++++++++++++++++++++++++++ fpakman/view/qt/window.py | 148 ++++------------------------------ 15 files changed, 268 insertions(+), 330 deletions(-) delete mode 100755 fpakman/core/model.py create mode 100644 fpakman/view/qt/thread.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 987cfb0f..e0dfe756 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,11 +10,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Allows app uninstall and downgrade by right clicking it. ### Improvements +- Performance and memory usage - Adding tooltips to toolbar buttons - "Update ?" column renamed to "Upgrade ?" - Management panel title renamed - Showing runtime apps when no app is available -- Allowing to specify a custom app translation with the environment variable FPAKMAN_LOCALE +- Allowing to specify a custom app translation with the environment variable **FPAKMAN_LOCALE** +- Adding expiration time for cached app data. Default to 1 hour. The environment variable **FPAKMAN_CACHE_EXPIRATION** can change this value. +- Retrieving only the installed applications data from the origin API. ## [0.2.1] - 2019-06-24 ### Features diff --git a/README.md b/README.md index 99b65781..5e4efe8d 100644 --- a/README.md +++ b/README.md @@ -43,8 +43,7 @@ You can change some application settings via environment variables: - **FPAKMAN_UPDATE_NOTIFICATION**: enable or disable system updates notifications. Use **0** (disable) or **1** (enable, default). - **FPAKMAN_CHECK_INTERVAL**: define the updates check interval in seconds. Default: 60. - **FPAKMAN_LOCALE**: define a custom app translation for a given locale key (e.g: 'pt', 'en', 'es', ...). Default: system locale. +- **FPAKMAN_CACHE_EXPIRATION**: define a custom expiration time in SECONDS for application data retrieved from the origin API. Default: 3600 (1 hour). ### Roadmap -- Search and install applications -- Uninstall applications -- Downgrade applications \ No newline at end of file +- Search and install applications \ No newline at end of file diff --git a/fpakman/app.py b/fpakman/app.py index 8700fc90..bac75a8a 100755 --- a/fpakman/app.py +++ b/fpakman/app.py @@ -8,8 +8,7 @@ from PyQt5.QtWidgets import QApplication from fpakman import __version__ from fpakman.core import resource from fpakman.core import util -from fpakman.core.controller import FlatpakController -from fpakman.core.model import FlatpakManager +from fpakman.core.controller import FlatpakManager from fpakman.view.qt.systray import TrayIcon parser = argparse.ArgumentParser(prog='fpakman', description="GUI for Flatpak applications management") @@ -20,12 +19,10 @@ locale_keys = util.get_locale_keys(os.getenv('FPAKMAN_LOCALE', None)) app = QApplication(sys.argv) app.setWindowIcon(QIcon(resource.get_path('img/flathub_45.svg'))) -manager = FlatpakManager() -manager.load_database_async() -controller = FlatpakController(manager) + +manager = FlatpakManager(cache_expire=int(os.getenv('FPAKMAN_CACHE_EXPIRATION', 60 * 60))) trayIcon = TrayIcon(locale_keys=locale_keys, - controller=controller, manager=manager, check_interval=int(os.getenv('FPAKMAN_CHECK_INTERVAL', 60))) trayIcon.show() diff --git a/fpakman/core/controller.py b/fpakman/core/controller.py index 4a05e3a2..d8ebddbb 100755 --- a/fpakman/core/controller.py +++ b/fpakman/core/controller.py @@ -1,25 +1,100 @@ +from datetime import datetime, timedelta +from threading import Lock, Thread from typing import List -from fpakman.core.model import FlatpakManager +import requests + +from fpakman.core import flatpak + +__FLATHUB_URL__ = 'https://flathub.org' +__FLATHUB_API_URL__ = __FLATHUB_URL__ + '/api/v1' -class FlatpakController: +class FlatpakManager: - def __init__(self, model: FlatpakManager): - self.model = model + def __init__(self, cache_expire: int = 60 * 60): + self.cache_apps = {} + self.cache_expire = cache_expire + self.http_session = requests.Session() + self.lock_db_read = Lock() + self.lock_read = Lock() - def refresh(self) -> List[dict]: - return self.model.read_installed() + # TODO remove if not necessary for future releases + def load_full_database_async(self): + Thread(target=self.load_full_database, daemon=True).start() - def update(self, app_ref: str): - return self.model.update_app(app_ref) + # TODO remove if not necessary for future releases + def load_full_database(self): - def check_installed(self) -> bool: - version = self.model.get_version() - return False if version is None else version + self.lock_db_read.acquire() - def get_version(self) -> str: - return self.model.get_version() + try: + res = self.http_session.get(__FLATHUB_API_URL__ + '/apps') - def uninstall(self, app_ref: str): - return self.model.remove_app(app_ref) + if res.status_code == 200: + for app in res.json(): + self.cache_apps[app['flatpakAppId']] = app + finally: + self.lock_db_read.release() + + def _request_app_data(self, app_id: str): + + try: + res = self.http_session.get('{}/apps/{}'.format(__FLATHUB_API_URL__, app_id), timeout=60) + + if res.status_code == 200: + return res.json() + else: + print("Could not retrieve app data for id '{}'. Server response: {}".format(app_id, res.status_code)) + except: + print("Could not retrieve app data for id '{}'. Timeout".format(app_id)) + return None + + def read_installed(self) -> List[dict]: + + self.lock_read.acquire() + + try: + installed = flatpak.list_installed() + + if installed: + installed.sort(key=lambda p: p['name'].lower()) + + available_updates = flatpak.list_updates_as_str() + + for app in installed: + + app_data = self.cache_apps.get(app['id']) + + if (not app['runtime'] and not app_data) or (app_data and app_data['expires_at'] <= datetime.utcnow()): # if data is not cached or expired, tries to retrieve it + app_data = self._request_app_data(app['id']) + app_data['expires_at'] = datetime.utcnow() + timedelta(seconds=self.cache_expire) + self.cache_apps[app['id']] = app_data + + if not app_data: + app['latest_version'] = None + app['icon'] = None + else: + app['latest_version'] = app_data['currentReleaseVersion'] + app['icon'] = app_data['iconMobileUrl'] + + if app['icon'].startswith('/'): + app['icon'] = __FLATHUB_URL__ + app['icon'] + + app['update'] = app['id'] in available_updates + + return installed + + finally: + self.lock_read.release() + + def downgrade_app(self, app: dict, root_password: str): + + commits = flatpak.get_app_commits(app['ref'], app['origin']) + commit_idx = commits.index(app['commit']) + + # downgrade is not possible if the app current commit in the first one: + if commit_idx == len(commits) - 1: + return None + + return flatpak.downgrade_and_stream(app['ref'], commits[commit_idx + 1], root_password) diff --git a/fpakman/core/flatpak.py b/fpakman/core/flatpak.py index f8ba10be..2c725a29 100755 --- a/fpakman/core/flatpak.py +++ b/fpakman/core/flatpak.py @@ -66,6 +66,11 @@ def get_app_info_fields(app_id: str, branch: str, fields: List[str] = [], check_ return data +def is_installed(): + version = get_version() + return False if version is None else True + + def get_version(): res = system.run_cmd('{} --version'.format(BASE_CMD)) return res.split(' ')[1].strip() if res else None @@ -86,10 +91,6 @@ def list_installed() -> List[dict]: return [] -def update(app_ref: str) -> bool: - return bool(system.run_cmd('{} update -y {}'.format(BASE_CMD, app_ref))) - - def update_and_stream(app_ref: str): """ Updates the app reference and streams Flatpak output, diff --git a/fpakman/core/model.py b/fpakman/core/model.py deleted file mode 100755 index 7b953806..00000000 --- a/fpakman/core/model.py +++ /dev/null @@ -1,142 +0,0 @@ -from threading import Lock, Thread -from typing import List - -import requests - -from fpakman.core import flatpak - -__FLATHUB_URL__ = 'https://flathub.org' -__FLATHUB_API_URL__ = __FLATHUB_URL__ + '/api/v1' - - -class ImpossibleDowngradeException(Exception): - pass - - -class FlatpakManager: - - def __init__(self): - self.apps = [] - self.apps_db = {} - self.http_session = requests.Session() - self.lock_db_read = Lock() - - def load_database_async(self): - Thread(target=self.load_database, daemon=True).start() - - def load_database(self): - - self.lock_db_read.acquire() - - try: - res = self.http_session.get(__FLATHUB_API_URL__ + '/apps') - - if res.status_code == 200: - for app in res.json(): - self.apps_db[app['flatpakAppId']] = app - finally: - self.lock_db_read.release() - - def get_version(self): - return flatpak.get_version() - - def read_installed(self) -> List[dict]: - - installed = flatpak.list_installed() - - if installed: - installed.sort(key=lambda p: p['name'].lower()) - - available_updates = flatpak.list_updates_as_str() - - for app in installed: - - if not self.apps_db: - self.load_database() - - if self.apps_db: - app_data = self.apps_db.get(app['id'], None) - else: - app_data = None - - if not app_data: - app['latest_version'] = None - app['icon'] = None - else: - app['latest_version'] = app_data['currentReleaseVersion'] - app['icon'] = app_data['iconMobileUrl'] - - if app['icon'].startswith('/'): - app['icon'] = __FLATHUB_URL__ + app['icon'] - - app['update'] = app['id'] in available_updates - - self.apps = installed - return [*self.apps] - - def update_apps(self, refs: List[str]) -> List[dict]: - - if self.apps: - - for ref in refs: - package_found = [app for app in self.apps if app['ref'] == ref] - - if package_found: - package_found = package_found[0] - updated = flatpak.update(ref) - - if updated: - package_found['update'] = not updated - - return [*self.apps] - - return [] - - def _find_by_ref(self, ref: str): - package_found = [app for app in self.apps if app['ref'] == ref] - - if package_found: - return package_found[0] - - def update_app(self, ref: str): - - """ - :param ref: - :return: the update command stream - """ - - if self.apps: - - app_found = self._find_by_ref(ref) - - if app_found: - return flatpak.update_and_stream(ref) - - def remove_app(self, ref: str): - """ - :param ref: - :return: the uninstall command stream - """ - if self.apps: - - app_found = self._find_by_ref(ref) - - if app_found: - return flatpak.uninstall_and_stream(ref) - - def downgrade_app(self, ref: str, root_password: str): - - if self.apps: - - app_found = self._find_by_ref(ref) - - if app_found: - commits = flatpak.get_app_commits(app_found['ref'], app_found['origin']) - - commit_idx = commits.index(app_found['commit']) - - # downgrade is not possible if the app current commit in the first one: - if commit_idx == len(commits) - 1: - raise ImpossibleDowngradeException() - - return flatpak.downgrade_and_stream(app_found['ref'], commits[commit_idx + 1], root_password) diff --git a/fpakman/resources/locale/en b/fpakman/resources/locale/en index b2a4787b..8ef2420d 100644 --- a/fpakman/resources/locale/en +++ b/fpakman/resources/locale/en @@ -24,8 +24,8 @@ popup.root.title=Requires root privileges popup.root.password=Password popup.root.bad_password.title=Error popup.root.bad_password.body=Wrong password -popup.downgrade.impossible.title=Impossible to downgrade -popup.downgrade.impossible.body=The app is in its first version +popup.downgrade.impossible.title=Error +popup.downgrade.impossible.body=Impossible to downgrade: the app is in its first version popup.history.title=History popup.history.selected.tooltip=Current version popup.button.yes=Yes diff --git a/fpakman/resources/locale/es b/fpakman/resources/locale/es index e4c897fb..58e5a2cc 100644 --- a/fpakman/resources/locale/es +++ b/fpakman/resources/locale/es @@ -24,8 +24,8 @@ popup.root.title=Requiere privilegios de root popup.root.password=Contraseña popup.root.bad_password.title=Error popup.root.bad_password.body=Contraseña incorrecta -popup.downgrade.impossible.title=Imposible revertir la versión -popup.downgrade.impossible.body=El aplicativo está en su primera versión +popup.downgrade.impossible.title=Error +popup.downgrade.impossible.body=Imposible revertir la versión: el aplicativo está en su primera versión popup.history.title=Historia popup.history.selected.tooltip=Versión actual popup.button.yes=Sí diff --git a/fpakman/resources/locale/pt b/fpakman/resources/locale/pt index 5775be5e..a20cd821 100644 --- a/fpakman/resources/locale/pt +++ b/fpakman/resources/locale/pt @@ -24,8 +24,8 @@ popup.root.title=Requer privilégios de root popup.root.password=Senha popup.root.bad_password.title=Erro popup.root.bad_password.body=Senha incorreta -popup.downgrade.impossible.title=Impossível reverter a versão -popup.downgrade.impossible.body=O aplicativo está na sua primeira versão +popup.downgrade.impossible.title=Erro +popup.downgrade.impossible.body=Impossível reverter a versão: o aplicativo está na sua primeira versão popup.history.title=Histórico popup.history.selected.tooltip=Versão atual popup.button.yes=Sim diff --git a/fpakman/view/qt/apps_table.py b/fpakman/view/qt/apps_table.py index 00b54437..675cebe3 100644 --- a/fpakman/view/qt/apps_table.py +++ b/fpakman/view/qt/apps_table.py @@ -1,14 +1,12 @@ -import json from typing import List -from PyQt5.QtCore import Qt, QUrl, QThread, pyqtSignal +from PyQt5.QtCore import Qt, QUrl from PyQt5.QtGui import QPixmap, QIcon, QColor, QCursor from PyQt5.QtNetwork import QNetworkRequest, QNetworkAccessManager from PyQt5.QtWidgets import QTableWidget, QTableView, QMenu, QAction, QTableWidgetItem, QToolButton, QWidget, \ - QMessageBox, QHeaderView, QDialog, QLineEdit, QVBoxLayout, QPlainTextEdit + QHeaderView -from fpakman.core import resource, flatpak -from fpakman.core.controller import FlatpakController +from fpakman.core import resource from fpakman.view.qt import dialog @@ -36,10 +34,9 @@ class UpdateToggleButton(QToolButton): class AppsTable(QTableWidget): - def __init__(self, parent: QWidget, controller: FlatpakController, columns: List[str]): + def __init__(self, parent: QWidget, columns: List[str]): super(AppsTable, self).__init__() self.parent = parent - self.controller = controller self.columns = columns self.setColumnCount(len(columns)) self.setFocusPolicy(Qt.NoFocus) @@ -105,7 +102,7 @@ class AppsTable(QTableWidget): if dialog.ask_confirmation(title=self.parent.locale_keys['manage_window.apps_table.row.actions.downgrade'], body=self.parent.locale_keys['manage_window.apps_table.row.actions.downgrade.popup.body'].format(selected_app['model']['name']), locale_keys=self.parent.locale_keys): - self.parent.downgrade_app(selected_app['model']['ref']) + self.parent.downgrade_app(selected_app) def _get_app_info(self): self.parent.get_app_info(self.get_selected_app()) diff --git a/fpakman/view/qt/dialog.py b/fpakman/view/qt/dialog.py index ec9c8210..cc2847be 100644 --- a/fpakman/view/qt/dialog.py +++ b/fpakman/view/qt/dialog.py @@ -12,12 +12,13 @@ def show_error(title: str, body: str, icon: QIcon = QIcon(resource.get_path('img if icon: error_msg.setWindowIcon(icon) + error_msg.exec_() def ask_confirmation(title: str, body: str, locale_keys: dict, icon: QIcon = QIcon(resource.get_path('img/flathub_45.svg'))): dialog_confirmation = QMessageBox() - dialog_confirmation.setIcon(QMessageBox.Critical) + dialog_confirmation.setIcon(QMessageBox.Question) dialog_confirmation.setWindowTitle(title) dialog_confirmation.setText(body) bt_yes = dialog_confirmation.addButton(locale_keys['popup.button.yes'], QMessageBox.YesRole) diff --git a/fpakman/view/qt/root.py b/fpakman/view/qt/root.py index 0d68b3ea..483f04a2 100644 --- a/fpakman/view/qt/root.py +++ b/fpakman/view/qt/root.py @@ -1,3 +1,4 @@ +import io import os import subprocess @@ -32,8 +33,13 @@ def ask_root_password(locale_keys: dict): def validate_password(password: str) -> bool: - f = os.popen('echo {} | sudo -S whoami'.format(password)) - res = f.read() - f.close() + proc = subprocess.Popen('echo {} | sudo -S whoami'.format(password), + shell=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + bufsize=-1) + stream = os._wrap_close(io.TextIOWrapper(proc.stdout), proc) + res = stream.read() + stream.close() return bool(res.strip()) diff --git a/fpakman/view/qt/systray.py b/fpakman/view/qt/systray.py index 00ff02ac..a888772d 100755 --- a/fpakman/view/qt/systray.py +++ b/fpakman/view/qt/systray.py @@ -4,10 +4,9 @@ import time from PyQt5.QtCore import QThread, pyqtSignal, QCoreApplication from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import QSystemTrayIcon, QMenu -from fpakman.core.model import FlatpakManager +from fpakman.core.controller import FlatpakManager from fpakman.core import resource -from fpakman.core.controller import FlatpakController from fpakman.view.qt.window import ManageWindow @@ -15,16 +14,16 @@ class UpdateCheck(QThread): signal = pyqtSignal(int) - def __init__(self, check_interval: int, controller: FlatpakController, parent=None): + def __init__(self, manager: FlatpakManager, check_interval: int, parent=None): super(UpdateCheck, self).__init__(parent) - self.controller = controller self.check_interval = check_interval + self.manager = manager def run(self): while True: - apps = self.controller.refresh() + apps = self.manager.read_installed() updates = len([app for app in apps if app['update']]) @@ -35,10 +34,9 @@ class UpdateCheck(QThread): class TrayIcon(QSystemTrayIcon): - def __init__(self, locale_keys: dict, controller: FlatpakController, manager: FlatpakManager, check_interval: int = 60): + def __init__(self, locale_keys: dict, manager: FlatpakManager, check_interval: int = 60): super(TrayIcon, self).__init__() self.locale_keys = locale_keys - self.controller = controller self.manager = manager self.icon_default = QIcon(resource.get_path('img/flathub_45.svg')) @@ -52,8 +50,8 @@ class TrayIcon(QSystemTrayIcon): self.action_exit.triggered.connect(lambda: QCoreApplication.exit()) self.setContextMenu(self.menu) - self.manage_window = ManageWindow(locale_keys=self.locale_keys, controller=controller, manager=self.manager, tray_icon=self) - self.check_thread = UpdateCheck(check_interval=check_interval, controller=self.controller) + self.manage_window = ManageWindow(locale_keys=self.locale_keys, manager=self.manager, tray_icon=self) + self.check_thread = UpdateCheck(check_interval=check_interval, manager=self.manager) self.check_thread.signal.connect(self.notify_updates) self.check_thread.start() @@ -78,7 +76,9 @@ class TrayIcon(QSystemTrayIcon): def show_manage_window(self): if not self.manage_window: - self.manage_window = ManageWindow(controller=self.controller) + self.manage_window = ManageWindow(locale_keys=self.locale_keys, + manager=self.manager, + tray_icon=self) self.manage_window.refresh() self.manage_window.show() diff --git a/fpakman/view/qt/thread.py b/fpakman/view/qt/thread.py new file mode 100644 index 00000000..c00ccd5c --- /dev/null +++ b/fpakman/view/qt/thread.py @@ -0,0 +1,115 @@ +from PyQt5.QtCore import QThread, pyqtSignal + +from fpakman.core import flatpak +from fpakman.core.controller import FlatpakManager +from fpakman.view.qt import dialog + + +class UpdateSelectedApps(QThread): + + signal_finished = pyqtSignal() + signal_output = pyqtSignal(str) + + def __init__(self): + super(UpdateSelectedApps, self).__init__() + self.refs_to_update = [] + + def run(self): + + for app_ref in self.refs_to_update: + for output in flatpak.update_and_stream(app_ref): + line = output.decode().strip() + if line: + self.signal_output.emit(line) + + self.signal_finished.emit() + + +class RefreshApps(QThread): + + signal = pyqtSignal(list) + + def __init__(self, manager: FlatpakManager): + super(RefreshApps, self).__init__() + self.manager = manager + + def run(self): + self.signal.emit(self.manager.read_installed()) + + +class UninstallApp(QThread): + signal_finished = pyqtSignal() + signal_output = pyqtSignal(str) + + def __init__(self): + super(UninstallApp, self).__init__() + self.app_ref = None + + def run(self): + if self.app_ref: + for output in flatpak.uninstall_and_stream(self.app_ref): + line = output.decode().strip() + if line: + self.signal_output.emit(line) + + self.signal_finished.emit() + + +class DowngradeApp(QThread): + signal_finished = pyqtSignal() + signal_output = pyqtSignal(str) + + def __init__(self, manager: FlatpakManager, locale_keys: dict): + super(DowngradeApp, self).__init__() + self.manager = manager + self.app = None + self.root_password = None + self.locale_keys = locale_keys + + def run(self): + if self.app: + + stream = self.manager.downgrade_app(self.app['model'], self.root_password) + + if stream is None: + dialog.show_error(title=self.locale_keys['popup.downgrade.impossible.title'], + body=self.locale_keys['popup.downgrade.impossible.body']) + else: + for output in self.manager.downgrade_app(self.app['model'], self.root_password): + line = output.decode().strip() + if line: + self.signal_output.emit(line) + + self.app = None + self.root_password = None + self.signal_finished.emit() + + +class GetAppInfo(QThread): + signal_finished = pyqtSignal(dict) + + def __init__(self): + super(GetAppInfo, self).__init__() + self.app = None + + def run(self): + if self.app: + app_info = flatpak.get_app_info_fields(self.app['model']['id'], self.app['model']['branch']) + app_info['name'] = self.app['model']['name'] + app_info['type'] = 'runtime' if self.app['model']['runtime'] else 'app' + self.signal_finished.emit(app_info) + self.app = None + + +class GetAppHistory(QThread): + signal_finished = pyqtSignal(dict) + + def __init__(self): + super(GetAppHistory, self).__init__() + self.app = None + + def run(self): + if self.app: + commits = flatpak.get_app_commits_data(self.app['model']['ref'], self.app['model']['origin']) + self.signal_finished.emit({'model': self.app['model'], 'commits': commits}) + self.app = None diff --git a/fpakman/view/qt/window.py b/fpakman/view/qt/window.py index 137d9606..fe7313c3 100755 --- a/fpakman/view/qt/window.py +++ b/fpakman/view/qt/window.py @@ -1,30 +1,30 @@ -import json import operator from functools import reduce from threading import Lock from typing import List -from PyQt5.QtCore import QThread, pyqtSignal, QEvent, Qt +from PyQt5.QtCore import QEvent from PyQt5.QtGui import QIcon, QWindowStateChangeEvent from PyQt5.QtWidgets import QWidget, QVBoxLayout, QApplication, QCheckBox, QHeaderView, QToolButton, QToolBar, \ - QSizePolicy, QLabel, QPlainTextEdit, QDialog, QGroupBox, QFormLayout, QLineEdit, QTableWidget, QTableWidgetItem + QSizePolicy, QLabel, QPlainTextEdit from fpakman import __version__ from fpakman.core import resource, flatpak -from fpakman.core.controller import FlatpakController -from fpakman.core.model import FlatpakManager, ImpossibleDowngradeException +from fpakman.core.controller import FlatpakManager from fpakman.view.qt import dialog from fpakman.view.qt.apps_table import AppsTable from fpakman.view.qt.history import HistoryDialog from fpakman.view.qt.info import InfoDialog from fpakman.view.qt.root import is_root, ask_root_password +from fpakman.view.qt.thread import UpdateSelectedApps, RefreshApps, UninstallApp, DowngradeApp, GetAppInfo, \ + GetAppHistory class ManageWindow(QWidget): __BASE_HEIGHT__ = 400 - def __init__(self, locale_keys: dict, controller: FlatpakController, manager: FlatpakManager, tray_icon=None): + def __init__(self, locale_keys: dict, manager: FlatpakManager, tray_icon=None): super(ManageWindow, self).__init__() self.locale_keys = locale_keys self.column_names = [locale_keys[key].capitalize() for key in ['flatpak.info.name', @@ -35,7 +35,6 @@ class ManageWindow(QWidget): 'flatpak.info.ref', 'flatpak.info.origin', 'manage_window.columns.update']] - self.controller = controller self.manager = manager self.tray_icon = tray_icon self.thread_lock = Lock() @@ -88,7 +87,7 @@ class ManageWindow(QWidget): self.layout.addWidget(toolbar) - self.table_apps = AppsTable(self, self.controller, self.column_names) + self.table_apps = AppsTable(self, self.column_names) self.table_apps.change_headers_policy() self.layout.addWidget(self.table_apps) @@ -100,18 +99,18 @@ class ManageWindow(QWidget): self.textarea_output.setVisible(False) self.textarea_output.setReadOnly(True) - self.thread_update = UpdateSelectedApps(self.controller) + self.thread_update = UpdateSelectedApps() self.thread_update.signal_output.connect(self._update_action_output) self.thread_update.signal_finished.connect(self._finish_update_selected) - self.thread_refresh = RefreshApps(self.controller) + self.thread_refresh = RefreshApps(self.manager) self.thread_refresh.signal.connect(self._finish_refresh) - self.thread_uninstall = UninstallApp(self.controller) + self.thread_uninstall = UninstallApp() self.thread_uninstall.signal_output.connect(self._update_action_output) self.thread_uninstall.signal_finished.connect(self._finish_uninstall) - self.thread_downgrade = DowngradeApp(self.manager) + self.thread_downgrade = DowngradeApp(self.manager, self.locale_keys) self.thread_downgrade.signal_output.connect(self._update_action_output) self.thread_downgrade.signal_finished.connect(self._finish_downgrade) @@ -148,7 +147,7 @@ class ManageWindow(QWidget): def _check_flatpak_installed(self): - if not self.controller.check_installed(): + if not flatpak.is_installed(): dialog.show_error(title=self.locale_keys['popup.flatpak_not_installed.title'], body=self.locale_keys['popup.flatpak_not_installed.msg'] + '...', icon=self.icon_flathub) @@ -158,7 +157,7 @@ class ManageWindow(QWidget): self.label_flatpak.setText(self._get_flatpak_label()) def _get_flatpak_label(self): - return 'flatpak: ' + self.controller.get_version() + return 'flatpak: ' + flatpak.get_version() def _acquire_lock(self): @@ -191,9 +190,9 @@ class ManageWindow(QWidget): self.thread_refresh.start() - def _finish_refresh(self): + def _finish_refresh(self, apps: List[dict]): - self.update_apps(self.thread_refresh.apps) + self.update_apps(apps) self.finish_action() self._release_lock() @@ -337,7 +336,7 @@ class ManageWindow(QWidget): self.table_apps.setEnabled(True) self.label_status.setText('') - def downgrade_app(self, app_ref: str): + def downgrade_app(self, app: dict): self._check_flatpak_installed() @@ -356,7 +355,7 @@ class ManageWindow(QWidget): self.textarea_output.setVisible(True) self._begin_action(self.locale_keys['manage_window.status.downgrading']) - self.thread_downgrade.app_ref = app_ref + self.thread_downgrade.app = app self.thread_downgrade.root_password = pwd self.thread_downgrade.start() @@ -391,116 +390,3 @@ class ManageWindow(QWidget): dialog_history = HistoryDialog(app, self.table_apps.get_selected_app_icon(), self.locale_keys) dialog_history.exec_() - - -# Threaded actions -class UpdateSelectedApps(QThread): - - signal_finished = pyqtSignal() - signal_output = pyqtSignal(str) - - def __init__(self, controller: FlatpakController): - super(UpdateSelectedApps, self).__init__() - self.controller = controller - self.refs_to_update = [] - - def run(self): - - for app_ref in self.refs_to_update: - for output in self.controller.update(app_ref): - line = output.decode().strip() - if line: - self.signal_output.emit(line) - - self.signal_finished.emit() - - -class RefreshApps(QThread): - - signal = pyqtSignal() - - def __init__(self, controller: FlatpakController): - super(RefreshApps, self).__init__() - self.controller = controller - self.apps = None - - def run(self): - self.apps = self.controller.refresh() - self.signal.emit() - - -class UninstallApp(QThread): - signal_finished = pyqtSignal() - signal_output = pyqtSignal(str) - - def __init__(self, controller: FlatpakController): - super(UninstallApp, self).__init__() - self.controller = controller - self.app_ref = None - - def run(self): - if self.app_ref: - for output in self.controller.uninstall(self.app_ref): - line = output.decode().strip() - if line: - self.signal_output.emit(line) - - self.signal_finished.emit() - - -class DowngradeApp(QThread): - signal_finished = pyqtSignal() - signal_output = pyqtSignal(str) - - def __init__(self, manager: FlatpakManager): - super(DowngradeApp, self).__init__() - self.manager = manager - self.app_ref = None - self.root_password = None - - def run(self): - if self.app_ref: - try: - for output in self.manager.downgrade_app(self.app_ref, self.root_password): - line = output.decode().strip() - if line: - self.signal_output.emit(line) - - except ImpossibleDowngradeException: - dialog.show_error(title=self.locale_keys['popup.downgrade.impossible.title'], - body=self.locale_keys['popup.downgrade.impossible.body'], - icon=self.icon_flathub) - - self.app_ref = None - self.root_password = None - self.signal_finished.emit() - - -class GetAppInfo(QThread): - signal_finished = pyqtSignal(dict) - - def __init__(self): - super(GetAppInfo, self).__init__() - self.app = None - - def run(self): - if self.app: - app_info = flatpak.get_app_info_fields(self.app['model']['id'], self.app['model']['branch']) - app_info['name'] = self.app['model']['name'] - app_info['type'] = 'runtime' if self.app['model']['runtime'] else 'app' - self.signal_finished.emit(app_info) - self.app = None - - -class GetAppHistory(QThread): - signal_finished = pyqtSignal(dict) - - def __init__(self): - super(GetAppHistory, self).__init__() - self.app = None - - def run(self): - if self.app: - commits = flatpak.get_app_commits_data(self.app['model']['ref'], self.app['model']['origin']) - self.signal_finished.emit({'model': self.app['model'], 'commits': commits}) - self.app = None From 713e97b93b77bfc7767ff27ec0e845be0633381b Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Wed, 26 Jun 2019 18:59:46 -0300 Subject: [PATCH 070/123] Allowing runtime uninstall when no app is available --- fpakman/view/qt/apps_table.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/fpakman/view/qt/apps_table.py b/fpakman/view/qt/apps_table.py index 675cebe3..c9d90978 100644 --- a/fpakman/view/qt/apps_table.py +++ b/fpakman/view/qt/apps_table.py @@ -53,6 +53,8 @@ class AppsTable(QTableWidget): self.icon_cache = {} def contextMenuEvent(self, QContextMenuEvent): # selected row right click event + napps = len([app for app in self.parent.apps if not app['model']['runtime']]) + menu_row = QMenu() action_info = QAction(self.parent.locale_keys["manage_window.apps_table.row.actions.info"]) @@ -65,14 +67,15 @@ class AppsTable(QTableWidget): action_history.triggered.connect(self._get_app_history) menu_row.addAction(action_history) + app = self.get_selected_app() + action_uninstall = QAction(self.parent.locale_keys["manage_window.apps_table.row.actions.uninstall"]) action_uninstall.setIcon(QIcon(resource.get_path('img/uninstall.svg'))) action_uninstall.triggered.connect(self._uninstall_app) + action_uninstall.setEnabled(not napps or not app['model']['runtime']) # only enabled for runtimes when no apps are available menu_row.addAction(action_uninstall) - app = self.get_selected_app() - - if not app['model']['runtime']: # downgrade only allowed for apps + if not app['model']['runtime']: # not available for runtimes action_downgrade = QAction(self.parent.locale_keys["manage_window.apps_table.row.actions.downgrade"]) action_downgrade.triggered.connect(self._downgrade_app) action_downgrade.setIcon(QIcon(resource.get_path('img/downgrade.svg'))) From b2176cadda247359b845fbd64ece30eca0d70721 Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Thu, 27 Jun 2019 12:13:18 -0300 Subject: [PATCH 071/123] About window --- CHANGELOG.md | 2 + README.md | 1 + fpakman/resources/locale/en | 8 +++- fpakman/resources/locale/es | 8 +++- fpakman/resources/locale/pt | 8 +++- fpakman/view/qt/about.py | 77 +++++++++++++++++++++++++++++++++++ fpakman/view/qt/apps_table.py | 5 ++- fpakman/view/qt/systray.py | 14 +++++++ fpakman/view/qt/window.py | 10 +++-- 9 files changed, 125 insertions(+), 8 deletions(-) create mode 100644 fpakman/view/qt/about.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e0dfe756..2b06b8f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Features - Allows to retrieve app information and history by right clicking it. - Allows app uninstall and downgrade by right clicking it. +- "About" window available when right-clicking the tray icon. ### Improvements - Performance and memory usage @@ -18,6 +19,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Allowing to specify a custom app translation with the environment variable **FPAKMAN_LOCALE** - Adding expiration time for cached app data. Default to 1 hour. The environment variable **FPAKMAN_CACHE_EXPIRATION** can change this value. - Retrieving only the installed applications data from the origin API. +- Minor GUI improvements ## [0.2.1] - 2019-06-24 ### Features diff --git a/README.md b/README.md index 5e4efe8d..1c54fcb8 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ sudo pip3 install fpakman ``` **AUR** + As **fpakman** package. There is also a staging version (**fpakman-staging**) but is intended for testing and may not work properly. diff --git a/fpakman/resources/locale/en b/fpakman/resources/locale/en index 8ef2420d..fc2de6c2 100644 --- a/fpakman/resources/locale/en +++ b/fpakman/resources/locale/en @@ -8,6 +8,7 @@ manage_window.apps_table.row.actions.uninstall.popup.title=Uninstall manage_window.apps_table.row.actions.uninstall.popup.body=Remove {} ? manage_window.apps_table.row.actions.downgrade=Downgrade manage_window.apps_table.row.actions.downgrade.popup.body=Do you really want to downgrade {} ? +manage_window.apps_table.upgrade_toggle.tooltip=Click it to check or uncheck the update manage_window.checkbox.only_apps=Apps manage_window.label.updates=Updates manage_window.status.refreshing=Refreshing @@ -33,6 +34,7 @@ popup.button.no=No popup.button.cancel=Cancel tray.action.manage=Manage applications tray.action.exit=Exit +tray.action.about=About notification.new_updates=Updates found flatpak.info.arch=arch flatpak.info.branch=branch @@ -51,4 +53,8 @@ flatpak.info.runtime=runtime flatpak.info.sdk=sdk flatpak.info.subject=subject flatpak.info.type=type -flatpak.info.version=version \ No newline at end of file +flatpak.info.version=version +about.info.desc=Non-official Flatpak application management graphical interface +about.info.link=More information at +about.info.license=Free license +about.info.rate=If this tool is useful for you, give it a star on Github to keep it up \ No newline at end of file diff --git a/fpakman/resources/locale/es b/fpakman/resources/locale/es index 58e5a2cc..65a35dba 100644 --- a/fpakman/resources/locale/es +++ b/fpakman/resources/locale/es @@ -8,6 +8,7 @@ manage_window.apps_table.row.actions.uninstall.popup.title=Desinstalación manage_window.apps_table.row.actions.uninstall.popup.body=¿Eliminar {}? manage_window.apps_table.row.actions.downgrade=Revertir versión manage_window.apps_table.row.actions.downgrade.popup.body=¿Realmente quieres revertir la versión actual de {}? +manage_window.apps_table.upgrade_toggle.tooltip=Haz clic para marcar o desmarcar la actualización manage_window.checkbox.only_apps=Aplicativos manage_window.label.updates=Actualizaciones manage_window.status.refreshing=Recargando @@ -33,6 +34,7 @@ popup.button.no=No popup.button.cancel=Cancelar tray.action.manage=Administrar aplicativos tray.action.exit=Salir +tray.action.about=Sobre notification.new_updates=Actualizaciones encontradas flatpak.info.arch=arquitectura flatpak.info.branch=rama @@ -51,4 +53,8 @@ flatpak.info.runtime=runtime flatpak.info.sdk=sdk flatpak.info.subject=tema flatpak.info.type=tipo -flatpak.info.version=versión \ No newline at end of file +flatpak.info.version=versión +about.info.desc=Interfaz grafica no oficial para administración de aplicativos Flatpak +about.info.link=Mas informaciones en +about.info.license=Licencia gratuita +about.info.rate=Si esta herramienta es útil para ti, dale una estrella en Github para mantenerla \ No newline at end of file diff --git a/fpakman/resources/locale/pt b/fpakman/resources/locale/pt index a20cd821..6622e9ed 100644 --- a/fpakman/resources/locale/pt +++ b/fpakman/resources/locale/pt @@ -8,6 +8,7 @@ manage_window.apps_table.row.actions.uninstall.popup.title=Desinstalação manage_window.apps_table.row.actions.uninstall.popup.body=Remover {}? manage_window.apps_table.row.actions.downgrade=Reverter versão manage_window.apps_table.row.actions.downgrade.popup.body=Você realmente quer reverter a versão atual de {} ? +manage_window.apps_table.upgrade_toggle.tooltip=Clique para marcar ou desmarcar a atualização manage_window.checkbox.only_apps=Aplicativos manage_window.label.updates=Atualizações manage_window.status.refreshing=Recarregando @@ -33,6 +34,7 @@ popup.button.no=Não popup.button.cancel=Cancelar tray.action.manage=Gerenciar aplicativos tray.action.exit=Sair +tray.action.about=Sobre notification.new_updates=Atualizações encontradas flatpak.info.arch=arquitetura flatpak.info.branch=ramo @@ -51,4 +53,8 @@ flatpak.info.runtime=runtime flatpak.info.sdk=sdk flatpak.info.subject=assunto flatpak.info.type=tipo -flatpak.info.version=versão \ No newline at end of file +flatpak.info.version=versão +about.info.desc=Interface gráfica não oficial para gerenciamento de aplicativos Flatpak +about.info.link=Mais informações em +about.info.license=Licença gratuita +about.info.rate=Se essa ferramenta é útil para você, dê uma estrela no Github para mantê-la de pé \ No newline at end of file diff --git a/fpakman/view/qt/about.py b/fpakman/view/qt/about.py new file mode 100644 index 00000000..a55c3e70 --- /dev/null +++ b/fpakman/view/qt/about.py @@ -0,0 +1,77 @@ +from PyQt5.QtCore import Qt +from PyQt5.QtGui import QPixmap +from PyQt5.QtWidgets import QVBoxLayout, QDialog, QLabel + +from fpakman import __version__ +from fpakman.core import resource + +PROJECT_URL = 'https://github.com/vinifmor/fpakman' +LICENSE_URL = 'https://raw.githubusercontent.com/vinifmor/fpakman/master/LICENSE' + + +class AboutDialog(QDialog): + + def __init__(self, locale_keys: dict): + super(AboutDialog, self).__init__() + self.setWindowTitle(locale_keys['tray.action.about']) + layout = QVBoxLayout() + self.setLayout(layout) + + label_logo = QLabel(self) + label_logo.setPixmap(QPixmap(resource.get_path('img/flathub_45.svg'))) + label_logo.setAlignment(Qt.AlignCenter) + layout.addWidget(label_logo) + + label_name = QLabel('fpakman ( {} {} )'.format(locale_keys['flatpak.info.version'].lower(), __version__)) + label_name.setStyleSheet('font-weight: bold;') + label_name.setAlignment(Qt.AlignCenter) + layout.addWidget(label_name) + + label_flathub = QLabel() + label_flathub.setStyleSheet('font-size: 7px; color: red; font-weight: bold;') + label_flathub.setText('* logo by Flathub (' + " {url} )".format(url='https://flathub.org')) + label_flathub.setOpenExternalLinks(True) + label_flathub.setAlignment(Qt.AlignRight) + layout.addWidget(label_flathub) + + layout.addWidget(QLabel('')) + + line_desc = QLabel(self) + line_desc.setStyleSheet('font-size: 10px; font-weight: bold;') + line_desc.setText(locale_keys['about.info.desc']) + line_desc.setAlignment(Qt.AlignCenter) + line_desc.setMinimumWidth(400) + layout.addWidget(line_desc) + + layout.addWidget(QLabel('')) + + label_more_info = QLabel() + label_more_info.setStyleSheet('font-size: 9px;') + label_more_info.setText(locale_keys['about.info.link'] + ": {url}".format(url=PROJECT_URL)) + label_more_info.setOpenExternalLinks(True) + label_more_info.setAlignment(Qt.AlignCenter) + layout.addWidget(label_more_info) + + label_license = QLabel() + label_license.setStyleSheet('font-size: 9px;') + label_license.setText("{}".format(LICENSE_URL, locale_keys['about.info.license'])) + label_license.setOpenExternalLinks(True) + label_license.setAlignment(Qt.AlignCenter) + layout.addWidget(label_license) + + layout.addWidget(QLabel('')) + + label_rate = QLabel() + label_rate.setStyleSheet('font-size: 9px; font-weight: bold;') + label_rate.setText(locale_keys['about.info.rate'] + ' :)') + label_rate.setOpenExternalLinks(True) + label_rate.setAlignment(Qt.AlignCenter) + layout.addWidget(label_rate) + + layout.addWidget(QLabel('')) + + self.adjustSize() + + def closeEvent(self, event): + event.ignore() + self.hide() diff --git a/fpakman/view/qt/apps_table.py b/fpakman/view/qt/apps_table.py index c9d90978..b4df5c1e 100644 --- a/fpakman/view/qt/apps_table.py +++ b/fpakman/view/qt/apps_table.py @@ -12,7 +12,7 @@ from fpakman.view.qt import dialog class UpdateToggleButton(QToolButton): - def __init__(self, model: dict, root: QWidget, checked: bool = True): + def __init__(self, model: dict, root: QWidget, locale_keys: dict, checked: bool = True): super(UpdateToggleButton, self).__init__() self.model = model self.root = root @@ -22,6 +22,7 @@ class UpdateToggleButton(QToolButton): self.icon_off = QIcon(resource.get_path('img/toggle_off.svg')) self.setIcon(self.icon_on) self.setStyleSheet('border: 0px;') + self.setToolTip(locale_keys['manage_window.apps_table.upgrade_toggle.tooltip']) if not checked: self.click() @@ -183,7 +184,7 @@ class AppsTable(QTableWidget): col_origin.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) self.setItem(idx, 6, col_origin) - col_update = UpdateToggleButton(app['model'], self.parent, app['model']['update']) if app['model']['update'] else None + col_update = UpdateToggleButton(app['model'], self.parent, self.parent.locale_keys, app['model']['update']) if app['model']['update'] else None self.setCellWidget(idx, 7, col_update) def change_headers_policy(self, policy: QHeaderView = QHeaderView.ResizeToContents): diff --git a/fpakman/view/qt/systray.py b/fpakman/view/qt/systray.py index a888772d..b6e6601b 100755 --- a/fpakman/view/qt/systray.py +++ b/fpakman/view/qt/systray.py @@ -7,6 +7,7 @@ from PyQt5.QtWidgets import QSystemTrayIcon, QMenu from fpakman.core.controller import FlatpakManager from fpakman.core import resource +from fpakman.view.qt.about import AboutDialog from fpakman.view.qt.window import ManageWindow @@ -44,10 +45,16 @@ class TrayIcon(QSystemTrayIcon): self.setIcon(self.icon_default) self.menu = QMenu() + self.action_manage = self.menu.addAction(self.locale_keys['tray.action.manage']) self.action_manage.triggered.connect(self.show_manage_window) + + self.action_about = self.menu.addAction(self.locale_keys['tray.action.about']) + self.action_about.triggered.connect(self.show_about) + self.action_exit = self.menu.addAction(self.locale_keys['tray.action.exit']) self.action_exit.triggered.connect(lambda: QCoreApplication.exit()) + self.setContextMenu(self.menu) self.manage_window = ManageWindow(locale_keys=self.locale_keys, manager=self.manager, tray_icon=self) @@ -55,6 +62,8 @@ class TrayIcon(QSystemTrayIcon): self.check_thread.signal.connect(self.notify_updates) self.check_thread.start() + self.dialog_about = AboutDialog(self.locale_keys) + def notify_updates(self, updates: int): if updates > 0: if self.icon().cacheKey() != self.icon_update.cacheKey(): @@ -82,3 +91,8 @@ class TrayIcon(QSystemTrayIcon): self.manage_window.refresh() self.manage_window.show() + + def show_about(self): + + if self.dialog_about.isHidden(): + self.dialog_about.show() diff --git a/fpakman/view/qt/window.py b/fpakman/view/qt/window.py index fe7313c3..ef333601 100755 --- a/fpakman/view/qt/window.py +++ b/fpakman/view/qt/window.py @@ -8,7 +8,6 @@ from PyQt5.QtGui import QIcon, QWindowStateChangeEvent from PyQt5.QtWidgets import QWidget, QVBoxLayout, QApplication, QCheckBox, QHeaderView, QToolButton, QToolBar, \ QSizePolicy, QLabel, QPlainTextEdit -from fpakman import __version__ from fpakman.core import resource, flatpak from fpakman.core.controller import FlatpakManager from fpakman.view.qt import dialog @@ -45,7 +44,7 @@ class ManageWindow(QWidget): self.icon_flathub = QIcon(resource.get_path('img/flathub_45.svg')) self._check_flatpak_installed() self.resize(ManageWindow.__BASE_HEIGHT__, ManageWindow.__BASE_HEIGHT__) - self.setWindowTitle('{} ({})'.format(locale_keys['manage_window.title'], __version__)) + self.setWindowTitle(locale_keys['manage_window.title']) self.setWindowIcon(self.icon_flathub) self.layout = QVBoxLayout() @@ -122,6 +121,7 @@ class ManageWindow(QWidget): self.toolbar_bottom = QToolBar() self.label_updates = QLabel('') + self.label_updates.setStyleSheet("color: red; font-weight: bold") self.toolbar_bottom.addWidget(self.label_updates) spacer = QWidget() spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) @@ -247,7 +247,11 @@ class ManageWindow(QWidget): total_updates = app_updates + runtime_updates if total_updates > 0: - self.label_updates.setText('{}: {} ( {} apps | {} runtimes )'.format(self.locale_keys['manage_window.label.updates'], total_updates, app_updates, runtime_updates)) + self.label_updates.setText('{}: {} ( {} {} | {} runtimes )'.format(self.locale_keys['manage_window.label.updates'], + total_updates, + app_updates, + self.locale_keys['manage_window.checkbox.only_apps'].lower(), + runtime_updates)) else: self.label_updates.setText('') From c7f92ee2f8b7f702c55e3bed313d8c6691468f13 Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Thu, 27 Jun 2019 12:21:01 -0300 Subject: [PATCH 072/123] About window fixed size --- fpakman/view/qt/about.py | 1 + fpakman/view/qt/window.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/fpakman/view/qt/about.py b/fpakman/view/qt/about.py index a55c3e70..4b0652b6 100644 --- a/fpakman/view/qt/about.py +++ b/fpakman/view/qt/about.py @@ -71,6 +71,7 @@ class AboutDialog(QDialog): layout.addWidget(QLabel('')) self.adjustSize() + self.setFixedSize(self.size()) def closeEvent(self, event): event.ignore() diff --git a/fpakman/view/qt/window.py b/fpakman/view/qt/window.py index ef333601..e94d5194 100755 --- a/fpakman/view/qt/window.py +++ b/fpakman/view/qt/window.py @@ -121,7 +121,7 @@ class ManageWindow(QWidget): self.toolbar_bottom = QToolBar() self.label_updates = QLabel('') - self.label_updates.setStyleSheet("color: red; font-weight: bold") + self.label_updates.setStyleSheet("color: red;") self.toolbar_bottom.addWidget(self.label_updates) spacer = QWidget() spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) From ead09077c0bc164f38fbe009311b5a9c22be55c1 Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Thu, 27 Jun 2019 12:28:49 -0300 Subject: [PATCH 073/123] Update README.md and CHANGELOG.md --- CHANGELOG.md | 2 +- README.md | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b06b8f6..29b4dbb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). -## [0.3.0] +## [0.3.0] - 2019-06-XX ### Features - Allows to retrieve app information and history by right clicking it. - Allows app uninstall and downgrade by right clicking it. diff --git a/README.md b/README.md index 1c54fcb8..71ff47d3 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,10 @@ Non-official graphical user interface for Flatpak application management. It is an application management panel. ### Developed with: -- Python3 and QT 5. +- Python3 and Qt5. ### Requirements -- libappindicator3 (for GTK3 desktop environments) +- libappindicator3 ( for GTK3 desktop environments ) #### Debian-based distros - python3-venv #### Arch-based distros @@ -36,7 +36,7 @@ env/bin/fpakman ``` ### Autostart -In order to autostart the application, use your Desktop Environment settings to register it as startup script ("fpakman"). +In order to autostart the application, use your Desktop Environment settings to register it as a startup application / script ("fpakman"). (P.S: the installation script currently does not do that) ### Settings From a741c3fa082d9153f197c4014e731e71366dfd00 Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Thu, 27 Jun 2019 13:09:47 -0300 Subject: [PATCH 074/123] Fix: upgrade button status --- fpakman/view/qt/apps_table.py | 6 +++--- fpakman/view/qt/window.py | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/fpakman/view/qt/apps_table.py b/fpakman/view/qt/apps_table.py index b4df5c1e..5c626696 100644 --- a/fpakman/view/qt/apps_table.py +++ b/fpakman/view/qt/apps_table.py @@ -14,7 +14,7 @@ class UpdateToggleButton(QToolButton): def __init__(self, model: dict, root: QWidget, locale_keys: dict, checked: bool = True): super(UpdateToggleButton, self).__init__() - self.model = model + self.app = model self.root = root self.setCheckable(True) self.clicked.connect(self.change_state) @@ -28,7 +28,7 @@ class UpdateToggleButton(QToolButton): self.click() def change_state(self, not_checked: bool): - self.model['update_checked'] = not not_checked + self.app['update_checked'] = not not_checked self.setIcon(self.icon_on if not not_checked else self.icon_off) self.root.change_update_state() @@ -184,7 +184,7 @@ class AppsTable(QTableWidget): col_origin.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) self.setItem(idx, 6, col_origin) - col_update = UpdateToggleButton(app['model'], self.parent, self.parent.locale_keys, app['model']['update']) if app['model']['update'] else None + col_update = UpdateToggleButton(app, self.parent, self.parent.locale_keys, app['model']['update']) if app['model']['update'] else None self.setCellWidget(idx, 7, col_update) def change_headers_policy(self, policy: QHeaderView = QHeaderView.ResizeToContents): diff --git a/fpakman/view/qt/window.py b/fpakman/view/qt/window.py index e94d5194..22016f17 100755 --- a/fpakman/view/qt/window.py +++ b/fpakman/view/qt/window.py @@ -385,12 +385,13 @@ class ManageWindow(QWidget): def _finish_get_info(self, app_info: dict): self._release_lock() self.finish_action() + self.change_update_state() dialog_info = InfoDialog(app_info, self.table_apps.get_selected_app_icon(), self.locale_keys) dialog_info.exec_() def _finish_get_history(self, app: dict): self._release_lock() self.finish_action() - + self.change_update_state() dialog_history = HistoryDialog(app, self.table_apps.get_selected_app_icon(), self.locale_keys) dialog_history.exec_() From e3a8df5dabffd388a79322a8e31207900b844587 Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Thu, 27 Jun 2019 15:04:42 -0300 Subject: [PATCH 075/123] Apps table refactoring --- fpakman/view/qt/apps_table.py | 58 ++++++++++++++++++++--------------- fpakman/view/qt/window.py | 12 ++------ 2 files changed, 35 insertions(+), 35 deletions(-) diff --git a/fpakman/view/qt/apps_table.py b/fpakman/view/qt/apps_table.py index 5c626696..b8e37748 100644 --- a/fpakman/view/qt/apps_table.py +++ b/fpakman/view/qt/apps_table.py @@ -35,16 +35,24 @@ class UpdateToggleButton(QToolButton): class AppsTable(QTableWidget): - def __init__(self, parent: QWidget, columns: List[str]): + def __init__(self, parent: QWidget): super(AppsTable, self).__init__() - self.parent = parent - self.columns = columns - self.setColumnCount(len(columns)) + self.setParent(parent) + self.window = parent + self.column_names = [parent.locale_keys[key].capitalize() for key in ['flatpak.info.name', + 'flatpak.info.version', + 'manage_window.columns.latest_version', + 'flatpak.info.branch', + 'flatpak.info.arch', + 'flatpak.info.ref', + 'flatpak.info.origin', + 'manage_window.columns.update']] + self.setColumnCount(len(self.column_names)) self.setFocusPolicy(Qt.NoFocus) self.setShowGrid(False) self.verticalHeader().setVisible(False) self.setSelectionBehavior(QTableView.SelectRows) - self.setHorizontalHeaderLabels(columns) + self.setHorizontalHeaderLabels(self.column_names) self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.icon_flathub = QIcon(resource.get_path('img/flathub_45.svg')) @@ -54,30 +62,30 @@ class AppsTable(QTableWidget): self.icon_cache = {} def contextMenuEvent(self, QContextMenuEvent): # selected row right click event - napps = len([app for app in self.parent.apps if not app['model']['runtime']]) + napps = len([app for app in self.window.apps if not app['model']['runtime']]) menu_row = QMenu() - action_info = QAction(self.parent.locale_keys["manage_window.apps_table.row.actions.info"]) + action_info = QAction(self.window.locale_keys["manage_window.apps_table.row.actions.info"]) action_info.setIcon(QIcon(resource.get_path('img/info.svg'))) action_info.triggered.connect(self._get_app_info) menu_row.addAction(action_info) - action_history = QAction(self.parent.locale_keys["manage_window.apps_table.row.actions.history"]) + action_history = QAction(self.window.locale_keys["manage_window.apps_table.row.actions.history"]) action_history.setIcon(QIcon(resource.get_path('img/history.svg'))) action_history.triggered.connect(self._get_app_history) menu_row.addAction(action_history) app = self.get_selected_app() - action_uninstall = QAction(self.parent.locale_keys["manage_window.apps_table.row.actions.uninstall"]) + action_uninstall = QAction(self.window.locale_keys["manage_window.apps_table.row.actions.uninstall"]) action_uninstall.setIcon(QIcon(resource.get_path('img/uninstall.svg'))) action_uninstall.triggered.connect(self._uninstall_app) action_uninstall.setEnabled(not napps or not app['model']['runtime']) # only enabled for runtimes when no apps are available menu_row.addAction(action_uninstall) if not app['model']['runtime']: # not available for runtimes - action_downgrade = QAction(self.parent.locale_keys["manage_window.apps_table.row.actions.downgrade"]) + action_downgrade = QAction(self.window.locale_keys["manage_window.apps_table.row.actions.downgrade"]) action_downgrade.triggered.connect(self._downgrade_app) action_downgrade.setIcon(QIcon(resource.get_path('img/downgrade.svg'))) menu_row.addAction(action_downgrade) @@ -87,7 +95,7 @@ class AppsTable(QTableWidget): menu_row.exec_() def get_selected_app(self): - return self.parent.apps[self.currentRow()] + return self.window.apps[self.currentRow()] def get_selected_app_icon(self): return self.item(self.currentRow(), 0).icon() @@ -95,24 +103,24 @@ class AppsTable(QTableWidget): def _uninstall_app(self): selected_app = self.get_selected_app() - if dialog.ask_confirmation(title=self.parent.locale_keys['manage_window.apps_table.row.actions.uninstall.popup.title'], - body=self.parent.locale_keys['manage_window.apps_table.row.actions.uninstall.popup.body'].format(selected_app['model']['name']), - locale_keys=self.parent.locale_keys): - self.parent.uninstall_app(selected_app['model']['ref']) + if dialog.ask_confirmation(title=self.window.locale_keys['manage_window.apps_table.row.actions.uninstall.popup.title'], + body=self.window.locale_keys['manage_window.apps_table.row.actions.uninstall.popup.body'].format(selected_app['model']['name']), + locale_keys=self.window.locale_keys): + self.window.uninstall_app(selected_app['model']['ref']) def _downgrade_app(self): selected_app = self.get_selected_app() - if dialog.ask_confirmation(title=self.parent.locale_keys['manage_window.apps_table.row.actions.downgrade'], - body=self.parent.locale_keys['manage_window.apps_table.row.actions.downgrade.popup.body'].format(selected_app['model']['name']), - locale_keys=self.parent.locale_keys): - self.parent.downgrade_app(selected_app) + if dialog.ask_confirmation(title=self.window.locale_keys['manage_window.apps_table.row.actions.downgrade'], + body=self.window.locale_keys['manage_window.apps_table.row.actions.downgrade.popup.body'].format(selected_app['model']['name']), + locale_keys=self.window.locale_keys): + self.window.downgrade_app(selected_app) def _get_app_info(self): - self.parent.get_app_info(self.get_selected_app()) + self.window.get_app_info(self.get_selected_app()) def _get_app_history(self): - self.parent.get_app_history(self.get_selected_app()) + self.window.get_app_history(self.get_selected_app()) def _load_icon(self, http_response): icon_url = http_response.url().toString() @@ -121,10 +129,10 @@ class AppsTable(QTableWidget): icon = QIcon(pixmap) self.icon_cache[icon_url] = icon - for idx, app in enumerate(self.parent.apps): + for idx, app in enumerate(self.window.apps): if app['model']['icon'] == icon_url: self.item(idx, 0).setIcon(icon) - self.parent.resize_and_center() + self.window.resize_and_center() break def update_apps(self, apps: List[dict]): @@ -184,10 +192,10 @@ class AppsTable(QTableWidget): col_origin.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) self.setItem(idx, 6, col_origin) - col_update = UpdateToggleButton(app, self.parent, self.parent.locale_keys, app['model']['update']) if app['model']['update'] else None + col_update = UpdateToggleButton(app, self.window, self.window.locale_keys, app['model']['update']) if app['model']['update'] else None self.setCellWidget(idx, 7, col_update) def change_headers_policy(self, policy: QHeaderView = QHeaderView.ResizeToContents): header_horizontal = self.horizontalHeader() - for i in range(0, len(self.columns)): + for i in range(self.columnCount()): header_horizontal.setSectionResizeMode(i, policy) diff --git a/fpakman/view/qt/window.py b/fpakman/view/qt/window.py index 22016f17..c21f8b07 100755 --- a/fpakman/view/qt/window.py +++ b/fpakman/view/qt/window.py @@ -26,14 +26,6 @@ class ManageWindow(QWidget): def __init__(self, locale_keys: dict, manager: FlatpakManager, tray_icon=None): super(ManageWindow, self).__init__() self.locale_keys = locale_keys - self.column_names = [locale_keys[key].capitalize() for key in ['flatpak.info.name', - 'flatpak.info.version', - 'manage_window.columns.latest_version', - 'flatpak.info.branch', - 'flatpak.info.arch', - 'flatpak.info.ref', - 'flatpak.info.origin', - 'manage_window.columns.update']] self.manager = manager self.tray_icon = tray_icon self.thread_lock = Lock() @@ -86,7 +78,7 @@ class ManageWindow(QWidget): self.layout.addWidget(toolbar) - self.table_apps = AppsTable(self, self.column_names) + self.table_apps = AppsTable(self) self.table_apps.change_headers_policy() self.layout.addWidget(self.table_apps) @@ -300,7 +292,7 @@ class ManageWindow(QWidget): self.resize_and_center() def resize_and_center(self): - new_width = reduce(operator.add, [self.table_apps.columnWidth(i) for i in range(len(self.column_names))]) * 1.05 + new_width = reduce(operator.add, [self.table_apps.columnWidth(i) for i in range(len(self.table_apps.column_names))]) * 1.05 self.resize(new_width, self.height()) self.centralize() From 22eed7af670f130d9599139c8d8726d583a577db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vin=C3=ADcius=20Moreira?= Date: Fri, 28 Jun 2019 19:01:37 -0300 Subject: [PATCH 076/123] search and install --- CHANGELOG.md | 9 ++- fpakman/app.py | 1 + fpakman/core/controller.py | 83 ++++++++++++++------- fpakman/core/flatpak.py | 64 ++++++++++++++++ fpakman/core/system.py | 8 ++ fpakman/core/util.py | 7 ++ fpakman/resources/img/install.svg | 120 ++++++++++++++++++++++++++++++ fpakman/resources/img/search.svg | 120 ++++++++++++++++++++++++++++++ fpakman/resources/locale/en | 12 ++- fpakman/resources/locale/es | 12 ++- fpakman/resources/locale/pt | 12 ++- fpakman/view/qt/apps_table.py | 89 ++++++++++++++-------- fpakman/view/qt/info.py | 17 +++-- fpakman/view/qt/systray.py | 35 +++++++-- fpakman/view/qt/thread.py | 40 ++++++++++ fpakman/view/qt/window.py | 103 ++++++++++++++++++++++--- 16 files changed, 649 insertions(+), 83 deletions(-) create mode 100755 fpakman/resources/img/install.svg create mode 100755 fpakman/resources/img/search.svg diff --git a/CHANGELOG.md b/CHANGELOG.md index 29b4dbb5..0613a9b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [0.3.0] - 2019-06-XX ### Features -- Allows to retrieve app information and history by right clicking it. -- Allows app uninstall and downgrade by right clicking it. +- Applications search +- Now when you right-click a selected application you can: + - retrieve its information + - retrieve its commit history + - downgrade + - install it - "About" window available when right-clicking the tray icon. ### Improvements @@ -18,7 +22,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Showing runtime apps when no app is available - Allowing to specify a custom app translation with the environment variable **FPAKMAN_LOCALE** - Adding expiration time for cached app data. Default to 1 hour. The environment variable **FPAKMAN_CACHE_EXPIRATION** can change this value. -- Retrieving only the installed applications data from the origin API. - Minor GUI improvements ## [0.2.1] - 2019-06-24 diff --git a/fpakman/app.py b/fpakman/app.py index bac75a8a..081e0460 100755 --- a/fpakman/app.py +++ b/fpakman/app.py @@ -25,6 +25,7 @@ manager = FlatpakManager(cache_expire=int(os.getenv('FPAKMAN_CACHE_EXPIRATION', trayIcon = TrayIcon(locale_keys=locale_keys, manager=manager, check_interval=int(os.getenv('FPAKMAN_CHECK_INTERVAL', 60))) +trayIcon.load_database() trayIcon.show() sys.exit(app.exec_()) diff --git a/fpakman/core/controller.py b/fpakman/core/controller.py index d8ebddbb..373e77d3 100755 --- a/fpakman/core/controller.py +++ b/fpakman/core/controller.py @@ -1,5 +1,5 @@ from datetime import datetime, timedelta -from threading import Lock, Thread +from threading import Lock from typing import List import requests @@ -19,17 +19,12 @@ class FlatpakManager: self.lock_db_read = Lock() self.lock_read = Lock() - # TODO remove if not necessary for future releases - def load_full_database_async(self): - Thread(target=self.load_full_database, daemon=True).start() - - # TODO remove if not necessary for future releases def load_full_database(self): self.lock_db_read.acquire() try: - res = self.http_session.get(__FLATHUB_API_URL__ + '/apps') + res = self.http_session.get(__FLATHUB_API_URL__ + '/apps', timeout=30) if res.status_code == 200: for app in res.json(): @@ -40,7 +35,7 @@ class FlatpakManager: def _request_app_data(self, app_id: str): try: - res = self.http_session.get('{}/apps/{}'.format(__FLATHUB_API_URL__, app_id), timeout=60) + res = self.http_session.get('{}/apps/{}'.format(__FLATHUB_API_URL__, app_id), timeout=30) if res.status_code == 200: return res.json() @@ -50,6 +45,58 @@ class FlatpakManager: print("Could not retrieve app data for id '{}'. Timeout".format(app_id)) return None + def _fill_api_data(self, app: dict): + + api_data = self.cache_apps.get(app['id']) + + if (not app['runtime'] and not api_data) or (api_data and api_data.get('expires_at') and api_data['expires_at'] <= datetime.utcnow()): # if api data is not cached or expired, tries to retrieve it + api_data = self._request_app_data(app['id']) + + if api_data: + api_data['expires_at'] = datetime.utcnow() + timedelta(seconds=self.cache_expire) + self.cache_apps[app['id']] = api_data + + if not api_data: + for attr in ('latest_version', 'icon', 'description'): + if attr not in app: + app[attr] = None + else: + app['latest_version'] = api_data.get('currentReleaseVersion') + app['icon'] = api_data.get('iconMobileUrl') + + for attr in ('name', 'description'): + if not app.get(attr): + app[attr] = api_data.get(attr) + + if app['icon'].startswith('/'): + app['icon'] = __FLATHUB_URL__ + app['icon'] + + def search(self, word: str) -> List[dict]: + + res = [] + apps_found = flatpak.search(word) + + if apps_found: + + already_read = set() + installed_apps = self.read_installed() + + if installed_apps: + for app in apps_found: + for installed_app in installed_apps: + if app['id'] == installed_app['id']: + res.append(installed_app) + already_read.add(app['id']) + + for app in apps_found: + if app['id'] not in already_read: + app['update'] = False + app['installed'] = False + self._fill_api_data(app) + res.append(app) + + return res + def read_installed(self) -> List[dict]: self.lock_read.acquire() @@ -63,25 +110,9 @@ class FlatpakManager: available_updates = flatpak.list_updates_as_str() for app in installed: - - app_data = self.cache_apps.get(app['id']) - - if (not app['runtime'] and not app_data) or (app_data and app_data['expires_at'] <= datetime.utcnow()): # if data is not cached or expired, tries to retrieve it - app_data = self._request_app_data(app['id']) - app_data['expires_at'] = datetime.utcnow() + timedelta(seconds=self.cache_expire) - self.cache_apps[app['id']] = app_data - - if not app_data: - app['latest_version'] = None - app['icon'] = None - else: - app['latest_version'] = app_data['currentReleaseVersion'] - app['icon'] = app_data['iconMobileUrl'] - - if app['icon'].startswith('/'): - app['icon'] = __FLATHUB_URL__ + app['icon'] - + self._fill_api_data(app) app['update'] = app['id'] in available_updates + app['installed'] = True return installed diff --git a/fpakman/core/flatpak.py b/fpakman/core/flatpak.py index 2c725a29..5da0358b 100755 --- a/fpakman/core/flatpak.py +++ b/fpakman/core/flatpak.py @@ -147,3 +147,67 @@ def get_app_commits_data(app_ref: str, origin: str) -> List[dict]: commit = {} return commits + + +def search(word: str) -> List[dict]: + cli_version = get_version() + + res = system.run_cmd('{} search {}'.format(BASE_CMD, word)) + + found = [] + + split_res = res.split('\n') + + if split_res and split_res[0].lower() != 'no matches found': + for info in split_res: + if info: + info_list = info.split('\t') + + if cli_version >= '1.3.0': + version = info_list[3].strip() + found.append({ + 'name': info_list[0].strip(), + 'description': info_list[1].strip(), + 'id': info_list[2].strip(), + 'version': version, + 'latest_version': version, + 'branch': info_list[4].strip(), + 'origin': info_list[5].strip(), + 'runtime': False, + 'arch': None, # unknown at this moment, + 'ref': None # unknown at this moment + }) + elif cli_version >= '1.2.0': + desc = info_list[0].split('-') + version = info_list[2].strip() + found.append({ + 'name': desc[0].strip(), + 'description': desc[1].strip(), + 'id': info_list[1].strip(), + 'version': version, + 'latest_version': version, + 'branch': info_list[3].strip(), + 'origin': info_list[4].strip(), + 'runtime': False, + 'arch': None, # unknown at this moment, + 'ref': None # unknown at this moment + }) + else: + version = info_list[1].strip() + found.append({ + 'name': '', + 'description': info_list[4].strip(), + 'id': info_list[0].strip(), + 'version': version, + 'latest_version': version, + 'branch': info_list[2].strip(), + 'origin': info_list[3].strip(), + 'runtime': False, + 'arch': None, # unknown at this moment, + 'ref': None # unknown at this moment + }) + return found + + +def install_and_stream(app_id: str, origin: str): + return system.stream_cmd([BASE_CMD, 'install', origin, app_id, '-y']) diff --git a/fpakman/core/system.py b/fpakman/core/system.py index a7b1715b..937a27bb 100644 --- a/fpakman/core/system.py +++ b/fpakman/core/system.py @@ -1,6 +1,9 @@ +import os import subprocess from typing import List +from fpakman.core import resource + def run_cmd(cmd: str, expected_code: int = 0, ignore_return_code: bool = False) -> str: res = subprocess.run(cmd, shell=True, stdout=subprocess.PIPE, env={'LANG': 'en'}) @@ -9,3 +12,8 @@ def run_cmd(cmd: str, expected_code: int = 0, ignore_return_code: bool = False) def stream_cmd(cmd: List[str]): return subprocess.Popen(cmd, stdout=subprocess.PIPE, env={'LANG': 'en'}).stdout + + +def notify_user(msg: str, icon_path: str = resource.get_path('img/flathub_45.svg')): + if bool(os.getenv('FPAKMAN_UPDATE_NOTIFICATION', 1)): + os.system("notify-send {} '{}'".format("-i {}".format(icon_path) if icon_path else '', msg)) diff --git a/fpakman/core/util.py b/fpakman/core/util.py index 4a6afda1..a83daf1b 100644 --- a/fpakman/core/util.py +++ b/fpakman/core/util.py @@ -2,6 +2,9 @@ import locale from fpakman.core import resource import glob +import re + +HTML_RE = re.compile(r'<[^>]+>') def get_locale_keys(key: str = None): @@ -38,3 +41,7 @@ def get_locale_keys(key: str = None): locale_obj[keyval[0].strip()] = keyval[1].strip() return locale_obj + + +def strip_html(string: str): + return HTML_RE.sub('', string) diff --git a/fpakman/resources/img/install.svg b/fpakman/resources/img/install.svg new file mode 100755 index 00000000..8f43edb1 --- /dev/null +++ b/fpakman/resources/img/install.svg @@ -0,0 +1,120 @@ + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/fpakman/resources/img/search.svg b/fpakman/resources/img/search.svg new file mode 100755 index 00000000..1519d2f8 --- /dev/null +++ b/fpakman/resources/img/search.svg @@ -0,0 +1,120 @@ + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/fpakman/resources/locale/en b/fpakman/resources/locale/en index fc2de6c2..353c87e2 100644 --- a/fpakman/resources/locale/en +++ b/fpakman/resources/locale/en @@ -1,5 +1,6 @@ manage_window.title=Flatpak Application Manager manage_window.columns.latest_version=Latest Version +manage_window.columns.installed=Installed manage_window.columns.update=Upgrade ? manage_window.apps_table.row.actions.info=Information manage_window.apps_table.row.actions.history=History @@ -8,8 +9,11 @@ manage_window.apps_table.row.actions.uninstall.popup.title=Uninstall manage_window.apps_table.row.actions.uninstall.popup.body=Remove {} ? manage_window.apps_table.row.actions.downgrade=Downgrade manage_window.apps_table.row.actions.downgrade.popup.body=Do you really want to downgrade {} ? +manage_window.apps_table.row.actions.install=Install manage_window.apps_table.upgrade_toggle.tooltip=Click it to check or uncheck the update manage_window.checkbox.only_apps=Apps +window_manage.input_search.placeholder=Search +window_manage.input_search.tooltip=Type and press ENTER to search for applications manage_window.label.updates=Updates manage_window.status.refreshing=Refreshing manage_window.status.upgrading=Upgrading @@ -17,6 +21,8 @@ manage_window.status.uninstalling=Uninstalling manage_window.status.downgrading=Downgrading manage_window.status.info=Retrieving information manage_window.status.history=Retrieving history +manage_window.status.searching=Searching +manage_window.status.installing=Installing manage_window.bt.refresh.tooltip=Reload the data about installed applications manage_window.bt.upgrade.tooltip=Upgrade all selected applications popup.flatpak_not_installed.title=Error @@ -35,12 +41,14 @@ popup.button.cancel=Cancel tray.action.manage=Manage applications tray.action.exit=Exit tray.action.about=About +tray.action.refreshing=Refreshing notification.new_updates=Updates found flatpak.info.arch=arch flatpak.info.branch=branch flatpak.info.collection=collection flatpak.info.commit=commit flatpak.info.date=date +flatpak.info.description=description flatpak.info.id=id flatpak.info.installation=installation flatpak.info.installed=installed @@ -57,4 +65,6 @@ flatpak.info.version=version about.info.desc=Non-official Flatpak application management graphical interface about.info.link=More information at about.info.license=Free license -about.info.rate=If this tool is useful for you, give it a star on Github to keep it up \ No newline at end of file +about.info.rate=If this tool is useful for you, give it a star on Github to keep it up +yes=yes +no=no \ No newline at end of file diff --git a/fpakman/resources/locale/es b/fpakman/resources/locale/es index 65a35dba..1fac6233 100644 --- a/fpakman/resources/locale/es +++ b/fpakman/resources/locale/es @@ -1,6 +1,7 @@ manage_window.title=Administrador de Aplicativos Flatpak manage_window.columns.latest_version=Ultima Versión manage_window.columns.update=Actualizar ? +manage_window.columns.installed=Instalado manage_window.apps_table.row.actions.info=Información manage_window.apps_table.row.actions.history=Historia manage_window.apps_table.row.actions.uninstall=Desinstalar @@ -8,8 +9,11 @@ manage_window.apps_table.row.actions.uninstall.popup.title=Desinstalación manage_window.apps_table.row.actions.uninstall.popup.body=¿Eliminar {}? manage_window.apps_table.row.actions.downgrade=Revertir versión manage_window.apps_table.row.actions.downgrade.popup.body=¿Realmente quieres revertir la versión actual de {}? +manage_window.apps_table.row.actions.install=Instalar manage_window.apps_table.upgrade_toggle.tooltip=Haz clic para marcar o desmarcar la actualización manage_window.checkbox.only_apps=Aplicativos +window_manage.input_search.placeholder=Buscar +window_manage.input_search.tooltip=Escriba y oprima ENTER para buscar aplicativos manage_window.label.updates=Actualizaciones manage_window.status.refreshing=Recargando manage_window.status.upgrading=Actualizando @@ -17,6 +21,8 @@ manage_window.status.uninstalling=Desinstalando manage_window.status.downgrading=Revirtiendo manage_window.status.info=Obteniendo información manage_window.status.history=Obteniendo la historia +manage_window.status.searching=Buscando +manage_window.status.installing=Instalando manage_window.bt.refresh.tooltip=Recarga los datos sobre los aplicativos instalados manage_window.bt.upgrade.tooltip=Actualiza todos los aplicativos seleccionados popup.flatpak_not_installed.title=Error @@ -35,12 +41,14 @@ popup.button.cancel=Cancelar tray.action.manage=Administrar aplicativos tray.action.exit=Salir tray.action.about=Sobre +tray.action.refreshing=Recargando notification.new_updates=Actualizaciones encontradas flatpak.info.arch=arquitectura flatpak.info.branch=rama flatpak.info.collection=colección flatpak.info.commit=commit flatpak.info.date=fecha +flatpak.info.description=descripción flatpak.info.id=id flatpak.info.installation=instalación flatpak.info.installed=instalado @@ -57,4 +65,6 @@ flatpak.info.version=versión about.info.desc=Interfaz grafica no oficial para administración de aplicativos Flatpak about.info.link=Mas informaciones en about.info.license=Licencia gratuita -about.info.rate=Si esta herramienta es útil para ti, dale una estrella en Github para mantenerla \ No newline at end of file +about.info.rate=Si esta herramienta es útil para ti, dale una estrella en Github para mantenerla +yes=sí +no=no \ No newline at end of file diff --git a/fpakman/resources/locale/pt b/fpakman/resources/locale/pt index 6622e9ed..776dd22c 100644 --- a/fpakman/resources/locale/pt +++ b/fpakman/resources/locale/pt @@ -1,6 +1,7 @@ manage_window.title=Gerenciador de Aplicativos Flatpak manage_window.columns.latest_version=Última Versão manage_window.columns.update=Atualizar ? +manage_window.columns.installed=Instalado manage_window.apps_table.row.actions.info=Informação manage_window.apps_table.row.actions.history=Histórico manage_window.apps_table.row.actions.uninstall=Desinstalar @@ -8,8 +9,11 @@ manage_window.apps_table.row.actions.uninstall.popup.title=Desinstalação manage_window.apps_table.row.actions.uninstall.popup.body=Remover {}? manage_window.apps_table.row.actions.downgrade=Reverter versão manage_window.apps_table.row.actions.downgrade.popup.body=Você realmente quer reverter a versão atual de {} ? +manage_window.apps_table.row.actions.install=Instalar manage_window.apps_table.upgrade_toggle.tooltip=Clique para marcar ou desmarcar a atualização manage_window.checkbox.only_apps=Aplicativos +window_manage.input_search.placeholder=Buscar +window_manage.input_search.tooltip=Digite e pressione ENTER para buscar aplicativos manage_window.label.updates=Atualizações manage_window.status.refreshing=Recarregando manage_window.status.upgrading=Atualizando @@ -17,6 +21,8 @@ manage_window.status.uninstalling=Desinstalando manage_window.status.downgrading=Revertendo manage_window.status.info=Obtendo informação manage_window.status.history=Obtendo histórico +manage_window.status.searching=Buscando +manage_window.status.installing=Instalando manage_window.bt.refresh.tooltip=Recarrega os dados sobre os aplicativos instalados manage_window.bt.upgrade.tooltip=Atualiza todos os aplicativos selecionados popup.flatpak_not_installed.title=Erro @@ -35,12 +41,14 @@ popup.button.cancel=Cancelar tray.action.manage=Gerenciar aplicativos tray.action.exit=Sair tray.action.about=Sobre +tray.action.refreshing=Recarregando notification.new_updates=Atualizações encontradas flatpak.info.arch=arquitetura flatpak.info.branch=ramo flatpak.info.collection=coleção flatpak.info.commit=commit flatpak.info.date=data +flatpak.info.description=descrição flatpak.info.id=id flatpak.info.installation=instalação flatpak.info.installed=instalado @@ -57,4 +65,6 @@ flatpak.info.version=versão about.info.desc=Interface gráfica não oficial para gerenciamento de aplicativos Flatpak about.info.link=Mais informações em about.info.license=Licença gratuita -about.info.rate=Se essa ferramenta é útil para você, dê uma estrela no Github para mantê-la de pé \ No newline at end of file +about.info.rate=Se essa ferramenta é útil para você, dê uma estrela no Github para mantê-la de pé +yes=sim +no=não \ No newline at end of file diff --git a/fpakman/view/qt/apps_table.py b/fpakman/view/qt/apps_table.py index b8e37748..b2696601 100644 --- a/fpakman/view/qt/apps_table.py +++ b/fpakman/view/qt/apps_table.py @@ -6,7 +6,7 @@ from PyQt5.QtNetwork import QNetworkRequest, QNetworkAccessManager from PyQt5.QtWidgets import QTableWidget, QTableView, QMenu, QAction, QTableWidgetItem, QToolButton, QWidget, \ QHeaderView -from fpakman.core import resource +from fpakman.core import resource, util from fpakman.view.qt import dialog @@ -44,8 +44,9 @@ class AppsTable(QTableWidget): 'manage_window.columns.latest_version', 'flatpak.info.branch', 'flatpak.info.arch', - 'flatpak.info.ref', + 'flatpak.info.id', 'flatpak.info.origin', + 'manage_window.columns.installed', 'manage_window.columns.update']] self.setColumnCount(len(self.column_names)) self.setFocusPolicy(Qt.NoFocus) @@ -62,33 +63,40 @@ class AppsTable(QTableWidget): self.icon_cache = {} def contextMenuEvent(self, QContextMenuEvent): # selected row right click event - napps = len([app for app in self.window.apps if not app['model']['runtime']]) - - menu_row = QMenu() - - action_info = QAction(self.window.locale_keys["manage_window.apps_table.row.actions.info"]) - action_info.setIcon(QIcon(resource.get_path('img/info.svg'))) - action_info.triggered.connect(self._get_app_info) - menu_row.addAction(action_info) - - action_history = QAction(self.window.locale_keys["manage_window.apps_table.row.actions.history"]) - action_history.setIcon(QIcon(resource.get_path('img/history.svg'))) - action_history.triggered.connect(self._get_app_history) - menu_row.addAction(action_history) app = self.get_selected_app() - action_uninstall = QAction(self.window.locale_keys["manage_window.apps_table.row.actions.uninstall"]) - action_uninstall.setIcon(QIcon(resource.get_path('img/uninstall.svg'))) - action_uninstall.triggered.connect(self._uninstall_app) - action_uninstall.setEnabled(not napps or not app['model']['runtime']) # only enabled for runtimes when no apps are available - menu_row.addAction(action_uninstall) + menu_row = QMenu() - if not app['model']['runtime']: # not available for runtimes - action_downgrade = QAction(self.window.locale_keys["manage_window.apps_table.row.actions.downgrade"]) - action_downgrade.triggered.connect(self._downgrade_app) - action_downgrade.setIcon(QIcon(resource.get_path('img/downgrade.svg'))) - menu_row.addAction(action_downgrade) + if app['model']['installed']: + napps = len([app for app in self.window.apps if not app['model']['runtime']]) + + action_info = QAction(self.window.locale_keys["manage_window.apps_table.row.actions.info"]) + action_info.setIcon(QIcon(resource.get_path('img/info.svg'))) + action_info.triggered.connect(self._get_app_info) + menu_row.addAction(action_info) + + action_history = QAction(self.window.locale_keys["manage_window.apps_table.row.actions.history"]) + action_history.setIcon(QIcon(resource.get_path('img/history.svg'))) + action_history.triggered.connect(self._get_app_history) + menu_row.addAction(action_history) + + action_uninstall = QAction(self.window.locale_keys["manage_window.apps_table.row.actions.uninstall"]) + action_uninstall.setIcon(QIcon(resource.get_path('img/uninstall.svg'))) + action_uninstall.triggered.connect(self._uninstall_app) + action_uninstall.setEnabled(not napps or not app['model']['runtime']) # only enabled for runtimes when no apps are available + menu_row.addAction(action_uninstall) + + if not app['model']['runtime']: # not available for runtimes + action_downgrade = QAction(self.window.locale_keys["manage_window.apps_table.row.actions.downgrade"]) + action_downgrade.triggered.connect(self._downgrade_app) + action_downgrade.setIcon(QIcon(resource.get_path('img/downgrade.svg'))) + menu_row.addAction(action_downgrade) + else: + action_install = QAction(self.window.locale_keys["manage_window.apps_table.row.actions.install"]) + action_install.setIcon(QIcon(resource.get_path('img/install.svg'))) + action_install.triggered.connect(self._install_app) + menu_row.addAction(action_install) menu_row.adjustSize() menu_row.popup(QCursor.pos()) @@ -122,6 +130,9 @@ class AppsTable(QTableWidget): def _get_app_history(self): self.window.get_app_history(self.get_selected_app()) + def _install_app(self): + self.window.install_app(self.get_selected_app()) + def _load_icon(self, http_response): icon_url = http_response.url().toString() pixmap = QPixmap() @@ -142,9 +153,12 @@ class AppsTable(QTableWidget): if apps: for idx, app in enumerate(apps): + tooltip = util.strip_html(app['model']['description']) if app['model']['description'] else None + col_name = QTableWidgetItem() col_name.setText(app['model']['name']) col_name.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) + col_name.setToolTip(tooltip) if not app['model']['icon']: col_name.setIcon(self.icon_flathub) @@ -162,11 +176,13 @@ class AppsTable(QTableWidget): col_version = QTableWidgetItem() col_version.setText(app['model']['version']) col_version.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) + col_version.setToolTip(tooltip) self.setItem(idx, 1, col_version) col_release = QTableWidgetItem() col_release.setText(app['model']['latest_version']) col_release.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) + col_release.setToolTip(tooltip) self.setItem(idx, 2, col_release) if app['model']['version'] and app['model']['latest_version'] and app['model']['version'] < app['model']['latest_version']: @@ -175,25 +191,38 @@ class AppsTable(QTableWidget): col_branch = QTableWidgetItem() col_branch.setText(app['model']['branch']) col_branch.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) + col_branch.setToolTip(tooltip) self.setItem(idx, 3, col_branch) col_arch = QTableWidgetItem() col_arch.setText(app['model']['arch']) col_arch.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) + col_arch.setToolTip(tooltip) self.setItem(idx, 4, col_arch) - col_package = QTableWidgetItem() - col_package.setText(app['model']['ref']) - col_package.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) - self.setItem(idx, 5, col_package) + col_id = QTableWidgetItem() + col_id.setText(app['model']['id']) + col_id.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) + col_id.setToolTip(tooltip) + self.setItem(idx, 5, col_id) col_origin = QTableWidgetItem() col_origin.setText(app['model']['origin']) col_origin.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) + col_origin.setToolTip(tooltip) self.setItem(idx, 6, col_origin) + col_installed = QTableWidgetItem() + col_installed.setText(self.parent().locale_keys['yes'] if app['model']['installed'] else self.parent().locale_keys['no']) + col_installed.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) + col_installed.setTextAlignment(Qt.AlignCenter) + col_installed.setToolTip(tooltip) + col_installed.setForeground(Qt.blue if app['model']['installed'] else Qt.red) + + self.setItem(idx, 7, col_installed) + col_update = UpdateToggleButton(app, self.window, self.window.locale_keys, app['model']['update']) if app['model']['update'] else None - self.setCellWidget(idx, 7, col_update) + self.setCellWidget(idx, 8, col_update) def change_headers_policy(self, policy: QHeaderView = QHeaderView.ResizeToContents): header_horizontal = self.horizontalHeader() diff --git a/fpakman/view/qt/info.py b/fpakman/view/qt/info.py index db32fb30..0ee09ecf 100644 --- a/fpakman/view/qt/info.py +++ b/fpakman/view/qt/info.py @@ -1,6 +1,6 @@ from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import QDialog, QVBoxLayout, QFormLayout, QGroupBox, \ - QLineEdit, QLabel + QLineEdit, QLabel, QPlainTextEdit class InfoDialog(QDialog): @@ -19,12 +19,17 @@ class InfoDialog(QDialog): for attr in sorted(app.keys()): - if attr != 'name': - text = QLineEdit() - text.setText(app[attr]) - text.setStyleSheet("width: 400px") + if attr != 'name' and app[attr]: + if attr == 'description': + text = QPlainTextEdit() + text.appendHtml(app[attr]) + else: + text = QLineEdit() + text.setText(app[attr]) + text.setCursorPosition(0) + text.setStyleSheet("width: 400px") + text.setReadOnly(True) - text.setCursorPosition(0) label = QLabel("{}: ".format(locale_keys.get('flatpak.info.' + attr, attr)).capitalize()) label.setStyleSheet("font-weight: bold") diff --git a/fpakman/view/qt/systray.py b/fpakman/view/qt/systray.py index b6e6601b..7fba210e 100755 --- a/fpakman/view/qt/systray.py +++ b/fpakman/view/qt/systray.py @@ -6,7 +6,7 @@ from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import QSystemTrayIcon, QMenu from fpakman.core.controller import FlatpakManager -from fpakman.core import resource +from fpakman.core import resource, system from fpakman.view.qt.about import AboutDialog from fpakman.view.qt.window import ManageWindow @@ -33,6 +33,19 @@ class UpdateCheck(QThread): time.sleep(self.check_interval) +class LoadDatabase(QThread): + + signal_finished = pyqtSignal() + + def __init__(self, manager: FlatpakManager, parent=None): + super(LoadDatabase, self).__init__(parent) + self.manager = manager + + def run(self): + self.manager.load_full_database() + self.signal_finished.emit() + + class TrayIcon(QSystemTrayIcon): def __init__(self, locale_keys: dict, manager: FlatpakManager, check_interval: int = 60): @@ -46,8 +59,12 @@ class TrayIcon(QSystemTrayIcon): self.menu = QMenu() + self.action_refreshing = self.menu.addAction(self.locale_keys['tray.action.refreshing'] + '...') + self.action_refreshing.setEnabled(False) + self.action_manage = self.menu.addAction(self.locale_keys['tray.action.manage']) self.action_manage.triggered.connect(self.show_manage_window) + self.action_manage.setVisible(False) self.action_about = self.menu.addAction(self.locale_keys['tray.action.about']) self.action_about.triggered.connect(self.show_about) @@ -64,6 +81,16 @@ class TrayIcon(QSystemTrayIcon): self.dialog_about = AboutDialog(self.locale_keys) + self.thread_database = LoadDatabase(manager) + self.thread_database.signal_finished.connect(self._update_menu) + + def load_database(self): + self.thread_database.start() + + def _update_menu(self): + self.action_refreshing.setVisible(False) + self.action_manage.setVisible(True) + def notify_updates(self, updates: int): if updates > 0: if self.icon().cacheKey() != self.icon_update.cacheKey(): @@ -72,11 +99,7 @@ class TrayIcon(QSystemTrayIcon): msg = '{}: {}'.format(self.locale_keys['notification.new_updates'], updates) self.setToolTip(msg) - if bool(os.getenv('FPAKMAN_UPDATE_NOTIFICATION', 1)): - os.system("notify-send -i {} '{}'".format(resource.get_path('img/flathub_45.svg'), msg)) - - if self.manage_window: - self.manage_window.refresh() + system.notify_user(msg) else: self.setIcon(self.icon_default) diff --git a/fpakman/view/qt/thread.py b/fpakman/view/qt/thread.py index c00ccd5c..f21fef81 100644 --- a/fpakman/view/qt/thread.py +++ b/fpakman/view/qt/thread.py @@ -97,6 +97,7 @@ class GetAppInfo(QThread): app_info = flatpak.get_app_info_fields(self.app['model']['id'], self.app['model']['branch']) app_info['name'] = self.app['model']['name'] app_info['type'] = 'runtime' if self.app['model']['runtime'] else 'app' + app_info['description'] = self.app['model']['description'] self.signal_finished.emit(app_info) self.app = None @@ -113,3 +114,42 @@ class GetAppHistory(QThread): commits = flatpak.get_app_commits_data(self.app['model']['ref'], self.app['model']['origin']) self.signal_finished.emit({'model': self.app['model'], 'commits': commits}) self.app = None + + +class SearchApps(QThread): + signal_finished = pyqtSignal(list) + + def __init__(self, manager: FlatpakManager): + super(SearchApps, self).__init__() + self.word = None + self.manager = manager + + def run(self): + apps_found = [] + + if self.word: + apps_found = self.manager.search(self.word) + + self.signal_finished.emit(apps_found) + self.word = None + + +class InstallApp(QThread): + + signal_finished = pyqtSignal() + signal_output = pyqtSignal(str) + + def __init__(self): + super(InstallApp, self).__init__() + self.app = None + + def run(self): + + if self.app: + for output in flatpak.install_and_stream(self.app['model']['id'], self.app['model']['origin']): + line = output.decode().strip() + if line: + self.signal_output.emit(line) + + self.app = None + self.signal_finished.emit() diff --git a/fpakman/view/qt/window.py b/fpakman/view/qt/window.py index c21f8b07..feb0de3c 100755 --- a/fpakman/view/qt/window.py +++ b/fpakman/view/qt/window.py @@ -4,9 +4,9 @@ from threading import Lock from typing import List from PyQt5.QtCore import QEvent -from PyQt5.QtGui import QIcon, QWindowStateChangeEvent +from PyQt5.QtGui import QIcon, QWindowStateChangeEvent, QPixmap from PyQt5.QtWidgets import QWidget, QVBoxLayout, QApplication, QCheckBox, QHeaderView, QToolButton, QToolBar, \ - QSizePolicy, QLabel, QPlainTextEdit + QSizePolicy, QLabel, QPlainTextEdit, QLineEdit from fpakman.core import resource, flatpak from fpakman.core.controller import FlatpakManager @@ -16,7 +16,7 @@ from fpakman.view.qt.history import HistoryDialog from fpakman.view.qt.info import InfoDialog from fpakman.view.qt.root import is_root, ask_root_password from fpakman.view.qt.thread import UpdateSelectedApps, RefreshApps, UninstallApp, DowngradeApp, GetAppInfo, \ - GetAppHistory + GetAppHistory, SearchApps, InstallApp class ManageWindow(QWidget): @@ -42,12 +42,43 @@ class ManageWindow(QWidget): self.layout = QVBoxLayout() self.setLayout(self.layout) + self.toolbar_search = QToolBar() + self.toolbar_search.setStyleSheet("spacing: 0px;") + self.toolbar_search.setContentsMargins(0, 0, 0, 0) + + spacer = QWidget() + spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + + self.toolbar_search.addWidget(spacer) + + label_pre_search = QLabel() + label_pre_search.setStyleSheet("background: white; border-top-left-radius: 5px; border-bottom-left-radius: 5px;") + self.toolbar_search.addWidget(label_pre_search) + + self.input_search = QLineEdit() + self.input_search.setFrame(False) + self.input_search.setPlaceholderText(self.locale_keys['window_manage.input_search.placeholder']+"...") + self.input_search.setToolTip(self.locale_keys['window_manage.input_search.tooltip']) + self.input_search.setStyleSheet("QLineEdit { background-color: white; color: grey; spacing: 0;}") + self.input_search.returnPressed.connect(self.search) + self.toolbar_search.addWidget(self.input_search) + + label_pos_search = QLabel() + label_pos_search.setPixmap(QPixmap(resource.get_path('img/search.svg'))) + label_pos_search.setStyleSheet("background: white; padding-right: 10px; border-top-right-radius: 5px; border-bottom-right-radius: 5px;") + self.toolbar_search.addWidget(label_pos_search) + + spacer = QWidget() + spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + self.toolbar_search.addWidget(spacer) + self.layout.addWidget(self.toolbar_search) + + toolbar = QToolBar() + self.checkbox_only_apps = QCheckBox() self.checkbox_only_apps.setText(self.locale_keys['manage_window.checkbox.only_apps']) self.checkbox_only_apps.setChecked(True) self.checkbox_only_apps.stateChanged.connect(self.filter_only_apps) - - toolbar = QToolBar() toolbar.addWidget(self.checkbox_only_apps) spacer = QWidget() @@ -56,7 +87,7 @@ class ManageWindow(QWidget): self.label_status = QLabel() self.label_status.setText('') - self.label_status.setStyleSheet("color: orange") + self.label_status.setStyleSheet("color: orange; font-weight: bold") toolbar.addWidget(self.label_status) spacer = QWidget() @@ -111,6 +142,13 @@ class ManageWindow(QWidget): self.thread_get_history = GetAppHistory() self.thread_get_history.signal_finished.connect(self._finish_get_history) + self.thread_search = SearchApps(self.manager) + self.thread_search.signal_finished.connect(self._finish_search) + + self.thread_install = InstallApp() + self.thread_install.signal_output.connect(self._update_action_output) + self.thread_install.signal_finished.connect(self._finish_install) + self.toolbar_bottom = QToolBar() self.label_updates = QLabel('') self.label_updates.setStyleSheet("color: red;") @@ -170,6 +208,14 @@ class ManageWindow(QWidget): self.thread_lock.release() + def _hide_output(self): + self.textarea_output.clear() + self.textarea_output.hide() + + def _show_output(self): + self.textarea_output.clear() + self.textarea_output.show() + def refresh(self, clear_output: bool = True): if self._acquire_lock(): @@ -177,8 +223,7 @@ class ManageWindow(QWidget): self._begin_action(self.locale_keys['manage_window.status.refreshing']) if clear_output: - self.textarea_output.clear() - self.textarea_output.hide() + self._hide_output() self.thread_refresh.start() @@ -321,17 +366,23 @@ class ManageWindow(QWidget): def _begin_action(self, action_label: str): self.label_status.setText(action_label + "...") + self.toolbar_search.setVisible(False) self.bt_upgrade.setEnabled(False) self.bt_refresh.setEnabled(False) self.checkbox_only_apps.setEnabled(False) self.table_apps.setEnabled(False) - def finish_action(self): + def finish_action(self, clear_search: bool = True): self.bt_refresh.setEnabled(True) + self.toolbar_search.setVisible(True) self.checkbox_only_apps.setEnabled(True) self.table_apps.setEnabled(True) + self.input_search.setEnabled(True) self.label_status.setText('') + if clear_search: + self.input_search.setText('') + def downgrade_app(self, app: dict): self._check_flatpak_installed() @@ -387,3 +438,37 @@ class ManageWindow(QWidget): self.change_update_state() dialog_history = HistoryDialog(app, self.table_apps.get_selected_app_icon(), self.locale_keys) dialog_history.exec_() + + def search(self): + + word = self.input_search.text().strip() + + if word and self._acquire_lock(): + self.textarea_output.clear() + self.textarea_output.setVisible(False) + self._begin_action(self.locale_keys['manage_window.status.searching']) + self.thread_search.word = word + self.thread_search.start() + + def _finish_search(self, apps_found: List[dict]): + + self._release_lock() + self.finish_action(clear_search=False) + self.update_apps(apps_found) + + def install_app(self, app: dict): + + self._check_flatpak_installed() + + if self._acquire_lock(): + self._begin_action(self.locale_keys['manage_window.status.installing']) + self._show_output() + + self.thread_install.app = app + self.thread_install.start() + + def _finish_install(self): + self.input_search.setText('') + self.finish_action() + self._release_lock() + self.refresh(clear_output=False) From d980d5475bbda278b54f5315e0f01c4b97b8d65f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vin=C3=ADcius=20Moreira?= Date: Sun, 30 Jun 2019 18:25:07 -0300 Subject: [PATCH 077/123] Small design improvements --- fpakman/resources/img/refresh.svg | 81 ++++++++++++++++---------- fpakman/resources/img/toggle_on.svg | 52 ++++++++++++----- fpakman/resources/img/update_green.svg | 71 +++++++++++++++++----- fpakman/resources/locale/en | 2 +- fpakman/resources/locale/es | 2 +- fpakman/resources/locale/pt | 2 +- fpakman/view/qt/apps_table.py | 11 ++-- fpakman/view/qt/window.py | 11 ++-- 8 files changed, 158 insertions(+), 74 deletions(-) mode change 100644 => 100755 fpakman/resources/img/refresh.svg diff --git a/fpakman/resources/img/refresh.svg b/fpakman/resources/img/refresh.svg old mode 100644 new mode 100755 index 0158d7d0..16952792 --- a/fpakman/resources/img/refresh.svg +++ b/fpakman/resources/img/refresh.svg @@ -7,22 +7,39 @@ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" + xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" version="1.1" id="Capa_1" x="0px" y="0px" - width="45" - height="44.999996" - viewBox="0 0 44.999999 44.999995" + width="48" + height="48" + viewBox="0 0 47.999999 47.999999" xml:space="preserve" - sodipodi:docname="refresh_blue.svg" - inkscape:version="0.92.4 5da689c313, 2019-01-14">image/svg+xml + transform="matrix(0.10547014,0,0,0.0985161,-1.6940777,0)" + style="fill:url(#linearGradient542);fill-opacity:1"> + style="fill:url(#linearGradient542);fill-opacity:1"> + style="fill:url(#linearGradient542);fill-opacity:1" /> + style="fill:url(#linearGradient542);fill-opacity:1" /> + transform="translate(-16.062155,-439.23)"> + transform="translate(-16.062155,-439.23)"> + transform="translate(-16.062155,-439.23)"> + transform="translate(-16.062155,-439.23)"> + transform="translate(-16.062155,-439.23)"> + transform="translate(-16.062155,-439.23)"> + transform="translate(-16.062155,-439.23)"> + transform="translate(-16.062155,-439.23)"> + transform="translate(-16.062155,-439.23)"> + transform="translate(-16.062155,-439.23)"> + transform="translate(-16.062155,-439.23)"> + transform="translate(-16.062155,-439.23)"> + transform="translate(-16.062155,-439.23)"> + transform="translate(-16.062155,-439.23)"> + transform="translate(-16.062155,-439.23)"> \ No newline at end of file diff --git a/fpakman/resources/img/toggle_on.svg b/fpakman/resources/img/toggle_on.svg index 5c341548..7d05dace 100755 --- a/fpakman/resources/img/toggle_on.svg +++ b/fpakman/resources/img/toggle_on.svg @@ -7,17 +7,41 @@ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" + xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" - width="45" - height="45" - viewBox="0 0 11.90625 11.90625" + width="48" + height="48" + viewBox="0 0 12.7 12.7" version="1.1" id="svg8" inkscape:version="0.92.3 (2405546, 2018-03-11)" - sodipodi:docname="pak_update_green.svg"> + sodipodi:docname="toggle_on_3.svg"> + id="defs2"> + + + + + + @@ -58,12 +82,12 @@ inkscape:label="Layer 1" inkscape:groupmode="layer" id="layer1" - transform="translate(198.05951,-115.76041)"> + transform="translate(198.0595,-114.96666)"> + cx="-191.7095" + cy="121.31666" + r="6.3499999" /> diff --git a/fpakman/resources/img/update_green.svg b/fpakman/resources/img/update_green.svg index 85063166..d4fc7a00 100755 --- a/fpakman/resources/img/update_green.svg +++ b/fpakman/resources/img/update_green.svg @@ -5,16 +5,17 @@ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" + xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" version="1.1" - viewBox="0 0 45 45" + viewBox="0 0 48 48" enable-background="new 0 0 26 26" id="svg8" - sodipodi:docname="update_green.svg" + sodipodi:docname="update_green_2.svg" inkscape:version="0.92.3 (2405546, 2018-03-11)" - width="45" - height="45"> + width="48" + height="48"> @@ -28,7 +29,47 @@ + id="defs12"> + + + + + + + + + transform="matrix(1.8461538,0,0,2,0,-2)" + style="fill:url(#linearGradient311);fill-opacity:1"> + style="fill:url(#linearGradient313);fill-opacity:1" /> + style="fill:url(#linearGradient315);fill-opacity:1" /> + diff --git a/fpakman/resources/locale/en b/fpakman/resources/locale/en index 353c87e2..27d90a57 100644 --- a/fpakman/resources/locale/en +++ b/fpakman/resources/locale/en @@ -10,7 +10,7 @@ manage_window.apps_table.row.actions.uninstall.popup.body=Remove {} ? manage_window.apps_table.row.actions.downgrade=Downgrade manage_window.apps_table.row.actions.downgrade.popup.body=Do you really want to downgrade {} ? manage_window.apps_table.row.actions.install=Install -manage_window.apps_table.upgrade_toggle.tooltip=Click it to check or uncheck the update +manage_window.apps_table.upgrade_toggle.tooltip=Click here to check or uncheck the update manage_window.checkbox.only_apps=Apps window_manage.input_search.placeholder=Search window_manage.input_search.tooltip=Type and press ENTER to search for applications diff --git a/fpakman/resources/locale/es b/fpakman/resources/locale/es index 1fac6233..bf6d19c3 100644 --- a/fpakman/resources/locale/es +++ b/fpakman/resources/locale/es @@ -10,7 +10,7 @@ manage_window.apps_table.row.actions.uninstall.popup.body=¿Eliminar {}? manage_window.apps_table.row.actions.downgrade=Revertir versión manage_window.apps_table.row.actions.downgrade.popup.body=¿Realmente quieres revertir la versión actual de {}? manage_window.apps_table.row.actions.install=Instalar -manage_window.apps_table.upgrade_toggle.tooltip=Haz clic para marcar o desmarcar la actualización +manage_window.apps_table.upgrade_toggle.tooltip=Haces clic aqui para marcar o desmarcar la actualización manage_window.checkbox.only_apps=Aplicativos window_manage.input_search.placeholder=Buscar window_manage.input_search.tooltip=Escriba y oprima ENTER para buscar aplicativos diff --git a/fpakman/resources/locale/pt b/fpakman/resources/locale/pt index 776dd22c..8d30767c 100644 --- a/fpakman/resources/locale/pt +++ b/fpakman/resources/locale/pt @@ -10,7 +10,7 @@ manage_window.apps_table.row.actions.uninstall.popup.body=Remover {}? manage_window.apps_table.row.actions.downgrade=Reverter versão manage_window.apps_table.row.actions.downgrade.popup.body=Você realmente quer reverter a versão atual de {} ? manage_window.apps_table.row.actions.install=Instalar -manage_window.apps_table.upgrade_toggle.tooltip=Clique para marcar ou desmarcar a atualização +manage_window.apps_table.upgrade_toggle.tooltip=Clique aqui para marcar ou desmarcar a atualização manage_window.checkbox.only_apps=Aplicativos window_manage.input_search.placeholder=Buscar window_manage.input_search.tooltip=Digite e pressione ENTER para buscar aplicativos diff --git a/fpakman/view/qt/apps_table.py b/fpakman/view/qt/apps_table.py index b2696601..4853b384 100644 --- a/fpakman/view/qt/apps_table.py +++ b/fpakman/view/qt/apps_table.py @@ -4,7 +4,7 @@ from PyQt5.QtCore import Qt, QUrl from PyQt5.QtGui import QPixmap, QIcon, QColor, QCursor from PyQt5.QtNetwork import QNetworkRequest, QNetworkAccessManager from PyQt5.QtWidgets import QTableWidget, QTableView, QMenu, QAction, QTableWidgetItem, QToolButton, QWidget, \ - QHeaderView + QHeaderView, QLabel from fpakman.core import resource, util from fpakman.view.qt import dialog @@ -212,14 +212,13 @@ class AppsTable(QTableWidget): col_origin.setToolTip(tooltip) self.setItem(idx, 6, col_origin) - col_installed = QTableWidgetItem() + col_installed = QLabel() col_installed.setText(self.parent().locale_keys['yes'] if app['model']['installed'] else self.parent().locale_keys['no']) - col_installed.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) - col_installed.setTextAlignment(Qt.AlignCenter) col_installed.setToolTip(tooltip) - col_installed.setForeground(Qt.blue if app['model']['installed'] else Qt.red) + col_installed.setAlignment(Qt.AlignCenter) + col_installed.setStyleSheet("color: {}; font-weight: bold".format('#1E90FF' if app['model']['installed'] else 'red')) - self.setItem(idx, 7, col_installed) + self.setCellWidget(idx, 7, col_installed) col_update = UpdateToggleButton(app, self.window, self.window.locale_keys, app['model']['update']) if app['model']['update'] else None self.setCellWidget(idx, 8, col_update) diff --git a/fpakman/view/qt/window.py b/fpakman/view/qt/window.py index feb0de3c..5d688feb 100755 --- a/fpakman/view/qt/window.py +++ b/fpakman/view/qt/window.py @@ -151,7 +151,7 @@ class ManageWindow(QWidget): self.toolbar_bottom = QToolBar() self.label_updates = QLabel('') - self.label_updates.setStyleSheet("color: red;") + self.label_updates.setStyleSheet("color: #FF4500; font-weight: bold") self.toolbar_bottom.addWidget(self.label_updates) spacer = QWidget() spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) @@ -284,11 +284,10 @@ class ManageWindow(QWidget): total_updates = app_updates + runtime_updates if total_updates > 0: - self.label_updates.setText('{}: {} ( {} {} | {} runtimes )'.format(self.locale_keys['manage_window.label.updates'], - total_updates, - app_updates, - self.locale_keys['manage_window.checkbox.only_apps'].lower(), - runtime_updates)) + self.label_updates.setText('{}: {}'.format(self.locale_keys['manage_window.label.updates'], total_updates)) + self.label_updates.setToolTip('{} {} | {} runtimes'.format(app_updates, + self.locale_keys['manage_window.checkbox.only_apps'].lower(), + runtime_updates)) else: self.label_updates.setText('') From da00eacd1faa738ac85dcf0cd5518ad29bb2ccf5 Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Sun, 30 Jun 2019 18:58:56 -0300 Subject: [PATCH 078/123] notifying only new updates --- CHANGELOG.md | 1 + fpakman/view/qt/systray.py | 33 +++++++++++++++++++++------------ fpakman/view/qt/window.py | 3 +-- 3 files changed, 23 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0613a9b9..797c8053 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Allowing to specify a custom app translation with the environment variable **FPAKMAN_LOCALE** - Adding expiration time for cached app data. Default to 1 hour. The environment variable **FPAKMAN_CACHE_EXPIRATION** can change this value. - Minor GUI improvements +- Notifying only new updates ## [0.2.1] - 2019-06-24 ### Features diff --git a/fpakman/view/qt/systray.py b/fpakman/view/qt/systray.py index 7fba210e..1783ddf6 100755 --- a/fpakman/view/qt/systray.py +++ b/fpakman/view/qt/systray.py @@ -1,19 +1,19 @@ -import os import time +from typing import List from PyQt5.QtCore import QThread, pyqtSignal, QCoreApplication from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import QSystemTrayIcon, QMenu -from fpakman.core.controller import FlatpakManager from fpakman.core import resource, system +from fpakman.core.controller import FlatpakManager from fpakman.view.qt.about import AboutDialog from fpakman.view.qt.window import ManageWindow class UpdateCheck(QThread): - signal = pyqtSignal(int) + signal = pyqtSignal(list) def __init__(self, manager: FlatpakManager, check_interval: int, parent=None): super(UpdateCheck, self).__init__(parent) @@ -26,9 +26,10 @@ class UpdateCheck(QThread): apps = self.manager.read_installed() - updates = len([app for app in apps if app['update']]) + updates = [app for app in apps if app['update']] - self.signal.emit(updates) + if updates: + self.signal.emit(updates) time.sleep(self.check_interval) @@ -83,6 +84,7 @@ class TrayIcon(QSystemTrayIcon): self.thread_database = LoadDatabase(manager) self.thread_database.signal_finished.connect(self._update_menu) + self.last_updates = set() def load_database(self): self.thread_database.start() @@ -91,20 +93,27 @@ class TrayIcon(QSystemTrayIcon): self.action_refreshing.setVisible(False) self.action_manage.setVisible(True) - def notify_updates(self, updates: int): - if updates > 0: - if self.icon().cacheKey() != self.icon_update.cacheKey(): - self.setIcon(self.icon_update) + def notify_updates(self, updates: List[dict]): - msg = '{}: {}'.format(self.locale_keys['notification.new_updates'], updates) + if len(updates) > 0: + + update_keys = {'{}:{}'.format(app['id'], app['latest_version']) for app in updates} + + new_icon = self.icon_update + + if update_keys.difference(self.last_updates): + self.last_updates = update_keys + msg = '{}: {}'.format(self.locale_keys['notification.new_updates'], len(updates)) self.setToolTip(msg) - system.notify_user(msg) else: - self.setIcon(self.icon_default) + new_icon = self.icon_default self.setToolTip(None) + if self.icon().cacheKey() != new_icon.cacheKey(): # changes the icon if needed + self.setIcon(new_icon) + def show_manage_window(self): if not self.manage_window: diff --git a/fpakman/view/qt/window.py b/fpakman/view/qt/window.py index 5d688feb..d625aad6 100755 --- a/fpakman/view/qt/window.py +++ b/fpakman/view/qt/window.py @@ -297,8 +297,7 @@ class ManageWindow(QWidget): break self.bt_upgrade.setEnabled(enable_bt_update) - - self.tray_icon.notify_updates(total_updates) + self.tray_icon.notify_updates([app['model'] for app in self.apps if app['model']['update']]) def centralize(self): geo = self.frameGeometry() From 7ac23ef7dfddcb9e746f754b2beca055af1da750 Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Mon, 1 Jul 2019 11:07:50 -0300 Subject: [PATCH 079/123] New icon --- CHANGELOG.md | 1 + aur/desktop_entry.py | 2 +- fpakman/app.py | 2 +- fpakman/core/system.py | 2 +- .../img/{flathub_45.svg => flathub.svg} | 24 +- fpakman/resources/img/logo.svg | 477 ++++++++++++++++++ fpakman/resources/img/logo_update.svg | 473 +++++++++++++++++ fpakman/resources/img/update.svg | 69 --- fpakman/resources/locale/en | 2 +- fpakman/resources/locale/es | 2 +- fpakman/resources/locale/pt | 2 +- fpakman/view/qt/about.py | 9 +- fpakman/view/qt/apps_table.py | 2 +- fpakman/view/qt/dialog.py | 4 +- fpakman/view/qt/systray.py | 6 +- fpakman/view/qt/window.py | 2 +- 16 files changed, 977 insertions(+), 102 deletions(-) rename fpakman/resources/img/{flathub_45.svg => flathub.svg} (96%) create mode 100755 fpakman/resources/img/logo.svg create mode 100755 fpakman/resources/img/logo_update.svg delete mode 100755 fpakman/resources/img/update.svg diff --git a/CHANGELOG.md b/CHANGELOG.md index 797c8053..8194c508 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Adding expiration time for cached app data. Default to 1 hour. The environment variable **FPAKMAN_CACHE_EXPIRATION** can change this value. - Minor GUI improvements - Notifying only new updates +- New icon ## [0.2.1] - 2019-06-24 ### Features diff --git a/aur/desktop_entry.py b/aur/desktop_entry.py index 6d40717c..080ffa72 100644 --- a/aur/desktop_entry.py +++ b/aur/desktop_entry.py @@ -10,7 +10,7 @@ Name = fpakman Categories = System; Comment = Manage your Flatpak applications Exec = /usr/bin/fpakman -Icon = /usr/lib/python{version}/site-packages/fpakman/resources/img/flathub_45.svg +Icon = /usr/lib/python{version}/site-packages/fpakman/resources/img/logo.svg """.format(version="{}.{}".format(sys.version_info.major, sys.version_info.minor)) with open('fpakman.desktop', 'w+') as f: diff --git a/fpakman/app.py b/fpakman/app.py index 081e0460..7c986695 100755 --- a/fpakman/app.py +++ b/fpakman/app.py @@ -18,7 +18,7 @@ args = parser.parse_args() locale_keys = util.get_locale_keys(os.getenv('FPAKMAN_LOCALE', None)) app = QApplication(sys.argv) -app.setWindowIcon(QIcon(resource.get_path('img/flathub_45.svg'))) +app.setWindowIcon(QIcon(resource.get_path('img/logo.svg'))) manager = FlatpakManager(cache_expire=int(os.getenv('FPAKMAN_CACHE_EXPIRATION', 60 * 60))) diff --git a/fpakman/core/system.py b/fpakman/core/system.py index 937a27bb..ae39009d 100644 --- a/fpakman/core/system.py +++ b/fpakman/core/system.py @@ -14,6 +14,6 @@ def stream_cmd(cmd: List[str]): return subprocess.Popen(cmd, stdout=subprocess.PIPE, env={'LANG': 'en'}).stdout -def notify_user(msg: str, icon_path: str = resource.get_path('img/flathub_45.svg')): +def notify_user(msg: str, icon_path: str = resource.get_path('img/logo.svg')): if bool(os.getenv('FPAKMAN_UPDATE_NOTIFICATION', 1)): os.system("notify-send {} '{}'".format("-i {}".format(icon_path) if icon_path else '', msg)) diff --git a/fpakman/resources/img/flathub_45.svg b/fpakman/resources/img/flathub.svg similarity index 96% rename from fpakman/resources/img/flathub_45.svg rename to fpakman/resources/img/flathub.svg index 2c72e02e..1b44a5d4 100755 --- a/fpakman/resources/img/flathub_45.svg +++ b/fpakman/resources/img/flathub.svg @@ -9,13 +9,13 @@ xmlns="http://www.w3.org/2000/svg" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" - width="43.449291" - height="41.737198" - viewBox="0 0 11.495958 11.042967" + width="47.919052" + height="48.110374" + viewBox="0 0 12.678582 12.729203" version="1.1" id="svg3871" - inkscape:version="0.92.4 5da689c313, 2019-01-14" - sodipodi:docname="flathub_logo.svg"> + inkscape:version="0.92.3 (2405546, 2018-03-11)" + sodipodi:docname="flathub.svg"> + transform="translate(-12.122116,-167.33593)"> + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Layer 1 \ No newline at end of file diff --git a/fpakman/resources/img/logo_update.svg b/fpakman/resources/img/logo_update.svg new file mode 100755 index 00000000..2391fa85 --- /dev/null +++ b/fpakman/resources/img/logo_update.svg @@ -0,0 +1,473 @@ + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/fpakman/resources/img/update.svg b/fpakman/resources/img/update.svg deleted file mode 100755 index 3dfbb01c..00000000 --- a/fpakman/resources/img/update.svg +++ /dev/null @@ -1,69 +0,0 @@ - - - - - - image/svg+xml - - - - - - - - - - - - diff --git a/fpakman/resources/locale/en b/fpakman/resources/locale/en index 27d90a57..12881b09 100644 --- a/fpakman/resources/locale/en +++ b/fpakman/resources/locale/en @@ -42,7 +42,7 @@ tray.action.manage=Manage applications tray.action.exit=Exit tray.action.about=About tray.action.refreshing=Refreshing -notification.new_updates=Updates found +notification.new_updates={} updates flatpak.info.arch=arch flatpak.info.branch=branch flatpak.info.collection=collection diff --git a/fpakman/resources/locale/es b/fpakman/resources/locale/es index bf6d19c3..fd0987de 100644 --- a/fpakman/resources/locale/es +++ b/fpakman/resources/locale/es @@ -42,7 +42,7 @@ tray.action.manage=Administrar aplicativos tray.action.exit=Salir tray.action.about=Sobre tray.action.refreshing=Recargando -notification.new_updates=Actualizaciones encontradas +notification.new_updates=Actualizaciones {} flatpak.info.arch=arquitectura flatpak.info.branch=rama flatpak.info.collection=colección diff --git a/fpakman/resources/locale/pt b/fpakman/resources/locale/pt index 8d30767c..94c9477f 100644 --- a/fpakman/resources/locale/pt +++ b/fpakman/resources/locale/pt @@ -42,7 +42,7 @@ tray.action.manage=Gerenciar aplicativos tray.action.exit=Sair tray.action.about=Sobre tray.action.refreshing=Recarregando -notification.new_updates=Atualizações encontradas +notification.new_updates=Atualizações {} flatpak.info.arch=arquitetura flatpak.info.branch=ramo flatpak.info.collection=coleção diff --git a/fpakman/view/qt/about.py b/fpakman/view/qt/about.py index 4b0652b6..b9e319db 100644 --- a/fpakman/view/qt/about.py +++ b/fpakman/view/qt/about.py @@ -18,7 +18,7 @@ class AboutDialog(QDialog): self.setLayout(layout) label_logo = QLabel(self) - label_logo.setPixmap(QPixmap(resource.get_path('img/flathub_45.svg'))) + label_logo.setPixmap(QPixmap(resource.get_path('img/logo.svg'))) label_logo.setAlignment(Qt.AlignCenter) layout.addWidget(label_logo) @@ -27,13 +27,6 @@ class AboutDialog(QDialog): label_name.setAlignment(Qt.AlignCenter) layout.addWidget(label_name) - label_flathub = QLabel() - label_flathub.setStyleSheet('font-size: 7px; color: red; font-weight: bold;') - label_flathub.setText('* logo by Flathub (' + " {url} )".format(url='https://flathub.org')) - label_flathub.setOpenExternalLinks(True) - label_flathub.setAlignment(Qt.AlignRight) - layout.addWidget(label_flathub) - layout.addWidget(QLabel('')) line_desc = QLabel(self) diff --git a/fpakman/view/qt/apps_table.py b/fpakman/view/qt/apps_table.py index 4853b384..74f64f6f 100644 --- a/fpakman/view/qt/apps_table.py +++ b/fpakman/view/qt/apps_table.py @@ -55,7 +55,7 @@ class AppsTable(QTableWidget): self.setSelectionBehavior(QTableView.SelectRows) self.setHorizontalHeaderLabels(self.column_names) self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - self.icon_flathub = QIcon(resource.get_path('img/flathub_45.svg')) + self.icon_flathub = QIcon(resource.get_path('img/flathub.svg')) self.network_man = QNetworkAccessManager() self.network_man.finished.connect(self._load_icon) diff --git a/fpakman/view/qt/dialog.py b/fpakman/view/qt/dialog.py index cc2847be..6b27e6ff 100644 --- a/fpakman/view/qt/dialog.py +++ b/fpakman/view/qt/dialog.py @@ -4,7 +4,7 @@ from PyQt5.QtWidgets import QMessageBox from fpakman.core import resource -def show_error(title: str, body: str, icon: QIcon = QIcon(resource.get_path('img/flathub_45.svg'))): +def show_error(title: str, body: str, icon: QIcon = QIcon(resource.get_path('img/logo.svg'))): error_msg = QMessageBox() error_msg.setIcon(QMessageBox.Critical) error_msg.setWindowTitle(title) @@ -16,7 +16,7 @@ def show_error(title: str, body: str, icon: QIcon = QIcon(resource.get_path('img error_msg.exec_() -def ask_confirmation(title: str, body: str, locale_keys: dict, icon: QIcon = QIcon(resource.get_path('img/flathub_45.svg'))): +def ask_confirmation(title: str, body: str, locale_keys: dict, icon: QIcon = QIcon(resource.get_path('img/logo.svg'))): dialog_confirmation = QMessageBox() dialog_confirmation.setIcon(QMessageBox.Question) dialog_confirmation.setWindowTitle(title) diff --git a/fpakman/view/qt/systray.py b/fpakman/view/qt/systray.py index 1783ddf6..518c52c5 100755 --- a/fpakman/view/qt/systray.py +++ b/fpakman/view/qt/systray.py @@ -54,8 +54,8 @@ class TrayIcon(QSystemTrayIcon): self.locale_keys = locale_keys self.manager = manager - self.icon_default = QIcon(resource.get_path('img/flathub_45.svg')) - self.icon_update = QIcon(resource.get_path('img/update_logo.svg')) + self.icon_default = QIcon(resource.get_path('img/logo.svg')) + self.icon_update = QIcon(resource.get_path('img/logo_update.svg')) self.setIcon(self.icon_default) self.menu = QMenu() @@ -103,7 +103,7 @@ class TrayIcon(QSystemTrayIcon): if update_keys.difference(self.last_updates): self.last_updates = update_keys - msg = '{}: {}'.format(self.locale_keys['notification.new_updates'], len(updates)) + msg = '{}: {}'.format(self.locale_keys['notification.new_updates'].format('Flatpak'), len(updates)) self.setToolTip(msg) system.notify_user(msg) diff --git a/fpakman/view/qt/window.py b/fpakman/view/qt/window.py index d625aad6..90df8f70 100755 --- a/fpakman/view/qt/window.py +++ b/fpakman/view/qt/window.py @@ -33,7 +33,7 @@ class ManageWindow(QWidget): self.apps = [] self.label_flatpak = None - self.icon_flathub = QIcon(resource.get_path('img/flathub_45.svg')) + self.icon_flathub = QIcon(resource.get_path('img/logo.svg')) self._check_flatpak_installed() self.resize(ManageWindow.__BASE_HEIGHT__, ManageWindow.__BASE_HEIGHT__) self.setWindowTitle(locale_keys['manage_window.title']) From 88881edd9f314f830690e157e18ffae64beb7b8d Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Mon, 1 Jul 2019 11:10:34 -0300 Subject: [PATCH 080/123] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 71ff47d3..3e739d47 100644 --- a/README.md +++ b/README.md @@ -47,4 +47,4 @@ You can change some application settings via environment variables: - **FPAKMAN_CACHE_EXPIRATION**: define a custom expiration time in SECONDS for application data retrieved from the origin API. Default: 3600 (1 hour). ### Roadmap -- Search and install applications \ No newline at end of file +- Support for other packaging technologies From 1d2c25f1ca98e1fa456eb2cacef8add3fcd25df3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vin=C3=ADcius=20Moreira?= Date: Mon, 1 Jul 2019 11:14:54 -0300 Subject: [PATCH 081/123] README.md logo test --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 3e739d47..f4859857 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ ## fpakman + Non-official graphical user interface for Flatpak application management. It is a tray icon that let the user known when new updates are available and an application management panel. @@ -48,3 +49,5 @@ You can change some application settings via environment variables: ### Roadmap - Support for other packaging technologies + +![logo](fpakman/resources/img/logo.svg?raw=true "logo") From 1ecb199f2d860a3404549a338ae6a6a7224cd823 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vin=C3=ADcius=20Moreira?= Date: Mon, 1 Jul 2019 11:19:03 -0300 Subject: [PATCH 082/123] Update README.md --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index f4859857..3e3bbc60 100644 --- a/README.md +++ b/README.md @@ -49,5 +49,3 @@ You can change some application settings via environment variables: ### Roadmap - Support for other packaging technologies - -![logo](fpakman/resources/img/logo.svg?raw=true "logo") From 9acc6a8878be5ba7f52ec27d0ed8de6af0c28470 Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Mon, 1 Jul 2019 12:29:40 -0300 Subject: [PATCH 083/123] progress bar --- CHANGELOG.md | 1 + fpakman/view/qt/systray.py | 5 ++++- fpakman/view/qt/thread.py | 29 +++++++++++++++++++++++++++++ fpakman/view/qt/window.py | 28 ++++++++++++++++++++++++++-- 4 files changed, 60 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8194c508..3411366c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Minor GUI improvements - Notifying only new updates - New icon +- Progress bar ## [0.2.1] - 2019-06-24 ### Features diff --git a/fpakman/view/qt/systray.py b/fpakman/view/qt/systray.py index 518c52c5..51b88b67 100755 --- a/fpakman/view/qt/systray.py +++ b/fpakman/view/qt/systray.py @@ -80,7 +80,7 @@ class TrayIcon(QSystemTrayIcon): self.check_thread.signal.connect(self.notify_updates) self.check_thread.start() - self.dialog_about = AboutDialog(self.locale_keys) + self.dialog_about = None self.thread_database = LoadDatabase(manager) self.thread_database.signal_finished.connect(self._update_menu) @@ -126,5 +126,8 @@ class TrayIcon(QSystemTrayIcon): def show_about(self): + if self.dialog_about is None: + self.dialog_about = AboutDialog(self.locale_keys) + if self.dialog_about.isHidden(): self.dialog_about.show() diff --git a/fpakman/view/qt/thread.py b/fpakman/view/qt/thread.py index f21fef81..d967eed2 100644 --- a/fpakman/view/qt/thread.py +++ b/fpakman/view/qt/thread.py @@ -1,3 +1,5 @@ +import time + from PyQt5.QtCore import QThread, pyqtSignal from fpakman.core import flatpak @@ -153,3 +155,30 @@ class InstallApp(QThread): self.app = None self.signal_finished.emit() + + +class AnimateProgress(QThread): + + signal_change = pyqtSignal(int) + + def __init__(self): + super(AnimateProgress, self).__init__() + self.last_value = 0 + self.increment = 5 + self.stop = False + + def run(self): + + current_increment = self.increment + + while not self.stop: + self.signal_change.emit(self.last_value) + + if self.last_value == 100: + current_increment = -current_increment + if self.last_value == 0: + current_increment = self.increment + + self.last_value += current_increment + + time.sleep(0.05) diff --git a/fpakman/view/qt/window.py b/fpakman/view/qt/window.py index 90df8f70..ee352c5f 100755 --- a/fpakman/view/qt/window.py +++ b/fpakman/view/qt/window.py @@ -6,7 +6,7 @@ from typing import List from PyQt5.QtCore import QEvent from PyQt5.QtGui import QIcon, QWindowStateChangeEvent, QPixmap from PyQt5.QtWidgets import QWidget, QVBoxLayout, QApplication, QCheckBox, QHeaderView, QToolButton, QToolBar, \ - QSizePolicy, QLabel, QPlainTextEdit, QLineEdit + QSizePolicy, QLabel, QPlainTextEdit, QLineEdit, QProgressBar from fpakman.core import resource, flatpak from fpakman.core.controller import FlatpakManager @@ -16,7 +16,7 @@ from fpakman.view.qt.history import HistoryDialog from fpakman.view.qt.info import InfoDialog from fpakman.view.qt.root import is_root, ask_root_password from fpakman.view.qt.thread import UpdateSelectedApps, RefreshApps, UninstallApp, DowngradeApp, GetAppInfo, \ - GetAppHistory, SearchApps, InstallApp + GetAppHistory, SearchApps, InstallApp, AnimateProgress class ManageWindow(QWidget): @@ -149,16 +149,29 @@ class ManageWindow(QWidget): self.thread_install.signal_output.connect(self._update_action_output) self.thread_install.signal_finished.connect(self._finish_install) + self.thread_animate_progress = AnimateProgress() + self.thread_animate_progress.signal_change.connect(self._update_progress) + self.toolbar_bottom = QToolBar() self.label_updates = QLabel('') self.label_updates.setStyleSheet("color: #FF4500; font-weight: bold") self.toolbar_bottom.addWidget(self.label_updates) + + spacer = QWidget() + spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + self.toolbar_bottom.addWidget(spacer) + + self.progress_bar = QProgressBar() + self.progress_bar.setTextVisible(False) + self.ref_progress_bar = self.toolbar_bottom.addWidget(self.progress_bar) + spacer = QWidget() spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self.toolbar_bottom.addWidget(spacer) self.label_flatpak = QLabel(self._get_flatpak_label()) self.toolbar_bottom.addWidget(self.label_flatpak) + self.layout.addWidget(self.toolbar_bottom) self.centralize() @@ -363,6 +376,10 @@ class ManageWindow(QWidget): self.textarea_output.appendPlainText(output) def _begin_action(self, action_label: str): + self.thread_animate_progress.stop = False + self.thread_animate_progress.start() + self.ref_progress_bar.setVisible(True) + self.progress_bar.setValue(50) self.label_status.setText(action_label + "...") self.toolbar_search.setVisible(False) self.bt_upgrade.setEnabled(False) @@ -371,6 +388,10 @@ class ManageWindow(QWidget): self.table_apps.setEnabled(False) def finish_action(self, clear_search: bool = True): + self.thread_animate_progress.stop = True + self.ref_progress_bar.setVisible(False) + self.progress_bar.setValue(0) + self.progress_bar.setVisible(False) self.bt_refresh.setEnabled(True) self.toolbar_search.setVisible(True) self.checkbox_only_apps.setEnabled(True) @@ -470,3 +491,6 @@ class ManageWindow(QWidget): self.finish_action() self._release_lock() self.refresh(clear_output=False) + + def _update_progress(self, value: int): + self.progress_bar.setValue(value) From 23a0902e4919fc23de9df7d0d94ef941d30e2486 Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Mon, 1 Jul 2019 12:40:19 -0300 Subject: [PATCH 084/123] Progress bar improvement --- fpakman/view/qt/thread.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/fpakman/view/qt/thread.py b/fpakman/view/qt/thread.py index d967eed2..d289ab5c 100644 --- a/fpakman/view/qt/thread.py +++ b/fpakman/view/qt/thread.py @@ -163,7 +163,7 @@ class AnimateProgress(QThread): def __init__(self): super(AnimateProgress, self).__init__() - self.last_value = 0 + self.progress_value = 0 self.increment = 5 self.stop = False @@ -172,13 +172,15 @@ class AnimateProgress(QThread): current_increment = self.increment while not self.stop: - self.signal_change.emit(self.last_value) + self.signal_change.emit(self.progress_value) - if self.last_value == 100: + if self.progress_value == 100: current_increment = -current_increment - if self.last_value == 0: + if self.progress_value == 0: current_increment = self.increment - self.last_value += current_increment + self.progress_value += current_increment time.sleep(0.05) + + self.progress_value = 0 From a1e83a5d1908b70c7fe13d20521ec3bf06ce4c24 Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Mon, 1 Jul 2019 13:05:55 -0300 Subject: [PATCH 085/123] Fix: keeping root privileges active --- fpakman/view/qt/root.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fpakman/view/qt/root.py b/fpakman/view/qt/root.py index 483f04a2..55bef81d 100644 --- a/fpakman/view/qt/root.py +++ b/fpakman/view/qt/root.py @@ -33,7 +33,7 @@ def ask_root_password(locale_keys: dict): def validate_password(password: str) -> bool: - proc = subprocess.Popen('echo {} | sudo -S whoami'.format(password), + proc = subprocess.Popen('sudo -k && echo {} | sudo -S whoami'.format(password), shell=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, From 060acaea0834bef61aadfdfe5b20ff79c0210c0f Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Mon, 1 Jul 2019 16:37:00 -0300 Subject: [PATCH 086/123] accepting comand line arguments --- CHANGELOG.md | 5 +++-- README.md | 4 +++- fpakman/app.py | 39 +++++++++++++++++++++++++++++++---- fpakman/core/controller.py | 5 ++++- fpakman/core/system.py | 3 +-- fpakman/core/util.py | 2 +- fpakman/view/qt/apps_table.py | 3 --- fpakman/view/qt/systray.py | 11 ++++++---- requirements.txt | 3 ++- 9 files changed, 56 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3411366c..65fdc14f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,15 +4,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). -## [0.3.0] - 2019-06-XX +## [0.3.0] - 2019-07-XX ### Features - Applications search - Now when you right-click a selected application you can: - retrieve its information - retrieve its commit history - downgrade - - install it + - install and uninstall it - "About" window available when right-clicking the tray icon. +- Now the application accepts arguments related to environment variables as well. Check 'README.md'. ### Improvements - Performance and memory usage diff --git a/README.md b/README.md index 3e3bbc60..3fff9e2d 100644 --- a/README.md +++ b/README.md @@ -41,11 +41,13 @@ In order to autostart the application, use your Desktop Environment settings to (P.S: the installation script currently does not do that) ### Settings -You can change some application settings via environment variables: +You can change some application settings via environment variables or arguments (type ```fpakman --help``` to get more information). - **FPAKMAN_UPDATE_NOTIFICATION**: enable or disable system updates notifications. Use **0** (disable) or **1** (enable, default). - **FPAKMAN_CHECK_INTERVAL**: define the updates check interval in seconds. Default: 60. - **FPAKMAN_LOCALE**: define a custom app translation for a given locale key (e.g: 'pt', 'en', 'es', ...). Default: system locale. - **FPAKMAN_CACHE_EXPIRATION**: define a custom expiration time in SECONDS for application data retrieved from the origin API. Default: 3600 (1 hour). + ### Roadmap - Support for other packaging technologies +- Memory and performance improvements diff --git a/fpakman/app.py b/fpakman/app.py index 7c986695..f23df03b 100755 --- a/fpakman/app.py +++ b/fpakman/app.py @@ -4,6 +4,7 @@ import sys from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import QApplication +from colorama import Fore from fpakman import __version__ from fpakman.core import resource @@ -11,20 +12,50 @@ from fpakman.core import util from fpakman.core.controller import FlatpakManager from fpakman.view.qt.systray import TrayIcon -parser = argparse.ArgumentParser(prog='fpakman', description="GUI for Flatpak applications management") +app_name = 'fpakman' + + +def log_msg(msg: str, color: int = None): + + if color is None: + print('[{}] {}'.format(app_name, msg)) + else: + print('{}[{}] {}{}'.format(color, app_name, msg, Fore.RESET)) + + +parser = argparse.ArgumentParser(prog=app_name, description="GUI for Flatpak applications management") parser.add_argument('-v', '--version', action='version', version='%(prog)s {}'.format(__version__)) +parser.add_argument('-e', '--cache-exp', action="store", default=int(os.getenv('FPAKMAN_CACHE_EXPIRATION', 60 * 60)), type=int, help='cached application expiration time in SECONDS. Default: %(default)s') +parser.add_argument('-l', '--locale', action="store", default=os.getenv('FPAKMAN_LOCALE', 'en'), help='Translation key. Default: %(default)s') +parser.add_argument('-i', '--check-interval', action="store", default=int(os.getenv('FPAKMAN_CHECK_INTERVAL', 60)), type=int, help='Updates check interval in SECONDS. Default: %(default)s') +parser.add_argument('-n', '--update-notification', action="store", default=os.getenv('FPAKMAN_UPDATE_NOTIFICATION', 1), type=int, help='Enable/disable system notifications for new updates. Default: %(default)s') args = parser.parse_args() -locale_keys = util.get_locale_keys(os.getenv('FPAKMAN_LOCALE', None)) +if args.cache_exp <= 0: + log_msg("'cache-exp' set to '{}': cache will not expire.".format(args.cache_exp), Fore.YELLOW) + +if not args.locale.strip(): + log_msg("'locale' set as '{}'. You must provide a valid one. Aborting...".format(args.locale), Fore.RED) + exit(1) + +if args.check_interval <= 0: + log_msg("'check-interval' set as '{}'. It must be >= 0. Aborting...".format(args.check_interval), Fore.RED) + exit(1) + +if args.update_notification == 0: + log_msg('updates notifications are disabled', Fore.YELLOW) + +locale_keys = util.get_locale_keys(args.locale) app = QApplication(sys.argv) app.setWindowIcon(QIcon(resource.get_path('img/logo.svg'))) -manager = FlatpakManager(cache_expire=int(os.getenv('FPAKMAN_CACHE_EXPIRATION', 60 * 60))) +manager = FlatpakManager(cache_expire=args.cache_exp) trayIcon = TrayIcon(locale_keys=locale_keys, manager=manager, - check_interval=int(os.getenv('FPAKMAN_CHECK_INTERVAL', 60))) + check_interval=args.check_interval, + update_notification=bool(args.update_notification)) trayIcon.load_database() trayIcon.show() diff --git a/fpakman/core/controller.py b/fpakman/core/controller.py index 373e77d3..5e02af2d 100755 --- a/fpakman/core/controller.py +++ b/fpakman/core/controller.py @@ -53,7 +53,10 @@ class FlatpakManager: api_data = self._request_app_data(app['id']) if api_data: - api_data['expires_at'] = datetime.utcnow() + timedelta(seconds=self.cache_expire) + + if self.cache_expire > 0: + api_data['expires_at'] = datetime.utcnow() + timedelta(seconds=self.cache_expire) + self.cache_apps[app['id']] = api_data if not api_data: diff --git a/fpakman/core/system.py b/fpakman/core/system.py index ae39009d..462f9a66 100644 --- a/fpakman/core/system.py +++ b/fpakman/core/system.py @@ -15,5 +15,4 @@ def stream_cmd(cmd: List[str]): def notify_user(msg: str, icon_path: str = resource.get_path('img/logo.svg')): - if bool(os.getenv('FPAKMAN_UPDATE_NOTIFICATION', 1)): - os.system("notify-send {} '{}'".format("-i {}".format(icon_path) if icon_path else '', msg)) + os.system("notify-send {} '{}'".format("-i {}".format(icon_path) if icon_path else '', msg)) diff --git a/fpakman/core/util.py b/fpakman/core/util.py index a83daf1b..678c577d 100644 --- a/fpakman/core/util.py +++ b/fpakman/core/util.py @@ -14,7 +14,7 @@ def get_locale_keys(key: str = None): if key is None: current_locale = locale.getdefaultlocale() else: - current_locale = [key] + current_locale = [key.strip().lower()] if current_locale: current_locale = current_locale[0] diff --git a/fpakman/view/qt/apps_table.py b/fpakman/view/qt/apps_table.py index 74f64f6f..ff7618fc 100644 --- a/fpakman/view/qt/apps_table.py +++ b/fpakman/view/qt/apps_table.py @@ -69,8 +69,6 @@ class AppsTable(QTableWidget): menu_row = QMenu() if app['model']['installed']: - napps = len([app for app in self.window.apps if not app['model']['runtime']]) - action_info = QAction(self.window.locale_keys["manage_window.apps_table.row.actions.info"]) action_info.setIcon(QIcon(resource.get_path('img/info.svg'))) action_info.triggered.connect(self._get_app_info) @@ -84,7 +82,6 @@ class AppsTable(QTableWidget): action_uninstall = QAction(self.window.locale_keys["manage_window.apps_table.row.actions.uninstall"]) action_uninstall.setIcon(QIcon(resource.get_path('img/uninstall.svg'))) action_uninstall.triggered.connect(self._uninstall_app) - action_uninstall.setEnabled(not napps or not app['model']['runtime']) # only enabled for runtimes when no apps are available menu_row.addAction(action_uninstall) if not app['model']['runtime']: # not available for runtimes diff --git a/fpakman/view/qt/systray.py b/fpakman/view/qt/systray.py index 51b88b67..55627b88 100755 --- a/fpakman/view/qt/systray.py +++ b/fpakman/view/qt/systray.py @@ -49,7 +49,7 @@ class LoadDatabase(QThread): class TrayIcon(QSystemTrayIcon): - def __init__(self, locale_keys: dict, manager: FlatpakManager, check_interval: int = 60): + def __init__(self, locale_keys: dict, manager: FlatpakManager, check_interval: int = 60, update_notification: bool = True): super(TrayIcon, self).__init__() self.locale_keys = locale_keys self.manager = manager @@ -75,7 +75,7 @@ class TrayIcon(QSystemTrayIcon): self.setContextMenu(self.menu) - self.manage_window = ManageWindow(locale_keys=self.locale_keys, manager=self.manager, tray_icon=self) + self.manage_window = None self.check_thread = UpdateCheck(check_interval=check_interval, manager=self.manager) self.check_thread.signal.connect(self.notify_updates) self.check_thread.start() @@ -85,6 +85,7 @@ class TrayIcon(QSystemTrayIcon): self.thread_database = LoadDatabase(manager) self.thread_database.signal_finished.connect(self._update_menu) self.last_updates = set() + self.update_notification = update_notification def load_database(self): self.thread_database.start() @@ -105,7 +106,9 @@ class TrayIcon(QSystemTrayIcon): self.last_updates = update_keys msg = '{}: {}'.format(self.locale_keys['notification.new_updates'].format('Flatpak'), len(updates)) self.setToolTip(msg) - system.notify_user(msg) + + if self.update_notification: + system.notify_user(msg) else: new_icon = self.icon_default @@ -116,7 +119,7 @@ class TrayIcon(QSystemTrayIcon): def show_manage_window(self): - if not self.manage_window: + if self.manage_window is None: self.manage_window = ManageWindow(locale_keys=self.locale_keys, manager=self.manager, tray_icon=self) diff --git a/requirements.txt b/requirements.txt index 9ef4c21f..3415a156 100755 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ pyqt5>=5.12 -requests>=2.22 \ No newline at end of file +requests>=2.22 +colorama>=0.4.1 \ No newline at end of file From cf39d8823b7eb3bef1c254d1b3524352c40e6499 Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Mon, 1 Jul 2019 16:57:20 -0300 Subject: [PATCH 087/123] colored history lines to indicate the version state --- fpakman/resources/locale/en | 4 +++- fpakman/resources/locale/es | 4 +++- fpakman/resources/locale/pt | 4 +++- fpakman/view/qt/history.py | 6 ++++-- 4 files changed, 13 insertions(+), 5 deletions(-) diff --git a/fpakman/resources/locale/en b/fpakman/resources/locale/en index 12881b09..1cb4c76f 100644 --- a/fpakman/resources/locale/en +++ b/fpakman/resources/locale/en @@ -67,4 +67,6 @@ about.info.link=More information at about.info.license=Free license about.info.rate=If this tool is useful for you, give it a star on Github to keep it up yes=yes -no=no \ No newline at end of file +no=no +version.updated=updated +version.outdated=outdated \ No newline at end of file diff --git a/fpakman/resources/locale/es b/fpakman/resources/locale/es index fd0987de..19617178 100644 --- a/fpakman/resources/locale/es +++ b/fpakman/resources/locale/es @@ -67,4 +67,6 @@ about.info.link=Mas informaciones en about.info.license=Licencia gratuita about.info.rate=Si esta herramienta es útil para ti, dale una estrella en Github para mantenerla yes=sí -no=no \ No newline at end of file +no=no +version.updated=actualizada +version.outdated=desactualizada \ No newline at end of file diff --git a/fpakman/resources/locale/pt b/fpakman/resources/locale/pt index 94c9477f..7cddb50c 100644 --- a/fpakman/resources/locale/pt +++ b/fpakman/resources/locale/pt @@ -67,4 +67,6 @@ about.info.link=Mais informações em about.info.license=Licença gratuita about.info.rate=Se essa ferramenta é útil para você, dê uma estrela no Github para mantê-la de pé yes=sim -no=não \ No newline at end of file +no=não +version.updated=atualizada +version.outdated=desatualizada \ No newline at end of file diff --git a/fpakman/view/qt/history.py b/fpakman/view/qt/history.py index 5282fe97..3e73ad62 100644 --- a/fpakman/view/qt/history.py +++ b/fpakman/view/qt/history.py @@ -37,8 +37,10 @@ class HistoryDialog(QDialog): item.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) if current_app_commit: - item.setBackground(Qt.darkYellow) - item.setToolTip(locale_keys['popup.history.selected.tooltip']) + item.setBackground(Qt.yellow if row != 0 else Qt.green) + tip = '{}. {}.'.format(locale_keys['popup.history.selected.tooltip'], locale_keys['version.{}'.format('updated'if row == 0 else 'outdated')].capitalize()) + + item.setToolTip(tip) table_history.setItem(row, col, item) From 81f9b4bc0981f1665f6d5a9e208c1854f7632b26 Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Mon, 1 Jul 2019 17:01:13 -0300 Subject: [PATCH 088/123] changed oudated version color to improve dark theme experience --- fpakman/view/qt/history.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fpakman/view/qt/history.py b/fpakman/view/qt/history.py index 3e73ad62..5cf26324 100644 --- a/fpakman/view/qt/history.py +++ b/fpakman/view/qt/history.py @@ -37,7 +37,7 @@ class HistoryDialog(QDialog): item.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) if current_app_commit: - item.setBackground(Qt.yellow if row != 0 else Qt.green) + item.setBackground(Qt.darkYellow if row != 0 else Qt.green) tip = '{}. {}.'.format(locale_keys['popup.history.selected.tooltip'], locale_keys['version.{}'.format('updated'if row == 0 else 'outdated')].capitalize()) item.setToolTip(tip) From d190392633cb262d0977d72215fed8b4b0c64be6 Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Mon, 1 Jul 2019 17:26:37 -0300 Subject: [PATCH 089/123] Icons for installed/uninstalled --- fpakman/resources/img/checked.svg | 62 +++++++ fpakman/resources/img/downgrade.svg | 16 +- fpakman/resources/img/history.svg | 262 ++++++++++++++------------- fpakman/resources/img/info.svg | 45 ++--- fpakman/resources/img/install.svg | 46 ++--- fpakman/resources/img/red_cross.svg | 115 ++++++++++++ fpakman/resources/img/toggle_off.svg | 26 +-- fpakman/resources/img/toggle_on.svg | 24 +-- fpakman/resources/img/uninstall.svg | 70 +++---- fpakman/view/qt/apps_table.py | 3 +- 10 files changed, 426 insertions(+), 243 deletions(-) create mode 100755 fpakman/resources/img/checked.svg create mode 100755 fpakman/resources/img/red_cross.svg diff --git a/fpakman/resources/img/checked.svg b/fpakman/resources/img/checked.svg new file mode 100755 index 00000000..d61b9228 --- /dev/null +++ b/fpakman/resources/img/checked.svg @@ -0,0 +1,62 @@ + + + + + + image/svg+xml + + + + + + + + + diff --git a/fpakman/resources/img/downgrade.svg b/fpakman/resources/img/downgrade.svg index f1e6a7bd..aea4c0e8 100755 --- a/fpakman/resources/img/downgrade.svg +++ b/fpakman/resources/img/downgrade.svg @@ -8,13 +8,13 @@ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" version="1.1" - viewBox="0 0 48 48" + viewBox="0 0 24 24" enable-background="new 0 0 26 26" id="svg8" sodipodi:docname="downgrade.svg" inkscape:version="0.92.3 (2405546, 2018-03-11)" - width="48" - height="48"> + width="24" + height="24"> @@ -39,13 +39,13 @@ inkscape:pageopacity="0" inkscape:pageshadow="2" inkscape:window-width="1366" - inkscape:window-height="739" + inkscape:window-height="703" id="namedview10" showgrid="false" showborder="false" - inkscape:zoom="9.0769231" - inkscape:cx="-0.33050843" - inkscape:cy="20.813559" + inkscape:zoom="4.5384616" + inkscape:cx="-20.270304" + inkscape:cy="12.13426" inkscape:window-x="0" inkscape:window-y="260" inkscape:window-maximized="1" @@ -56,7 +56,7 @@ fit-margin-bottom="0" /> image/svg+xml - - - - - - - - - - - - - - - - - + + transform="translate(0,-8)"> + transform="translate(0,-8)"> + transform="translate(0,-8)"> + transform="translate(0,-8)"> + transform="translate(0,-8)"> + transform="translate(0,-8)"> + transform="translate(0,-8)"> + transform="translate(0,-8)"> + transform="translate(0,-8)"> + transform="translate(0,-8)"> + transform="translate(0,-8)"> + transform="translate(0,-8)"> + transform="translate(0,-8)"> + transform="translate(0,-8)"> + transform="translate(0,-8)"> - \ No newline at end of file + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/fpakman/resources/img/info.svg b/fpakman/resources/img/info.svg index 7983485e..a276e4fb 100755 --- a/fpakman/resources/img/info.svg +++ b/fpakman/resources/img/info.svg @@ -14,9 +14,9 @@ id="Capa_1" x="0px" y="0px" - width="47.999996" - height="48.000004" - viewBox="0 0 47.999997 48.000005" + width="24" + height="24" + viewBox="0 0 24 24" xml:space="preserve" sodipodi:docname="info.svg" inkscape:version="0.92.3 (2405546, 2018-03-11)"> + transform="translate(0,-414.534)"> + transform="translate(0,-414.534)"> + transform="translate(0,-414.534)"> + transform="translate(0,-414.534)"> + transform="translate(0,-414.534)"> + transform="translate(0,-414.534)"> + transform="translate(0,-414.534)"> + transform="translate(0,-414.534)"> + transform="translate(0,-414.534)"> + transform="translate(0,-414.534)"> + transform="translate(0,-414.534)"> + transform="translate(0,-414.534)"> + transform="translate(0,-414.534)"> + transform="translate(0,-414.534)"> + transform="translate(0,-414.534)"> diff --git a/fpakman/resources/img/install.svg b/fpakman/resources/img/install.svg index 8f43edb1..253d0b9b 100755 --- a/fpakman/resources/img/install.svg +++ b/fpakman/resources/img/install.svg @@ -13,9 +13,9 @@ id="Capa_1" x="0px" y="0px" - width="48" - height="48" - viewBox="0 0 48 47.999999" + width="12" + height="12" + viewBox="0 0 12 12" xml:space="preserve" sodipodi:docname="install.svg" inkscape:version="0.92.3 (2405546, 2018-03-11)"> + transform="scale(0.034381)" + style="fill:#008000"> + style="fill:#008000" /> + transform="translate(0,-337.03)"> + transform="translate(0,-337.03)"> + transform="translate(0,-337.03)"> + transform="translate(0,-337.03)"> + transform="translate(0,-337.03)"> + transform="translate(0,-337.03)"> + transform="translate(0,-337.03)"> + transform="translate(0,-337.03)"> + transform="translate(0,-337.03)"> + transform="translate(0,-337.03)"> + transform="translate(0,-337.03)"> + transform="translate(0,-337.03)"> + transform="translate(0,-337.03)"> + transform="translate(0,-337.03)"> + transform="translate(0,-337.03)"> \ No newline at end of file diff --git a/fpakman/resources/img/red_cross.svg b/fpakman/resources/img/red_cross.svg new file mode 100755 index 00000000..28054ffc --- /dev/null +++ b/fpakman/resources/img/red_cross.svg @@ -0,0 +1,115 @@ + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/fpakman/resources/img/toggle_off.svg b/fpakman/resources/img/toggle_off.svg index f50edee5..48f4b89a 100755 --- a/fpakman/resources/img/toggle_off.svg +++ b/fpakman/resources/img/toggle_off.svg @@ -9,13 +9,13 @@ xmlns="http://www.w3.org/2000/svg" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" - width="45" - height="45" - viewBox="0 0 11.90625 11.90625" + width="24" + height="24" + viewBox="0 0 6.35 6.35" version="1.1" id="svg8" inkscape:version="0.92.3 (2405546, 2018-03-11)" - sodipodi:docname="toogle_red.svg"> + sodipodi:docname="toggle_off.svg"> @@ -50,7 +50,7 @@ image/svg+xml - + @@ -58,12 +58,12 @@ inkscape:label="Layer 1" inkscape:groupmode="layer" id="layer1" - transform="translate(198.05951,-115.76041)"> + transform="translate(198.05952,-121.31665)"> + cx="-194.88452" + cy="124.49165" + r="3.175" /> diff --git a/fpakman/resources/img/toggle_on.svg b/fpakman/resources/img/toggle_on.svg index 7d05dace..4d89b0ca 100755 --- a/fpakman/resources/img/toggle_on.svg +++ b/fpakman/resources/img/toggle_on.svg @@ -10,13 +10,13 @@ xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" - width="48" - height="48" - viewBox="0 0 12.7 12.7" + width="24" + height="24" + viewBox="0 0 6.35 6.35" version="1.1" id="svg8" inkscape:version="0.92.3 (2405546, 2018-03-11)" - sodipodi:docname="toggle_on_3.svg"> + sodipodi:docname="toggle_on.svg"> + gradientTransform="matrix(0.53333336,0,0,0.53333336,-92.427766,59.577773)" /> + transform="translate(198.05951,-121.31666)"> + cx="-194.88451" + cy="124.49166" + r="3.175" /> diff --git a/fpakman/resources/img/uninstall.svg b/fpakman/resources/img/uninstall.svg index 3b421a01..b228fbcd 100755 --- a/fpakman/resources/img/uninstall.svg +++ b/fpakman/resources/img/uninstall.svg @@ -7,9 +7,9 @@ xmlns="http://www.w3.org/2000/svg" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" - height="48.000004" - viewBox="-40 0 36.000001 36.000114" - width="48" + height="24" + viewBox="-40 0 18 18.000056" + width="24" version="1.1" id="svg72" sodipodi:docname="uninstall.svg" @@ -38,7 +38,7 @@ inkscape:pageopacity="0" inkscape:pageshadow="2" inkscape:window-width="1366" - inkscape:window-height="739" + inkscape:window-height="703" id="namedview74" showgrid="false" showborder="false" @@ -47,46 +47,50 @@ fit-margin-right="0" fit-margin-bottom="0" inkscape:zoom="5.7611241" - inkscape:cx="71.805983" - inkscape:cy="3.8950259" + inkscape:cx="40.648821" + inkscape:cy="17.781204" inkscape:window-x="0" inkscape:window-y="260" inkscape:window-maximized="1" inkscape:current-layer="svg72" units="px" /> + id="g5907" + transform="matrix(0.50000001,0,0,0.49999997,-20.000045,0)"> + + + + + + + id="path3791" + d="m -32.347465,34.215765 c -1.136967,-0.263564 -1.85228,-0.770702 -2.285878,-1.620623 -0.244025,-0.478328 -0.252697,-0.85963 -0.252697,-11.110455 V 10.869559 h 12.888149 12.8881497 l -0.0028,10.577396 c -0.0027,10.017853 -0.01562,10.604942 -0.243951,11.098129 -0.295923,0.639162 -0.8228787,1.147628 -1.5106647,1.457656 -0.486498,0.219295 -1.201157,0.235997 -10.870308,0.254055 -5.692267,0.01062 -10.46674,-0.0078 -10.609942,-0.04103 z m 4.983091,-3.760996 0.289334,-0.202658 v -8.369266 c 0,-8.31107 -0.0019,-8.370969 -0.270765,-8.614305 -0.358608,-0.324535 -1.182796,-0.324535 -1.541404,0 -0.268882,0.243336 -0.270765,0.303235 -0.270765,8.614305 v 8.369266 l 0.289334,0.202658 c 0.159133,0.11146 0.497593,0.202656 0.752133,0.202656 0.25454,0 0.593,-0.0912 0.752133,-0.202656 z m 6.118617,0 0.289333,-0.202658 v -8.369266 c 0,-8.31107 -0.0019,-8.370969 -0.270764,-8.614305 -0.358608,-0.324535 -1.182797,-0.324535 -1.541404,0 -0.268883,0.243336 -0.270765,0.303235 -0.270765,8.614305 v 8.369266 l 0.289333,0.202658 c 0.159134,0.11146 0.497593,0.202656 0.752133,0.202656 0.25454,0 0.593001,-0.0912 0.752134,-0.202656 z m 6.118616,0 0.289334,-0.202658 v -8.369266 c 0,-8.31107 -0.0019,-8.370969 -0.270765,-8.614305 -0.358608,-0.324535 -1.182796,-0.324535 -1.541404,0 -0.268882,0.243336 -0.270765,0.303235 -0.270765,8.614305 v 8.369266 l 0.289334,0.202658 c 0.159132,0.11146 0.497593,0.202656 0.752133,0.202656 0.254539,0 0.593,-0.0912 0.752133,-0.202656 z" + style="fill:#b3b3b3;stroke-width:0.13018332" /> + id="path3793" + d="m -36.877845,8.8644087 c -0.540224,-0.2384603 -0.844092,-0.6148864 -0.931583,-1.1540278 -0.06403,-0.3945707 -0.01083,-0.5268359 0.386094,-0.9599757 l 0.460354,-0.5023539 14.753284,-0.034506 c 10.894353,-0.025481 14.8480787,0.00467 15.1156987,0.115259 0.791461,0.3270622 1.121594,1.3085049 0.671312,1.9957216 -0.490508,0.7486088 0.06973,0.7229272 -15.6494407,0.7173712 -11.671083,-0.00413 -14.489515,-0.037913 -14.805719,-0.1774884 z" + style="fill:#b3b3b3;stroke-width:0.13018332" /> - + id="path3795" + d="m -28.586127,3.581076 c 0.04302,-0.5947544 0.131512,-0.8607897 0.373651,-1.1233362 0.63458,-0.6880588 0.750066,-0.7010134 6.249312,-0.7010134 4.435032,0 5.151954,0.027404 5.540514,0.2117885 0.718185,0.3408012 0.959962,0.7509159 0.959962,1.6283345 V 4.360393 h -6.589905 -6.589904 z" + style="fill:#b3b3b3;stroke-width:0.13018332" /> - - - diff --git a/fpakman/view/qt/apps_table.py b/fpakman/view/qt/apps_table.py index ff7618fc..e27a9f4a 100644 --- a/fpakman/view/qt/apps_table.py +++ b/fpakman/view/qt/apps_table.py @@ -210,10 +210,9 @@ class AppsTable(QTableWidget): self.setItem(idx, 6, col_origin) col_installed = QLabel() - col_installed.setText(self.parent().locale_keys['yes'] if app['model']['installed'] else self.parent().locale_keys['no']) + col_installed.setPixmap((QPixmap(resource.get_path('img/{}.svg'.format('checked' if app['model']['installed'] else 'red_cross'))))) col_installed.setToolTip(tooltip) col_installed.setAlignment(Qt.AlignCenter) - col_installed.setStyleSheet("color: {}; font-weight: bold".format('#1E90FF' if app['model']['installed'] else 'red')) self.setCellWidget(idx, 7, col_installed) From 70476bc4b78e7cd910e2ca629e80e7b05d2e54e4 Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Mon, 1 Jul 2019 17:30:41 -0300 Subject: [PATCH 090/123] Improved some icon colors for dark theme experience --- fpakman/resources/img/checked.svg | 4 ++-- fpakman/resources/img/install.svg | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/fpakman/resources/img/checked.svg b/fpakman/resources/img/checked.svg index d61b9228..40ad2bc5 100755 --- a/fpakman/resources/img/checked.svg +++ b/fpakman/resources/img/checked.svg @@ -48,7 +48,7 @@ fit-margin-right="0" fit-margin-bottom="0" inkscape:zoom="4.7692308" - inkscape:cx="-5.2370038" + inkscape:cx="-42.8741" inkscape:cy="25.96043" inkscape:window-x="0" inkscape:window-y="260" @@ -58,5 +58,5 @@ d="M 0.13846154,6.6 C 0.04615385,6.48 0,6.3 0,6.18 0,6.06 0.04615385,5.88 0.13846154,5.76 L 0.78461536,4.92 c 0.1846154,-0.24 0.46153844,-0.24 0.64615384,0 l 0.046153,0.06 2.5384614,3.54 c 0.092308,0.12 0.2307693,0.12 0.323077,0 L 10.523077,0.18 h 0.04615 v 0 c 0.184616,-0.24 0.461539,-0.24 0.646154,0 l 0.646154,0.84 c 0.184616,0.24 0.184616,0.6 0,0.84 v 0 L 4.4769229,11.82 C 4.3846154,11.94 4.2923076,12 4.1538462,12 4.0153844,12 3.923077,11.94 3.8307691,11.82 L 0.23076922,6.78 Z" id="path815" inkscape:connector-curvature="0" - style="fill:#008000;stroke-width:0.52623481" /> + style="fill:#00ff00;stroke-width:0.52623481" /> diff --git a/fpakman/resources/img/install.svg b/fpakman/resources/img/install.svg index 253d0b9b..b24ebfbb 100755 --- a/fpakman/resources/img/install.svg +++ b/fpakman/resources/img/install.svg @@ -40,9 +40,9 @@ fit-margin-left="0" fit-margin-right="0" fit-margin-bottom="0" - inkscape:zoom="0.67615769" - inkscape:cx="21.444202" - inkscape:cy="174.51451" + inkscape:zoom="1.9124627" + inkscape:cx="58.262905" + inkscape:cy="42.640588" inkscape:window-x="0" inkscape:window-y="260" inkscape:window-maximized="1" @@ -50,12 +50,12 @@ + style="fill:#00ff00"> + style="fill:#00ff00" /> Date: Mon, 1 Jul 2019 19:16:22 -0300 Subject: [PATCH 091/123] adding png logo icons to work with more recent KDE Plasma versions --- fpakman/resources/img/logo.png | Bin 0 -> 28863 bytes fpakman/resources/img/logo_update.png | Bin 0 -> 23931 bytes fpakman/view/qt/systray.py | 4 ++-- 3 files changed, 2 insertions(+), 2 deletions(-) create mode 100644 fpakman/resources/img/logo.png create mode 100644 fpakman/resources/img/logo_update.png diff --git a/fpakman/resources/img/logo.png b/fpakman/resources/img/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..c557b4c3e028f7a807bf66bc322a85f6104da6d2 GIT binary patch literal 28863 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 N6@{1b)v}i1{{=4qpsWA@ literal 0 HcmV?d00001 diff --git a/fpakman/resources/img/logo_update.png b/fpakman/resources/img/logo_update.png new file mode 100644 index 0000000000000000000000000000000000000000..6941519b32e70d144d8cdfac21062cc4c07514e3 GIT binary patch literal 23931 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 Uki9DKS#A*gyxqAv^K1A17dCODZ~y=R literal 0 HcmV?d00001 diff --git a/fpakman/view/qt/systray.py b/fpakman/view/qt/systray.py index 55627b88..40c36490 100755 --- a/fpakman/view/qt/systray.py +++ b/fpakman/view/qt/systray.py @@ -54,8 +54,8 @@ class TrayIcon(QSystemTrayIcon): self.locale_keys = locale_keys self.manager = manager - self.icon_default = QIcon(resource.get_path('img/logo.svg')) - self.icon_update = QIcon(resource.get_path('img/logo_update.svg')) + self.icon_default = QIcon(resource.get_path('img/logo.png')) + self.icon_update = QIcon(resource.get_path('img/logo_update.png')) self.setIcon(self.icon_default) self.menu = QMenu() From 59d53253621deb80b0eaa9ae09fb3be24bea17df Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Tue, 2 Jul 2019 10:00:28 -0300 Subject: [PATCH 092/123] showing up minimized management panel --- fpakman/view/qt/systray.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/fpakman/view/qt/systray.py b/fpakman/view/qt/systray.py index 40c36490..a462531b 100755 --- a/fpakman/view/qt/systray.py +++ b/fpakman/view/qt/systray.py @@ -1,7 +1,7 @@ import time from typing import List -from PyQt5.QtCore import QThread, pyqtSignal, QCoreApplication +from PyQt5.QtCore import QThread, pyqtSignal, QCoreApplication, Qt from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import QSystemTrayIcon, QMenu @@ -124,8 +124,11 @@ class TrayIcon(QSystemTrayIcon): manager=self.manager, tray_icon=self) - self.manage_window.refresh() - self.manage_window.show() + if self.manage_window.isMinimized(): + self.manage_window.setWindowState(Qt.WindowNoState) + else: + self.manage_window.refresh() + self.manage_window.show() def show_about(self): From a92a3137b08c31dc7ac9d478e0f8e65cbb954f39 Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Tue, 2 Jul 2019 10:15:14 -0300 Subject: [PATCH 093/123] Improving color experience for dark themes --- fpakman/view/qt/history.py | 2 +- fpakman/view/qt/window.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/fpakman/view/qt/history.py b/fpakman/view/qt/history.py index 5cf26324..8cb5877f 100644 --- a/fpakman/view/qt/history.py +++ b/fpakman/view/qt/history.py @@ -37,7 +37,7 @@ class HistoryDialog(QDialog): item.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) if current_app_commit: - item.setBackground(Qt.darkYellow if row != 0 else Qt.green) + item.setBackground(Qt.darkYellow if row != 0 else Qt.darkGreen) tip = '{}. {}.'.format(locale_keys['popup.history.selected.tooltip'], locale_keys['version.{}'.format('updated'if row == 0 else 'outdated')].capitalize()) item.setToolTip(tip) diff --git a/fpakman/view/qt/window.py b/fpakman/view/qt/window.py index ee352c5f..da82e51c 100755 --- a/fpakman/view/qt/window.py +++ b/fpakman/view/qt/window.py @@ -87,7 +87,7 @@ class ManageWindow(QWidget): self.label_status = QLabel() self.label_status.setText('') - self.label_status.setStyleSheet("color: orange; font-weight: bold") + self.label_status.setStyleSheet("color: #FF4500; font-weight: bold") toolbar.addWidget(self.label_status) spacer = QWidget() From 709915bc0a2a1b25dcd0638c5bbe54cda0c25364 Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Tue, 2 Jul 2019 10:28:21 -0300 Subject: [PATCH 094/123] small refactoring --- fpakman/view/qt/window.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/fpakman/view/qt/window.py b/fpakman/view/qt/window.py index da82e51c..9a28625c 100755 --- a/fpakman/view/qt/window.py +++ b/fpakman/view/qt/window.py @@ -18,6 +18,8 @@ from fpakman.view.qt.root import is_root, ask_root_password from fpakman.view.qt.thread import UpdateSelectedApps, RefreshApps, UninstallApp, DowngradeApp, GetAppInfo, \ GetAppHistory, SearchApps, InstallApp, AnimateProgress +DARK_ORANGE = '#FF4500' + class ManageWindow(QWidget): @@ -87,7 +89,7 @@ class ManageWindow(QWidget): self.label_status = QLabel() self.label_status.setText('') - self.label_status.setStyleSheet("color: #FF4500; font-weight: bold") + self.label_status.setStyleSheet("color: {}; font-weight: bold".format(DARK_ORANGE)) toolbar.addWidget(self.label_status) spacer = QWidget() @@ -154,7 +156,7 @@ class ManageWindow(QWidget): self.toolbar_bottom = QToolBar() self.label_updates = QLabel('') - self.label_updates.setStyleSheet("color: #FF4500; font-weight: bold") + self.label_updates.setStyleSheet("color: {}; font-weight: bold".format(DARK_ORANGE)) self.toolbar_bottom.addWidget(self.label_updates) spacer = QWidget() From d13df2c7a3b382667581e56c83d0b15f3afe27b1 Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Tue, 2 Jul 2019 10:49:53 -0300 Subject: [PATCH 095/123] README and CHANGELOG improved --- CHANGELOG.md | 4 ++-- README.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 65fdc14f..da7ba9ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). -## [0.3.0] - 2019-07-XX +## [0.3.0] - 2019-07-02 ### Features - Applications search - Now when you right-click a selected application you can: @@ -13,7 +13,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - downgrade - install and uninstall it - "About" window available when right-clicking the tray icon. -- Now the application accepts arguments related to environment variables as well. Check 'README.md'. ### Improvements - Performance and memory usage @@ -23,6 +22,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Showing runtime apps when no app is available - Allowing to specify a custom app translation with the environment variable **FPAKMAN_LOCALE** - Adding expiration time for cached app data. Default to 1 hour. The environment variable **FPAKMAN_CACHE_EXPIRATION** can change this value. +- Now the application accepts arguments related to environment variables as well. Check 'README.md'. - Minor GUI improvements - Notifying only new updates - New icon diff --git a/README.md b/README.md index 3fff9e2d..d6e35040 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ ## fpakman Non-official graphical user interface for Flatpak application management. It is a tray icon that let the user known when new updates are available and -an application management panel. +an application management panel where you can search, update, install and uninstall applications. ### Developed with: - Python3 and Qt5. @@ -38,7 +38,7 @@ env/bin/fpakman ### Autostart In order to autostart the application, use your Desktop Environment settings to register it as a startup application / script ("fpakman"). -(P.S: the installation script currently does not do that) + ### Settings You can change some application settings via environment variables or arguments (type ```fpakman --help``` to get more information). From 735e5907564672db5aa05a40b44437891f394536 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vin=C3=ADcius=20Moreira?= Date: Thu, 4 Jul 2019 17:28:09 -0300 Subject: [PATCH 096/123] optional console output --- CHANGELOG.md | 4 ++ fpakman/resources/locale/en | 1 + fpakman/resources/locale/es | 1 + fpakman/resources/locale/pt | 1 + fpakman/view/qt/window.py | 114 +++++++++++++++++++----------------- 5 files changed, 67 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da7ba9ef..871544e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [0.3.1] +### Improvements +- Console output now is optional and now shown by default. + ## [0.3.0] - 2019-07-02 ### Features - Applications search diff --git a/fpakman/resources/locale/en b/fpakman/resources/locale/en index 1cb4c76f..7f198369 100644 --- a/fpakman/resources/locale/en +++ b/fpakman/resources/locale/en @@ -25,6 +25,7 @@ manage_window.status.searching=Searching manage_window.status.installing=Installing manage_window.bt.refresh.tooltip=Reload the data about installed applications manage_window.bt.upgrade.tooltip=Upgrade all selected applications +manage_window.checkbox.show_details=Show details popup.flatpak_not_installed.title=Error popup.flatpak_not_installed.msg=flatpak seems not to be installed. Exiting popup.root.title=Requires root privileges diff --git a/fpakman/resources/locale/es b/fpakman/resources/locale/es index 19617178..27c17625 100644 --- a/fpakman/resources/locale/es +++ b/fpakman/resources/locale/es @@ -25,6 +25,7 @@ manage_window.status.searching=Buscando manage_window.status.installing=Instalando manage_window.bt.refresh.tooltip=Recarga los datos sobre los aplicativos instalados manage_window.bt.upgrade.tooltip=Actualiza todos los aplicativos seleccionados +manage_window.checkbox.show_details=Mostrar detalles popup.flatpak_not_installed.title=Error popup.flatpak_not_installed.msg=flatpak no parece estar instalado. Saliendo popup.root.title=Requiere privilegios de root diff --git a/fpakman/resources/locale/pt b/fpakman/resources/locale/pt index 7cddb50c..7997e7c2 100644 --- a/fpakman/resources/locale/pt +++ b/fpakman/resources/locale/pt @@ -25,6 +25,7 @@ manage_window.status.searching=Buscando manage_window.status.installing=Instalando manage_window.bt.refresh.tooltip=Recarrega os dados sobre os aplicativos instalados manage_window.bt.upgrade.tooltip=Atualiza todos os aplicativos selecionados +manage_window.checkbox.show_details=Mostrar detalhes popup.flatpak_not_installed.title=Erro popup.flatpak_not_installed.msg=flatpak não parece estar instalado. Saindo popup.root.title=Requer privilégios de root diff --git a/fpakman/view/qt/window.py b/fpakman/view/qt/window.py index 9a28625c..955f30a1 100755 --- a/fpakman/view/qt/window.py +++ b/fpakman/view/qt/window.py @@ -22,7 +22,6 @@ DARK_ORANGE = '#FF4500' class ManageWindow(QWidget): - __BASE_HEIGHT__ = 400 def __init__(self, locale_keys: dict, manager: FlatpakManager, tray_icon=None): @@ -47,19 +46,16 @@ class ManageWindow(QWidget): self.toolbar_search = QToolBar() self.toolbar_search.setStyleSheet("spacing: 0px;") self.toolbar_search.setContentsMargins(0, 0, 0, 0) - - spacer = QWidget() - spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) - - self.toolbar_search.addWidget(spacer) + self.toolbar_search.addWidget(self._new_spacer()) label_pre_search = QLabel() - label_pre_search.setStyleSheet("background: white; border-top-left-radius: 5px; border-bottom-left-radius: 5px;") + label_pre_search.setStyleSheet( + "background: white; border-top-left-radius: 5px; border-bottom-left-radius: 5px;") self.toolbar_search.addWidget(label_pre_search) self.input_search = QLineEdit() self.input_search.setFrame(False) - self.input_search.setPlaceholderText(self.locale_keys['window_manage.input_search.placeholder']+"...") + self.input_search.setPlaceholderText(self.locale_keys['window_manage.input_search.placeholder'] + "...") self.input_search.setToolTip(self.locale_keys['window_manage.input_search.tooltip']) self.input_search.setStyleSheet("QLineEdit { background-color: white; color: grey; spacing: 0;}") self.input_search.returnPressed.connect(self.search) @@ -70,9 +66,7 @@ class ManageWindow(QWidget): label_pos_search.setStyleSheet("background: white; padding-right: 10px; border-top-right-radius: 5px; border-bottom-right-radius: 5px;") self.toolbar_search.addWidget(label_pos_search) - spacer = QWidget() - spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) - self.toolbar_search.addWidget(spacer) + self.toolbar_search.addWidget(self._new_spacer()) self.layout.addWidget(self.toolbar_search) toolbar = QToolBar() @@ -83,23 +77,19 @@ class ManageWindow(QWidget): self.checkbox_only_apps.stateChanged.connect(self.filter_only_apps) toolbar.addWidget(self.checkbox_only_apps) - spacer = QWidget() - spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) - toolbar.addWidget(spacer) + toolbar.addWidget(self._new_spacer()) self.label_status = QLabel() self.label_status.setText('') self.label_status.setStyleSheet("color: {}; font-weight: bold".format(DARK_ORANGE)) toolbar.addWidget(self.label_status) - spacer = QWidget() - spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) - toolbar.addWidget(spacer) + toolbar.addWidget(self._new_spacer()) self.bt_refresh = QToolButton() self.bt_refresh.setToolTip(locale_keys['manage_window.bt.refresh.tooltip']) self.bt_refresh.setIcon(QIcon(resource.get_path('img/refresh.svg'))) - self.bt_refresh.clicked.connect(lambda: self.refresh(clear_output=True)) + self.bt_refresh.clicked.connect(self.refresh) toolbar.addWidget(self.bt_refresh) self.bt_upgrade = QToolButton() @@ -116,6 +106,17 @@ class ManageWindow(QWidget): self.layout.addWidget(self.table_apps) + toolbar_console = QToolBar() + + self.checkbox_console = QCheckBox() + self.checkbox_console.setText(self.locale_keys['manage_window.checkbox.show_details']) + self.checkbox_console.stateChanged.connect(self._handle_console) + self.checkbox_console.setVisible(False) + self.ref_checkbox_console = toolbar_console.addWidget(self.checkbox_console) + + toolbar_console.addWidget(self._new_spacer()) + self.layout.addWidget(toolbar_console) + self.textarea_output = QPlainTextEdit(self) self.textarea_output.resize(self.table_apps.size()) self.textarea_output.setStyleSheet("background: black; color: white;") @@ -157,19 +158,15 @@ class ManageWindow(QWidget): self.toolbar_bottom = QToolBar() self.label_updates = QLabel('') self.label_updates.setStyleSheet("color: {}; font-weight: bold".format(DARK_ORANGE)) - self.toolbar_bottom.addWidget(self.label_updates) + self.ref_label_updates = self.toolbar_bottom.addWidget(self.label_updates) - spacer = QWidget() - spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) - self.toolbar_bottom.addWidget(spacer) + self.toolbar_bottom.addWidget(self._new_spacer()) self.progress_bar = QProgressBar() self.progress_bar.setTextVisible(False) self.ref_progress_bar = self.toolbar_bottom.addWidget(self.progress_bar) - spacer = QWidget() - spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) - self.toolbar_bottom.addWidget(spacer) + self.toolbar_bottom.addWidget(self._new_spacer()) self.label_flatpak = QLabel(self._get_flatpak_label()) self.toolbar_bottom.addWidget(self.label_flatpak) @@ -178,6 +175,11 @@ class ManageWindow(QWidget): self.centralize() + def _new_spacer(self): + spacer = QWidget() + spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + return spacer + def changeEvent(self, e: QEvent): if isinstance(e, QWindowStateChangeEvent): @@ -189,6 +191,7 @@ class ManageWindow(QWidget): if self.tray_icon: event.ignore() self.hide() + self._handle_console_option(False) def _check_flatpak_installed(self): @@ -223,23 +226,28 @@ class ManageWindow(QWidget): self.thread_lock.release() - def _hide_output(self): - self.textarea_output.clear() + def _handle_console(self, checked: bool): + + if checked: + self.textarea_output.show() + else: + self.textarea_output.hide() + + def _handle_console_option(self, enable: bool): + + if enable: + self.textarea_output.clear() + + self.ref_checkbox_console.setVisible(enable) + self.checkbox_console.setChecked(False) self.textarea_output.hide() - def _show_output(self): - self.textarea_output.clear() - self.textarea_output.show() - - def refresh(self, clear_output: bool = True): + def refresh(self): if self._acquire_lock(): self._check_flatpak_installed() self._begin_action(self.locale_keys['manage_window.status.refreshing']) - if clear_output: - self._hide_output() - self.thread_refresh.start() def _finish_refresh(self, apps: List[dict]): @@ -252,8 +260,7 @@ class ManageWindow(QWidget): self._check_flatpak_installed() if self._acquire_lock(): - self.textarea_output.clear() - self.textarea_output.setVisible(True) + self._handle_console_option(True) self._begin_action(self.locale_keys['manage_window.status.uninstalling']) self.thread_uninstall.app_ref = app_ref @@ -262,12 +269,12 @@ class ManageWindow(QWidget): def _finish_uninstall(self): self.finish_action() self._release_lock() - self.refresh(clear_output=False) + self.refresh() def _finish_downgrade(self): self.finish_action() self._release_lock() - self.refresh(clear_output=False) + self.refresh() def filter_only_apps(self, only_apps: int): @@ -301,7 +308,8 @@ class ManageWindow(QWidget): if total_updates > 0: self.label_updates.setText('{}: {}'.format(self.locale_keys['manage_window.label.updates'], total_updates)) self.label_updates.setToolTip('{} {} | {} runtimes'.format(app_updates, - self.locale_keys['manage_window.checkbox.only_apps'].lower(), + self.locale_keys[ + 'manage_window.checkbox.only_apps'].lower(), runtime_updates)) else: self.label_updates.setText('') @@ -350,7 +358,8 @@ class ManageWindow(QWidget): self.resize_and_center() def resize_and_center(self): - new_width = reduce(operator.add, [self.table_apps.columnWidth(i) for i in range(len(self.table_apps.column_names))]) * 1.05 + new_width = reduce(operator.add, + [self.table_apps.columnWidth(i) for i in range(len(self.table_apps.column_names))]) * 1.05 self.resize(new_width, self.height()) self.centralize() @@ -362,8 +371,7 @@ class ManageWindow(QWidget): to_update = [pak['model']['ref'] for pak in self.apps if pak['visible'] and pak['update_checked']] if to_update: - self.textarea_output.clear() - self.textarea_output.setVisible(True) + self._handle_console_option(True) self._begin_action(self.locale_keys['manage_window.status.upgrading']) self.thread_update.refs_to_update = to_update @@ -372,12 +380,13 @@ class ManageWindow(QWidget): def _finish_update_selected(self): self.finish_action() self._release_lock() - self.refresh(clear_output=False) + self.refresh() def _update_action_output(self, output: str): self.textarea_output.appendPlainText(output) def _begin_action(self, action_label: str): + self.ref_label_updates.setVisible(False) self.thread_animate_progress.stop = False self.thread_animate_progress.start() self.ref_progress_bar.setVisible(True) @@ -390,6 +399,7 @@ class ManageWindow(QWidget): self.table_apps.setEnabled(False) def finish_action(self, clear_search: bool = True): + self.ref_label_updates.setVisible(True) self.thread_animate_progress.stop = True self.ref_progress_bar.setVisible(False) self.progress_bar.setValue(0) @@ -419,8 +429,7 @@ class ManageWindow(QWidget): self._release_lock() return - self.textarea_output.clear() - self.textarea_output.setVisible(True) + self._handle_console_option(True) self._begin_action(self.locale_keys['manage_window.status.downgrading']) self.thread_downgrade.app = app @@ -430,8 +439,7 @@ class ManageWindow(QWidget): def get_app_info(self, app: dict): if self._acquire_lock(): - self.textarea_output.clear() - self.textarea_output.setVisible(False) + self._handle_console_option(False) self._begin_action(self.locale_keys['manage_window.status.info']) self.thread_get_info.app = app @@ -439,8 +447,7 @@ class ManageWindow(QWidget): def get_app_history(self, app: dict): if self._acquire_lock(): - self.textarea_output.clear() - self.textarea_output.setVisible(False) + self._handle_console_option(False) self._begin_action(self.locale_keys['manage_window.status.history']) self.thread_get_history.app = app @@ -465,8 +472,7 @@ class ManageWindow(QWidget): word = self.input_search.text().strip() if word and self._acquire_lock(): - self.textarea_output.clear() - self.textarea_output.setVisible(False) + self._handle_console_option(False) self._begin_action(self.locale_keys['manage_window.status.searching']) self.thread_search.word = word self.thread_search.start() @@ -482,8 +488,8 @@ class ManageWindow(QWidget): self._check_flatpak_installed() if self._acquire_lock(): + self._handle_console_option(True) self._begin_action(self.locale_keys['manage_window.status.installing']) - self._show_output() self.thread_install.app = app self.thread_install.start() @@ -492,7 +498,7 @@ class ManageWindow(QWidget): self.input_search.setText('') self.finish_action() self._release_lock() - self.refresh(clear_output=False) + self.refresh() def _update_progress(self, value: int): self.progress_bar.setValue(value) From 74fa85a2bb1acb5a72c1ce5bde54d6d70b420cea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vin=C3=ADcius=20Moreira?= Date: Mon, 8 Jul 2019 12:15:15 -0300 Subject: [PATCH 097/123] improving initialization time and reducing async overload --- CHANGELOG.md | 5 ++ README.md | 3 +- fpakman/app.py | 25 ++++-- fpakman/core/constants.py | 2 + fpakman/core/controller.py | 137 ++++++++++++++----------------- fpakman/core/flatpak.py | 1 + fpakman/core/model.py | 42 ++++++++++ fpakman/core/worker.py | 119 +++++++++++++++++++++++++++ fpakman/util/__init__.py | 0 fpakman/util/cache.py | 50 ++++++++++++ fpakman/util/memory.py | 25 ++++++ fpakman/{core => util}/util.py | 0 fpakman/view/qt/apps_table.py | 142 ++++++++++++++++++++------------- fpakman/view/qt/history.py | 4 +- fpakman/view/qt/systray.py | 68 +++++++--------- fpakman/view/qt/thread.py | 94 ++++++++++++++++------ fpakman/view/qt/view_model.py | 27 +++++++ fpakman/view/qt/window.py | 73 +++++++++-------- 18 files changed, 577 insertions(+), 240 deletions(-) create mode 100644 fpakman/core/constants.py create mode 100644 fpakman/core/model.py create mode 100644 fpakman/core/worker.py create mode 100644 fpakman/util/__init__.py create mode 100644 fpakman/util/cache.py create mode 100644 fpakman/util/memory.py rename fpakman/{core => util}/util.py (100%) create mode 100644 fpakman/view/qt/view_model.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 871544e7..b8f8bc59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [0.3.1] ### Improvements - Console output now is optional and now shown by default. +- Full Flatpak database is not loaded during initialization (management panel is quickly available for the user) +- Applications data that must be retrieved from Flathub API are now retrieved on demand and cached +- Cache cleaners (for icons and API data) to improve memory usage. +- New environment variable and argument: FPAKMAN_ICON_EXPIRATION ('--icon-exp') +- Code was refactored to support other types of packaging ## [0.3.0] - 2019-07-02 ### Features diff --git a/README.md b/README.md index d6e35040..01efec3c 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,8 @@ You can change some application settings via environment variables or arguments - **FPAKMAN_UPDATE_NOTIFICATION**: enable or disable system updates notifications. Use **0** (disable) or **1** (enable, default). - **FPAKMAN_CHECK_INTERVAL**: define the updates check interval in seconds. Default: 60. - **FPAKMAN_LOCALE**: define a custom app translation for a given locale key (e.g: 'pt', 'en', 'es', ...). Default: system locale. -- **FPAKMAN_CACHE_EXPIRATION**: define a custom expiration time in SECONDS for application data retrieved from the origin API. Default: 3600 (1 hour). +- **FPAKMAN_CACHE_EXPIRATION**: define a custom expiration time in SECONDS for cached API data. Default: 3600 (1 hour). +- **FPAKMAN_ICON_EXPIRATION**: define a custom expiration time in SECONDS for cached icons. Default: 300 (5 minutes). ### Roadmap diff --git a/fpakman/app.py b/fpakman/app.py index f23df03b..9e6f5fe4 100755 --- a/fpakman/app.py +++ b/fpakman/app.py @@ -8,8 +8,10 @@ from colorama import Fore from fpakman import __version__ from fpakman.core import resource -from fpakman.core import util +from fpakman.util import util from fpakman.core.controller import FlatpakManager +from fpakman.util.cache import Cache +from fpakman.util.memory import CacheCleaner from fpakman.view.qt.systray import TrayIcon app_name = 'fpakman' @@ -25,15 +27,19 @@ def log_msg(msg: str, color: int = None): parser = argparse.ArgumentParser(prog=app_name, description="GUI for Flatpak applications management") parser.add_argument('-v', '--version', action='version', version='%(prog)s {}'.format(__version__)) -parser.add_argument('-e', '--cache-exp', action="store", default=int(os.getenv('FPAKMAN_CACHE_EXPIRATION', 60 * 60)), type=int, help='cached application expiration time in SECONDS. Default: %(default)s') +parser.add_argument('-e', '--cache-exp', action="store", default=int(os.getenv('FPAKMAN_CACHE_EXPIRATION', 60 * 60)), type=int, help='cached API data expiration time in SECONDS. Default: %(default)s') +parser.add_argument('-ie', '--icon-exp', action="store", default=int(os.getenv('FPAKMAN_ICON_EXPIRATION', 60 * 5)), type=int, help='cached icons expiration time in SECONDS. Default: %(default)s') parser.add_argument('-l', '--locale', action="store", default=os.getenv('FPAKMAN_LOCALE', 'en'), help='Translation key. Default: %(default)s') parser.add_argument('-i', '--check-interval', action="store", default=int(os.getenv('FPAKMAN_CHECK_INTERVAL', 60)), type=int, help='Updates check interval in SECONDS. Default: %(default)s') parser.add_argument('-n', '--update-notification', action="store", default=os.getenv('FPAKMAN_UPDATE_NOTIFICATION', 1), type=int, help='Enable/disable system notifications for new updates. Default: %(default)s') args = parser.parse_args() -if args.cache_exp <= 0: +if args.cache_exp < 0: log_msg("'cache-exp' set to '{}': cache will not expire.".format(args.cache_exp), Fore.YELLOW) +if args.icon_exp < 0: + log_msg("'icon-exp' set to '{}': cache will not expire.".format(args.cache_exp), Fore.YELLOW) + if not args.locale.strip(): log_msg("'locale' set as '{}'. You must provide a valid one. Aborting...".format(args.locale), Fore.RED) exit(1) @@ -45,18 +51,27 @@ if args.check_interval <= 0: if args.update_notification == 0: log_msg('updates notifications are disabled', Fore.YELLOW) +caches = [] +flatpak_api_cache = Cache(expiration_time=args.cache_exp) +caches.append(flatpak_api_cache) + +icon_cache = Cache(expiration_time=args.icon_exp) +caches.append(icon_cache) + locale_keys = util.get_locale_keys(args.locale) app = QApplication(sys.argv) app.setWindowIcon(QIcon(resource.get_path('img/logo.svg'))) -manager = FlatpakManager(cache_expire=args.cache_exp) +manager = FlatpakManager(flatpak_api_cache) trayIcon = TrayIcon(locale_keys=locale_keys, manager=manager, check_interval=args.check_interval, + icon_cache=icon_cache, update_notification=bool(args.update_notification)) -trayIcon.load_database() trayIcon.show() +CacheCleaner(caches).start() + sys.exit(app.exec_()) diff --git a/fpakman/core/constants.py b/fpakman/core/constants.py new file mode 100644 index 00000000..687356af --- /dev/null +++ b/fpakman/core/constants.py @@ -0,0 +1,2 @@ +FLATHUB_URL = 'https://flathub.org' +FLATHUB_API_URL = FLATHUB_URL + '/api/v1' diff --git a/fpakman/core/controller.py b/fpakman/core/controller.py index 5e02af2d..d0337496 100755 --- a/fpakman/core/controller.py +++ b/fpakman/core/controller.py @@ -1,80 +1,53 @@ -from datetime import datetime, timedelta +from datetime import datetime from threading import Lock from typing import List import requests from fpakman.core import flatpak - -__FLATHUB_URL__ = 'https://flathub.org' -__FLATHUB_API_URL__ = __FLATHUB_URL__ + '/api/v1' +from fpakman.core.model import FlatpakApplication, ApplicationData, ApplicationStatus +from fpakman.core.worker import FlatpakAsyncDataLoaderManager +from fpakman.util.cache import Cache class FlatpakManager: - def __init__(self, cache_expire: int = 60 * 60): - self.cache_apps = {} - self.cache_expire = cache_expire + def __init__(self, api_cache: Cache): + self.api_cache = api_cache self.http_session = requests.Session() - self.lock_db_read = Lock() self.lock_read = Lock() + self.async_data_loader = FlatpakAsyncDataLoaderManager(api_cache=self.api_cache) - def load_full_database(self): + def _map_to_model(self, app: dict) -> FlatpakApplication: - self.lock_db_read.acquire() + model = FlatpakApplication(arch=app.get('arch'), + branch=app.get('branch'), + origin=app.get('origin'), + runtime=app.get('runtime'), + ref=app.get('ref'), + commit=app.get('commit'), + base_data=ApplicationData(id=app.get('id'), + name=app.get('name'), + version=app.get('version'), + latest_version=app.get('latest_version'))) - try: - res = self.http_session.get(__FLATHUB_API_URL__ + '/apps', timeout=30) + api_data = self.api_cache.get(app['id']) - if res.status_code == 200: - for app in res.json(): - self.cache_apps[app['flatpakAppId']] = app - finally: - self.lock_db_read.release() + expired_data = api_data and api_data.get('expires_at') and api_data['expires_at'] <= datetime.utcnow() - def _request_app_data(self, app_id: str): + if not api_data or expired_data: + if not app['runtime']: + model.status = ApplicationStatus.LOADING_DATA + self.async_data_loader.load(model) - try: - res = self.http_session.get('{}/apps/{}'.format(__FLATHUB_API_URL__, app_id), timeout=30) + else: # filling cached data + for attr, val in api_data.items(): + if attr != 'expires_at' and val: + setattr(model.base_data, attr, val) - if res.status_code == 200: - return res.json() - else: - print("Could not retrieve app data for id '{}'. Server response: {}".format(app_id, res.status_code)) - except: - print("Could not retrieve app data for id '{}'. Timeout".format(app_id)) - return None + return model - def _fill_api_data(self, app: dict): - - api_data = self.cache_apps.get(app['id']) - - if (not app['runtime'] and not api_data) or (api_data and api_data.get('expires_at') and api_data['expires_at'] <= datetime.utcnow()): # if api data is not cached or expired, tries to retrieve it - api_data = self._request_app_data(app['id']) - - if api_data: - - if self.cache_expire > 0: - api_data['expires_at'] = datetime.utcnow() + timedelta(seconds=self.cache_expire) - - self.cache_apps[app['id']] = api_data - - if not api_data: - for attr in ('latest_version', 'icon', 'description'): - if attr not in app: - app[attr] = None - else: - app['latest_version'] = api_data.get('currentReleaseVersion') - app['icon'] = api_data.get('iconMobileUrl') - - for attr in ('name', 'description'): - if not app.get(attr): - app[attr] = api_data.get(attr) - - if app['icon'].startswith('/'): - app['icon'] = __FLATHUB_URL__ + app['icon'] - - def search(self, word: str) -> List[dict]: + def search(self, word: str) -> List[FlatpakApplication]: res = [] apps_found = flatpak.search(word) @@ -82,25 +55,24 @@ class FlatpakManager: if apps_found: already_read = set() - installed_apps = self.read_installed() + installed_apps = self.read_installed(keep_workers=True) if installed_apps: - for app in apps_found: + for app_found in apps_found: for installed_app in installed_apps: - if app['id'] == installed_app['id']: + if app_found['id'] == installed_app.base_data.id: res.append(installed_app) - already_read.add(app['id']) + already_read.add(app_found['id']) - for app in apps_found: - if app['id'] not in already_read: - app['update'] = False - app['installed'] = False - self._fill_api_data(app) - res.append(app) + for app_found in apps_found: + if app_found['id'] not in already_read: + res.append(self._map_to_model(app_found)) + + self.async_data_loader.stop_current_workers() return res - def read_installed(self) -> List[dict]: + def read_installed(self, keep_workers: bool = False) -> List[FlatpakApplication]: self.lock_read.acquire() @@ -112,23 +84,34 @@ class FlatpakManager: available_updates = flatpak.list_updates_as_str() - for app in installed: - self._fill_api_data(app) - app['update'] = app['id'] in available_updates - app['installed'] = True + models = [] - return installed + for app in installed: + model = self._map_to_model(app) + model.installed = True + model.update = app['id'] in available_updates + models.append(model) + + if not keep_workers: + self.async_data_loader.stop_current_workers() + + return models + + return [] finally: self.lock_read.release() - def downgrade_app(self, app: dict, root_password: str): + def downgrade_app(self, app: FlatpakApplication, root_password: str): - commits = flatpak.get_app_commits(app['ref'], app['origin']) - commit_idx = commits.index(app['commit']) + commits = flatpak.get_app_commits(app.ref, app.origin) + commit_idx = commits.index(app.commit) # downgrade is not possible if the app current commit in the first one: if commit_idx == len(commits) - 1: return None - return flatpak.downgrade_and_stream(app['ref'], commits[commit_idx + 1], root_password) + return flatpak.downgrade_and_stream(app.ref, commits[commit_idx + 1], root_password) + + def clean_cache_for(self, app_id: str): + self.api_cache.delete(app_id) diff --git a/fpakman/core/flatpak.py b/fpakman/core/flatpak.py index 5da0358b..7470c839 100755 --- a/fpakman/core/flatpak.py +++ b/fpakman/core/flatpak.py @@ -3,6 +3,7 @@ import subprocess from typing import List from fpakman.core import system +from fpakman.core.model import Application BASE_CMD = 'flatpak' diff --git a/fpakman/core/model.py b/fpakman/core/model.py new file mode 100644 index 00000000..1bc58ea9 --- /dev/null +++ b/fpakman/core/model.py @@ -0,0 +1,42 @@ +from abc import ABC +from enum import Enum + + +class ApplicationStatus(Enum): + READY = 1 + LOADING_DATA = 2 + + +class ApplicationData: + + def __init__(self, id: str, version: str, name: str = None, description: str = None, latest_version: str = None, icon_url: str = None): + self.id = id + self.name = name + self.version = version + self.description = description + self.latest_version = latest_version + self.icon_url = icon_url + + +class Application(ABC): + + def __init__(self, base_data: ApplicationData, status: ApplicationStatus = ApplicationStatus.READY, installed: bool = False, update: bool = False): + self.base_data = base_data + self.status = status + self.installed = installed + self.update = update + + +class FlatpakApplication(Application): + + def __init__(self, base_data: ApplicationData, branch: str, arch: str, origin: str, runtime: bool, ref: str, commit: str): + super(FlatpakApplication, self).__init__(base_data=base_data) + self.ref = ref + self.branch = branch + self.arch = arch + self.origin = origin + self.runtime = runtime + self.commit = commit + + def is_incomplete(self): + return self.base_data.description is None and self.base_data.icon_url diff --git a/fpakman/core/worker.py b/fpakman/core/worker.py new file mode 100644 index 00000000..170ecc2e --- /dev/null +++ b/fpakman/core/worker.py @@ -0,0 +1,119 @@ +import traceback +from io import StringIO +from threading import Thread +from typing import List + +import requests +from colorama import Fore + +from fpakman.core.constants import FLATHUB_API_URL, FLATHUB_URL +from fpakman.core.model import FlatpakApplication, ApplicationStatus +from fpakman.util.cache import Cache + + +class FlatpakAsyncDataLoader(Thread): + + def __init__(self, http_session, api_cache: Cache, attempts: int = 3): + super(FlatpakAsyncDataLoader, self).__init__(daemon=True) + self.apps = [] + self.http_session = http_session + self.attempts = attempts + self.api_cache = api_cache + self.id_ = '{}#{}'.format(self.__class__.__name__, id(self)) + self.stop = False + + def log_msg(self, msg: str, color: int = None): + final_msg = StringIO() + + if color: + final_msg.write(str(color)) + + final_msg.write('[{}] '.format(self.id_)) + + final_msg.write(msg) + + if color: + final_msg.write(Fore.RESET) + + final_msg.seek(0) + + print(final_msg.read()) + + def run(self): + while True: + if not self.apps and self.stop: + break # stop working + else: + if self.apps: + app = self.apps[0] + app.status = ApplicationStatus.LOADING_DATA + + for _ in range(0, self.attempts): + try: + res = self.http_session.get('{}/apps/{}'.format(FLATHUB_API_URL, app.base_data.id), timeout=30) + + if res.status_code == 200 and res.text: + data = res.json() + + if not app.base_data.version: + app.base_data.version = data.get('version') + + app.base_data.description = data.get('description', data.get('summary', None)) + app.base_data.icon_url = data.get('iconMobileUrl', None) + app.base_data.latest_version = data.get('currentReleaseVersion', app.base_data.version) + + if app.base_data.icon_url and app.base_data.icon_url.startswith('/'): + app.base_data.icon_url = FLATHUB_URL + app.base_data.icon_url + + self.api_cache.add(app.base_data.id, { + 'description': app.base_data.description, + 'icon_url': app.base_data.icon_url, + 'latest_version': app.base_data.latest_version, + 'version': app.base_data.version + }) + + app.status = ApplicationStatus.READY + break + else: + self.log_msg("Could not retrieve app data for id '{}'. Server response: {}. Body: {}".format(app.base_data.id, res.status_code, res.content.decode()), Fore.RED) + except: + self.log_msg("Could not retrieve app data for id '{}'".format(app.base_data.id), Fore.YELLOW) + traceback.print_exc() + + del self.apps[0] + + def add(self, app: FlatpakApplication): + self.apps.append(app) + + def current_load(self): + return len(self.apps) + + +class FlatpakAsyncDataLoaderManager: + + def __init__(self, api_cache: Cache, worker_load: int = 3, workers: List[FlatpakAsyncDataLoader] = []): + self.worker_load = worker_load + self.current_workers = workers + self.http_session = requests.Session() + self.api_cache = api_cache + + def load(self, app: FlatpakApplication): + + available_workers = [w for w in self.current_workers if w.current_load() < self.worker_load] + + if available_workers: + worker = available_workers[0] + else: # new worker + worker = FlatpakAsyncDataLoader(http_session=self.http_session, + api_cache=self.api_cache) + worker.start() + self.current_workers.append(worker) + + worker.add(app) + + def stop_current_workers(self): + + for w in self.current_workers: + w.stop = True + + self.current_workers = [] diff --git a/fpakman/util/__init__.py b/fpakman/util/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/fpakman/util/cache.py b/fpakman/util/cache.py new file mode 100644 index 00000000..4fc2a434 --- /dev/null +++ b/fpakman/util/cache.py @@ -0,0 +1,50 @@ +from datetime import datetime, timedelta +from threading import Lock + + +class Cache: + + def __init__(self, expiration_time: int): + self.expiration_time = expiration_time + self._cache = {} + self.lock = Lock() + + def is_enabled(self): + return self.expiration_time < 0 or self.expiration_time > 0 + + def add(self, key: str, val: object): + + if self.is_enabled(): + self.lock.acquire() + self._cache[key] = {'val': val, 'expires_at': datetime.utcnow() + timedelta(seconds=self.expiration_time) if self.expiration_time > 0 else None} + self.lock.release() + + def get(self, key: str): + if self.is_enabled(): + val = self._cache.get(key) + + if val: + expiration = val.get('expires_at') + + if expiration and expiration <= datetime.utcnow(): + self.lock.acquire() + del self._cache[key] + self.lock.release() + return None + + return val['val'] + + def delete(self, key): + if self.is_enabled(): + if key in self._cache: + self.lock.acquire() + del self._cache[key] + self.lock.release() + + def keys(self): + return set(self._cache.keys()) if self.is_enabled() else set() + + def clean_expired(self): + if self.is_enabled(): + for key in self.keys(): + self.get(key) diff --git a/fpakman/util/memory.py b/fpakman/util/memory.py new file mode 100644 index 00000000..35ec2659 --- /dev/null +++ b/fpakman/util/memory.py @@ -0,0 +1,25 @@ +import time +from threading import Thread +from typing import List + +from fpakman.util.cache import Cache +import gc + + +class CacheCleaner(Thread): + + def __init__(self, caches: List[Cache], check_interval: int = 15): + super(CacheCleaner, self).__init__(daemon=True) + self.caches = [c for c in caches if c.is_enabled()] + self.check_interval = check_interval + + def run(self): + + if self.caches: + while True: + for cache in self.caches: + cache.clean_expired() + + gc.collect() + time.sleep(self.check_interval) + diff --git a/fpakman/core/util.py b/fpakman/util/util.py similarity index 100% rename from fpakman/core/util.py rename to fpakman/util/util.py diff --git a/fpakman/view/qt/apps_table.py b/fpakman/view/qt/apps_table.py index e27a9f4a..a9393002 100644 --- a/fpakman/view/qt/apps_table.py +++ b/fpakman/view/qt/apps_table.py @@ -1,20 +1,24 @@ +from threading import Lock from typing import List from PyQt5.QtCore import Qt, QUrl from PyQt5.QtGui import QPixmap, QIcon, QColor, QCursor -from PyQt5.QtNetwork import QNetworkRequest, QNetworkAccessManager +from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkRequest from PyQt5.QtWidgets import QTableWidget, QTableView, QMenu, QAction, QTableWidgetItem, QToolButton, QWidget, \ QHeaderView, QLabel -from fpakman.core import resource, util +from fpakman.core import resource +from fpakman.core.model import FlatpakApplication, ApplicationStatus +from fpakman.util.cache import Cache from fpakman.view.qt import dialog +from fpakman.view.qt.view_model import ApplicationView, ApplicationViewStatus class UpdateToggleButton(QToolButton): - def __init__(self, model: dict, root: QWidget, locale_keys: dict, checked: bool = True): + def __init__(self, app_view: ApplicationView, root: QWidget, locale_keys: dict, checked: bool = True): super(UpdateToggleButton, self).__init__() - self.app = model + self.app_view = app_view self.root = root self.setCheckable(True) self.clicked.connect(self.change_state) @@ -28,14 +32,14 @@ class UpdateToggleButton(QToolButton): self.click() def change_state(self, not_checked: bool): - self.app['update_checked'] = not not_checked + self.app_view.updated_checked = not not_checked self.setIcon(self.icon_on if not not_checked else self.icon_off) self.root.change_update_state() class AppsTable(QTableWidget): - def __init__(self, parent: QWidget): + def __init__(self, parent: QWidget, icon_cache: Cache): super(AppsTable, self).__init__() self.setParent(parent) self.window = parent @@ -44,7 +48,7 @@ class AppsTable(QTableWidget): 'manage_window.columns.latest_version', 'flatpak.info.branch', 'flatpak.info.arch', - 'flatpak.info.id', + 'flatpak.info.description', 'flatpak.info.origin', 'manage_window.columns.installed', 'manage_window.columns.update']] @@ -60,7 +64,8 @@ class AppsTable(QTableWidget): self.network_man = QNetworkAccessManager() self.network_man.finished.connect(self._load_icon) - self.icon_cache = {} + self.icon_cache = icon_cache + self.lock_async_data = Lock() def contextMenuEvent(self, QContextMenuEvent): # selected row right click event @@ -68,7 +73,7 @@ class AppsTable(QTableWidget): menu_row = QMenu() - if app['model']['installed']: + if app.model.installed: action_info = QAction(self.window.locale_keys["manage_window.apps_table.row.actions.info"]) action_info.setIcon(QIcon(resource.get_path('img/info.svg'))) action_info.triggered.connect(self._get_app_info) @@ -84,7 +89,7 @@ class AppsTable(QTableWidget): action_uninstall.triggered.connect(self._uninstall_app) menu_row.addAction(action_uninstall) - if not app['model']['runtime']: # not available for runtimes + if isinstance(app.model, FlatpakApplication) and not app.model.runtime: # not available for runtimes action_downgrade = QAction(self.window.locale_keys["manage_window.apps_table.row.actions.downgrade"]) action_downgrade.triggered.connect(self._downgrade_app) action_downgrade.setIcon(QIcon(resource.get_path('img/downgrade.svg'))) @@ -99,25 +104,54 @@ class AppsTable(QTableWidget): menu_row.popup(QCursor.pos()) menu_row.exec_() - def get_selected_app(self): + def fill_async_data(self): + + self.lock_async_data.acquire() + + if self.window.apps: + for idx, app_v in enumerate(self.window.apps): + if app_v.visible and app_v.status == ApplicationViewStatus.LOADING and app_v.model.status == ApplicationStatus.READY: + self.network_man.get(QNetworkRequest(QUrl(app_v.model.base_data.icon_url))) + self.item(idx, 2).setText(app_v.model.base_data.latest_version) + + desc = app_v.get_async_attr('description', strip_html=True) + self.item(idx, 5).setText(desc) + self.item(idx, 5).setToolTip(desc) + app_v.status = ApplicationViewStatus.READY + + visible, ready = 0, 0 + + for app_v in self.window.apps: + if app_v.visible: + visible += 1 + + if app_v.status == ApplicationViewStatus.READY: + ready += 1 + + if ready == visible: + self.window.resize_and_center() + + self.lock_async_data.release() + + def get_selected_app(self) -> ApplicationView: return self.window.apps[self.currentRow()] - def get_selected_app_icon(self): + def get_selected_app_icon(self) -> QIcon: return self.item(self.currentRow(), 0).icon() def _uninstall_app(self): selected_app = self.get_selected_app() if dialog.ask_confirmation(title=self.window.locale_keys['manage_window.apps_table.row.actions.uninstall.popup.title'], - body=self.window.locale_keys['manage_window.apps_table.row.actions.uninstall.popup.body'].format(selected_app['model']['name']), + body=self.window.locale_keys['manage_window.apps_table.row.actions.uninstall.popup.body'].format(selected_app.model.base_data.name), locale_keys=self.window.locale_keys): - self.window.uninstall_app(selected_app['model']['ref']) + self.window.uninstall_app(selected_app) def _downgrade_app(self): selected_app = self.get_selected_app() if dialog.ask_confirmation(title=self.window.locale_keys['manage_window.apps_table.row.actions.downgrade'], - body=self.window.locale_keys['manage_window.apps_table.row.actions.downgrade.popup.body'].format(selected_app['model']['name']), + body=self.window.locale_keys['manage_window.apps_table.row.actions.downgrade.popup.body'].format(selected_app.model.base_data.name), locale_keys=self.window.locale_keys): self.window.downgrade_app(selected_app) @@ -132,94 +166,92 @@ class AppsTable(QTableWidget): def _load_icon(self, http_response): icon_url = http_response.url().toString() - pixmap = QPixmap() - pixmap.loadFromData(http_response.readAll()) - icon = QIcon(pixmap) - self.icon_cache[icon_url] = icon - for idx, app in enumerate(self.window.apps): - if app['model']['icon'] == icon_url: - self.item(idx, 0).setIcon(icon) - self.window.resize_and_center() - break + if not self.icon_cache.get(icon_url): + pixmap = QPixmap() + pixmap.loadFromData(http_response.readAll()) + icon = QIcon(pixmap) + self.icon_cache.add(icon_url, icon) - def update_apps(self, apps: List[dict]): + for idx, app in enumerate(self.window.apps): + if app.model.base_data.icon_url == icon_url: + self.item(idx, 0).setIcon(icon) + break + + def update_apps(self, app_views: List[ApplicationView]): self.setEnabled(True) - self.setRowCount(len(apps) if apps else 0) + self.setRowCount(len(app_views) if app_views else 0) - if apps: - for idx, app in enumerate(apps): - - tooltip = util.strip_html(app['model']['description']) if app['model']['description'] else None + if app_views: + for idx, app_v in enumerate(app_views): col_name = QTableWidgetItem() - col_name.setText(app['model']['name']) + col_name.setText(app_v.model.base_data.name) col_name.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) - col_name.setToolTip(tooltip) - if not app['model']['icon']: + if not app_v.model.base_data.icon_url: col_name.setIcon(self.icon_flathub) else: - cached_icon = self.icon_cache.get(app['model']['icon']) + cached_icon = self.icon_cache.get(app_v.model.base_data.icon_url) if cached_icon: col_name.setIcon(cached_icon) else: col_name.setIcon(self.icon_flathub) - self.network_man.get(QNetworkRequest(QUrl(app['model']['icon']))) self.setItem(idx, 0, col_name) col_version = QTableWidgetItem() - col_version.setText(app['model']['version']) + col_version.setText(app_v.model.base_data.version) col_version.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) - col_version.setToolTip(tooltip) self.setItem(idx, 1, col_version) col_release = QTableWidgetItem() - col_release.setText(app['model']['latest_version']) + col_release.setText(app_v.get_async_attr('latest_version')) col_release.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) - col_release.setToolTip(tooltip) self.setItem(idx, 2, col_release) - if app['model']['version'] and app['model']['latest_version'] and app['model']['version'] < app['model']['latest_version']: + if app_v.model.base_data.version and app_v.model.base_data.latest_version and app_v.model.base_data.version < app_v.model.base_data.latest_version: col_release.setForeground(QColor('orange')) col_branch = QTableWidgetItem() - col_branch.setText(app['model']['branch']) + col_branch.setText(app_v.model.branch if isinstance(app_v.model, FlatpakApplication) else '') col_branch.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) - col_branch.setToolTip(tooltip) self.setItem(idx, 3, col_branch) col_arch = QTableWidgetItem() - col_arch.setText(app['model']['arch']) + col_arch.setText(app_v.model.arch if isinstance(app_v.model, FlatpakApplication) else '') col_arch.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) - col_arch.setToolTip(tooltip) self.setItem(idx, 4, col_arch) - col_id = QTableWidgetItem() - col_id.setText(app['model']['id']) - col_id.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) - col_id.setToolTip(tooltip) - self.setItem(idx, 5, col_id) + desc = app_v.get_async_attr('description', strip_html=True) + col_description = QTableWidgetItem() + col_description.setText(desc) + col_description.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) + + if app_v.model.status == ApplicationStatus.READY: + col_description.setToolTip(desc) + + self.setItem(idx, 5, col_description) col_origin = QTableWidgetItem() - col_origin.setText(app['model']['origin']) + col_origin.setText(app_v.model.origin if isinstance(app_v.model, FlatpakApplication) else '') col_origin.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) - col_origin.setToolTip(tooltip) self.setItem(idx, 6, col_origin) col_installed = QLabel() - col_installed.setPixmap((QPixmap(resource.get_path('img/{}.svg'.format('checked' if app['model']['installed'] else 'red_cross'))))) - col_installed.setToolTip(tooltip) + col_installed.setPixmap((QPixmap(resource.get_path('img/{}.svg'.format('checked' if app_v.model.installed else 'red_cross'))))) col_installed.setAlignment(Qt.AlignCenter) self.setCellWidget(idx, 7, col_installed) - col_update = UpdateToggleButton(app, self.window, self.window.locale_keys, app['model']['update']) if app['model']['update'] else None + col_update = UpdateToggleButton(app_v, self.window, self.window.locale_keys, app_v.model.update) if app_v.model.update else None self.setCellWidget(idx, 8, col_update) def change_headers_policy(self, policy: QHeaderView = QHeaderView.ResizeToContents): header_horizontal = self.horizontalHeader() for i in range(self.columnCount()): - header_horizontal.setSectionResizeMode(i, policy) + if i == 5: + header_horizontal.setSectionResizeMode(i, QHeaderView.Stretch) + else: + header_horizontal.setSectionResizeMode(i, policy) diff --git a/fpakman/view/qt/history.py b/fpakman/view/qt/history.py index 8cb5877f..965928da 100644 --- a/fpakman/view/qt/history.py +++ b/fpakman/view/qt/history.py @@ -11,7 +11,7 @@ class HistoryDialog(QDialog): def __init__(self, app: dict, app_icon: QIcon, locale_keys: dict): super(HistoryDialog, self).__init__() - self.setWindowTitle('{} - {} '.format(locale_keys['popup.history.title'], app['model']['name'])) + self.setWindowTitle('{} - {} '.format(locale_keys['popup.history.title'], app['model'].base_data.name)) self.setWindowIcon(app_icon) layout = QVBoxLayout() @@ -29,7 +29,7 @@ class HistoryDialog(QDialog): for row, commit in enumerate(app['commits']): - current_app_commit = app['model']['commit'] == commit['commit'] + current_app_commit = app['model'].commit == commit['commit'] for col, key in enumerate(sorted(commit.keys())): item = QTableWidgetItem() diff --git a/fpakman/view/qt/systray.py b/fpakman/view/qt/systray.py index a462531b..3b327bd1 100755 --- a/fpakman/view/qt/systray.py +++ b/fpakman/view/qt/systray.py @@ -1,4 +1,5 @@ import time +from threading import Lock from typing import List from PyQt5.QtCore import QThread, pyqtSignal, QCoreApplication, Qt @@ -7,6 +8,8 @@ from PyQt5.QtWidgets import QSystemTrayIcon, QMenu from fpakman.core import resource, system from fpakman.core.controller import FlatpakManager +from fpakman.core.model import Application +from fpakman.util.cache import Cache from fpakman.view.qt.about import AboutDialog from fpakman.view.qt.window import ManageWindow @@ -26,7 +29,7 @@ class UpdateCheck(QThread): apps = self.manager.read_installed() - updates = [app for app in apps if app['update']] + updates = [app for app in apps if app.update] if updates: self.signal.emit(updates) @@ -34,25 +37,13 @@ class UpdateCheck(QThread): time.sleep(self.check_interval) -class LoadDatabase(QThread): - - signal_finished = pyqtSignal() - - def __init__(self, manager: FlatpakManager, parent=None): - super(LoadDatabase, self).__init__(parent) - self.manager = manager - - def run(self): - self.manager.load_full_database() - self.signal_finished.emit() - - class TrayIcon(QSystemTrayIcon): - def __init__(self, locale_keys: dict, manager: FlatpakManager, check_interval: int = 60, update_notification: bool = True): + def __init__(self, locale_keys: dict, manager: FlatpakManager, icon_cache: Cache, check_interval: int = 60, update_notification: bool = True): super(TrayIcon, self).__init__() self.locale_keys = locale_keys self.manager = manager + self.icon_cache = icon_cache self.icon_default = QIcon(resource.get_path('img/logo.png')) self.icon_update = QIcon(resource.get_path('img/logo_update.png')) @@ -60,12 +51,8 @@ class TrayIcon(QSystemTrayIcon): self.menu = QMenu() - self.action_refreshing = self.menu.addAction(self.locale_keys['tray.action.refreshing'] + '...') - self.action_refreshing.setEnabled(False) - self.action_manage = self.menu.addAction(self.locale_keys['tray.action.manage']) self.action_manage.triggered.connect(self.show_manage_window) - self.action_manage.setVisible(False) self.action_about = self.menu.addAction(self.locale_keys['tray.action.about']) self.action_about.triggered.connect(self.show_about) @@ -82,46 +69,45 @@ class TrayIcon(QSystemTrayIcon): self.dialog_about = None - self.thread_database = LoadDatabase(manager) - self.thread_database.signal_finished.connect(self._update_menu) self.last_updates = set() self.update_notification = update_notification + self.lock_notify = Lock() - def load_database(self): - self.thread_database.start() + def notify_updates(self, updates: List[Application]): - def _update_menu(self): - self.action_refreshing.setVisible(False) - self.action_manage.setVisible(True) + self.lock_notify.acquire() - def notify_updates(self, updates: List[dict]): + try: + if len(updates) > 0: - if len(updates) > 0: + update_keys = {'{}:{}'.format(app.base_data.id, app.base_data.version) for app in updates} - update_keys = {'{}:{}'.format(app['id'], app['latest_version']) for app in updates} + new_icon = self.icon_update - new_icon = self.icon_update + if update_keys.difference(self.last_updates): + self.last_updates = update_keys + msg = '{}: {}'.format(self.locale_keys['notification.new_updates'].format('Flatpak'), len(updates)) + self.setToolTip(msg) - if update_keys.difference(self.last_updates): - self.last_updates = update_keys - msg = '{}: {}'.format(self.locale_keys['notification.new_updates'].format('Flatpak'), len(updates)) - self.setToolTip(msg) + if self.update_notification: + system.notify_user(msg) - if self.update_notification: - system.notify_user(msg) + else: + new_icon = self.icon_default + self.setToolTip(None) - else: - new_icon = self.icon_default - self.setToolTip(None) + if self.icon().cacheKey() != new_icon.cacheKey(): # changes the icon if needed + self.setIcon(new_icon) - if self.icon().cacheKey() != new_icon.cacheKey(): # changes the icon if needed - self.setIcon(new_icon) + finally: + self.lock_notify.release() def show_manage_window(self): if self.manage_window is None: self.manage_window = ManageWindow(locale_keys=self.locale_keys, manager=self.manager, + icon_cache=self.icon_cache, tray_icon=self) if self.manage_window.isMinimized(): diff --git a/fpakman/view/qt/thread.py b/fpakman/view/qt/thread.py index d289ab5c..ea4dbcb1 100644 --- a/fpakman/view/qt/thread.py +++ b/fpakman/view/qt/thread.py @@ -1,10 +1,15 @@ import time +from datetime import datetime, timedelta +from typing import List from PyQt5.QtCore import QThread, pyqtSignal from fpakman.core import flatpak from fpakman.core.controller import FlatpakManager +from fpakman.core.model import ApplicationStatus, FlatpakApplication +from fpakman.util.cache import Cache from fpakman.view.qt import dialog +from fpakman.view.qt.view_model import ApplicationView, ApplicationViewStatus class UpdateSelectedApps(QThread): @@ -43,17 +48,21 @@ class UninstallApp(QThread): signal_finished = pyqtSignal() signal_output = pyqtSignal(str) - def __init__(self): + def __init__(self, manager: FlatpakManager, icon_cache: Cache, app: ApplicationView = None): super(UninstallApp, self).__init__() - self.app_ref = None + self.app = app + self.manager = manager + self.icon_cache = icon_cache def run(self): - if self.app_ref: - for output in flatpak.uninstall_and_stream(self.app_ref): + if self.app and isinstance(self.app.model, FlatpakApplication): + for output in flatpak.uninstall_and_stream(self.app.model.ref): line = output.decode().strip() if line: self.signal_output.emit(line) + self.icon_cache.delete(self.app.model.base_data.icon_url) + self.manager.clean_cache_for(self.app.model.base_data.id) self.signal_finished.emit() @@ -61,23 +70,23 @@ class DowngradeApp(QThread): signal_finished = pyqtSignal() signal_output = pyqtSignal(str) - def __init__(self, manager: FlatpakManager, locale_keys: dict): + def __init__(self, manager: FlatpakManager, locale_keys: dict, app: ApplicationView = None): super(DowngradeApp, self).__init__() self.manager = manager - self.app = None - self.root_password = None + self.app = app self.locale_keys = locale_keys + self.root_password = None def run(self): - if self.app: + if self.app and isinstance(self.app.model, FlatpakApplication): - stream = self.manager.downgrade_app(self.app['model'], self.root_password) + stream = self.manager.downgrade_app(self.app.model, self.root_password) if stream is None: dialog.show_error(title=self.locale_keys['popup.downgrade.impossible.title'], body=self.locale_keys['popup.downgrade.impossible.body']) else: - for output in self.manager.downgrade_app(self.app['model'], self.root_password): + for output in self.manager.downgrade_app(self.app.model, self.root_password): line = output.decode().strip() if line: self.signal_output.emit(line) @@ -90,16 +99,16 @@ class DowngradeApp(QThread): class GetAppInfo(QThread): signal_finished = pyqtSignal(dict) - def __init__(self): + def __init__(self, app: ApplicationView = None): super(GetAppInfo, self).__init__() - self.app = None + self.app = app def run(self): - if self.app: - app_info = flatpak.get_app_info_fields(self.app['model']['id'], self.app['model']['branch']) - app_info['name'] = self.app['model']['name'] - app_info['type'] = 'runtime' if self.app['model']['runtime'] else 'app' - app_info['description'] = self.app['model']['description'] + if self.app and isinstance(self.app.model, FlatpakApplication): + app_info = flatpak.get_app_info_fields(self.app.model.base_data.id, self.app.model.branch) + app_info['name'] = self.app.model.base_data.name + app_info['type'] = 'runtime' if self.app.model.runtime else 'app' + app_info['description'] = self.app.model.base_data.description self.signal_finished.emit(app_info) self.app = None @@ -107,14 +116,14 @@ class GetAppInfo(QThread): class GetAppHistory(QThread): signal_finished = pyqtSignal(dict) - def __init__(self): + def __init__(self, app: ApplicationView = None): super(GetAppHistory, self).__init__() - self.app = None + self.app = app def run(self): - if self.app: - commits = flatpak.get_app_commits_data(self.app['model']['ref'], self.app['model']['origin']) - self.signal_finished.emit({'model': self.app['model'], 'commits': commits}) + if self.app and isinstance(self.app.model, FlatpakApplication): + commits = flatpak.get_app_commits_data(self.app.model.ref, self.app.model.origin) + self.signal_finished.emit({'model': self.app.model, 'commits': commits}) self.app = None @@ -141,14 +150,14 @@ class InstallApp(QThread): signal_finished = pyqtSignal() signal_output = pyqtSignal(str) - def __init__(self): + def __init__(self, app: ApplicationView = None): super(InstallApp, self).__init__() - self.app = None + self.app = app def run(self): - if self.app: - for output in flatpak.install_and_stream(self.app['model']['id'], self.app['model']['origin']): + if self.app and isinstance(self.app.model, FlatpakApplication): + for output in flatpak.install_and_stream(self.app.model.base_data.id, self.app.model.origin): line = output.decode().strip() if line: self.signal_output.emit(line) @@ -184,3 +193,36 @@ class AnimateProgress(QThread): time.sleep(0.05) self.progress_value = 0 + + +class VerifyModels(QThread): + + signal_updates = pyqtSignal() + + def __init__(self, apps: List[ApplicationView] = None): + super(VerifyModels, self).__init__() + self.apps = apps + + def run(self): + + if self.apps: + + stop_at = datetime.utcnow() + timedelta(seconds=30) + last_ready = 0 + + while True: + current_ready = 0 + + for app in self.apps: + current_ready += 1 if app.model.status == ApplicationStatus.READY else 0 + + if current_ready > last_ready: + last_ready = current_ready + self.signal_updates.emit() + + if current_ready == len(self.apps): + self.signal_updates.emit() + break + + if stop_at <= datetime.utcnow(): + break diff --git a/fpakman/view/qt/view_model.py b/fpakman/view/qt/view_model.py new file mode 100644 index 00000000..6a090bf7 --- /dev/null +++ b/fpakman/view/qt/view_model.py @@ -0,0 +1,27 @@ +from enum import Enum + +from fpakman.util import util +from fpakman.core.model import Application, ApplicationStatus, FlatpakApplication + + +class ApplicationViewStatus(Enum): + LOADING = 0 + READY = 1 + + +class ApplicationView: + + def __init__(self, model: Application, visible: bool = True): + self.model = model + self.update_checked = model.update + self.visible = visible + self.status = ApplicationViewStatus.LOADING + + def get_async_attr(self, attr: str, strip_html: bool = False, default: str = '...'): + + if isinstance(self.model, FlatpakApplication) and self.model.runtime: + res = getattr(self.model.base_data, attr) + else: + res = getattr(self.model.base_data, attr) if self.model.status == ApplicationStatus.READY and getattr(self.model.base_data, attr) else default + + return util.strip_html(res) if res and strip_html else res diff --git a/fpakman/view/qt/window.py b/fpakman/view/qt/window.py index 955f30a1..8e23a1d5 100755 --- a/fpakman/view/qt/window.py +++ b/fpakman/view/qt/window.py @@ -10,13 +10,16 @@ from PyQt5.QtWidgets import QWidget, QVBoxLayout, QApplication, QCheckBox, QHead from fpakman.core import resource, flatpak from fpakman.core.controller import FlatpakManager +from fpakman.core.model import Application, FlatpakApplication +from fpakman.util.cache import Cache from fpakman.view.qt import dialog from fpakman.view.qt.apps_table import AppsTable from fpakman.view.qt.history import HistoryDialog from fpakman.view.qt.info import InfoDialog from fpakman.view.qt.root import is_root, ask_root_password from fpakman.view.qt.thread import UpdateSelectedApps, RefreshApps, UninstallApp, DowngradeApp, GetAppInfo, \ - GetAppHistory, SearchApps, InstallApp, AnimateProgress + GetAppHistory, SearchApps, InstallApp, AnimateProgress, VerifyModels +from fpakman.view.qt.view_model import ApplicationView DARK_ORANGE = '#FF4500' @@ -24,7 +27,7 @@ DARK_ORANGE = '#FF4500' class ManageWindow(QWidget): __BASE_HEIGHT__ = 400 - def __init__(self, locale_keys: dict, manager: FlatpakManager, tray_icon=None): + def __init__(self, locale_keys: dict, icon_cache: Cache, manager: FlatpakManager, tray_icon=None): super(ManageWindow, self).__init__() self.locale_keys = locale_keys self.manager = manager @@ -33,6 +36,7 @@ class ManageWindow(QWidget): self.working = False # restrict the number of threaded actions self.apps = [] self.label_flatpak = None + self.icon_cache = icon_cache self.icon_flathub = QIcon(resource.get_path('img/logo.svg')) self._check_flatpak_installed() @@ -101,7 +105,7 @@ class ManageWindow(QWidget): self.layout.addWidget(toolbar) - self.table_apps = AppsTable(self) + self.table_apps = AppsTable(self, self.icon_cache) self.table_apps.change_headers_policy() self.layout.addWidget(self.table_apps) @@ -131,7 +135,7 @@ class ManageWindow(QWidget): self.thread_refresh = RefreshApps(self.manager) self.thread_refresh.signal.connect(self._finish_refresh) - self.thread_uninstall = UninstallApp() + self.thread_uninstall = UninstallApp(self.manager, self.icon_cache) self.thread_uninstall.signal_output.connect(self._update_action_output) self.thread_uninstall.signal_finished.connect(self._finish_uninstall) @@ -155,6 +159,9 @@ class ManageWindow(QWidget): self.thread_animate_progress = AnimateProgress() self.thread_animate_progress.signal_change.connect(self._update_progress) + self.thread_verify_models = VerifyModels() + self.thread_verify_models.signal_updates.connect(self._notify_model_data_change) + self.toolbar_bottom = QToolBar() self.label_updates = QLabel('') self.label_updates.setStyleSheet("color: {}; font-weight: bold".format(DARK_ORANGE)) @@ -175,6 +182,9 @@ class ManageWindow(QWidget): self.centralize() + def _notify_model_data_change(self): + self.table_apps.fill_async_data() + def _new_spacer(self): spacer = QWidget() spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) @@ -250,20 +260,20 @@ class ManageWindow(QWidget): self.thread_refresh.start() - def _finish_refresh(self, apps: List[dict]): + def _finish_refresh(self, apps: List[Application]): self.update_apps(apps) self.finish_action() self._release_lock() - def uninstall_app(self, app_ref: str): + def uninstall_app(self, app: ApplicationView): self._check_flatpak_installed() if self._acquire_lock(): self._handle_console_option(True) self._begin_action(self.locale_keys['manage_window.status.uninstalling']) - self.thread_uninstall.app_ref = app_ref + self.thread_uninstall.app = app self.thread_uninstall.start() def _finish_uninstall(self): @@ -281,10 +291,10 @@ class ManageWindow(QWidget): if self.apps: show_only_apps = True if only_apps == 2 else False - for idx, app in enumerate(self.apps): - hidden = show_only_apps and app['model']['runtime'] + for idx, app_v in enumerate(self.apps): + hidden = show_only_apps and isinstance(app_v.model, FlatpakApplication) and app_v.model.runtime self.table_apps.setRowHidden(idx, hidden) - app['visible'] = not hidden + app_v.visible = not hidden self.change_update_state() self.table_apps.change_headers_policy(QHeaderView.Stretch) @@ -297,9 +307,9 @@ class ManageWindow(QWidget): app_updates, runtime_updates = 0, 0 - for app in self.apps: - if app['model']['update']: - if app['model']['runtime']: + for app_v in self.apps: + if app_v.model.update: + if app_v.model.runtime: runtime_updates += 1 else: app_updates += 1 @@ -314,13 +324,13 @@ class ManageWindow(QWidget): else: self.label_updates.setText('') - for app in self.apps: - if app['visible'] and app['update_checked']: + for app_v in self.apps: + if app_v.visible and app_v.update_checked: enable_bt_update = True break self.bt_upgrade.setEnabled(enable_bt_update) - self.tray_icon.notify_updates([app['model'] for app in self.apps if app['model']['update']]) + self.tray_icon.notify_updates([app.model for app in self.apps if app.model.update]) def centralize(self): geo = self.frameGeometry() @@ -329,7 +339,7 @@ class ManageWindow(QWidget): geo.moveCenter(center_point) self.move(geo.topLeft()) - def update_apps(self, apps: List[dict]): + def update_apps(self, apps: List[Application]): self._check_flatpak_installed() self.apps = [] @@ -338,11 +348,10 @@ class ManageWindow(QWidget): if apps: for app in apps: - app_model = {'model': app, - 'update_checked': app['update'], - 'visible': not app['runtime'] or not self.checkbox_only_apps.isChecked()} + app_model = ApplicationView(model=app, + visible=(isinstance(app, FlatpakApplication) and not app.runtime) or not self.checkbox_only_apps.isChecked()) - napps += 1 if not app['runtime'] else 0 + napps += 1 if isinstance(app, FlatpakApplication) and not app.runtime else 0 self.apps.append(app_model) if napps == 0: @@ -357,9 +366,11 @@ class ManageWindow(QWidget): self.filter_only_apps(2 if self.checkbox_only_apps.isChecked() else 0) self.resize_and_center() + self.thread_verify_models.apps = self.apps + self.thread_verify_models.start() + def resize_and_center(self): - new_width = reduce(operator.add, - [self.table_apps.columnWidth(i) for i in range(len(self.table_apps.column_names))]) * 1.05 + new_width = reduce(operator.add, [self.table_apps.columnWidth(i) for i in range(len(self.table_apps.column_names))]) * 1.05 self.resize(new_width, self.height()) self.centralize() @@ -368,7 +379,7 @@ class ManageWindow(QWidget): if self._acquire_lock(): if self.apps: - to_update = [pak['model']['ref'] for pak in self.apps if pak['visible'] and pak['update_checked']] + to_update = [app_v.model.ref for app_v in self.apps if app_v.visible and app_v.update_checked and isinstance(app_v.model, FlatpakApplication)] if to_update: self._handle_console_option(True) @@ -390,7 +401,7 @@ class ManageWindow(QWidget): self.thread_animate_progress.stop = False self.thread_animate_progress.start() self.ref_progress_bar.setVisible(True) - self.progress_bar.setValue(50) + self.label_status.setText(action_label + "...") self.toolbar_search.setVisible(False) self.bt_upgrade.setEnabled(False) @@ -398,12 +409,11 @@ class ManageWindow(QWidget): self.checkbox_only_apps.setEnabled(False) self.table_apps.setEnabled(False) - def finish_action(self, clear_search: bool = True): + def finish_action(self): + self.ref_progress_bar.setVisible(False) self.ref_label_updates.setVisible(True) self.thread_animate_progress.stop = True - self.ref_progress_bar.setVisible(False) self.progress_bar.setValue(0) - self.progress_bar.setVisible(False) self.bt_refresh.setEnabled(True) self.toolbar_search.setVisible(True) self.checkbox_only_apps.setEnabled(True) @@ -411,9 +421,6 @@ class ManageWindow(QWidget): self.input_search.setEnabled(True) self.label_status.setText('') - if clear_search: - self.input_search.setText('') - def downgrade_app(self, app: dict): self._check_flatpak_installed() @@ -477,10 +484,10 @@ class ManageWindow(QWidget): self.thread_search.word = word self.thread_search.start() - def _finish_search(self, apps_found: List[dict]): + def _finish_search(self, apps_found: List[Application]): self._release_lock() - self.finish_action(clear_search=False) + self.finish_action() self.update_apps(apps_found) def install_app(self, app: dict): From 81b7846a7da420c467d3f05ac5b93ce3d96d64be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vin=C3=ADcius=20Moreira?= Date: Mon, 8 Jul 2019 12:55:08 -0300 Subject: [PATCH 098/123] setting flathub remote if it is not set (#18) --- fpakman/__init__.py | 2 +- fpakman/app.py | 7 +++++-- fpakman/core/controller.py | 1 + fpakman/core/flatpak.py | 5 +++++ fpakman/view/qt/apps_table.py | 38 ++++++++++++++--------------------- fpakman/view/qt/common.py | 10 +++++++++ fpakman/view/qt/window.py | 8 ++------ 7 files changed, 39 insertions(+), 32 deletions(-) create mode 100644 fpakman/view/qt/common.py diff --git a/fpakman/__init__.py b/fpakman/__init__.py index 0404d810..e1424ed0 100644 --- a/fpakman/__init__.py +++ b/fpakman/__init__.py @@ -1 +1 @@ -__version__ = '0.3.0' +__version__ = '0.3.1' diff --git a/fpakman/app.py b/fpakman/app.py index 9e6f5fe4..2029ab3d 100755 --- a/fpakman/app.py +++ b/fpakman/app.py @@ -12,6 +12,7 @@ from fpakman.util import util from fpakman.core.controller import FlatpakManager from fpakman.util.cache import Cache from fpakman.util.memory import CacheCleaner +from fpakman.view.qt import common from fpakman.view.qt.systray import TrayIcon app_name = 'fpakman' @@ -51,6 +52,10 @@ if args.check_interval <= 0: if args.update_notification == 0: log_msg('updates notifications are disabled', Fore.YELLOW) +locale_keys = util.get_locale_keys(args.locale) + +common.check_flatpak_installed(locale_keys) + caches = [] flatpak_api_cache = Cache(expiration_time=args.cache_exp) caches.append(flatpak_api_cache) @@ -58,8 +63,6 @@ caches.append(flatpak_api_cache) icon_cache = Cache(expiration_time=args.icon_exp) caches.append(icon_cache) -locale_keys = util.get_locale_keys(args.locale) - app = QApplication(sys.argv) app.setWindowIcon(QIcon(resource.get_path('img/logo.svg'))) diff --git a/fpakman/core/controller.py b/fpakman/core/controller.py index d0337496..0a020322 100755 --- a/fpakman/core/controller.py +++ b/fpakman/core/controller.py @@ -17,6 +17,7 @@ class FlatpakManager: self.http_session = requests.Session() self.lock_read = Lock() self.async_data_loader = FlatpakAsyncDataLoaderManager(api_cache=self.api_cache) + flatpak.set_default_remotes() def _map_to_model(self, app: dict) -> FlatpakApplication: diff --git a/fpakman/core/flatpak.py b/fpakman/core/flatpak.py index 7470c839..17fbe179 100755 --- a/fpakman/core/flatpak.py +++ b/fpakman/core/flatpak.py @@ -212,3 +212,8 @@ def search(word: str) -> List[dict]: def install_and_stream(app_id: str, origin: str): return system.stream_cmd([BASE_CMD, 'install', origin, app_id, '-y']) + + +def set_default_remotes(): + system.run_cmd('flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo') + diff --git a/fpakman/view/qt/apps_table.py b/fpakman/view/qt/apps_table.py index a9393002..e9951b06 100644 --- a/fpakman/view/qt/apps_table.py +++ b/fpakman/view/qt/apps_table.py @@ -47,7 +47,6 @@ class AppsTable(QTableWidget): 'flatpak.info.version', 'manage_window.columns.latest_version', 'flatpak.info.branch', - 'flatpak.info.arch', 'flatpak.info.description', 'flatpak.info.origin', 'manage_window.columns.installed', @@ -113,11 +112,7 @@ class AppsTable(QTableWidget): if app_v.visible and app_v.status == ApplicationViewStatus.LOADING and app_v.model.status == ApplicationStatus.READY: self.network_man.get(QNetworkRequest(QUrl(app_v.model.base_data.icon_url))) self.item(idx, 2).setText(app_v.model.base_data.latest_version) - - desc = app_v.get_async_attr('description', strip_html=True) - self.item(idx, 5).setText(desc) - self.item(idx, 5).setToolTip(desc) - app_v.status = ApplicationViewStatus.READY + self._set_col_description(self.item(idx, 4), app_v) visible, ready = 0, 0 @@ -219,39 +214,36 @@ class AppsTable(QTableWidget): col_branch.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) self.setItem(idx, 3, col_branch) - col_arch = QTableWidgetItem() - col_arch.setText(app_v.model.arch if isinstance(app_v.model, FlatpakApplication) else '') - col_arch.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) - self.setItem(idx, 4, col_arch) - - desc = app_v.get_async_attr('description', strip_html=True) col_description = QTableWidgetItem() - col_description.setText(desc) col_description.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) + self._set_col_description(col_description, app_v) - if app_v.model.status == ApplicationStatus.READY: - col_description.setToolTip(desc) - - self.setItem(idx, 5, col_description) + self.setItem(idx, 4, col_description) col_origin = QTableWidgetItem() col_origin.setText(app_v.model.origin if isinstance(app_v.model, FlatpakApplication) else '') col_origin.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) - self.setItem(idx, 6, col_origin) + self.setItem(idx, 5, col_origin) col_installed = QLabel() col_installed.setPixmap((QPixmap(resource.get_path('img/{}.svg'.format('checked' if app_v.model.installed else 'red_cross'))))) col_installed.setAlignment(Qt.AlignCenter) - self.setCellWidget(idx, 7, col_installed) + self.setCellWidget(idx, 6, col_installed) col_update = UpdateToggleButton(app_v, self.window, self.window.locale_keys, app_v.model.update) if app_v.model.update else None - self.setCellWidget(idx, 8, col_update) + self.setCellWidget(idx, 7, col_update) + + def _set_col_description(self, col: QTableWidgetItem, app_v: ApplicationView): + desc = app_v.get_async_attr('description', strip_html=True) + + if desc: + col.setText(desc[0:25] + '...') + + if app_v.model.status == ApplicationStatus.READY: + col.setToolTip(desc) def change_headers_policy(self, policy: QHeaderView = QHeaderView.ResizeToContents): header_horizontal = self.horizontalHeader() for i in range(self.columnCount()): - if i == 5: - header_horizontal.setSectionResizeMode(i, QHeaderView.Stretch) - else: header_horizontal.setSectionResizeMode(i, policy) diff --git a/fpakman/view/qt/common.py b/fpakman/view/qt/common.py new file mode 100644 index 00000000..50240cde --- /dev/null +++ b/fpakman/view/qt/common.py @@ -0,0 +1,10 @@ +from fpakman.core import flatpak +from fpakman.view.qt import dialog + + +def check_flatpak_installed(locale_keys: dict): + + if not flatpak.is_installed(): + dialog.show_error(title=locale_keys['popup.flatpak_not_installed.title'], + body=locale_keys['popup.flatpak_not_installed.msg'] + '...') + exit(1) diff --git a/fpakman/view/qt/window.py b/fpakman/view/qt/window.py index 8e23a1d5..d5ed2340 100755 --- a/fpakman/view/qt/window.py +++ b/fpakman/view/qt/window.py @@ -12,7 +12,7 @@ from fpakman.core import resource, flatpak from fpakman.core.controller import FlatpakManager from fpakman.core.model import Application, FlatpakApplication from fpakman.util.cache import Cache -from fpakman.view.qt import dialog +from fpakman.view.qt import dialog, common from fpakman.view.qt.apps_table import AppsTable from fpakman.view.qt.history import HistoryDialog from fpakman.view.qt.info import InfoDialog @@ -205,11 +205,7 @@ class ManageWindow(QWidget): def _check_flatpak_installed(self): - if not flatpak.is_installed(): - dialog.show_error(title=self.locale_keys['popup.flatpak_not_installed.title'], - body=self.locale_keys['popup.flatpak_not_installed.msg'] + '...', - icon=self.icon_flathub) - exit(1) + common.check_flatpak_installed(self.locale_keys) if self.label_flatpak: self.label_flatpak.setText(self._get_flatpak_label()) From 925aa7cec6a2d396ef27fd80a68b9880d5d21443 Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Wed, 10 Jul 2019 11:00:38 -0300 Subject: [PATCH 099/123] setting default async worker load to 1 --- fpakman/core/worker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fpakman/core/worker.py b/fpakman/core/worker.py index 170ecc2e..e5894609 100644 --- a/fpakman/core/worker.py +++ b/fpakman/core/worker.py @@ -91,7 +91,7 @@ class FlatpakAsyncDataLoader(Thread): class FlatpakAsyncDataLoaderManager: - def __init__(self, api_cache: Cache, worker_load: int = 3, workers: List[FlatpakAsyncDataLoader] = []): + def __init__(self, api_cache: Cache, worker_load: int = 1, workers: List[FlatpakAsyncDataLoader] = []): self.worker_load = worker_load self.current_workers = workers self.http_session = requests.Session() From ff8510153d0e27a6e3fce8a8a6a889062ea1c8c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vin=C3=ADcius=20Moreira?= Date: Wed, 10 Jul 2019 11:31:57 -0300 Subject: [PATCH 100/123] architecture expanded to support other types of packaging --- fpakman/app.py | 5 +- fpakman/core/controller.py | 134 +++++++++++++++++++++++++++++++++- fpakman/core/model.py | 59 ++++++++++++++- fpakman/resources/locale/en | 14 +++- fpakman/resources/locale/es | 13 +++- fpakman/resources/locale/pt | 13 +++- fpakman/view/qt/apps_table.py | 91 +++++++++++------------ fpakman/view/qt/history.py | 10 +-- fpakman/view/qt/systray.py | 8 +- fpakman/view/qt/thread.py | 58 +++++++-------- fpakman/view/qt/window.py | 54 ++++++-------- 11 files changed, 326 insertions(+), 133 deletions(-) diff --git a/fpakman/app.py b/fpakman/app.py index 2029ab3d..3ea20fe1 100755 --- a/fpakman/app.py +++ b/fpakman/app.py @@ -9,7 +9,7 @@ from colorama import Fore from fpakman import __version__ from fpakman.core import resource from fpakman.util import util -from fpakman.core.controller import FlatpakManager +from fpakman.core.controller import FlatpakManager, GenericApplicationManager from fpakman.util.cache import Cache from fpakman.util.memory import CacheCleaner from fpakman.view.qt import common @@ -66,7 +66,8 @@ caches.append(icon_cache) app = QApplication(sys.argv) app.setWindowIcon(QIcon(resource.get_path('img/logo.svg'))) -manager = FlatpakManager(flatpak_api_cache) +manager = GenericApplicationManager([FlatpakManager(flatpak_api_cache)]) + trayIcon = TrayIcon(locale_keys=locale_keys, manager=manager, diff --git a/fpakman/core/controller.py b/fpakman/core/controller.py index 0a020322..dc9a366b 100755 --- a/fpakman/core/controller.py +++ b/fpakman/core/controller.py @@ -1,3 +1,4 @@ +from abc import ABC, abstractmethod from datetime import datetime from threading import Lock from typing import List @@ -5,12 +6,59 @@ from typing import List import requests from fpakman.core import flatpak -from fpakman.core.model import FlatpakApplication, ApplicationData, ApplicationStatus +from fpakman.core.model import FlatpakApplication, ApplicationData, ApplicationStatus, Application from fpakman.core.worker import FlatpakAsyncDataLoaderManager from fpakman.util.cache import Cache -class FlatpakManager: +class ApplicationManager(ABC): + + @abstractmethod + def search(self, word: str) -> List[Application]: + pass + + @abstractmethod + def read_installed(self) -> List[Application]: + pass + + @abstractmethod + def downgrade_app(self, app: Application, root_password: str): + pass + + @abstractmethod + def clean_cache_for(self, app: Application): + pass + + @abstractmethod + def can_downgrade(self): + pass + + @abstractmethod + def update_and_stream(self, app: Application): + pass + + @abstractmethod + def uninstall_and_stream(self, app: Application): + pass + + @abstractmethod + def get_app_type(self) -> str: + pass + + @abstractmethod + def get_info(self, app: Application) -> dict: + pass + + @abstractmethod + def get_history(self, app: Application) -> List[dict]: + pass + + @abstractmethod + def install_and_stream(self, app: Application): + pass + + +class FlatpakManager(ApplicationManager): def __init__(self, api_cache: Cache): self.api_cache = api_cache @@ -19,6 +67,9 @@ class FlatpakManager: self.async_data_loader = FlatpakAsyncDataLoaderManager(api_cache=self.api_cache) flatpak.set_default_remotes() + def get_app_type(self): + return FlatpakApplication + def _map_to_model(self, app: dict) -> FlatpakApplication: model = FlatpakApplication(arch=app.get('arch'), @@ -103,6 +154,9 @@ class FlatpakManager: finally: self.lock_read.release() + def can_downgrade(self): + return True + def downgrade_app(self, app: FlatpakApplication, root_password: str): commits = flatpak.get_app_commits(app.ref, app.origin) @@ -114,5 +168,77 @@ class FlatpakManager: return flatpak.downgrade_and_stream(app.ref, commits[commit_idx + 1], root_password) - def clean_cache_for(self, app_id: str): - self.api_cache.delete(app_id) + def clean_cache_for(self, app: FlatpakApplication): + self.api_cache.delete(app.base_data.id) + + def update_and_stream(self, app: FlatpakApplication): + return flatpak.update_and_stream(app.ref) + + def uninstall_and_stream(self, app: FlatpakApplication): + return flatpak.uninstall_and_stream(app.ref) + + def get_info(self, app: FlatpakApplication) -> dict: + app_info = flatpak.get_app_info_fields(app.base_data.id, app.branch) + app_info['name'] = app.base_data.name + app_info['type'] = 'runtime' if app.runtime else 'app' + app_info['description'] = app.base_data.description + return app_info + + def get_history(self, app: FlatpakApplication) -> List[dict]: + return flatpak.get_app_commits_data(app.ref, app.origin) + + def install_and_stream(self, app: FlatpakApplication): + return flatpak.install_and_stream(app.base_data.id, app.origin) + + +class GenericApplicationManager(ApplicationManager): + + def __init__(self, managers: List[ApplicationManager]): + self.managers = managers + self.map = {m.get_app_type(): m for m in self.managers} + + def search(self, word: str) -> List[Application]: + apps = [] + for man in self.managers: + apps.extend(man.search(word)) + + return apps + + def read_installed(self) -> List[Application]: + installed = [] + for man in self.managers: + installed.extend(man.read_installed()) + + return installed + + def can_downgrade(self): + return True + + def downgrade_app(self, app: Application, root_password: str): + manager = self.map.get(app.__class__) + + if manager.can_downgrade(): + return manager.downgrade_app(app, root_password) + else: + raise Exception("downgrade is not possible for {}".format(app.__class__.__name__)) + + def clean_cache_for(self, app: Application): + self.map[app.__class__].clean_cache_for(app) + + def update_and_stream(self, app: Application): + return self.map[app.__class__].update_and_stream(app) + + def uninstall_and_stream(self, app: Application): + return self.map[app.__class__].uninstall_and_stream(app) + + def install_and_stream(self, app: Application): + return self.map[app.__class__].install_and_stream(app) + + def get_info(self, app: Application): + return self.map[app.__class__].get_info(app) + + def get_history(self, app: Application): + return self.map[app.__class__].get_history(app) + + def get_app_type(self): + return None diff --git a/fpakman/core/model.py b/fpakman/core/model.py index 1bc58ea9..390869e5 100644 --- a/fpakman/core/model.py +++ b/fpakman/core/model.py @@ -1,6 +1,8 @@ -from abc import ABC +from abc import ABC, abstractmethod from enum import Enum +from fpakman.core import resource + class ApplicationStatus(Enum): READY = 1 @@ -26,6 +28,37 @@ class Application(ABC): self.installed = installed self.update = update + @abstractmethod + def has_history(self): + pass + + @abstractmethod + def has_info(self): + pass + + @abstractmethod + def can_be_downgraded(self): + pass + + @abstractmethod + def can_be_uninstalled(self): + pass + + @abstractmethod + def can_be_installed(self): + pass + + @abstractmethod + def get_type(self): + pass + + def get_default_logo_path(self): + return resource.get_path('img/logo.svg') + + @abstractmethod + def is_library(self): + pass + class FlatpakApplication(Application): @@ -40,3 +73,27 @@ class FlatpakApplication(Application): def is_incomplete(self): return self.base_data.description is None and self.base_data.icon_url + + def has_history(self): + return True + + def has_info(self): + return True + + def can_be_downgraded(self): + return not self.runtime + + def can_be_uninstalled(self): + return True + + def can_be_installed(self): + return True + + def get_type(self): + return 'flatpak' + + def get_default_logo_path(self): + return resource.get_path('img/flathub.svg') + + def is_library(self): + return self.runtime diff --git a/fpakman/resources/locale/en b/fpakman/resources/locale/en index 7f198369..ddc8e8b4 100644 --- a/fpakman/resources/locale/en +++ b/fpakman/resources/locale/en @@ -1,6 +1,5 @@ -manage_window.title=Flatpak Application Manager +manage_window.title=Application Manager manage_window.columns.latest_version=Latest Version -manage_window.columns.installed=Installed manage_window.columns.update=Upgrade ? manage_window.apps_table.row.actions.info=Information manage_window.apps_table.row.actions.history=History @@ -43,7 +42,7 @@ tray.action.manage=Manage applications tray.action.exit=Exit tray.action.about=About tray.action.refreshing=Refreshing -notification.new_updates={} updates +notification.new_updates=Updates flatpak.info.arch=arch flatpak.info.branch=branch flatpak.info.collection=collection @@ -70,4 +69,11 @@ about.info.rate=If this tool is useful for you, give it a star on Github to keep yes=yes no=no version.updated=updated -version.outdated=outdated \ No newline at end of file +version.outdated=outdated +name=name +version=version +latest_version=latest version +description=description +type=type +installed=installed +others=others \ No newline at end of file diff --git a/fpakman/resources/locale/es b/fpakman/resources/locale/es index 27c17625..08040de1 100644 --- a/fpakman/resources/locale/es +++ b/fpakman/resources/locale/es @@ -1,4 +1,4 @@ -manage_window.title=Administrador de Aplicativos Flatpak +manage_window.title=Administrador de Aplicativos manage_window.columns.latest_version=Ultima Versión manage_window.columns.update=Actualizar ? manage_window.columns.installed=Instalado @@ -43,7 +43,7 @@ tray.action.manage=Administrar aplicativos tray.action.exit=Salir tray.action.about=Sobre tray.action.refreshing=Recargando -notification.new_updates=Actualizaciones {} +notification.new_updates=Actualizaciones flatpak.info.arch=arquitectura flatpak.info.branch=rama flatpak.info.collection=colección @@ -70,4 +70,11 @@ about.info.rate=Si esta herramienta es útil para ti, dale una estrella en Githu yes=sí no=no version.updated=actualizada -version.outdated=desactualizada \ No newline at end of file +version.outdated=desactualizada +name=nombre +version=versión +latest_version=ultima versión +description=descripción +type=tipo +installed=instalado +others=otros \ No newline at end of file diff --git a/fpakman/resources/locale/pt b/fpakman/resources/locale/pt index 7997e7c2..fe8f0ab7 100644 --- a/fpakman/resources/locale/pt +++ b/fpakman/resources/locale/pt @@ -1,4 +1,4 @@ -manage_window.title=Gerenciador de Aplicativos Flatpak +manage_window.title=Gerenciador de Aplicativos manage_window.columns.latest_version=Última Versão manage_window.columns.update=Atualizar ? manage_window.columns.installed=Instalado @@ -43,7 +43,7 @@ tray.action.manage=Gerenciar aplicativos tray.action.exit=Sair tray.action.about=Sobre tray.action.refreshing=Recarregando -notification.new_updates=Atualizações {} +notification.new_updates=Atualizações flatpak.info.arch=arquitetura flatpak.info.branch=ramo flatpak.info.collection=coleção @@ -70,4 +70,11 @@ about.info.rate=Se essa ferramenta é útil para você, dê uma estrela no Githu yes=sim no=não version.updated=atualizada -version.outdated=desatualizada \ No newline at end of file +version.outdated=desatualizada +name=nome +version=versão +latest_version=última versão +description=descrição +type=tipo +installed=instalado +others=outros \ No newline at end of file diff --git a/fpakman/view/qt/apps_table.py b/fpakman/view/qt/apps_table.py index e9951b06..037b0539 100644 --- a/fpakman/view/qt/apps_table.py +++ b/fpakman/view/qt/apps_table.py @@ -43,13 +43,12 @@ class AppsTable(QTableWidget): super(AppsTable, self).__init__() self.setParent(parent) self.window = parent - self.column_names = [parent.locale_keys[key].capitalize() for key in ['flatpak.info.name', - 'flatpak.info.version', - 'manage_window.columns.latest_version', - 'flatpak.info.branch', + self.column_names = [parent.locale_keys[key].capitalize() for key in ['name', + 'version', + 'latest_version', 'flatpak.info.description', - 'flatpak.info.origin', - 'manage_window.columns.installed', + 'type', + 'installed', 'manage_window.columns.update']] self.setColumnCount(len(self.column_names)) self.setFocusPolicy(Qt.NoFocus) @@ -59,6 +58,7 @@ class AppsTable(QTableWidget): self.setHorizontalHeaderLabels(self.column_names) self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.icon_flathub = QIcon(resource.get_path('img/flathub.svg')) + self.icon_logo = QIcon(resource.get_path('img/logo.svg')) self.network_man = QNetworkAccessManager() self.network_man.finished.connect(self._load_icon) @@ -73,31 +73,36 @@ class AppsTable(QTableWidget): menu_row = QMenu() if app.model.installed: - action_info = QAction(self.window.locale_keys["manage_window.apps_table.row.actions.info"]) - action_info.setIcon(QIcon(resource.get_path('img/info.svg'))) - action_info.triggered.connect(self._get_app_info) - menu_row.addAction(action_info) - action_history = QAction(self.window.locale_keys["manage_window.apps_table.row.actions.history"]) - action_history.setIcon(QIcon(resource.get_path('img/history.svg'))) - action_history.triggered.connect(self._get_app_history) - menu_row.addAction(action_history) + if app.model.has_info(): + action_info = QAction(self.window.locale_keys["manage_window.apps_table.row.actions.info"]) + action_info.setIcon(QIcon(resource.get_path('img/info.svg'))) + action_info.triggered.connect(self._get_app_info) + menu_row.addAction(action_info) - action_uninstall = QAction(self.window.locale_keys["manage_window.apps_table.row.actions.uninstall"]) - action_uninstall.setIcon(QIcon(resource.get_path('img/uninstall.svg'))) - action_uninstall.triggered.connect(self._uninstall_app) - menu_row.addAction(action_uninstall) + if app.model.has_history(): + action_history = QAction(self.window.locale_keys["manage_window.apps_table.row.actions.history"]) + action_history.setIcon(QIcon(resource.get_path('img/history.svg'))) + action_history.triggered.connect(self._get_app_history) + menu_row.addAction(action_history) - if isinstance(app.model, FlatpakApplication) and not app.model.runtime: # not available for runtimes + if app.model.can_be_uninstalled(): + action_uninstall = QAction(self.window.locale_keys["manage_window.apps_table.row.actions.uninstall"]) + action_uninstall.setIcon(QIcon(resource.get_path('img/uninstall.svg'))) + action_uninstall.triggered.connect(self._uninstall_app) + menu_row.addAction(action_uninstall) + + if app.model.can_be_downgraded(): action_downgrade = QAction(self.window.locale_keys["manage_window.apps_table.row.actions.downgrade"]) action_downgrade.triggered.connect(self._downgrade_app) action_downgrade.setIcon(QIcon(resource.get_path('img/downgrade.svg'))) menu_row.addAction(action_downgrade) else: - action_install = QAction(self.window.locale_keys["manage_window.apps_table.row.actions.install"]) - action_install.setIcon(QIcon(resource.get_path('img/install.svg'))) - action_install.triggered.connect(self._install_app) - menu_row.addAction(action_install) + if app.model.can_be_installed(): + action_install = QAction(self.window.locale_keys["manage_window.apps_table.row.actions.install"]) + action_install.setIcon(QIcon(resource.get_path('img/install.svg'))) + action_install.triggered.connect(self._install_app) + menu_row.addAction(action_install) menu_row.adjustSize() menu_row.popup(QCursor.pos()) @@ -108,20 +113,21 @@ class AppsTable(QTableWidget): self.lock_async_data.acquire() if self.window.apps: - for idx, app_v in enumerate(self.window.apps): - if app_v.visible and app_v.status == ApplicationViewStatus.LOADING and app_v.model.status == ApplicationStatus.READY: - self.network_man.get(QNetworkRequest(QUrl(app_v.model.base_data.icon_url))) - self.item(idx, 2).setText(app_v.model.base_data.latest_version) - self._set_col_description(self.item(idx, 4), app_v) visible, ready = 0, 0 + for idx, app_v in enumerate(self.window.apps): - for app_v in self.window.apps: if app_v.visible: visible += 1 - if app_v.status == ApplicationViewStatus.READY: - ready += 1 + if app_v.status == ApplicationViewStatus.LOADING and app_v.model.status == ApplicationStatus.READY: + self.network_man.get(QNetworkRequest(QUrl(app_v.model.base_data.icon_url))) + self.item(idx, 2).setText(app_v.model.base_data.latest_version) + self._set_col_description(self.item(idx, 3), app_v) + app_v.status = ApplicationViewStatus.READY + + if app_v.status == ApplicationViewStatus.READY: + ready += 1 if ready == visible: self.window.resize_and_center() @@ -185,14 +191,14 @@ class AppsTable(QTableWidget): col_name.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) if not app_v.model.base_data.icon_url: - col_name.setIcon(self.icon_flathub) + col_name.setIcon(QIcon(app_v.model.get_default_logo_path())) else: cached_icon = self.icon_cache.get(app_v.model.base_data.icon_url) if cached_icon: col_name.setIcon(cached_icon) else: - col_name.setIcon(self.icon_flathub) + col_name.setIcon(QIcon(app_v.model.get_default_logo_path())) self.setItem(idx, 0, col_name) @@ -209,30 +215,25 @@ class AppsTable(QTableWidget): if app_v.model.base_data.version and app_v.model.base_data.latest_version and app_v.model.base_data.version < app_v.model.base_data.latest_version: col_release.setForeground(QColor('orange')) - col_branch = QTableWidgetItem() - col_branch.setText(app_v.model.branch if isinstance(app_v.model, FlatpakApplication) else '') - col_branch.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) - self.setItem(idx, 3, col_branch) - col_description = QTableWidgetItem() col_description.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) self._set_col_description(col_description, app_v) - self.setItem(idx, 4, col_description) + self.setItem(idx, 3, col_description) - col_origin = QTableWidgetItem() - col_origin.setText(app_v.model.origin if isinstance(app_v.model, FlatpakApplication) else '') - col_origin.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) - self.setItem(idx, 5, col_origin) + col_type = QTableWidgetItem() + col_type.setText(app_v.model.get_type()) + col_type.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) + self.setItem(idx, 4, col_type) col_installed = QLabel() col_installed.setPixmap((QPixmap(resource.get_path('img/{}.svg'.format('checked' if app_v.model.installed else 'red_cross'))))) col_installed.setAlignment(Qt.AlignCenter) - self.setCellWidget(idx, 6, col_installed) + self.setCellWidget(idx, 5, col_installed) col_update = UpdateToggleButton(app_v, self.window, self.window.locale_keys, app_v.model.update) if app_v.model.update else None - self.setCellWidget(idx, 7, col_update) + self.setCellWidget(idx, 6, col_update) def _set_col_description(self, col: QTableWidgetItem, app_v: ApplicationView): desc = app_v.get_async_attr('description', strip_html=True) diff --git a/fpakman/view/qt/history.py b/fpakman/view/qt/history.py index 965928da..ae77232e 100644 --- a/fpakman/view/qt/history.py +++ b/fpakman/view/qt/history.py @@ -8,7 +8,7 @@ from PyQt5.QtWidgets import QDialog, QVBoxLayout, QTableWidget, QTableWidgetItem class HistoryDialog(QDialog): - def __init__(self, app: dict, app_icon: QIcon, locale_keys: dict): + def __init__(self, app: dict, app_icon: QIcon, locale_keys: dict): super(HistoryDialog, self).__init__() self.setWindowTitle('{} - {} '.format(locale_keys['popup.history.title'], app['model'].base_data.name)) @@ -23,11 +23,11 @@ class HistoryDialog(QDialog): table_history.verticalHeader().setVisible(False) table_history.setAlternatingRowColors(True) - table_history.setColumnCount(len(app['commits'][0])) - table_history.setRowCount(len(app['commits'])) - table_history.setHorizontalHeaderLabels([locale_keys['flatpak.info.' + key].capitalize() for key in sorted(app['commits'][0].keys())]) + table_history.setColumnCount(len(app['history'][0])) + table_history.setRowCount(len(app['history'])) + table_history.setHorizontalHeaderLabels([locale_keys['flatpak.info.' + key].capitalize() for key in sorted(app['history'][0].keys())]) - for row, commit in enumerate(app['commits']): + for row, commit in enumerate(app['history']): current_app_commit = app['model'].commit == commit['commit'] diff --git a/fpakman/view/qt/systray.py b/fpakman/view/qt/systray.py index 3b327bd1..25e61cec 100755 --- a/fpakman/view/qt/systray.py +++ b/fpakman/view/qt/systray.py @@ -7,7 +7,7 @@ from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import QSystemTrayIcon, QMenu from fpakman.core import resource, system -from fpakman.core.controller import FlatpakManager +from fpakman.core.controller import FlatpakManager, ApplicationManager from fpakman.core.model import Application from fpakman.util.cache import Cache from fpakman.view.qt.about import AboutDialog @@ -18,7 +18,7 @@ class UpdateCheck(QThread): signal = pyqtSignal(list) - def __init__(self, manager: FlatpakManager, check_interval: int, parent=None): + def __init__(self, manager: ApplicationManager, check_interval: int, parent=None): super(UpdateCheck, self).__init__(parent) self.check_interval = check_interval self.manager = manager @@ -39,7 +39,7 @@ class UpdateCheck(QThread): class TrayIcon(QSystemTrayIcon): - def __init__(self, locale_keys: dict, manager: FlatpakManager, icon_cache: Cache, check_interval: int = 60, update_notification: bool = True): + def __init__(self, locale_keys: dict, manager: ApplicationManager, icon_cache: Cache, check_interval: int = 60, update_notification: bool = True): super(TrayIcon, self).__init__() self.locale_keys = locale_keys self.manager = manager @@ -86,7 +86,7 @@ class TrayIcon(QSystemTrayIcon): if update_keys.difference(self.last_updates): self.last_updates = update_keys - msg = '{}: {}'.format(self.locale_keys['notification.new_updates'].format('Flatpak'), len(updates)) + msg = '{}: {}'.format(self.locale_keys['notification.new_updates'], len(updates)) self.setToolTip(msg) if self.update_notification: diff --git a/fpakman/view/qt/thread.py b/fpakman/view/qt/thread.py index ea4dbcb1..6a1a1f83 100644 --- a/fpakman/view/qt/thread.py +++ b/fpakman/view/qt/thread.py @@ -4,12 +4,11 @@ from typing import List from PyQt5.QtCore import QThread, pyqtSignal -from fpakman.core import flatpak -from fpakman.core.controller import FlatpakManager -from fpakman.core.model import ApplicationStatus, FlatpakApplication +from fpakman.core.controller import ApplicationManager +from fpakman.core.model import ApplicationStatus from fpakman.util.cache import Cache from fpakman.view.qt import dialog -from fpakman.view.qt.view_model import ApplicationView, ApplicationViewStatus +from fpakman.view.qt.view_model import ApplicationView class UpdateSelectedApps(QThread): @@ -17,14 +16,15 @@ class UpdateSelectedApps(QThread): signal_finished = pyqtSignal() signal_output = pyqtSignal(str) - def __init__(self): + def __init__(self, manager: ApplicationManager, apps_to_update: List[ApplicationView] = None): super(UpdateSelectedApps, self).__init__() - self.refs_to_update = [] + self.apps_to_update = apps_to_update + self.manager = manager def run(self): - for app_ref in self.refs_to_update: - for output in flatpak.update_and_stream(app_ref): + for app in self.apps_to_update: + for output in self.manager.update_and_stream(app.model): line = output.decode().strip() if line: self.signal_output.emit(line) @@ -36,7 +36,7 @@ class RefreshApps(QThread): signal = pyqtSignal(list) - def __init__(self, manager: FlatpakManager): + def __init__(self, manager: ApplicationManager): super(RefreshApps, self).__init__() self.manager = manager @@ -48,21 +48,21 @@ class UninstallApp(QThread): signal_finished = pyqtSignal() signal_output = pyqtSignal(str) - def __init__(self, manager: FlatpakManager, icon_cache: Cache, app: ApplicationView = None): + def __init__(self, manager: ApplicationManager, icon_cache: Cache, app: ApplicationView = None): super(UninstallApp, self).__init__() self.app = app self.manager = manager self.icon_cache = icon_cache def run(self): - if self.app and isinstance(self.app.model, FlatpakApplication): - for output in flatpak.uninstall_and_stream(self.app.model.ref): + if self.app: + for output in self.manager.uninstall_and_stream(self.app.model): line = output.decode().strip() if line: self.signal_output.emit(line) self.icon_cache.delete(self.app.model.base_data.icon_url) - self.manager.clean_cache_for(self.app.model.base_data.id) + self.manager.clean_cache_for(self.app.model) self.signal_finished.emit() @@ -70,7 +70,7 @@ class DowngradeApp(QThread): signal_finished = pyqtSignal() signal_output = pyqtSignal(str) - def __init__(self, manager: FlatpakManager, locale_keys: dict, app: ApplicationView = None): + def __init__(self, manager: ApplicationManager, locale_keys: dict, app: ApplicationView = None): super(DowngradeApp, self).__init__() self.manager = manager self.app = app @@ -78,7 +78,7 @@ class DowngradeApp(QThread): self.root_password = None def run(self): - if self.app and isinstance(self.app.model, FlatpakApplication): + if self.app: stream = self.manager.downgrade_app(self.app.model, self.root_password) @@ -99,38 +99,35 @@ class DowngradeApp(QThread): class GetAppInfo(QThread): signal_finished = pyqtSignal(dict) - def __init__(self, app: ApplicationView = None): + def __init__(self, manager: ApplicationManager, app: ApplicationView = None): super(GetAppInfo, self).__init__() self.app = app + self.manager = manager def run(self): - if self.app and isinstance(self.app.model, FlatpakApplication): - app_info = flatpak.get_app_info_fields(self.app.model.base_data.id, self.app.model.branch) - app_info['name'] = self.app.model.base_data.name - app_info['type'] = 'runtime' if self.app.model.runtime else 'app' - app_info['description'] = self.app.model.base_data.description - self.signal_finished.emit(app_info) + if self.app: + self.signal_finished.emit(self.manager.get_info(self.app.model)) self.app = None class GetAppHistory(QThread): signal_finished = pyqtSignal(dict) - def __init__(self, app: ApplicationView = None): + def __init__(self, manager: ApplicationManager, app: ApplicationView = None): super(GetAppHistory, self).__init__() self.app = app + self.manager = manager def run(self): - if self.app and isinstance(self.app.model, FlatpakApplication): - commits = flatpak.get_app_commits_data(self.app.model.ref, self.app.model.origin) - self.signal_finished.emit({'model': self.app.model, 'commits': commits}) + if self.app: + self.signal_finished.emit({'model': self.app.model, 'history': self.manager.get_history(self.app.model)}) self.app = None class SearchApps(QThread): signal_finished = pyqtSignal(list) - def __init__(self, manager: FlatpakManager): + def __init__(self, manager: ApplicationManager): super(SearchApps, self).__init__() self.word = None self.manager = manager @@ -150,14 +147,15 @@ class InstallApp(QThread): signal_finished = pyqtSignal() signal_output = pyqtSignal(str) - def __init__(self, app: ApplicationView = None): + def __init__(self, manager: ApplicationManager, app: ApplicationView = None): super(InstallApp, self).__init__() self.app = app + self.manager = manager def run(self): - if self.app and isinstance(self.app.model, FlatpakApplication): - for output in flatpak.install_and_stream(self.app.model.base_data.id, self.app.model.origin): + if self.app: + for output in self.manager.install_and_stream(self.app.model): line = output.decode().strip() if line: self.signal_output.emit(line) diff --git a/fpakman/view/qt/window.py b/fpakman/view/qt/window.py index d5ed2340..c84b673b 100755 --- a/fpakman/view/qt/window.py +++ b/fpakman/view/qt/window.py @@ -8,11 +8,11 @@ from PyQt5.QtGui import QIcon, QWindowStateChangeEvent, QPixmap from PyQt5.QtWidgets import QWidget, QVBoxLayout, QApplication, QCheckBox, QHeaderView, QToolButton, QToolBar, \ QSizePolicy, QLabel, QPlainTextEdit, QLineEdit, QProgressBar -from fpakman.core import resource, flatpak -from fpakman.core.controller import FlatpakManager -from fpakman.core.model import Application, FlatpakApplication +from fpakman.core import resource +from fpakman.core.controller import ApplicationManager +from fpakman.core.model import Application from fpakman.util.cache import Cache -from fpakman.view.qt import dialog, common +from fpakman.view.qt import common from fpakman.view.qt.apps_table import AppsTable from fpakman.view.qt.history import HistoryDialog from fpakman.view.qt.info import InfoDialog @@ -27,7 +27,7 @@ DARK_ORANGE = '#FF4500' class ManageWindow(QWidget): __BASE_HEIGHT__ = 400 - def __init__(self, locale_keys: dict, icon_cache: Cache, manager: FlatpakManager, tray_icon=None): + def __init__(self, locale_keys: dict, icon_cache: Cache, manager: ApplicationManager, tray_icon=None): super(ManageWindow, self).__init__() self.locale_keys = locale_keys self.manager = manager @@ -128,7 +128,7 @@ class ManageWindow(QWidget): self.textarea_output.setVisible(False) self.textarea_output.setReadOnly(True) - self.thread_update = UpdateSelectedApps() + self.thread_update = UpdateSelectedApps(self.manager) self.thread_update.signal_output.connect(self._update_action_output) self.thread_update.signal_finished.connect(self._finish_update_selected) @@ -143,16 +143,16 @@ class ManageWindow(QWidget): self.thread_downgrade.signal_output.connect(self._update_action_output) self.thread_downgrade.signal_finished.connect(self._finish_downgrade) - self.thread_get_info = GetAppInfo() + self.thread_get_info = GetAppInfo(self.manager) self.thread_get_info.signal_finished.connect(self._finish_get_info) - self.thread_get_history = GetAppHistory() + self.thread_get_history = GetAppHistory(self.manager) self.thread_get_history.signal_finished.connect(self._finish_get_history) self.thread_search = SearchApps(self.manager) self.thread_search.signal_finished.connect(self._finish_search) - self.thread_install = InstallApp() + self.thread_install = InstallApp(self.manager) self.thread_install.signal_output.connect(self._update_action_output) self.thread_install.signal_finished.connect(self._finish_install) @@ -175,9 +175,6 @@ class ManageWindow(QWidget): self.toolbar_bottom.addWidget(self._new_spacer()) - self.label_flatpak = QLabel(self._get_flatpak_label()) - self.toolbar_bottom.addWidget(self.label_flatpak) - self.layout.addWidget(self.toolbar_bottom) self.centralize() @@ -204,15 +201,8 @@ class ManageWindow(QWidget): self._handle_console_option(False) def _check_flatpak_installed(self): - common.check_flatpak_installed(self.locale_keys) - if self.label_flatpak: - self.label_flatpak.setText(self._get_flatpak_label()) - - def _get_flatpak_label(self): - return 'flatpak: ' + flatpak.get_version() - def _acquire_lock(self): self.thread_lock.acquire() @@ -288,7 +278,7 @@ class ManageWindow(QWidget): show_only_apps = True if only_apps == 2 else False for idx, app_v in enumerate(self.apps): - hidden = show_only_apps and isinstance(app_v.model, FlatpakApplication) and app_v.model.runtime + hidden = show_only_apps and app_v.model.is_library() self.table_apps.setRowHidden(idx, hidden) app_v.visible = not hidden @@ -301,22 +291,22 @@ class ManageWindow(QWidget): enable_bt_update = False - app_updates, runtime_updates = 0, 0 + app_updates, library_updates = 0, 0 for app_v in self.apps: if app_v.model.update: if app_v.model.runtime: - runtime_updates += 1 + library_updates += 1 else: app_updates += 1 - total_updates = app_updates + runtime_updates + total_updates = app_updates + library_updates if total_updates > 0: self.label_updates.setText('{}: {}'.format(self.locale_keys['manage_window.label.updates'], total_updates)) - self.label_updates.setToolTip('{} {} | {} runtimes'.format(app_updates, - self.locale_keys[ - 'manage_window.checkbox.only_apps'].lower(), - runtime_updates)) + self.label_updates.setToolTip('{} {} | {} {}'.format(app_updates, + self.locale_keys['manage_window.checkbox.only_apps'].lower(), + library_updates, + self.locale_keys['others'].lower())) else: self.label_updates.setText('') @@ -340,14 +330,14 @@ class ManageWindow(QWidget): self.apps = [] - napps = 0 # number of apps (not runtimes) + napps = 0 # number of apps (not libraries) if apps: for app in apps: app_model = ApplicationView(model=app, - visible=(isinstance(app, FlatpakApplication) and not app.runtime) or not self.checkbox_only_apps.isChecked()) + visible=(not app.is_library()) or not self.checkbox_only_apps.isChecked()) - napps += 1 if isinstance(app, FlatpakApplication) and not app.runtime else 0 + napps += 1 if not app.is_library() else 0 self.apps.append(app_model) if napps == 0: @@ -375,13 +365,13 @@ class ManageWindow(QWidget): if self._acquire_lock(): if self.apps: - to_update = [app_v.model.ref for app_v in self.apps if app_v.visible and app_v.update_checked and isinstance(app_v.model, FlatpakApplication)] + to_update = [app_v for app_v in self.apps if app_v.visible and app_v.update_checked] if to_update: self._handle_console_option(True) self._begin_action(self.locale_keys['manage_window.status.upgrading']) - self.thread_update.refs_to_update = to_update + self.thread_update.apps_to_update = to_update self.thread_update.start() def _finish_update_selected(self): From 238a9d38ae69c1944b3569438d052cac77372ae1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vin=C3=ADcius=20Moreira?= Date: Wed, 10 Jul 2019 12:18:12 -0300 Subject: [PATCH 101/123] Flatpak not required --- fpakman/app.py | 3 -- fpakman/core/controller.py | 56 +++++++++++++++++++++++++++++-------- fpakman/core/flatpak.py | 2 +- fpakman/core/system.py | 13 +++++++-- fpakman/resources/locale/en | 2 -- fpakman/resources/locale/es | 2 -- fpakman/resources/locale/pt | 2 -- fpakman/view/qt/common.py | 10 ------- fpakman/view/qt/window.py | 18 ------------ 9 files changed, 57 insertions(+), 51 deletions(-) delete mode 100644 fpakman/view/qt/common.py diff --git a/fpakman/app.py b/fpakman/app.py index 3ea20fe1..6375c6be 100755 --- a/fpakman/app.py +++ b/fpakman/app.py @@ -12,7 +12,6 @@ from fpakman.util import util from fpakman.core.controller import FlatpakManager, GenericApplicationManager from fpakman.util.cache import Cache from fpakman.util.memory import CacheCleaner -from fpakman.view.qt import common from fpakman.view.qt.systray import TrayIcon app_name = 'fpakman' @@ -54,8 +53,6 @@ if args.update_notification == 0: locale_keys = util.get_locale_keys(args.locale) -common.check_flatpak_installed(locale_keys) - caches = [] flatpak_api_cache = Cache(expiration_time=args.cache_exp) caches.append(flatpak_api_cache) diff --git a/fpakman/core/controller.py b/fpakman/core/controller.py index dc9a366b..d372a911 100755 --- a/fpakman/core/controller.py +++ b/fpakman/core/controller.py @@ -57,6 +57,10 @@ class ApplicationManager(ABC): def install_and_stream(self, app: Application): pass + @abstractmethod + def is_enabled(self) -> bool: + pass + class FlatpakManager(ApplicationManager): @@ -190,6 +194,9 @@ class FlatpakManager(ApplicationManager): def install_and_stream(self, app: FlatpakApplication): return flatpak.install_and_stream(app.base_data.id, app.origin) + def is_enabled(self): + return flatpak.is_installed() + class GenericApplicationManager(ApplicationManager): @@ -200,14 +207,16 @@ class GenericApplicationManager(ApplicationManager): def search(self, word: str) -> List[Application]: apps = [] for man in self.managers: - apps.extend(man.search(word)) + if man.is_enabled(): + apps.extend(man.search(word)) return apps def read_installed(self) -> List[Application]: installed = [] for man in self.managers: - installed.extend(man.read_installed()) + if man.is_enabled(): + installed.extend(man.read_installed()) return installed @@ -215,30 +224,55 @@ class GenericApplicationManager(ApplicationManager): return True def downgrade_app(self, app: Application, root_password: str): - manager = self.map.get(app.__class__) + man = self._get_manager_for(app) - if manager.can_downgrade(): - return manager.downgrade_app(app, root_password) + if man and man.can_downgrade(): + return man.downgrade_app(app, root_password) else: raise Exception("downgrade is not possible for {}".format(app.__class__.__name__)) def clean_cache_for(self, app: Application): - self.map[app.__class__].clean_cache_for(app) + man = self._get_manager_for(app) + + if man: + return man.clean_cache_for(app) def update_and_stream(self, app: Application): - return self.map[app.__class__].update_and_stream(app) + man = self._get_manager_for(app) + + if man: + return man.update_and_stream(app) def uninstall_and_stream(self, app: Application): - return self.map[app.__class__].uninstall_and_stream(app) + man = self._get_manager_for(app) + + if man: + return man.uninstall_and_stream(app) def install_and_stream(self, app: Application): - return self.map[app.__class__].install_and_stream(app) + man = self._get_manager_for(app) + + if man: + return man.install_and_stream(app) def get_info(self, app: Application): - return self.map[app.__class__].get_info(app) + man = self._get_manager_for(app) + + if man: + return man.get_info(app) def get_history(self, app: Application): - return self.map[app.__class__].get_history(app) + man = self._get_manager_for(app) + + if man: + return man.get_history(app) def get_app_type(self): return None + + def is_enabled(self): + return True + + def _get_manager_for(self, app: Application): + man = self.map[app.__class__] + return man if man and man.is_enabled() else None diff --git a/fpakman/core/flatpak.py b/fpakman/core/flatpak.py index 17fbe179..156b50f8 100755 --- a/fpakman/core/flatpak.py +++ b/fpakman/core/flatpak.py @@ -73,7 +73,7 @@ def is_installed(): def get_version(): - res = system.run_cmd('{} --version'.format(BASE_CMD)) + res = system.run_cmd('{} --version'.format(BASE_CMD), print_error=False) return res.split(' ')[1].strip() if res else None diff --git a/fpakman/core/system.py b/fpakman/core/system.py index 462f9a66..96ad7fc3 100644 --- a/fpakman/core/system.py +++ b/fpakman/core/system.py @@ -5,8 +5,17 @@ from typing import List from fpakman.core import resource -def run_cmd(cmd: str, expected_code: int = 0, ignore_return_code: bool = False) -> str: - res = subprocess.run(cmd, shell=True, stdout=subprocess.PIPE, env={'LANG': 'en'}) +def run_cmd(cmd: str, expected_code: int = 0, ignore_return_code: bool = False, print_error: bool = True) -> str: + args = { + "shell": True, + "stdout": subprocess.PIPE, + "env": {'LANG': 'en'} + } + + if not print_error: + args["stderr"] = subprocess.DEVNULL + + res = subprocess.run(cmd, **args) return res.stdout.decode() if ignore_return_code or res.returncode == expected_code else None diff --git a/fpakman/resources/locale/en b/fpakman/resources/locale/en index ddc8e8b4..702fa03d 100644 --- a/fpakman/resources/locale/en +++ b/fpakman/resources/locale/en @@ -25,8 +25,6 @@ manage_window.status.installing=Installing manage_window.bt.refresh.tooltip=Reload the data about installed applications manage_window.bt.upgrade.tooltip=Upgrade all selected applications manage_window.checkbox.show_details=Show details -popup.flatpak_not_installed.title=Error -popup.flatpak_not_installed.msg=flatpak seems not to be installed. Exiting popup.root.title=Requires root privileges popup.root.password=Password popup.root.bad_password.title=Error diff --git a/fpakman/resources/locale/es b/fpakman/resources/locale/es index 08040de1..e022cc90 100644 --- a/fpakman/resources/locale/es +++ b/fpakman/resources/locale/es @@ -26,8 +26,6 @@ manage_window.status.installing=Instalando manage_window.bt.refresh.tooltip=Recarga los datos sobre los aplicativos instalados manage_window.bt.upgrade.tooltip=Actualiza todos los aplicativos seleccionados manage_window.checkbox.show_details=Mostrar detalles -popup.flatpak_not_installed.title=Error -popup.flatpak_not_installed.msg=flatpak no parece estar instalado. Saliendo popup.root.title=Requiere privilegios de root popup.root.password=Contraseña popup.root.bad_password.title=Error diff --git a/fpakman/resources/locale/pt b/fpakman/resources/locale/pt index fe8f0ab7..a0e68b75 100644 --- a/fpakman/resources/locale/pt +++ b/fpakman/resources/locale/pt @@ -26,8 +26,6 @@ manage_window.status.installing=Instalando manage_window.bt.refresh.tooltip=Recarrega os dados sobre os aplicativos instalados manage_window.bt.upgrade.tooltip=Atualiza todos os aplicativos selecionados manage_window.checkbox.show_details=Mostrar detalhes -popup.flatpak_not_installed.title=Erro -popup.flatpak_not_installed.msg=flatpak não parece estar instalado. Saindo popup.root.title=Requer privilégios de root popup.root.password=Senha popup.root.bad_password.title=Erro diff --git a/fpakman/view/qt/common.py b/fpakman/view/qt/common.py deleted file mode 100644 index 50240cde..00000000 --- a/fpakman/view/qt/common.py +++ /dev/null @@ -1,10 +0,0 @@ -from fpakman.core import flatpak -from fpakman.view.qt import dialog - - -def check_flatpak_installed(locale_keys: dict): - - if not flatpak.is_installed(): - dialog.show_error(title=locale_keys['popup.flatpak_not_installed.title'], - body=locale_keys['popup.flatpak_not_installed.msg'] + '...') - exit(1) diff --git a/fpakman/view/qt/window.py b/fpakman/view/qt/window.py index c84b673b..f0f10c66 100755 --- a/fpakman/view/qt/window.py +++ b/fpakman/view/qt/window.py @@ -12,7 +12,6 @@ from fpakman.core import resource from fpakman.core.controller import ApplicationManager from fpakman.core.model import Application from fpakman.util.cache import Cache -from fpakman.view.qt import common from fpakman.view.qt.apps_table import AppsTable from fpakman.view.qt.history import HistoryDialog from fpakman.view.qt.info import InfoDialog @@ -39,7 +38,6 @@ class ManageWindow(QWidget): self.icon_cache = icon_cache self.icon_flathub = QIcon(resource.get_path('img/logo.svg')) - self._check_flatpak_installed() self.resize(ManageWindow.__BASE_HEIGHT__, ManageWindow.__BASE_HEIGHT__) self.setWindowTitle(locale_keys['manage_window.title']) self.setWindowIcon(self.icon_flathub) @@ -200,9 +198,6 @@ class ManageWindow(QWidget): self.hide() self._handle_console_option(False) - def _check_flatpak_installed(self): - common.check_flatpak_installed(self.locale_keys) - def _acquire_lock(self): self.thread_lock.acquire() @@ -241,20 +236,16 @@ class ManageWindow(QWidget): def refresh(self): if self._acquire_lock(): - self._check_flatpak_installed() self._begin_action(self.locale_keys['manage_window.status.refreshing']) self.thread_refresh.start() def _finish_refresh(self, apps: List[Application]): - self.update_apps(apps) self.finish_action() self._release_lock() def uninstall_app(self, app: ApplicationView): - self._check_flatpak_installed() - if self._acquire_lock(): self._handle_console_option(True) self._begin_action(self.locale_keys['manage_window.status.uninstalling']) @@ -326,8 +317,6 @@ class ManageWindow(QWidget): self.move(geo.topLeft()) def update_apps(self, apps: List[Application]): - self._check_flatpak_installed() - self.apps = [] napps = 0 # number of apps (not libraries) @@ -408,9 +397,6 @@ class ManageWindow(QWidget): self.label_status.setText('') def downgrade_app(self, app: dict): - - self._check_flatpak_installed() - if self._acquire_lock(): pwd = None @@ -471,15 +457,11 @@ class ManageWindow(QWidget): self.thread_search.start() def _finish_search(self, apps_found: List[Application]): - self._release_lock() self.finish_action() self.update_apps(apps_found) def install_app(self, app: dict): - - self._check_flatpak_installed() - if self._acquire_lock(): self._handle_console_option(True) self._begin_action(self.locale_keys['manage_window.status.installing']) From 0ef1fa678d5b9f5aebcd0eef0863ea9fef5f0be2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vin=C3=ADcius=20Moreira?= Date: Thu, 11 Jul 2019 15:44:07 -0300 Subject: [PATCH 102/123] Caching installed application data to disk --- CHANGELOG.md | 13 +++-- README.md | 1 + fpakman/__init__.py | 1 + fpakman/app.py | 11 +++- fpakman/core/controller.py | 84 ++++++++++++++++++-------- fpakman/core/disk.py | 79 +++++++++++++++++++++++++ fpakman/core/flatpak.py | 4 +- fpakman/core/model.py | 55 ++++++++++++++++- fpakman/core/structure.py | 12 ++++ fpakman/core/system.py | 6 +- fpakman/core/worker.py | 107 +++++++++++++++++++++------------- fpakman/util/cache.py | 16 ++++- fpakman/view/qt/apps_table.py | 92 ++++++++++++++++------------- fpakman/view/qt/systray.py | 6 +- fpakman/view/qt/thread.py | 44 ++++++++++++-- fpakman/view/qt/window.py | 8 ++- 16 files changed, 410 insertions(+), 129 deletions(-) create mode 100644 fpakman/core/disk.py create mode 100644 fpakman/core/structure.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b8f8bc59..8fa4af78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,12 +6,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [0.3.1] ### Improvements -- Console output now is optional and now shown by default. +- Console output now is optional and not shown by default. +- Cleaning search bar when 'Refresh' is clicked. - Full Flatpak database is not loaded during initialization (management panel is quickly available for the user) -- Applications data that must be retrieved from Flathub API are now retrieved on demand and cached -- Cache cleaners (for icons and API data) to improve memory usage. -- New environment variable and argument: FPAKMAN_ICON_EXPIRATION ('--icon-exp') -- Code was refactored to support other types of packaging +- Applications data not available offline are now retrieved from Flathub API on demand and cached in memory and disk (only installed) +- In-memory cached data have an expiration time and are cleaned overtime to reduce memory usage. +- Code was refactored to support other types of packaging in the future (e.g: snap) + +### Fixes: +- flatpak 1.0.X: search is now retrieving the application name ## [0.3.0] - 2019-07-02 ### Features diff --git a/README.md b/README.md index 01efec3c..565a7342 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ You can change some application settings via environment variables or arguments - **FPAKMAN_LOCALE**: define a custom app translation for a given locale key (e.g: 'pt', 'en', 'es', ...). Default: system locale. - **FPAKMAN_CACHE_EXPIRATION**: define a custom expiration time in SECONDS for cached API data. Default: 3600 (1 hour). - **FPAKMAN_ICON_EXPIRATION**: define a custom expiration time in SECONDS for cached icons. Default: 300 (5 minutes). +- **FPAKMAN_DISK_CACHE**: enables / disables disk cache. When disk cache is enabled, the installed applications data are loaded faster. Use **0** (disable) or **1** (enable, default). ### Roadmap diff --git a/fpakman/__init__.py b/fpakman/__init__.py index e1424ed0..0dcd6ff2 100644 --- a/fpakman/__init__.py +++ b/fpakman/__init__.py @@ -1 +1,2 @@ __version__ = '0.3.1' +__app_name__ = 'fpakman' diff --git a/fpakman/app.py b/fpakman/app.py index 6375c6be..65389f89 100755 --- a/fpakman/app.py +++ b/fpakman/app.py @@ -8,6 +8,8 @@ from colorama import Fore from fpakman import __version__ from fpakman.core import resource +from fpakman.core.disk import DiskCacheLoaderFactory +from fpakman.core.structure import prepare_folder_structure from fpakman.util import util from fpakman.core.controller import FlatpakManager, GenericApplicationManager from fpakman.util.cache import Cache @@ -31,7 +33,8 @@ parser.add_argument('-e', '--cache-exp', action="store", default=int(os.getenv(' parser.add_argument('-ie', '--icon-exp', action="store", default=int(os.getenv('FPAKMAN_ICON_EXPIRATION', 60 * 5)), type=int, help='cached icons expiration time in SECONDS. Default: %(default)s') parser.add_argument('-l', '--locale', action="store", default=os.getenv('FPAKMAN_LOCALE', 'en'), help='Translation key. Default: %(default)s') parser.add_argument('-i', '--check-interval', action="store", default=int(os.getenv('FPAKMAN_CHECK_INTERVAL', 60)), type=int, help='Updates check interval in SECONDS. Default: %(default)s') -parser.add_argument('-n', '--update-notification', action="store", default=os.getenv('FPAKMAN_UPDATE_NOTIFICATION', 1), type=int, help='Enable/disable system notifications for new updates. Default: %(default)s') +parser.add_argument('-n', '--update-notification', action="store", default=os.getenv('FPAKMAN_UPDATE_NOTIFICATION', 1), type=int, help='Enables / disables system notifications for new updates. Default: %(default)s') +parser.add_argument('-dc', '--disk-cache', action="store", default=os.getenv('FPAKMAN_DISK_CACHE', 1), type=int, help='Enables / disables disk cache. When disk cache is enabled, the installed applications data are loaded faster. Default: %(default)s') args = parser.parse_args() if args.cache_exp < 0: @@ -53,6 +56,8 @@ if args.update_notification == 0: locale_keys = util.get_locale_keys(args.locale) +prepare_folder_structure(disk_cache=args.disk_cache) + caches = [] flatpak_api_cache = Cache(expiration_time=args.cache_exp) caches.append(flatpak_api_cache) @@ -63,13 +68,15 @@ caches.append(icon_cache) app = QApplication(sys.argv) app.setWindowIcon(QIcon(resource.get_path('img/logo.svg'))) -manager = GenericApplicationManager([FlatpakManager(flatpak_api_cache)]) +disk_loader_factory = DiskCacheLoaderFactory(disk_cache=args.disk_cache, flatpak_api_cache=flatpak_api_cache) +manager = GenericApplicationManager([FlatpakManager(flatpak_api_cache, disk_cache=args.disk_cache)], disk_loader_factory=disk_loader_factory) trayIcon = TrayIcon(locale_keys=locale_keys, manager=manager, check_interval=args.check_interval, icon_cache=icon_cache, + disk_cache=args.disk_cache, update_notification=bool(args.update_notification)) trayIcon.show() diff --git a/fpakman/core/controller.py b/fpakman/core/controller.py index d372a911..13f42cb7 100755 --- a/fpakman/core/controller.py +++ b/fpakman/core/controller.py @@ -1,3 +1,5 @@ +import os +import shutil from abc import ABC, abstractmethod from datetime import datetime from threading import Lock @@ -5,20 +7,20 @@ from typing import List import requests -from fpakman.core import flatpak +from fpakman.core import flatpak, disk +from fpakman.core.disk import DiskCacheLoader, DiskCacheLoaderFactory from fpakman.core.model import FlatpakApplication, ApplicationData, ApplicationStatus, Application -from fpakman.core.worker import FlatpakAsyncDataLoaderManager from fpakman.util.cache import Cache class ApplicationManager(ABC): @abstractmethod - def search(self, word: str) -> List[Application]: + def search(self, word: str, disk_loader: DiskCacheLoader) -> List[Application]: pass @abstractmethod - def read_installed(self) -> List[Application]: + def read_installed(self, disk_loader: DiskCacheLoader, keep_workers: bool) -> List[Application]: pass @abstractmethod @@ -61,20 +63,27 @@ class ApplicationManager(ABC): def is_enabled(self) -> bool: pass + @abstractmethod + def cache_to_disk(self, app: Application, icon_bytes: bytes, only_icon: bool): + pass + +from fpakman.core.worker import FlatpakAsyncDataLoaderManager + class FlatpakManager(ApplicationManager): - def __init__(self, api_cache: Cache): + def __init__(self, api_cache: Cache, disk_cache: bool): self.api_cache = api_cache self.http_session = requests.Session() self.lock_read = Lock() - self.async_data_loader = FlatpakAsyncDataLoaderManager(api_cache=self.api_cache) + self.disk_cache = disk_cache + self.async_data_loader = FlatpakAsyncDataLoaderManager(api_cache=self.api_cache, manager=self) flatpak.set_default_remotes() def get_app_type(self): return FlatpakApplication - def _map_to_model(self, app: dict) -> FlatpakApplication: + def _map_to_model(self, app: dict, installed: bool, disk_loader: DiskCacheLoader) -> FlatpakApplication: model = FlatpakApplication(arch=app.get('arch'), branch=app.get('branch'), @@ -86,6 +95,7 @@ class FlatpakManager(ApplicationManager): name=app.get('name'), version=app.get('version'), latest_version=app.get('latest_version'))) + model.installed = installed api_data = self.api_cache.get(app['id']) @@ -93,25 +103,23 @@ class FlatpakManager(ApplicationManager): if not api_data or expired_data: if not app['runtime']: + disk_loader.add(model) # preloading cached disk data model.status = ApplicationStatus.LOADING_DATA self.async_data_loader.load(model) - else: # filling cached data - for attr, val in api_data.items(): - if attr != 'expires_at' and val: - setattr(model.base_data, attr, val) + else: + model.fill_cached_data(api_data) return model - def search(self, word: str) -> List[FlatpakApplication]: + def search(self, word: str, disk_loader: DiskCacheLoader) -> List[FlatpakApplication]: res = [] apps_found = flatpak.search(word) if apps_found: - already_read = set() - installed_apps = self.read_installed(keep_workers=True) + installed_apps = self.read_installed(disk_loader=disk_loader, keep_workers=True) if installed_apps: for app_found in apps_found: @@ -122,13 +130,15 @@ class FlatpakManager(ApplicationManager): for app_found in apps_found: if app_found['id'] not in already_read: - res.append(self._map_to_model(app_found)) + res.append(self._map_to_model(app_found, False, disk_loader)) + disk_loader.stop = True + disk_loader.join() self.async_data_loader.stop_current_workers() return res - def read_installed(self, keep_workers: bool = False) -> List[FlatpakApplication]: + def read_installed(self, disk_loader: DiskCacheLoader, keep_workers: bool = False) -> List[FlatpakApplication]: self.lock_read.acquire() @@ -143,8 +153,7 @@ class FlatpakManager(ApplicationManager): models = [] for app in installed: - model = self._map_to_model(app) - model.installed = True + model = self._map_to_model(app, True, disk_loader) model.update = app['id'] in available_updates models.append(model) @@ -175,6 +184,9 @@ class FlatpakManager(ApplicationManager): def clean_cache_for(self, app: FlatpakApplication): self.api_cache.delete(app.base_data.id) + if app.supports_disk_cache() and os.path.exists(app.get_disk_cache_path()): + shutil.rmtree(app.get_disk_cache_path()) + def update_and_stream(self, app: FlatpakApplication): return flatpak.update_and_stream(app.ref) @@ -197,26 +209,43 @@ class FlatpakManager(ApplicationManager): def is_enabled(self): return flatpak.is_installed() + def cache_to_disk(self, app: FlatpakApplication, icon_bytes: bytes, only_icon: bool): + if self.disk_cache and app.supports_disk_cache(): + disk.save(app, icon_bytes, only_icon) + class GenericApplicationManager(ApplicationManager): - def __init__(self, managers: List[ApplicationManager]): + def __init__(self, managers: List[ApplicationManager], disk_loader_factory: DiskCacheLoaderFactory): self.managers = managers self.map = {m.get_app_type(): m for m in self.managers} + self.disk_loader_factory = disk_loader_factory - def search(self, word: str) -> List[Application]: + def search(self, word: str, disk_loader: DiskCacheLoader = None) -> List[Application]: apps = [] + disk_loader = self.disk_loader_factory.new() + disk_loader.start() + for man in self.managers: if man.is_enabled(): - apps.extend(man.search(word)) + apps.extend(man.search(word, disk_loader)) + disk_loader.stop = True + disk_loader.join() return apps - def read_installed(self) -> List[Application]: + def read_installed(self, disk_loader: DiskCacheLoader = None, keep_workers: bool = False) -> List[Application]: installed = [] + + disk_loader = self.disk_loader_factory.new() + disk_loader.start() + for man in self.managers: if man.is_enabled(): - installed.extend(man.read_installed()) + installed.extend(man.read_installed(disk_loader=disk_loader, keep_workers=keep_workers)) + + disk_loader.stop = True + disk_loader.join() return installed @@ -273,6 +302,13 @@ class GenericApplicationManager(ApplicationManager): def is_enabled(self): return True - def _get_manager_for(self, app: Application): + def _get_manager_for(self, app: Application) -> ApplicationManager: man = self.map[app.__class__] return man if man and man.is_enabled() else None + + def cache_to_disk(self, app: Application, icon_bytes: bytes, only_icon: bool): + if self.disk_loader_factory.disk_cache and app.supports_disk_cache(): + man = self._get_manager_for(app) + + if man: + return man.cache_to_disk(app, icon_bytes=icon_bytes, only_icon=only_icon) diff --git a/fpakman/core/disk.py b/fpakman/core/disk.py new file mode 100644 index 00000000..3097a686 --- /dev/null +++ b/fpakman/core/disk.py @@ -0,0 +1,79 @@ +import json +import os +from pathlib import Path +from threading import Thread, Lock +from typing import List + +from fpakman.core.model import Application, FlatpakApplication +from fpakman.util.cache import Cache + + +class DiskCacheLoader(Thread): + + def __init__(self, enabled: bool, flatpak_api_cache: Cache, apps: List[Application] = []): + super(DiskCacheLoader, self).__init__(daemon=True) + self.apps = apps + self.stop = False + self.lock = Lock() + self.flatpak_api_cache = flatpak_api_cache + self.enabled = enabled + + def run(self): + + if self.enabled: + while True: + if self.apps: + self.lock.acquire() + app = self.apps[0] + del self.apps[0] + self.lock.release() + self.fill_cached_data(app) + elif self.stop: + break + + def add(self, app: Application): + if self.enabled: + if app and app.supports_disk_cache(): + self.lock.acquire() + self.apps.append(app) + self.lock.release() + + def fill_cached_data(self, app: Application): + if self.enabled: + if os.path.exists(app.get_disk_data_path()): + with open(app.get_disk_data_path()) as f: + cached_data = json.loads(f.read()) + app.fill_cached_data(cached_data) + + if isinstance(app, FlatpakApplication): + self.flatpak_api_cache.add_non_existing(app.base_data.id, cached_data) + + +class DiskCacheLoaderFactory: + + def __init__(self, disk_cache: bool, flatpak_api_cache: Cache): + self.disk_cache = disk_cache + self.flatpak_api_cache = flatpak_api_cache + + def new(self): + return DiskCacheLoader(enabled=self.disk_cache, flatpak_api_cache=self.flatpak_api_cache) + + +def save(app: Application, icon_bytes: bytes = None, only_icon: bool = False): + + if app.supports_disk_cache(): + + if not only_icon: + Path(app.get_disk_cache_path()).mkdir(parents=True, exist_ok=True) + + if isinstance(app, FlatpakApplication): + data = app.get_data_to_cache() + + with open(app.get_disk_data_path(), 'w+') as f: + f.write(json.dumps(data)) + + if icon_bytes: + Path(app.get_disk_cache_path()).mkdir(parents=True, exist_ok=True) + + with open(app.get_disk_icon_path(), 'wb+') as f: + f.write(icon_bytes) diff --git a/fpakman/core/flatpak.py b/fpakman/core/flatpak.py index 156b50f8..768c4a55 100755 --- a/fpakman/core/flatpak.py +++ b/fpakman/core/flatpak.py @@ -107,7 +107,7 @@ def uninstall_and_stream(app_ref: str): :param app_ref: :return: """ - return system.stream_cmd([BASE_CMD, 'uninstall', app_ref, '-y']) + return system.cmd_to_subprocess([BASE_CMD, 'uninstall', app_ref, '-y']) def list_updates_as_str(): @@ -211,7 +211,7 @@ def search(word: str) -> List[dict]: def install_and_stream(app_id: str, origin: str): - return system.stream_cmd([BASE_CMD, 'install', origin, app_id, '-y']) + return system.cmd_to_subprocess([BASE_CMD, 'install', origin, app_id, '-y']) def set_default_remotes(): diff --git a/fpakman/core/model.py b/fpakman/core/model.py index 390869e5..c0a31113 100644 --- a/fpakman/core/model.py +++ b/fpakman/core/model.py @@ -2,6 +2,7 @@ from abc import ABC, abstractmethod from enum import Enum from fpakman.core import resource +from fpakman.core.structure import flatpak_cache_path class ApplicationStatus(Enum): @@ -52,13 +53,37 @@ class Application(ABC): def get_type(self): pass - def get_default_logo_path(self): + def get_default_icon_path(self): return resource.get_path('img/logo.svg') @abstractmethod def is_library(self): pass + @abstractmethod + def supports_disk_cache(self): + pass + + @abstractmethod + def get_disk_cache_path(self): + pass + + @abstractmethod + def get_disk_icon_path(self): + pass + + @abstractmethod + def get_disk_data_path(self): + pass + + @abstractmethod + def get_data_to_cache(self): + pass + + @abstractmethod + def fill_cached_data(self, data: dict): + pass + class FlatpakApplication(Application): @@ -92,8 +117,34 @@ class FlatpakApplication(Application): def get_type(self): return 'flatpak' - def get_default_logo_path(self): + def get_default_icon_path(self): return resource.get_path('img/flathub.svg') def is_library(self): return self.runtime + + def supports_disk_cache(self): + return self.installed and not self.is_library() + + def get_disk_cache_path(self): + return '{}/{}'.format(flatpak_cache_path, self.base_data.id) + + def get_disk_icon_path(self): + return '{}/icon.png'.format(self.get_disk_cache_path()) + + def get_disk_data_path(self): + return '{}/data.json'.format(self.get_disk_cache_path()) + + def get_data_to_cache(self): + return { + 'description': self.base_data.description, + 'icon_url': self.base_data.icon_url, + 'latest_version': self.base_data.latest_version, + 'version': self.base_data.version, + 'name': self.base_data.name + } + + def fill_cached_data(self, data: dict): + for attr in self.get_data_to_cache().keys(): + if not getattr(self.base_data, attr): + setattr(self.base_data, attr, data[attr]) diff --git a/fpakman/core/structure.py b/fpakman/core/structure.py new file mode 100644 index 00000000..fd90cf53 --- /dev/null +++ b/fpakman/core/structure.py @@ -0,0 +1,12 @@ +from pathlib import Path + +from fpakman import __app_name__ + +home_path = Path.home() +cache_path = '{}/.cache/{}'.format(home_path, __app_name__) +flatpak_cache_path = '{}/flatpak/installed'.format(cache_path) + + +def prepare_folder_structure(disk_cache: bool): + if disk_cache: + Path(flatpak_cache_path).mkdir(parents=True, exist_ok=True) diff --git a/fpakman/core/system.py b/fpakman/core/system.py index 96ad7fc3..38edbaba 100644 --- a/fpakman/core/system.py +++ b/fpakman/core/system.py @@ -20,7 +20,11 @@ def run_cmd(cmd: str, expected_code: int = 0, ignore_return_code: bool = False, def stream_cmd(cmd: List[str]): - return subprocess.Popen(cmd, stdout=subprocess.PIPE, env={'LANG': 'en'}).stdout + return cmd_to_subprocess(cmd).stdout + + +def cmd_to_subprocess(cmd: List[str]): + return subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env={'LANG': 'en'}) def notify_user(msg: str, icon_path: str = resource.get_path('img/logo.svg')): diff --git a/fpakman/core/worker.py b/fpakman/core/worker.py index e5894609..9d33ebfc 100644 --- a/fpakman/core/worker.py +++ b/fpakman/core/worker.py @@ -7,20 +7,23 @@ import requests from colorama import Fore from fpakman.core.constants import FLATHUB_API_URL, FLATHUB_URL +from fpakman.core.controller import ApplicationManager from fpakman.core.model import FlatpakApplication, ApplicationStatus from fpakman.util.cache import Cache class FlatpakAsyncDataLoader(Thread): - def __init__(self, http_session, api_cache: Cache, attempts: int = 3): + def __init__(self, manager: ApplicationManager, http_session, api_cache: Cache, attempts: int = 3, apps: List[FlatpakApplication] = []): super(FlatpakAsyncDataLoader, self).__init__(daemon=True) - self.apps = [] + self.apps = apps self.http_session = http_session self.attempts = attempts self.api_cache = api_cache self.id_ = '{}#{}'.format(self.__class__.__name__, id(self)) self.stop = False + self.to_persist = {} # stores all data loaded by the instance + self.manager = manager def log_msg(self, msg: str, color: int = None): final_msg = StringIO() @@ -41,61 +44,80 @@ class FlatpakAsyncDataLoader(Thread): def run(self): while True: - if not self.apps and self.stop: - break # stop working - else: + if self.apps: + app = self.apps[0] + app.status = ApplicationStatus.LOADING_DATA + + for _ in range(0, self.attempts): + try: + res = self.http_session.get('{}/apps/{}'.format(FLATHUB_API_URL, app.base_data.id), timeout=30) + + if res.status_code == 200 and res.text: + data = res.json() + + if not app.base_data.version: + app.base_data.version = data.get('version') + + if not app.base_data.name: + app.base_data.name = data.get('name') + + app.base_data.description = data.get('description', data.get('summary', None)) + app.base_data.icon_url = data.get('iconMobileUrl', None) + app.base_data.latest_version = data.get('currentReleaseVersion', app.base_data.version) + + if not app.base_data.version and app.base_data.latest_version: + app.base_data.version = app.base_data.latest_version + + if app.base_data.icon_url and app.base_data.icon_url.startswith('/'): + app.base_data.icon_url = FLATHUB_URL + app.base_data.icon_url + + loaded_data = app.get_data_to_cache() + + self.api_cache.add(app.base_data.id, loaded_data) + app.status = ApplicationStatus.READY + + if app.supports_disk_cache(): + self.to_persist[app.base_data.id] = app + + break + else: + self.log_msg( + "Could not retrieve app data for id '{}'. Server response: {}. Body: {}".format( + app.base_data.id, res.status_code, res.content.decode()), Fore.RED) + except: + self.log_msg("Could not retrieve app data for id '{}'".format(app.base_data.id), Fore.YELLOW) + traceback.print_exc() + if self.apps: - app = self.apps[0] - app.status = ApplicationStatus.LOADING_DATA - - for _ in range(0, self.attempts): - try: - res = self.http_session.get('{}/apps/{}'.format(FLATHUB_API_URL, app.base_data.id), timeout=30) - - if res.status_code == 200 and res.text: - data = res.json() - - if not app.base_data.version: - app.base_data.version = data.get('version') - - app.base_data.description = data.get('description', data.get('summary', None)) - app.base_data.icon_url = data.get('iconMobileUrl', None) - app.base_data.latest_version = data.get('currentReleaseVersion', app.base_data.version) - - if app.base_data.icon_url and app.base_data.icon_url.startswith('/'): - app.base_data.icon_url = FLATHUB_URL + app.base_data.icon_url - - self.api_cache.add(app.base_data.id, { - 'description': app.base_data.description, - 'icon_url': app.base_data.icon_url, - 'latest_version': app.base_data.latest_version, - 'version': app.base_data.version - }) - - app.status = ApplicationStatus.READY - break - else: - self.log_msg("Could not retrieve app data for id '{}'. Server response: {}. Body: {}".format(app.base_data.id, res.status_code, res.content.decode()), Fore.RED) - except: - self.log_msg("Could not retrieve app data for id '{}'".format(app.base_data.id), Fore.YELLOW) - traceback.print_exc() - del self.apps[0] + elif self.stop: + self.cache_to_disk() + break # stop working + def add(self, app: FlatpakApplication): self.apps.append(app) def current_load(self): return len(self.apps) + def cache_to_disk(self): + + if self.to_persist: + for app in self.to_persist.values(): + self.manager.cache_to_disk(app=app, icon_bytes=None, only_icon=False) + + self.to_persist = {} + class FlatpakAsyncDataLoaderManager: - def __init__(self, api_cache: Cache, worker_load: int = 1, workers: List[FlatpakAsyncDataLoader] = []): + def __init__(self, manager: ApplicationManager, api_cache: Cache, worker_load: int = 1, workers: List[FlatpakAsyncDataLoader] = []): self.worker_load = worker_load self.current_workers = workers self.http_session = requests.Session() self.api_cache = api_cache + self.manager = manager def load(self, app: FlatpakApplication): @@ -105,7 +127,8 @@ class FlatpakAsyncDataLoaderManager: worker = available_workers[0] else: # new worker worker = FlatpakAsyncDataLoader(http_session=self.http_session, - api_cache=self.api_cache) + api_cache=self.api_cache, + manager=self.manager) worker.start() self.current_workers.append(worker) diff --git a/fpakman/util/cache.py b/fpakman/util/cache.py index 4fc2a434..d316a125 100644 --- a/fpakman/util/cache.py +++ b/fpakman/util/cache.py @@ -16,7 +16,21 @@ class Cache: if self.is_enabled(): self.lock.acquire() - self._cache[key] = {'val': val, 'expires_at': datetime.utcnow() + timedelta(seconds=self.expiration_time) if self.expiration_time > 0 else None} + self._add(key, val) + self.lock.release() + + def _add(self, key: str, val: object): + self._cache[key] = {'val': val, 'expires_at': datetime.utcnow() + timedelta(seconds=self.expiration_time) if self.expiration_time > 0 else None} + + def add_non_existing(self, key: str, val: object): + + if self. is_enabled(): + self.lock.acquire() + cur_val = self.get(key) + + if cur_val is None: + self._add(key, val) + self.lock.release() def get(self, key: str): diff --git a/fpakman/view/qt/apps_table.py b/fpakman/view/qt/apps_table.py index 037b0539..703a2c2f 100644 --- a/fpakman/view/qt/apps_table.py +++ b/fpakman/view/qt/apps_table.py @@ -1,3 +1,4 @@ +import os from threading import Lock from typing import List @@ -8,7 +9,7 @@ from PyQt5.QtWidgets import QTableWidget, QTableView, QMenu, QAction, QTableWidg QHeaderView, QLabel from fpakman.core import resource -from fpakman.core.model import FlatpakApplication, ApplicationStatus +from fpakman.core.model import ApplicationStatus from fpakman.util.cache import Cache from fpakman.view.qt import dialog from fpakman.view.qt.view_model import ApplicationView, ApplicationViewStatus @@ -39,10 +40,11 @@ class UpdateToggleButton(QToolButton): class AppsTable(QTableWidget): - def __init__(self, parent: QWidget, icon_cache: Cache): + def __init__(self, parent: QWidget, icon_cache: Cache, disk_cache: bool): super(AppsTable, self).__init__() self.setParent(parent) self.window = parent + self.disk_cache = disk_cache self.column_names = [parent.locale_keys[key].capitalize() for key in ['name', 'version', 'latest_version', @@ -61,7 +63,7 @@ class AppsTable(QTableWidget): self.icon_logo = QIcon(resource.get_path('img/logo.svg')) self.network_man = QNetworkAccessManager() - self.network_man.finished.connect(self._load_icon) + self.network_man.finished.connect(self._load_icon_and_cache) self.icon_cache = icon_cache self.lock_async_data = Lock() @@ -114,23 +116,21 @@ class AppsTable(QTableWidget): if self.window.apps: - visible, ready = 0, 0 for idx, app_v in enumerate(self.window.apps): - if app_v.visible: - visible += 1 + if app_v.visible and app_v.status == ApplicationViewStatus.LOADING and app_v.model.status == ApplicationStatus.READY: + self.network_man.get(QNetworkRequest(QUrl(app_v.model.base_data.icon_url))) - if app_v.status == ApplicationViewStatus.LOADING and app_v.model.status == ApplicationStatus.READY: - self.network_man.get(QNetworkRequest(QUrl(app_v.model.base_data.icon_url))) - self.item(idx, 2).setText(app_v.model.base_data.latest_version) - self._set_col_description(self.item(idx, 3), app_v) - app_v.status = ApplicationViewStatus.READY + app_name = self.item(idx, 0).text() - if app_v.status == ApplicationViewStatus.READY: - ready += 1 + if not app_name or app_name == '...': + self.item(idx, 0).setText(app_v.model.base_data.name) - if ready == visible: - self.window.resize_and_center() + self.item(idx, 2).setText(app_v.model.base_data.latest_version) + self._set_col_description(self.item(idx, 3), app_v) + app_v.status = ApplicationViewStatus.READY + + self.window.resize_and_center() self.lock_async_data.release() @@ -165,19 +165,29 @@ class AppsTable(QTableWidget): def _install_app(self): self.window.install_app(self.get_selected_app()) - def _load_icon(self, http_response): + def _load_icon_and_cache(self, http_response): icon_url = http_response.url().toString() - if not self.icon_cache.get(icon_url): - pixmap = QPixmap() - pixmap.loadFromData(http_response.readAll()) - icon = QIcon(pixmap) - self.icon_cache.add(icon_url, icon) + icon_data = self.icon_cache.get(icon_url) + icon_was_cached = True - for idx, app in enumerate(self.window.apps): - if app.model.base_data.icon_url == icon_url: - self.item(idx, 0).setIcon(icon) - break + if not icon_data: + icon_was_cached = False + pixmap = QPixmap() + icon_bytes = http_response.readAll() + pixmap.loadFromData(icon_bytes) + icon = QIcon(pixmap) + icon_data = {'icon': icon, 'bytes': icon_bytes} + self.icon_cache.add(icon_url, icon_data) + + for idx, app in enumerate(self.window.apps): + if app.model.base_data.icon_url == icon_url: + col_name = self.item(idx, 0) + col_name.setIcon(icon_data['icon']) + + if self.disk_cache and app.model.supports_disk_cache(): + if not icon_was_cached or not os.path.exists(app.model.get_disk_icon_path()): + self.window.manager.cache_to_disk(app=app.model, icon_bytes=icon_data['bytes'], only_icon=True) def update_apps(self, app_views: List[ApplicationView]): self.setEnabled(True) @@ -185,21 +195,7 @@ class AppsTable(QTableWidget): if app_views: for idx, app_v in enumerate(app_views): - - col_name = QTableWidgetItem() - col_name.setText(app_v.model.base_data.name) - col_name.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) - - if not app_v.model.base_data.icon_url: - col_name.setIcon(QIcon(app_v.model.get_default_logo_path())) - else: - cached_icon = self.icon_cache.get(app_v.model.base_data.icon_url) - - if cached_icon: - col_name.setIcon(cached_icon) - else: - col_name.setIcon(QIcon(app_v.model.get_default_logo_path())) - + col_name = self._gen_col_name(app_v) self.setItem(idx, 0, col_name) col_version = QTableWidgetItem() @@ -235,6 +231,22 @@ class AppsTable(QTableWidget): col_update = UpdateToggleButton(app_v, self.window, self.window.locale_keys, app_v.model.update) if app_v.model.update else None self.setCellWidget(idx, 6, col_update) + def _gen_col_name(self, app_v: ApplicationView): + col = QTableWidgetItem() + col.setText(app_v.model.base_data.name if app_v.model.base_data.name else '...') + col.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) + + if app_v.model.supports_disk_cache() and os.path.exists(app_v.model.get_disk_icon_path()): + icon = QIcon(app_v.model.get_disk_icon_path()) + elif not app_v.model.base_data.icon_url: + icon = QIcon(app_v.model.get_default_icon_path()) + else: + icon_data = self.icon_cache.get(app_v.model.base_data.icon_url) + icon = icon_data['icon'] if icon_data else QIcon(app_v.model.get_default_icon_path()) + + col.setIcon(icon) + return col + def _set_col_description(self, col: QTableWidgetItem, app_v: ApplicationView): desc = app_v.get_async_attr('description', strip_html=True) diff --git a/fpakman/view/qt/systray.py b/fpakman/view/qt/systray.py index 25e61cec..0923fcfe 100755 --- a/fpakman/view/qt/systray.py +++ b/fpakman/view/qt/systray.py @@ -39,11 +39,12 @@ class UpdateCheck(QThread): class TrayIcon(QSystemTrayIcon): - def __init__(self, locale_keys: dict, manager: ApplicationManager, icon_cache: Cache, check_interval: int = 60, update_notification: bool = True): + def __init__(self, locale_keys: dict, manager: ApplicationManager, icon_cache: Cache, disk_cache: bool, check_interval: int = 60, update_notification: bool = True): super(TrayIcon, self).__init__() self.locale_keys = locale_keys self.manager = manager self.icon_cache = icon_cache + self.disk_cache = disk_cache self.icon_default = QIcon(resource.get_path('img/logo.png')) self.icon_update = QIcon(resource.get_path('img/logo_update.png')) @@ -108,7 +109,8 @@ class TrayIcon(QSystemTrayIcon): self.manage_window = ManageWindow(locale_keys=self.locale_keys, manager=self.manager, icon_cache=self.icon_cache, - tray_icon=self) + tray_icon=self, + disk_cache=self.disk_cache) if self.manage_window.isMinimized(): self.manage_window.setWindowState(Qt.WindowNoState) diff --git a/fpakman/view/qt/thread.py b/fpakman/view/qt/thread.py index 6a1a1f83..394d0772 100644 --- a/fpakman/view/qt/thread.py +++ b/fpakman/view/qt/thread.py @@ -56,13 +56,25 @@ class UninstallApp(QThread): def run(self): if self.app: - for output in self.manager.uninstall_and_stream(self.app.model): + subproc = self.manager.uninstall_and_stream(self.app.model) + + for output in subproc.stdout: line = output.decode().strip() if line: self.signal_output.emit(line) - self.icon_cache.delete(self.app.model.base_data.icon_url) - self.manager.clean_cache_for(self.app.model) + error = False + + for output in subproc.stderr: + line = output.decode().strip() + if line: + error = True + self.signal_output.emit(line) + + if not error: + self.icon_cache.delete(self.app.model.base_data.icon_url) + self.manager.clean_cache_for(self.app.model) + self.signal_finished.emit() @@ -147,19 +159,39 @@ class InstallApp(QThread): signal_finished = pyqtSignal() signal_output = pyqtSignal(str) - def __init__(self, manager: ApplicationManager, app: ApplicationView = None): + def __init__(self, manager: ApplicationManager, disk_cache: bool, icon_cache: Cache, app: ApplicationView = None): super(InstallApp, self).__init__() self.app = app self.manager = manager + self.icon_cache = icon_cache + self.disk_cache = disk_cache def run(self): if self.app: - for output in self.manager.install_and_stream(self.app.model): + subproc = self.manager.install_and_stream(self.app.model) + + for output in subproc.stdout: line = output.decode().strip() if line: self.signal_output.emit(line) + error = False + + for output in subproc.stderr: + line = output.decode().strip() + if line: + error = True + self.signal_output.emit(line) + + if not error and self.disk_cache: + self.app.model.installed = True + + if self.app.model.supports_disk_cache(): + self.manager.cache_to_disk(app=self.app.model, + icon_bytes=self.icon_cache.get(self.app.model.base_data.icon_url)['bytes'], + only_icon=False) + self.app = None self.signal_finished.emit() @@ -224,3 +256,5 @@ class VerifyModels(QThread): if stop_at <= datetime.utcnow(): break + + self.apps = None diff --git a/fpakman/view/qt/window.py b/fpakman/view/qt/window.py index f0f10c66..765d756e 100755 --- a/fpakman/view/qt/window.py +++ b/fpakman/view/qt/window.py @@ -26,7 +26,7 @@ DARK_ORANGE = '#FF4500' class ManageWindow(QWidget): __BASE_HEIGHT__ = 400 - def __init__(self, locale_keys: dict, icon_cache: Cache, manager: ApplicationManager, tray_icon=None): + def __init__(self, locale_keys: dict, icon_cache: Cache, manager: ApplicationManager, disk_cache: bool, tray_icon=None): super(ManageWindow, self).__init__() self.locale_keys = locale_keys self.manager = manager @@ -36,6 +36,7 @@ class ManageWindow(QWidget): self.apps = [] self.label_flatpak = None self.icon_cache = icon_cache + self.disk_cache = disk_cache self.icon_flathub = QIcon(resource.get_path('img/logo.svg')) self.resize(ManageWindow.__BASE_HEIGHT__, ManageWindow.__BASE_HEIGHT__) @@ -103,7 +104,7 @@ class ManageWindow(QWidget): self.layout.addWidget(toolbar) - self.table_apps = AppsTable(self, self.icon_cache) + self.table_apps = AppsTable(self, self.icon_cache, disk_cache=self.disk_cache) self.table_apps.change_headers_policy() self.layout.addWidget(self.table_apps) @@ -150,7 +151,7 @@ class ManageWindow(QWidget): self.thread_search = SearchApps(self.manager) self.thread_search.signal_finished.connect(self._finish_search) - self.thread_install = InstallApp(self.manager) + self.thread_install = InstallApp(manager=self.manager, disk_cache=self.disk_cache, icon_cache=self.icon_cache) self.thread_install.signal_output.connect(self._update_action_output) self.thread_install.signal_finished.connect(self._finish_install) @@ -236,6 +237,7 @@ class ManageWindow(QWidget): def refresh(self): if self._acquire_lock(): + self.input_search.clear() self._begin_action(self.locale_keys['manage_window.status.refreshing']) self.thread_refresh.start() From 5e74d79359da48eaae2c784f346ac8f9f2c3c50f Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Thu, 11 Jul 2019 15:55:22 -0300 Subject: [PATCH 103/123] refactoring app name and improving CHANGELOG.md --- CHANGELOG.md | 8 ++++---- fpakman/app.py | 10 ++++------ fpakman/view/qt/about.py | 4 ++-- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fa4af78..d85866ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,11 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [0.3.1] ### Improvements -- Console output now is optional and not shown by default. -- Cleaning search bar when 'Refresh' is clicked. -- Full Flatpak database is not loaded during initialization (management panel is quickly available for the user) +- Console output now is optional and not shown by default +- Search bar is cleaned when 'Refresh' is clicked +- Full Flatpak database is not loaded during initialization: speeds up the process and reduces memory usage - Applications data not available offline are now retrieved from Flathub API on demand and cached in memory and disk (only installed) -- In-memory cached data have an expiration time and are cleaned overtime to reduce memory usage. +- In-memory cached data have an expiration time and are cleaned overtime to reduce memory usage - Code was refactored to support other types of packaging in the future (e.g: snap) ### Fixes: diff --git a/fpakman/app.py b/fpakman/app.py index 65389f89..2ec1d15c 100755 --- a/fpakman/app.py +++ b/fpakman/app.py @@ -6,7 +6,7 @@ from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import QApplication from colorama import Fore -from fpakman import __version__ +from fpakman import __version__, __app_name__ from fpakman.core import resource from fpakman.core.disk import DiskCacheLoaderFactory from fpakman.core.structure import prepare_folder_structure @@ -16,18 +16,16 @@ from fpakman.util.cache import Cache from fpakman.util.memory import CacheCleaner from fpakman.view.qt.systray import TrayIcon -app_name = 'fpakman' - def log_msg(msg: str, color: int = None): if color is None: - print('[{}] {}'.format(app_name, msg)) + print('[{}] {}'.format(__app_name__, msg)) else: - print('{}[{}] {}{}'.format(color, app_name, msg, Fore.RESET)) + print('{}[{}] {}{}'.format(color, __app_name__, msg, Fore.RESET)) -parser = argparse.ArgumentParser(prog=app_name, description="GUI for Flatpak applications management") +parser = argparse.ArgumentParser(prog=__app_name__, description="GUI for Flatpak applications management") parser.add_argument('-v', '--version', action='version', version='%(prog)s {}'.format(__version__)) parser.add_argument('-e', '--cache-exp', action="store", default=int(os.getenv('FPAKMAN_CACHE_EXPIRATION', 60 * 60)), type=int, help='cached API data expiration time in SECONDS. Default: %(default)s') parser.add_argument('-ie', '--icon-exp', action="store", default=int(os.getenv('FPAKMAN_ICON_EXPIRATION', 60 * 5)), type=int, help='cached icons expiration time in SECONDS. Default: %(default)s') diff --git a/fpakman/view/qt/about.py b/fpakman/view/qt/about.py index b9e319db..87810957 100644 --- a/fpakman/view/qt/about.py +++ b/fpakman/view/qt/about.py @@ -2,7 +2,7 @@ from PyQt5.QtCore import Qt from PyQt5.QtGui import QPixmap from PyQt5.QtWidgets import QVBoxLayout, QDialog, QLabel -from fpakman import __version__ +from fpakman import __version__, __app_name__ from fpakman.core import resource PROJECT_URL = 'https://github.com/vinifmor/fpakman' @@ -22,7 +22,7 @@ class AboutDialog(QDialog): label_logo.setAlignment(Qt.AlignCenter) layout.addWidget(label_logo) - label_name = QLabel('fpakman ( {} {} )'.format(locale_keys['flatpak.info.version'].lower(), __version__)) + label_name = QLabel('{} ( {} {} )'.format(__app_name__, locale_keys['flatpak.info.version'].lower(), __version__)) label_name.setStyleSheet('font-weight: bold;') label_name.setAlignment(Qt.AlignCenter) layout.addWidget(label_name) From b48914b402b336d817d34ebeb29222e41098baa3 Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Thu, 11 Jul 2019 16:00:03 -0300 Subject: [PATCH 104/123] CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d85866ac..c372a511 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Applications data not available offline are now retrieved from Flathub API on demand and cached in memory and disk (only installed) - In-memory cached data have an expiration time and are cleaned overtime to reduce memory usage - Code was refactored to support other types of packaging in the future (e.g: snap) +- flatpak is not a requirement anymore ### Fixes: - flatpak 1.0.X: search is now retrieving the application name From ad59e22fce6cd2bff6ae96aee062fe5bf93c691d Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Thu, 11 Jul 2019 16:17:35 -0300 Subject: [PATCH 105/123] CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c372a511..c878b613 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - In-memory cached data have an expiration time and are cleaned overtime to reduce memory usage - Code was refactored to support other types of packaging in the future (e.g: snap) - flatpak is not a requirement anymore +- the amount of columns of the applications table was reduced to improve the user experience ### Fixes: - flatpak 1.0.X: search is now retrieving the application name From 4d3abfcf8922572f51da8119be121ceeff34ad65 Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Thu, 11 Jul 2019 16:26:25 -0300 Subject: [PATCH 106/123] Fix: manage panel title for 'en' --- fpakman/resources/locale/en | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fpakman/resources/locale/en b/fpakman/resources/locale/en index 702fa03d..9198e09f 100644 --- a/fpakman/resources/locale/en +++ b/fpakman/resources/locale/en @@ -1,4 +1,4 @@ -manage_window.title=Application Manager +manage_window.title=Applications Manager manage_window.columns.latest_version=Latest Version manage_window.columns.update=Upgrade ? manage_window.apps_table.row.actions.info=Information From 2f4fc0335d1edf19740105df3e943d609efb988a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vin=C3=ADcius=20Moreira?= Date: Thu, 11 Jul 2019 17:02:44 -0300 Subject: [PATCH 107/123] Caching icon loaded from disk --- fpakman/core/worker.py | 4 +--- fpakman/view/qt/apps_table.py | 8 +++++++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/fpakman/core/worker.py b/fpakman/core/worker.py index 9d33ebfc..f9648a37 100644 --- a/fpakman/core/worker.py +++ b/fpakman/core/worker.py @@ -81,9 +81,7 @@ class FlatpakAsyncDataLoader(Thread): break else: - self.log_msg( - "Could not retrieve app data for id '{}'. Server response: {}. Body: {}".format( - app.base_data.id, res.status_code, res.content.decode()), Fore.RED) + self.log_msg("Could not retrieve app data for id '{}'. Server response: {}. Body: {}".format(app.base_data.id, res.status_code, res.content.decode()), Fore.RED) except: self.log_msg("Could not retrieve app data for id '{}'".format(app.base_data.id), Fore.YELLOW) traceback.print_exc() diff --git a/fpakman/view/qt/apps_table.py b/fpakman/view/qt/apps_table.py index 703a2c2f..445a6a83 100644 --- a/fpakman/view/qt/apps_table.py +++ b/fpakman/view/qt/apps_table.py @@ -237,7 +237,13 @@ class AppsTable(QTableWidget): col.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) if app_v.model.supports_disk_cache() and os.path.exists(app_v.model.get_disk_icon_path()): - icon = QIcon(app_v.model.get_disk_icon_path()) + + with open (app_v.model.get_disk_icon_path(), 'rb') as f: + icon_bytes = f.read() + pixmap = QPixmap() + pixmap.loadFromData(icon_bytes) + icon = QIcon(pixmap) + self.icon_cache.add_non_existing(app_v.model.base_data.icon_url, {'icon': icon, 'bytes': icon_bytes}) elif not app_v.model.base_data.icon_url: icon = QIcon(app_v.model.get_default_icon_path()) else: From 8e930d57eac8e03ac46e115af75536e6f372ef7e Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Thu, 11 Jul 2019 17:29:53 -0300 Subject: [PATCH 108/123] fix: calling downgrade app twice --- fpakman/view/qt/thread.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fpakman/view/qt/thread.py b/fpakman/view/qt/thread.py index 394d0772..61eb7167 100644 --- a/fpakman/view/qt/thread.py +++ b/fpakman/view/qt/thread.py @@ -98,7 +98,7 @@ class DowngradeApp(QThread): dialog.show_error(title=self.locale_keys['popup.downgrade.impossible.title'], body=self.locale_keys['popup.downgrade.impossible.body']) else: - for output in self.manager.downgrade_app(self.app.model, self.root_password): + for output in stream: line = output.decode().strip() if line: self.signal_output.emit(line) From b6bfecf4ef6299407a5f7a2c3d7e41f52738443b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vin=C3=ADcius=20Moreira?= Date: Thu, 11 Jul 2019 18:04:12 -0300 Subject: [PATCH 109/123] Fix: app crashes when no internet connection --- fpakman/core/controller.py | 2 ++ fpakman/core/exception.py | 3 +++ fpakman/core/flatpak.py | 10 ++++++++- fpakman/resources/locale/en | 3 ++- fpakman/resources/locale/es | 3 ++- fpakman/resources/locale/pt | 3 ++- fpakman/view/qt/thread.py | 43 +++++++++++++++++++++++-------------- fpakman/view/qt/window.py | 11 +++++++--- 8 files changed, 55 insertions(+), 23 deletions(-) create mode 100644 fpakman/core/exception.py diff --git a/fpakman/core/controller.py b/fpakman/core/controller.py index 13f42cb7..1b1c4029 100755 --- a/fpakman/core/controller.py +++ b/fpakman/core/controller.py @@ -67,6 +67,7 @@ class ApplicationManager(ABC): def cache_to_disk(self, app: Application, icon_bytes: bytes, only_icon: bool): pass + from fpakman.core.worker import FlatpakAsyncDataLoaderManager @@ -173,6 +174,7 @@ class FlatpakManager(ApplicationManager): def downgrade_app(self, app: FlatpakApplication, root_password: str): commits = flatpak.get_app_commits(app.ref, app.origin) + commit_idx = commits.index(app.commit) # downgrade is not possible if the app current commit in the first one: diff --git a/fpakman/core/exception.py b/fpakman/core/exception.py new file mode 100644 index 00000000..8d00ac1a --- /dev/null +++ b/fpakman/core/exception.py @@ -0,0 +1,3 @@ + +class NoInternetException(Exception): + pass diff --git a/fpakman/core/flatpak.py b/fpakman/core/flatpak.py index 768c4a55..256c2ebc 100755 --- a/fpakman/core/flatpak.py +++ b/fpakman/core/flatpak.py @@ -3,6 +3,7 @@ import subprocess from typing import List from fpakman.core import system +from fpakman.core.exception import NoInternetException from fpakman.core.model import Application BASE_CMD = 'flatpak' @@ -128,12 +129,19 @@ def downgrade_and_stream(app_ref: str, commit: str, root_password: str): def get_app_commits(app_ref: str, origin: str) -> List[str]: log = system.run_cmd('{} remote-info --log {} {}'.format(BASE_CMD, origin, app_ref)) - return re.findall(r'Commit+:\s(.+)', log) + + if log: + return re.findall(r'Commit+:\s(.+)', log) + else: + raise NoInternetException() def get_app_commits_data(app_ref: str, origin: str) -> List[dict]: log = system.run_cmd('{} remote-info --log {} {}'.format(BASE_CMD, origin, app_ref)) + if not log: + raise NoInternetException() + res = re.findall(r'(Commit|Subject|Date):\s(.+)', log) commits = [] diff --git a/fpakman/resources/locale/en b/fpakman/resources/locale/en index 9198e09f..a486a107 100644 --- a/fpakman/resources/locale/en +++ b/fpakman/resources/locale/en @@ -74,4 +74,5 @@ latest_version=latest version description=description type=type installed=installed -others=others \ No newline at end of file +others=others +internet.required=Internet connection is required \ No newline at end of file diff --git a/fpakman/resources/locale/es b/fpakman/resources/locale/es index e022cc90..fab85ca0 100644 --- a/fpakman/resources/locale/es +++ b/fpakman/resources/locale/es @@ -75,4 +75,5 @@ latest_version=ultima versión description=descripción type=tipo installed=instalado -others=otros \ No newline at end of file +others=otros +internet.required=Se requiere conexión a internet \ No newline at end of file diff --git a/fpakman/resources/locale/pt b/fpakman/resources/locale/pt index a0e68b75..8fdbd388 100644 --- a/fpakman/resources/locale/pt +++ b/fpakman/resources/locale/pt @@ -75,4 +75,5 @@ latest_version=última versão description=descrição type=tipo installed=instalado -others=outros \ No newline at end of file +others=outros +internet.required=É necessário estar conectado a Internet \ No newline at end of file diff --git a/fpakman/view/qt/thread.py b/fpakman/view/qt/thread.py index 61eb7167..a54daa6b 100644 --- a/fpakman/view/qt/thread.py +++ b/fpakman/view/qt/thread.py @@ -2,9 +2,11 @@ import time from datetime import datetime, timedelta from typing import List +import requests from PyQt5.QtCore import QThread, pyqtSignal from fpakman.core.controller import ApplicationManager +from fpakman.core.exception import NoInternetException from fpakman.core.model import ApplicationStatus from fpakman.util.cache import Cache from fpakman.view.qt import dialog @@ -92,20 +94,23 @@ class DowngradeApp(QThread): def run(self): if self.app: - stream = self.manager.downgrade_app(self.app.model, self.root_password) + try: + stream = self.manager.downgrade_app(self.app.model, self.root_password) - if stream is None: - dialog.show_error(title=self.locale_keys['popup.downgrade.impossible.title'], - body=self.locale_keys['popup.downgrade.impossible.body']) - else: - for output in stream: - line = output.decode().strip() - if line: - self.signal_output.emit(line) - - self.app = None - self.root_password = None - self.signal_finished.emit() + if stream is None: + dialog.show_error(title=self.locale_keys['popup.downgrade.impossible.title'], + body=self.locale_keys['popup.downgrade.impossible.body']) + else: + for output in stream: + line = output.decode().strip() + if line: + self.signal_output.emit(line) + except (requests.exceptions.ConnectionError, NoInternetException): + self.signal_output.emit(self.locale_keys['internet.required']) + finally: + self.app = None + self.root_password = None + self.signal_finished.emit() class GetAppInfo(QThread): @@ -125,15 +130,21 @@ class GetAppInfo(QThread): class GetAppHistory(QThread): signal_finished = pyqtSignal(dict) - def __init__(self, manager: ApplicationManager, app: ApplicationView = None): + def __init__(self, manager: ApplicationManager, locale_keys: dict, app: ApplicationView = None): super(GetAppHistory, self).__init__() self.app = app self.manager = manager + self.locale_keys = locale_keys def run(self): if self.app: - self.signal_finished.emit({'model': self.app.model, 'history': self.manager.get_history(self.app.model)}) - self.app = None + try: + res = {'model': self.app.model, 'history': self.manager.get_history(self.app.model)} + self.signal_finished.emit(res) + except (requests.exceptions.ConnectionError, NoInternetException): + self.signal_finished.emit({'error': self.locale_keys['internet.required']}) + finally: + self.app = None class SearchApps(QThread): diff --git a/fpakman/view/qt/window.py b/fpakman/view/qt/window.py index 765d756e..9b416a01 100755 --- a/fpakman/view/qt/window.py +++ b/fpakman/view/qt/window.py @@ -145,7 +145,7 @@ class ManageWindow(QWidget): self.thread_get_info = GetAppInfo(self.manager) self.thread_get_info.signal_finished.connect(self._finish_get_info) - self.thread_get_history = GetAppHistory(self.manager) + self.thread_get_history = GetAppHistory(self.manager, self.locale_keys) self.thread_get_history.signal_finished.connect(self._finish_get_history) self.thread_search = SearchApps(self.manager) @@ -445,8 +445,13 @@ class ManageWindow(QWidget): self._release_lock() self.finish_action() self.change_update_state() - dialog_history = HistoryDialog(app, self.table_apps.get_selected_app_icon(), self.locale_keys) - dialog_history.exec_() + + if app.get('error'): + self._handle_console_option(True) + self.textarea_output.appendPlainText(app['error']) + else: + dialog_history = HistoryDialog(app, self.table_apps.get_selected_app_icon(), self.locale_keys) + dialog_history.exec_() def search(self): From 23819aea46bffafcdb8aebfa7d764df68ef8444c Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Thu, 11 Jul 2019 18:11:48 -0300 Subject: [PATCH 110/123] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c878b613..e3ef9290 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Fixes: - flatpak 1.0.X: search is now retrieving the application name +- app crashes when there is no internet connection while downgrading or retrieving app history ## [0.3.0] - 2019-07-02 ### Features From a9e3d13aa08fb795345ddd10d69c4446edacd6e2 Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Thu, 11 Jul 2019 18:24:57 -0300 Subject: [PATCH 111/123] fix: loading cached icon from disk when disk cache is disabled --- fpakman/view/qt/apps_table.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/fpakman/view/qt/apps_table.py b/fpakman/view/qt/apps_table.py index 445a6a83..7b55a4bc 100644 --- a/fpakman/view/qt/apps_table.py +++ b/fpakman/view/qt/apps_table.py @@ -236,9 +236,8 @@ class AppsTable(QTableWidget): col.setText(app_v.model.base_data.name if app_v.model.base_data.name else '...') col.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) - if app_v.model.supports_disk_cache() and os.path.exists(app_v.model.get_disk_icon_path()): - - with open (app_v.model.get_disk_icon_path(), 'rb') as f: + if self.disk_cache and app_v.model.supports_disk_cache() and os.path.exists(app_v.model.get_disk_icon_path()): + with open(app_v.model.get_disk_icon_path(), 'rb') as f: icon_bytes = f.read() pixmap = QPixmap() pixmap.loadFromData(icon_bytes) From a8bc8ffb3b68285a4503949280d100c9f957ce51 Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Thu, 11 Jul 2019 19:08:20 -0300 Subject: [PATCH 112/123] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3ef9290..ec55a76f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Code was refactored to support other types of packaging in the future (e.g: snap) - flatpak is not a requirement anymore - the amount of columns of the applications table was reduced to improve the user experience +- new environment variables and arguments: FPAKMAN_ICON_EXPIRATION (--icon-exp), FPAKMAN_DISK_CACHE (--disk-cache) ### Fixes: - flatpak 1.0.X: search is now retrieving the application name From 9df53f080affb66d88830101a37d064a972b5f7f Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Fri, 12 Jul 2019 10:25:34 -0300 Subject: [PATCH 113/123] fix: keeping upgrade button enabled when there are no upgrades selected --- fpakman/view/qt/apps_table.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fpakman/view/qt/apps_table.py b/fpakman/view/qt/apps_table.py index 7b55a4bc..70878070 100644 --- a/fpakman/view/qt/apps_table.py +++ b/fpakman/view/qt/apps_table.py @@ -33,7 +33,7 @@ class UpdateToggleButton(QToolButton): self.click() def change_state(self, not_checked: bool): - self.app_view.updated_checked = not not_checked + self.app_view.update_checked = not not_checked self.setIcon(self.icon_on if not not_checked else self.icon_off) self.root.change_update_state() From f969c5c8170ffa8cfa7daea70dec6f8fe470db18 Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Fri, 12 Jul 2019 10:37:02 -0300 Subject: [PATCH 114/123] Update CHANGELOG.md --- CHANGELOG.md | 2 +- fpakman/view/qt/window.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec55a76f..a183cb69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Fixes: - flatpak 1.0.X: search is now retrieving the application name -- app crashes when there is no internet connection while downgrading or retrieving app history +- app crashes when there is no internet connection while initializing, downgrading or retrieving app history ## [0.3.0] - 2019-07-02 ### Features diff --git a/fpakman/view/qt/window.py b/fpakman/view/qt/window.py index 9b416a01..1f1dbf7e 100755 --- a/fpakman/view/qt/window.py +++ b/fpakman/view/qt/window.py @@ -84,7 +84,7 @@ class ManageWindow(QWidget): self.label_status = QLabel() self.label_status.setText('') - self.label_status.setStyleSheet("color: {}; font-weight: bold".format(DARK_ORANGE)) + self.label_status.setStyleSheet("font-weight: bold") toolbar.addWidget(self.label_status) toolbar.addWidget(self._new_spacer()) From 77b65780ad111efb43b97e862f867109afe7b5ab Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Fri, 12 Jul 2019 11:15:36 -0300 Subject: [PATCH 115/123] center alignment for version, latest version and type columns --- CHANGELOG.md | 1 + fpakman/view/qt/apps_table.py | 25 +++++++++++-------------- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a183cb69..88b3a814 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - flatpak is not a requirement anymore - the amount of columns of the applications table was reduced to improve the user experience - new environment variables and arguments: FPAKMAN_ICON_EXPIRATION (--icon-exp), FPAKMAN_DISK_CACHE (--disk-cache) +- minor GUI improvements ### Fixes: - flatpak 1.0.X: search is now retrieving the application name diff --git a/fpakman/view/qt/apps_table.py b/fpakman/view/qt/apps_table.py index 70878070..6e5f4f7b 100644 --- a/fpakman/view/qt/apps_table.py +++ b/fpakman/view/qt/apps_table.py @@ -126,7 +126,7 @@ class AppsTable(QTableWidget): if not app_name or app_name == '...': self.item(idx, 0).setText(app_v.model.base_data.name) - self.item(idx, 2).setText(app_v.model.base_data.latest_version) + self.cellWidget(idx, 2).setText(app_v.model.base_data.latest_version) self._set_col_description(self.item(idx, 3), app_v) app_v.status = ApplicationViewStatus.READY @@ -198,18 +198,16 @@ class AppsTable(QTableWidget): col_name = self._gen_col_name(app_v) self.setItem(idx, 0, col_name) - col_version = QTableWidgetItem() - col_version.setText(app_v.model.base_data.version) - col_version.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) - self.setItem(idx, 1, col_version) + col_version = QLabel(app_v.model.base_data.version) + col_version.setAlignment(Qt.AlignCenter) + self.setCellWidget(idx, 1, col_version) - col_release = QTableWidgetItem() - col_release.setText(app_v.get_async_attr('latest_version')) - col_release.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) - self.setItem(idx, 2, col_release) + col_release = QLabel(app_v.get_async_attr('latest_version')) + col_release.setAlignment(Qt.AlignCenter) + self.setCellWidget(idx, 2, col_release) if app_v.model.base_data.version and app_v.model.base_data.latest_version and app_v.model.base_data.version < app_v.model.base_data.latest_version: - col_release.setForeground(QColor('orange')) + col_release.setStyleSheet("color: orange") col_description = QTableWidgetItem() col_description.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) @@ -217,10 +215,9 @@ class AppsTable(QTableWidget): self.setItem(idx, 3, col_description) - col_type = QTableWidgetItem() - col_type.setText(app_v.model.get_type()) - col_type.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) - self.setItem(idx, 4, col_type) + col_type = QLabel(app_v.model.get_type()) + col_type.setAlignment(Qt.AlignCenter) + self.setCellWidget(idx, 4, col_type) col_installed = QLabel() col_installed.setPixmap((QPixmap(resource.get_path('img/{}.svg'.format('checked' if app_v.model.installed else 'red_cross'))))) From b3a99c6dec6694514c848b1b37def69fd595582f Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Fri, 12 Jul 2019 11:29:23 -0300 Subject: [PATCH 116/123] task status showing the application name | keeping search bar visible when searching --- fpakman/view/qt/window.py | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/fpakman/view/qt/window.py b/fpakman/view/qt/window.py index 1f1dbf7e..3fafe5a2 100755 --- a/fpakman/view/qt/window.py +++ b/fpakman/view/qt/window.py @@ -250,7 +250,7 @@ class ManageWindow(QWidget): def uninstall_app(self, app: ApplicationView): if self._acquire_lock(): self._handle_console_option(True) - self._begin_action(self.locale_keys['manage_window.status.uninstalling']) + self._begin_action('{} {}'.format(self.locale_keys['manage_window.status.uninstalling'], app.model.base_data.name)) self.thread_uninstall.app = app self.thread_uninstall.start() @@ -373,32 +373,37 @@ class ManageWindow(QWidget): def _update_action_output(self, output: str): self.textarea_output.appendPlainText(output) - def _begin_action(self, action_label: str): + def _begin_action(self, action_label: str, keep_search: bool = False): self.ref_label_updates.setVisible(False) self.thread_animate_progress.stop = False self.thread_animate_progress.start() self.ref_progress_bar.setVisible(True) self.label_status.setText(action_label + "...") - self.toolbar_search.setVisible(False) self.bt_upgrade.setEnabled(False) self.bt_refresh.setEnabled(False) self.checkbox_only_apps.setEnabled(False) self.table_apps.setEnabled(False) + if keep_search: + self.toolbar_search.setEnabled(False) + else: + self.toolbar_search.setVisible(False) + def finish_action(self): self.ref_progress_bar.setVisible(False) self.ref_label_updates.setVisible(True) self.thread_animate_progress.stop = True self.progress_bar.setValue(0) self.bt_refresh.setEnabled(True) - self.toolbar_search.setVisible(True) self.checkbox_only_apps.setEnabled(True) self.table_apps.setEnabled(True) self.input_search.setEnabled(True) self.label_status.setText('') + self.toolbar_search.setVisible(True) + self.toolbar_search.setEnabled(True) - def downgrade_app(self, app: dict): + def downgrade_app(self, app: ApplicationView): if self._acquire_lock(): pwd = None @@ -411,7 +416,7 @@ class ManageWindow(QWidget): return self._handle_console_option(True) - self._begin_action(self.locale_keys['manage_window.status.downgrading']) + self._begin_action('{} {}'.format(self.locale_keys['manage_window.status.downgrading'], app.model.base_data.name)) self.thread_downgrade.app = app self.thread_downgrade.root_password = pwd @@ -459,7 +464,7 @@ class ManageWindow(QWidget): if word and self._acquire_lock(): self._handle_console_option(False) - self._begin_action(self.locale_keys['manage_window.status.searching']) + self._begin_action(self.locale_keys['manage_window.status.searching'], keep_search=True) self.thread_search.word = word self.thread_search.start() @@ -468,10 +473,10 @@ class ManageWindow(QWidget): self.finish_action() self.update_apps(apps_found) - def install_app(self, app: dict): + def install_app(self, app: ApplicationView): if self._acquire_lock(): self._handle_console_option(True) - self._begin_action(self.locale_keys['manage_window.status.installing']) + self._begin_action('{} {}'.format(self.locale_keys['manage_window.status.installing'], app.model.base_data.name)) self.thread_install.app = app self.thread_install.start() From 53095f94387ffd27aa9757aa31b6f0f9ec71d296 Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Fri, 12 Jul 2019 11:48:15 -0300 Subject: [PATCH 117/123] auto-showing the details when history or downgrading fails --- fpakman/view/qt/thread.py | 6 ++++-- fpakman/view/qt/window.py | 9 +++++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/fpakman/view/qt/thread.py b/fpakman/view/qt/thread.py index a54daa6b..be93fd82 100644 --- a/fpakman/view/qt/thread.py +++ b/fpakman/view/qt/thread.py @@ -81,7 +81,7 @@ class UninstallApp(QThread): class DowngradeApp(QThread): - signal_finished = pyqtSignal() + signal_finished = pyqtSignal(bool) signal_output = pyqtSignal(str) def __init__(self, manager: ApplicationManager, locale_keys: dict, app: ApplicationView = None): @@ -94,6 +94,7 @@ class DowngradeApp(QThread): def run(self): if self.app: + success = True try: stream = self.manager.downgrade_app(self.app.model, self.root_password) @@ -106,11 +107,12 @@ class DowngradeApp(QThread): if line: self.signal_output.emit(line) except (requests.exceptions.ConnectionError, NoInternetException): + success = False self.signal_output.emit(self.locale_keys['internet.required']) finally: self.app = None self.root_password = None - self.signal_finished.emit() + self.signal_finished.emit(success) class GetAppInfo(QThread): diff --git a/fpakman/view/qt/window.py b/fpakman/view/qt/window.py index 3fafe5a2..6604d1c3 100755 --- a/fpakman/view/qt/window.py +++ b/fpakman/view/qt/window.py @@ -260,10 +260,14 @@ class ManageWindow(QWidget): self._release_lock() self.refresh() - def _finish_downgrade(self): + def _finish_downgrade(self, success: bool): self.finish_action() self._release_lock() - self.refresh() + + if success: + self.refresh() + else: + self.checkbox_console.setChecked(True) def filter_only_apps(self, only_apps: int): @@ -454,6 +458,7 @@ class ManageWindow(QWidget): if app.get('error'): self._handle_console_option(True) self.textarea_output.appendPlainText(app['error']) + self.checkbox_console.setChecked(True) else: dialog_history = HistoryDialog(app, self.table_apps.get_selected_app_icon(), self.locale_keys) dialog_history.exec_() From 55978be0f7dfe784d48211141b4196c406a4788c Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Fri, 12 Jul 2019 12:05:36 -0300 Subject: [PATCH 118/123] hiding details when 'refresh' is clicked | showing details when an error happens during installation --- fpakman/view/qt/thread.py | 6 +++--- fpakman/view/qt/window.py | 16 ++++++++++++---- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/fpakman/view/qt/thread.py b/fpakman/view/qt/thread.py index be93fd82..bac154db 100644 --- a/fpakman/view/qt/thread.py +++ b/fpakman/view/qt/thread.py @@ -169,7 +169,7 @@ class SearchApps(QThread): class InstallApp(QThread): - signal_finished = pyqtSignal() + signal_finished = pyqtSignal(bool) signal_output = pyqtSignal(str) def __init__(self, manager: ApplicationManager, disk_cache: bool, icon_cache: Cache, app: ApplicationView = None): @@ -205,8 +205,8 @@ class InstallApp(QThread): icon_bytes=self.icon_cache.get(self.app.model.base_data.icon_url)['bytes'], only_icon=False) - self.app = None - self.signal_finished.emit() + self.app = None + self.signal_finished.emit(not error) class AnimateProgress(QThread): diff --git a/fpakman/view/qt/window.py b/fpakman/view/qt/window.py index 6604d1c3..8956482a 100755 --- a/fpakman/view/qt/window.py +++ b/fpakman/view/qt/window.py @@ -92,7 +92,7 @@ class ManageWindow(QWidget): self.bt_refresh = QToolButton() self.bt_refresh.setToolTip(locale_keys['manage_window.bt.refresh.tooltip']) self.bt_refresh.setIcon(QIcon(resource.get_path('img/refresh.svg'))) - self.bt_refresh.clicked.connect(self.refresh) + self.bt_refresh.clicked.connect(lambda: self.refresh(keep_console=False)) toolbar.addWidget(self.bt_refresh) self.bt_upgrade = QToolButton() @@ -234,10 +234,14 @@ class ManageWindow(QWidget): self.checkbox_console.setChecked(False) self.textarea_output.hide() - def refresh(self): + def refresh(self, keep_console: bool = True): if self._acquire_lock(): self.input_search.clear() + + if not keep_console: + self._handle_console_option(False) + self._begin_action(self.locale_keys['manage_window.status.refreshing']) self.thread_refresh.start() @@ -486,11 +490,15 @@ class ManageWindow(QWidget): self.thread_install.app = app self.thread_install.start() - def _finish_install(self): + def _finish_install(self, success: bool): self.input_search.setText('') self.finish_action() self._release_lock() - self.refresh() + + if success: + self.refresh() + else: + self.checkbox_console.setChecked(True) def _update_progress(self, value: int): self.progress_bar.setValue(value) From 1b8cf8a2f35a6f5cb138f2340b5e579d23fc4f6f Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Fri, 12 Jul 2019 12:16:21 -0300 Subject: [PATCH 119/123] showing executed command when installing or uninstalling --- fpakman/view/qt/thread.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/fpakman/view/qt/thread.py b/fpakman/view/qt/thread.py index bac154db..80aa6d6b 100644 --- a/fpakman/view/qt/thread.py +++ b/fpakman/view/qt/thread.py @@ -59,6 +59,7 @@ class UninstallApp(QThread): def run(self): if self.app: subproc = self.manager.uninstall_and_stream(self.app.model) + self.signal_output.emit(' '.join(subproc.args) + '\n') for output in subproc.stdout: line = output.decode().strip() @@ -183,6 +184,7 @@ class InstallApp(QThread): if self.app: subproc = self.manager.install_and_stream(self.app.model) + self.signal_output.emit(' '.join(subproc.args) + '\n') for output in subproc.stdout: line = output.decode().strip() From 9af8b73e23235e0cb43146b9c6437313356d0174 Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Fri, 12 Jul 2019 14:33:14 -0300 Subject: [PATCH 120/123] showing console details when upgrading fails --- fpakman/core/flatpak.py | 2 +- fpakman/view/qt/thread.py | 24 +++++++++++++++++++++--- fpakman/view/qt/window.py | 8 ++++++-- 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/fpakman/core/flatpak.py b/fpakman/core/flatpak.py index 256c2ebc..5c27bb2e 100755 --- a/fpakman/core/flatpak.py +++ b/fpakman/core/flatpak.py @@ -99,7 +99,7 @@ def update_and_stream(app_ref: str): :param app_ref: :return: """ - return system.stream_cmd([BASE_CMD, 'update', '-y', app_ref]) + return system.cmd_to_subprocess([BASE_CMD, 'update', '-y', app_ref]) def uninstall_and_stream(app_ref: str): diff --git a/fpakman/view/qt/thread.py b/fpakman/view/qt/thread.py index 80aa6d6b..755b568d 100644 --- a/fpakman/view/qt/thread.py +++ b/fpakman/view/qt/thread.py @@ -15,7 +15,7 @@ from fpakman.view.qt.view_model import ApplicationView class UpdateSelectedApps(QThread): - signal_finished = pyqtSignal() + signal_finished = pyqtSignal(bool) signal_output = pyqtSignal(str) def __init__(self, manager: ApplicationManager, apps_to_update: List[ApplicationView] = None): @@ -25,13 +25,31 @@ class UpdateSelectedApps(QThread): def run(self): + error = False + for app in self.apps_to_update: - for output in self.manager.update_and_stream(app.model): + + subproc = self.manager.update_and_stream(app.model) + + self.signal_output.emit(' '.join(subproc.args) + '\n') + + for output in subproc.stdout: line = output.decode().strip() if line: self.signal_output.emit(line) - self.signal_finished.emit() + for output in subproc.stderr: + line = output.decode().strip() + if line: + error = True + self.signal_output.emit(line) + + self.signal_output.emit('\n') + + if error: + break + + self.signal_finished.emit(not error) class RefreshApps(QThread): diff --git a/fpakman/view/qt/window.py b/fpakman/view/qt/window.py index 8956482a..d225a41b 100755 --- a/fpakman/view/qt/window.py +++ b/fpakman/view/qt/window.py @@ -373,10 +373,14 @@ class ManageWindow(QWidget): self.thread_update.apps_to_update = to_update self.thread_update.start() - def _finish_update_selected(self): + def _finish_update_selected(self, success: bool): self.finish_action() self._release_lock() - self.refresh() + + if success: + self.refresh() + else: + self.checkbox_console.setChecked(True) def _update_action_output(self, output: str): self.textarea_output.appendPlainText(output) From 1ecfc83ed51fa040dfb0ebb906660aa55dd6e2f8 Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Fri, 12 Jul 2019 14:42:10 -0300 Subject: [PATCH 121/123] fix: button upgrade remains uncheckable after upgrading fails --- fpakman/view/qt/window.py | 1 + 1 file changed, 1 insertion(+) diff --git a/fpakman/view/qt/window.py b/fpakman/view/qt/window.py index d225a41b..a9926e84 100755 --- a/fpakman/view/qt/window.py +++ b/fpakman/view/qt/window.py @@ -380,6 +380,7 @@ class ManageWindow(QWidget): if success: self.refresh() else: + self.bt_upgrade.setEnabled(True) self.checkbox_console.setChecked(True) def _update_action_output(self, output: str): From 5ca3ee1ad84f9898a99132bb2a51396dd7fd1527 Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Sat, 13 Jul 2019 11:16:51 -0300 Subject: [PATCH 122/123] Update CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 88b3a814..3452a69a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). -## [0.3.1] +## [0.3.1] - 2019-07-13 ### Improvements - Console output now is optional and not shown by default - Search bar is cleaned when 'Refresh' is clicked From 332d3cb787046c759100d4ca0388fbc91e887482 Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Sat, 13 Jul 2019 12:53:08 -0300 Subject: [PATCH 123/123] fix: app crashes after installation --- fpakman/util/cache.py | 9 ++++----- fpakman/view/qt/thread.py | 3 ++- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/fpakman/util/cache.py b/fpakman/util/cache.py index d316a125..971909bb 100644 --- a/fpakman/util/cache.py +++ b/fpakman/util/cache.py @@ -13,8 +13,7 @@ class Cache: return self.expiration_time < 0 or self.expiration_time > 0 def add(self, key: str, val: object): - - if self.is_enabled(): + if key and self.is_enabled(): self.lock.acquire() self._add(key, val) self.lock.release() @@ -24,7 +23,7 @@ class Cache: def add_non_existing(self, key: str, val: object): - if self. is_enabled(): + if key and self. is_enabled(): self.lock.acquire() cur_val = self.get(key) @@ -34,7 +33,7 @@ class Cache: self.lock.release() def get(self, key: str): - if self.is_enabled(): + if key and self.is_enabled(): val = self._cache.get(key) if val: @@ -49,7 +48,7 @@ class Cache: return val['val'] def delete(self, key): - if self.is_enabled(): + if key and self.is_enabled(): if key in self._cache: self.lock.acquire() del self._cache[key] diff --git a/fpakman/view/qt/thread.py b/fpakman/view/qt/thread.py index 755b568d..16335302 100644 --- a/fpakman/view/qt/thread.py +++ b/fpakman/view/qt/thread.py @@ -221,8 +221,9 @@ class InstallApp(QThread): self.app.model.installed = True if self.app.model.supports_disk_cache(): + icon_data = self.icon_cache.get(self.app.model.base_data.icon_url) self.manager.cache_to_disk(app=self.app.model, - icon_bytes=self.icon_cache.get(self.app.model.base_data.icon_url)['bytes'], + icon_bytes=icon_data.get('bytes') if icon_data else None, only_icon=False) self.app = None