commit 1520e4d9e7dc60dea92e128450e97657d3cdf316 Author: dries.k Date: Thu Aug 5 18:56:17 2021 +0200 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..93f3b55 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.idea +.vscode +__pycache__ +build +dist +inno-output + +config.json +*.log +*.exe \ No newline at end of file diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..09c9140 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,50 @@ +jobs: + include: + - stage: deploy + name: "Python 3.9 on Windows" + if: tag IS NOT blank + os: windows + language: shell + before_install: + - choco install python --version=3.9.5 + - choco install innosetup + - python -m pip install --upgrade pip + env: PATH=/c/Python39:/c/Python39/Scripts:$PATH + install: + - pip install -r requirements.txt + - pip install pyinstaller + script: + - pyinstaller gotify-tray.spec + - iscc gotify-tray.iss + - mv "inno-output\gotify-tray-installer.exe" "gotify-tray-installer-win.exe" + deploy: + provider: releases + edge: true + cleanup: false + api_key: $GITHUB_TOKEN + file: gotify-tray-installer-win.exe + on: + tags: true + - stage: deploy + name: "Python 3.9 on ubuntu focal" + if: tag IS NOT blank + os: linux + dist: focal + language: python + python: 3.9 + before_install: + - pip install --upgrade pip + install: + - pip install -r requirements.txt + - pip install pyinstaller + script: + - make build + - cp dist/gotify-tray_amd64.deb gotify-tray_amd64_focal.deb + deploy: + provider: releases + edge: true + cleanup: false + api_key: $GITHUB_TOKEN + file: gotify-tray_amd64_focal.deb + on: + tags: true diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e72bfdd --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + 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. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..05164d7 --- /dev/null +++ b/Makefile @@ -0,0 +1,17 @@ +build: clean + pip install -r requirements.txt + pip install pyinstaller + pyinstaller gotify-tray.spec + cp -r debian build/debian + mkdir build/debian/usr/lib + cp -r dist/gotify-tray build/debian/usr/lib/gotify-tray + dpkg -b build/debian dist/gotify-tray_amd64.deb + +install: build + sudo dpkg -i dist/gotify-tray_amd64.deb + +uninstall: + sudo dpkg -r gotify-tray + +clean: + rm -rf dist build diff --git a/README.md b/README.md new file mode 100644 index 0000000..4e9cd34 --- /dev/null +++ b/README.md @@ -0,0 +1,76 @@ +# Gotify Tray + +[![Build Status](https://travis-ci.com/seird/gotify-tray.svg?token=NwPpjHLfYaQgjnfyARPa&branch=master)](https://travis-ci.com/seird/gotify-tray) + + +A tray notification application for receiving messages from a [Gotify server](https://github.com/gotify/server). + + +[![logo](https://raw.githubusercontent.com/gotify/logo/master/gotify-logo.png)](https://github.com/gotify/logo) + +## Download + + +[Download the latest release.](https://github.com/seird/gotify-tray/releases/latest) + + +## Install + +Get the source and install the requirements: + +``` +$ git clone https://github.com/seird/gotify-tray.git +$ cd gotify-tray +$ pip install -r requirements.txt +``` + +### Run from source + +``` +$ python entry_point.py +``` + +### Create a pyinstaller executable + +``` +$ pip install pyinstaller==4.4 +$ pyinstaller gotify-tray.spec +``` +An executable is created at `dist/gotify-tray/`. + + +### Create a deb package + +``` +$ make build + +# or install + +$ sudo make install +``` + +### (Inno setup (Windows)) + +Create an installer for windows with inno setup: + +``` +$ iscc gotify-tray.iss +``` + + +## Images + +![notification](images/notification.png) + +![main_window](images/main_window.png) + +![notification_centre](images/notification_centre.png) + + + +## Requirements + +- python 3.9 +- PyQt6 +- requests +- websocket-client diff --git a/build-win.ps1 b/build-win.ps1 new file mode 100644 index 0000000..c5f5cdf --- /dev/null +++ b/build-win.ps1 @@ -0,0 +1,11 @@ +echo "Creating executable" + + +try {C:/Python39/Scripts/pyinstaller gotify-tray.spec} +catch {pyinstaller gotify-tray.spec} + +try {Remove-Item "dist/gotify-tray/opengl32sw.dll"} catch {} + +echo "Creating installer" +iscc gotify-tray.iss +Move-Item "inno-output\gotify-tray-installer.exe" "gotify-tray-installer-win.exe" diff --git a/compile_designs.py b/compile_designs.py new file mode 100644 index 0000000..9b9b3a4 --- /dev/null +++ b/compile_designs.py @@ -0,0 +1,8 @@ +import glob +import os + + +ui_files = glob.glob("gotify_tray/gui/designs/*.ui") +for ui_file in ui_files: + fname, _ = os.path.splitext(ui_file) + os.system(f"pyuic6 -x {ui_file} -o {fname}.py") diff --git a/debian/DEBIAN/control b/debian/DEBIAN/control new file mode 100644 index 0000000..01be1bf --- /dev/null +++ b/debian/DEBIAN/control @@ -0,0 +1,6 @@ +Package: gotify-tray +Version: 0.0.11 +Architecture: amd64 +Maintainer: k.dries@protonmail.com +Description: gotify-tray + A tray notification application for receiving messages from a Gotify server. diff --git a/debian/usr/share/applications/gotifytray.desktop b/debian/usr/share/applications/gotifytray.desktop new file mode 100644 index 0000000..81ad25b --- /dev/null +++ b/debian/usr/share/applications/gotifytray.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Name=gotify-tray +Comment=A tray notification application for receiving messages from a Gotify server. +Path=/usr/lib/gotify-tray +Exec=/usr/lib/gotify-tray/gotify-tray +Icon=/usr/share/icons/gotify-tray.ico +Terminal=false +Type=Application +Categories=Network; diff --git a/debian/usr/share/icons/gotify-tray.ico b/debian/usr/share/icons/gotify-tray.ico new file mode 100644 index 0000000..f6607b6 Binary files /dev/null and b/debian/usr/share/icons/gotify-tray.ico differ diff --git a/entry_point.py b/entry_point.py new file mode 100644 index 0000000..420d067 --- /dev/null +++ b/entry_point.py @@ -0,0 +1,39 @@ +import logging +import os +import sys + +from PyQt6 import QtCore, QtGui, QtWidgets + +from gotify_tray.__version__ import __title__ +from gotify_tray.utils import verify_server + + +if __name__ == "__main__": + title = __title__.replace(" ", "-") + + app = QtWidgets.QApplication(sys.argv) + app.setApplicationName(title) + app.setQuitOnLastWindowClosed(False) + app.setWindowIcon(QtGui.QIcon("gotify_tray/gui/images/gotify-small.png")) + app.setStyle("fusion") + + logdir = QtCore.QStandardPaths.standardLocations( + QtCore.QStandardPaths.StandardLocation.AppDataLocation + )[0] + if not os.path.exists(logdir): + os.mkdir(logdir) + logging.basicConfig( + filename=os.path.join(logdir, f"{title}.log"), + format="%(levelname)s > %(name)s > %(asctime)s > %(message)s", + level=logging.ERROR, + ) + + # import from gui has to happen after 'setApplicationName' to make sure the correct cache directory is created + from gotify_tray.gui import MainWindow + + window = MainWindow(app) + + # prevent multiple instances + if (window.acquire_lock() or "--no-lock" in sys.argv) and verify_server(): + window.init_ui() + sys.exit(app.exec()) diff --git a/gotify-tray.iss b/gotify-tray.iss new file mode 100644 index 0000000..c550d3a --- /dev/null +++ b/gotify-tray.iss @@ -0,0 +1,47 @@ +; Script generated by the Inno Setup Script Wizard. +; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! + +#define MyAppName "Gotify Tray" +#define VersionFile FileOpen("version.txt") +#define MyAppVersion FileRead(VersionFile) +#define MyAppURL "https://github.com/seird/gotify-tray" +#define MyAppExeName "gotify-tray.exe" + +[Setup] +; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications. +; (To generate a new GUID, click Tools | Generate GUID inside the IDE.) +AppId={{D08886B0-D287-4EA5-B482-B506205AE567} +AppName={#MyAppName} +AppVersion={#MyAppVersion} +;AppVerName={#MyAppName} {#MyAppVersion} +AppPublisherURL={#MyAppURL} +AppSupportURL={#MyAppURL} +AppUpdatesURL={#MyAppURL} +DefaultDirName={autopf}\{#MyAppName} +DisableProgramGroupPage=yes +; Remove the following line to run in administrative install mode (install for all users.) +PrivilegesRequired=lowest +OutputDir=.\inno-output +OutputBaseFilename=gotify-tray-installer +Compression=lzma +SolidCompression=yes +WizardStyle=modern + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Tasks] +Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked + +[Files] +Source: ".\dist\gotify-tray\gotify-tray.exe"; DestDir: "{app}"; Flags: ignoreversion +Source: ".\dist\gotify-tray\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs +; NOTE: Don't use "Flags: ignoreversion" on any shared system files + +[Icons] +Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" +Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon + +[Run] +Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent + diff --git a/gotify-tray.spec b/gotify-tray.spec new file mode 100644 index 0000000..e51b539 --- /dev/null +++ b/gotify-tray.spec @@ -0,0 +1,37 @@ +# -*- mode: python -*- + +block_cipher = None + +a = Analysis(['entry_point.py'], + pathex=[os.getcwd()], + binaries=[], + datas=[('gotify_tray/gui/images', 'gotify_tray/gui/images')], + hiddenimports=[], + hookspath=[], + runtime_hooks=[], + excludes=[], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False) +pyz = PYZ(a.pure, a.zipped_data, + cipher=block_cipher) +exe = EXE(pyz, + a.scripts, + [], + exclude_binaries=True, + name='gotify-tray', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=False, + version='version.py', + icon='logo.ico') +coll = COLLECT(exe, + a.binaries, + a.zipfiles, + a.datas, + strip=False, + upx=True, + name='gotify-tray') diff --git a/gotify_tray/__init__.py b/gotify_tray/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gotify_tray/__version__.py b/gotify_tray/__version__.py new file mode 100644 index 0000000..ddc3e7e --- /dev/null +++ b/gotify_tray/__version__.py @@ -0,0 +1,4 @@ +__title__ = "Gotify Tray" +__description__ = "A tray notification application for receiving messages from a Gotify server." +__url__ = "https://github.com/seird/gotify-tray" +__version__ = "0.0.11" diff --git a/gotify_tray/database/__init__.py b/gotify_tray/database/__init__.py new file mode 100644 index 0000000..c0ec5b4 --- /dev/null +++ b/gotify_tray/database/__init__.py @@ -0,0 +1,4 @@ +from .cache import Cache +from .database import Database +from .downloader import Downloader +from .settings import Settings diff --git a/gotify_tray/database/cache.py b/gotify_tray/database/cache.py new file mode 100644 index 0000000..cb95681 --- /dev/null +++ b/gotify_tray/database/cache.py @@ -0,0 +1,74 @@ +import glob +import logging +import os +import time + +import requests + +from PyQt6 import QtCore + +from .database import Database + +logger = logging.getLogger("logger") + + +class Cache(object): + def __init__(self): + self.database = Database("cache") + self.cursor = self.database.cursor() + self.cursor.execute( + """CREATE TABLE IF NOT EXISTS cache ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + url TEXT, + filename TEXT, + cached_on TEXT) + """ + ) + + # create a directory to store cached files + path = QtCore.QStandardPaths.standardLocations( + QtCore.QStandardPaths.StandardLocation.CacheLocation + )[0] + self.cache_dir = os.path.join(path, "cache") + os.makedirs(self.cache_dir, exist_ok=True) + + def clear(self): + self.cursor.execute("DELETE FROM cache") + self.database.commit() + for filename in glob.glob(self.cache_dir + "/*"): + os.remove(filename) + + def lookup(self, key: str) -> str: + q = self.cursor.execute( + "SELECT filename, cached_on FROM cache WHERE url=?", (key,) + ).fetchone() + if q: + # Cache hit + filename, cached_on = q + return filename + else: + # Cache miss + return "" + + def store( + self, key: str, response: requests.Response, add_time: bool = True + ) -> str: + # Create a file and store the response contents + filename = str(time.time()).replace(".", "") if add_time else "" + if "Content-Disposition" in response.headers.keys(): + filename += response.headers["Content-Disposition"] + else: + filename += response.url.split("/")[-1] + + filename = "".join([c for c in filename if c.isalpha() or c.isdigit()]).rstrip() + filename = os.path.join(self.cache_dir, filename) + + with open(filename, "wb") as f: + f.write(response.content) + + self.cursor.execute( + "INSERT INTO cache (url, filename, cached_on) VALUES(?, ?, datetime('now', 'localtime'))", + (key, filename), + ) + self.database.commit() + return filename diff --git a/gotify_tray/database/database.py b/gotify_tray/database/database.py new file mode 100644 index 0000000..15046fb --- /dev/null +++ b/gotify_tray/database/database.py @@ -0,0 +1,19 @@ +import logging +import os +import sqlite3 + +from PyQt6 import QtCore + + +logger = logging.getLogger("logger") + + +class Database(sqlite3.Connection): + def __init__(self, database: str, *args, **kwargs): + self.dir = QtCore.QStandardPaths.standardLocations( + QtCore.QStandardPaths.StandardLocation.CacheLocation + )[0] + os.makedirs(self.dir, exist_ok=True) + path = os.path.join(self.dir, database + ".db.sqlite3") + super(Database, self).__init__(database=path, *args, **kwargs) + self.row_factory = sqlite3.Row diff --git a/gotify_tray/database/default_settings.py b/gotify_tray/database/default_settings.py new file mode 100644 index 0000000..8dfdb4d --- /dev/null +++ b/gotify_tray/database/default_settings.py @@ -0,0 +1,18 @@ +DEFAULT_SETTINGS = { + "MainWindow/start_minimized": True, + "MainWindow/theme": "default", + "MainWidget/status_image/size": 28, + "MessageWidget/image/show": True, + "MessageWidget/image/size": 33, + "MessageWidget/font/title": "Noto Sans,17,-1,5,75,0,0,0,0,0,Bold", + "MessageWidget/font/date": "Noto Sans,11,-1,5,50,1,0,0,0,0,Italic", + "MessageWidget/font/content": "Noto Sans,11,-1,5,50,0,0,0,0,0,Regular", + "ApplicationModelItem/icon/show": True, + "ApplicationModelItem/icon/size": 33, + "tray/notifications/enabled": True, + "tray/notifications/priority": 5, + "tray/show": True, + "tray/minimize": True, + "tray/notifications/duration_ms": 5000, + "tray/notifications/icon/show": True, +} diff --git a/gotify_tray/database/downloader.py b/gotify_tray/database/downloader.py new file mode 100644 index 0000000..6feb9ac --- /dev/null +++ b/gotify_tray/database/downloader.py @@ -0,0 +1,78 @@ +import logging + +import requests + +from .cache import Cache +from .settings import Settings + + +logger = logging.getLogger("logger") +settings = Settings("gotify-tray") + + +class Downloader(object): + def __init__(self): + self.cache = Cache() + self.session = requests.Session() + self.session.proxies.update( + { + "https": settings.value("proxies/https", type=str), + "http": settings.value("proxies/http", type=str), + } + ) + + def get(self, url: str) -> requests.Response: + """ + Get the response of an http get request. + Bypasses the cache. + """ + return self.session.get(url) + + def get_bytes(self, url: str, cached: bool = True, add_time: bool = True) -> bytes: + """ + Get the content of an http get request, as bytes. + Optionally use the cache. + """ + if cached: + # Retrieve from cache + filename = self.cache.lookup(url) + if filename: + with open(filename, "rb") as f: + return f.read() + + try: + response = self.get(url) + except Exception as e: + logger.error(f"get_bytes: downloading {url} failed.: {e}") + return b"" + + if not response.ok: + return b"" + + if cached: + # Store in cache + self.cache.store(url, response, add_time=add_time) + + return response.content + + def get_filename( + self, url: str, retrieve_from_cache: bool = True, add_time: bool = True + ) -> str: + """ + Get the content of an http get request, as a filename. + """ + if retrieve_from_cache: + filename = self.cache.lookup(url) + if filename: + return filename + + try: + response = self.get(url) + except Exception as e: + logger.error(f"get_filename: downloading {url} failed.: {e}") + return "" + + if not response.ok: + return "" + + return self.cache.store(url, response, add_time=add_time) diff --git a/gotify_tray/database/settings.py b/gotify_tray/database/settings.py new file mode 100644 index 0000000..a14e474 --- /dev/null +++ b/gotify_tray/database/settings.py @@ -0,0 +1,13 @@ +from typing import Any +from .default_settings import DEFAULT_SETTINGS + + +from PyQt6 import QtCore + + +class Settings(QtCore.QSettings): + def value(self, key: str, defaultValue: Any = None, type: Any = None) -> Any: + if type: + return super().value(key, defaultValue=defaultValue or DEFAULT_SETTINGS.get(key), type=type) + else: + return super().value(key, defaultValue=defaultValue or DEFAULT_SETTINGS.get(key)) diff --git a/gotify_tray/gotify/__init__.py b/gotify_tray/gotify/__init__.py new file mode 100644 index 0000000..f484701 --- /dev/null +++ b/gotify_tray/gotify/__init__.py @@ -0,0 +1,10 @@ +from .api import GotifyApplication, GotifyClient +from .models import ( + GotifyApplicationModel, + GotifyErrorModel, + GotifyHealthModel, + GotifyMessageModel, + GotifyPagedMessagesModel, + GotifyPagingModel, + GotifyVersionModel, +) diff --git a/gotify_tray/gotify/api.py b/gotify_tray/gotify/api.py new file mode 100644 index 0000000..0b6ce08 --- /dev/null +++ b/gotify_tray/gotify/api.py @@ -0,0 +1,223 @@ +import logging +from typing import Callable, List, Optional, Union + +import requests + +from .listener import Listener +from .models import ( + GotifyApplicationModel, + GotifyErrorModel, + GotifyHealthModel, + GotifyMessageModel, + GotifyPagedMessagesModel, + GotifyPagingModel, + GotifyVersionModel, +) + +logger = logging.getLogger("logger") + + +class GotifySession(object): + def __init__(self, url: str, token: str): + self.url = url.rstrip("/") + + self.session = requests.Session() + self.session.headers.update({"X-Gotify-Key": token}) + + def _get(self, endpoint: str = "/", **kwargs) -> requests.Response: + return self.session.get(self.url + endpoint, **kwargs) + + def _post(self, endpoint: str = "/", **kwargs) -> requests.Response: + return self.session.post(self.url + endpoint, **kwargs) + + def _put(self, endpoint: str = "/", **kwargs) -> requests.Response: + return self.session.put(self.url + endpoint, **kwargs) + + def _delete(self, endpoint: str = "/", **kwargs) -> requests.Response: + return self.session.delete(self.url + endpoint, **kwargs) + + +# For sending messages + + +class GotifyApplication(GotifySession): + def __init__(self, url: str, application_token: str): + super(GotifyApplication, self).__init__(url, application_token) + + def push( + self, title: str = "", message: str = "", priority: int = 0, extras: dict = None + ) -> Union[GotifyMessageModel, GotifyErrorModel]: + response = self._post( + "/message", + json={ + "title": title, + "message": message, + "priority": priority, + "extras": extras, + }, + ) + return ( + GotifyMessageModel(response.json()) + if response.ok + else GotifyErrorModel(response) + ) + + +# For everything else + + +class GotifyClient(GotifySession): + def __init__(self, url: str, client_token: str): + super(GotifyClient, self).__init__(url, client_token) + self.hostname = self.url.lstrip("https://").lstrip("http://") + self.listener = Listener(self.hostname, client_token) + + """ + Application + """ + + def get_applications(self) -> Union[List[GotifyApplicationModel], GotifyErrorModel]: + response = self._get("/application") + return ( + [GotifyApplicationModel(x) for x in response.json()] + if response.ok + else GotifyErrorModel(response) + ) + + def create_application( + self, name: str, description: str = "" + ) -> Union[GotifyApplicationModel, GotifyErrorModel]: + response = self._post( + "/application", json={"name": name, "description": description} + ) + return ( + GotifyApplicationModel(response.json()) + if response.ok + else GotifyErrorModel(response) + ) + + def update_application( + self, application_id: int, name: str, description: str = "" + ) -> Union[GotifyApplicationModel, GotifyErrorModel]: + response = self._put( + f"/application/{application_id}", + json={"name": name, "description": description}, + ) + return ( + GotifyApplicationModel(response.json()) + if response.ok + else GotifyErrorModel(response) + ) + + def delete_application(self, application_id: int) -> bool: + return self._delete(f"/application/{application_id}").ok + + def upload_application_image( + self, application_id: int, img_path: str + ) -> Optional[GotifyApplicationModel]: + try: + with open(img_path, "rb") as f: + response = self._post( + f"/application/{application_id}/image", files={"file": f} + ) + return response.json() if response.ok else None + except FileNotFoundError: + logger.error( + f"GotifyClient.upload_application_image: image '{img_path}' not found." + ) + return None + + """ + Message + """ + + def get_application_messages( + self, application_id: int, limit: int = 100, since: int = None + ) -> Union[GotifyPagedMessagesModel, GotifyErrorModel]: + response = self._get( + f"/application/{application_id}/message", + params={"limit": limit, "since": since}, + ) + if not response.ok: + return GotifyErrorModel(response) + j = response.json() + return GotifyPagedMessagesModel( + messages=[GotifyMessageModel(m) for m in j["messages"]], + paging=GotifyPagingModel(j["paging"]), + ) + + def delete_application_messages(self, application_id: int) -> bool: + return self._delete(f"/application/{application_id}/message").ok + + def get_messages( + self, limit: int = 100, since: int = None + ) -> Union[GotifyPagedMessagesModel, GotifyErrorModel]: + response = self._get("/message", params={"limit": limit, "since": since}) + if not response.ok: + return GotifyErrorModel(response) + j = response.json() + return GotifyPagedMessagesModel( + messages=[GotifyMessageModel(m) for m in j["messages"]], + paging=GotifyPagingModel(j["paging"]), + ) + + def delete_messages(self) -> bool: + return self._delete("/message").ok + + def delete_message(self, message_id: int) -> bool: + return self._delete(f"/message/{message_id}").ok + + def listen( + self, + opened_callback: Callable[[], None] = None, + closed_callback: Callable[[int, str], None] = None, + new_message_callback: Callable[[GotifyMessageModel], None] = None, + error_callback: Callable[[Exception], None] = None, + ): + def dummy(*args): + ... + + self.listener.opened.connect(lambda: self.opened_callback(opened_callback)) + self.listener.closed.connect(closed_callback or dummy) + self.listener.new_message.connect(new_message_callback or dummy) + self.listener.error.connect(error_callback or dummy) + self.listener.start() + + def opened_callback(self, user_callback: Callable[[], None] = None): + self.listener.reset_wait_time() + if user_callback: + user_callback() + + def reconnect(self, increase_wait_time: bool = True): + if increase_wait_time: + self.listener.increase_wait_time() + self.listener.start() + + def stop(self, reset_wait: bool = False): + if reset_wait: + self.listener.reset_wait_time() + self.listener.stop() + + """ + Health + """ + + def health(self) -> Union[GotifyHealthModel, GotifyErrorModel]: + response = self._get("/health") + return ( + GotifyHealthModel(response.json()) + if response.ok + else GotifyErrorModel(response) + ) + + """ + Version + """ + + def version(self) -> Union[GotifyVersionModel, GotifyErrorModel]: + response = self._get("/version") + return ( + GotifyVersionModel(response.json()) + if response.ok + else GotifyErrorModel(response) + ) diff --git a/gotify_tray/gotify/listener.py b/gotify_tray/gotify/listener.py new file mode 100644 index 0000000..c5d47b1 --- /dev/null +++ b/gotify_tray/gotify/listener.py @@ -0,0 +1,67 @@ +import json +import time + +import websocket +from PyQt6 import QtCore + +from .models import GotifyMessageModel, GotifyErrorModel + + +class Listener(QtCore.QThread): + new_message = QtCore.pyqtSignal(GotifyMessageModel) + error = QtCore.pyqtSignal(Exception) + opened = QtCore.pyqtSignal() + closed = QtCore.pyqtSignal(int, str) + + def __init__(self, hostname: str, client_token: str): + super(Listener, self).__init__() + self.hostname = hostname + self.client_token = client_token + + self.ws = websocket.WebSocketApp( + f"wss://{self.hostname}/stream?token={self.client_token}", + on_message=self._on_message, + on_error=self._on_error, + on_open=self._on_open, + on_close=self._on_close, + ) + + self.wait_time = 0 + + self.running = False + + def reset_wait_time(self): + self.wait_time = 0 + + def increase_wait_time(self): + if self.wait_time == 0: + self.wait_time = 1 + else: + self.wait_time = min(self.wait_time * 2, 10 * 60) + + def _on_message(self, ws: websocket.WebSocketApp, message: str): + self.new_message.emit(GotifyMessageModel(json.loads(message))) + + def _on_error(self, ws: websocket.WebSocketApp, error: Exception): + self.error.emit(error) + + def _on_open(self, ws: websocket.WebSocketApp): + self.opened.emit() + + def _on_close( + self, ws: websocket.WebSocketApp, close_status_code: int, close_msg: str + ): + self.closed.emit(close_status_code, close_msg) + + def stop(self): + self.ws.close() + self.running = False + + def run(self): + self.running = True + + try: + time.sleep(self.wait_time) + self.ws.run_forever() + finally: + self.running = False diff --git a/gotify_tray/gotify/models.py b/gotify_tray/gotify/models.py new file mode 100644 index 0000000..4829124 --- /dev/null +++ b/gotify_tray/gotify/models.py @@ -0,0 +1,91 @@ +import datetime +import logging +from typing import List, Optional + +import requests + + +logger = logging.getLogger("logger") + + +try: + local_timezone = datetime.datetime.utcnow().astimezone().tzinfo +except Exception as e: + logger.error(f"gotify.models.local_timezone error: {e}") + local_timezone = None + + +class AttributeDict(dict): + def __init__(self, *args, **kwargs): + super(AttributeDict, self).__init__(*args, **kwargs) + self.__dict__ = self + + +class GotifyApplicationModel(AttributeDict): + description: str + id: int + image: str + internal: bool + name: str + token: str + + +class GotifyPagingModel(AttributeDict): + limit: int + next: Optional[str] = None + since: int + size: int + + +class GotifyMessageModel(AttributeDict): + appid: int + date: datetime.datetime + extras: Optional[dict] = None + id: int + message: str + priority: Optional[int] = None + title: Optional[str] = None + + def __init__(self, d: dict, *args, **kwargs): + d.update( + { + "date": datetime.datetime.fromisoformat( + d["date"].split(".")[0] + ".000000+00:00" + ).astimezone(local_timezone) + } + ) + super(GotifyMessageModel, self).__init__(d, *args, **kwargs) + + +class GotifyPagedMessagesModel(AttributeDict): + messages: List[GotifyMessageModel] + paging: GotifyPagingModel + + +class GotifyHealthModel(AttributeDict): + database: str + health: str + + +class GotifyVersionModel(AttributeDict): + buildDate: str + commit: str + version: str + + +class GotifyErrorModel(AttributeDict): + error: str + errorCode: int + errorDescription: str + + def __init__(self, response: requests.Response, *args, **kwargs): + try: + j = response.json() + except ValueError: + j = { + "error": "unknown", + "errorCode": response.status_code, + "errorDescription": "", + } + + super(GotifyErrorModel, self).__init__(j, *args, **kwargs) diff --git a/gotify_tray/gui/ApplicationModel.py b/gotify_tray/gui/ApplicationModel.py new file mode 100644 index 0000000..e59d02c --- /dev/null +++ b/gotify_tray/gui/ApplicationModel.py @@ -0,0 +1,41 @@ +from typing import Optional, Union +from PyQt6 import QtCore, QtGui +from gotify_tray import gotify + + +class ApplicationModelItem(QtGui.QStandardItem): + def __init__( + self, + application: gotify.GotifyApplicationModel, + icon: Optional[QtGui.QIcon] = None, + *args, + **kwargs + ): + super(ApplicationModelItem, self).__init__(application.name) + self.application = application + if icon: + self.setIcon(icon) + + +class ApplicationAllMessagesItem(QtGui.QStandardItem): + def __init__(self, *args, **kwargs): + super(ApplicationAllMessagesItem, self).__init__("ALL MESSAGES") + + +class ApplicationModel(QtGui.QStandardItemModel): + def setItem(self, row: int, column: int, item: Union[ApplicationModelItem, ApplicationAllMessagesItem]) -> None: + super(ApplicationModel, self).setItem(row, column, item) + + def itemFromIndex( + self, index: QtCore.QModelIndex + ) -> Union[ApplicationModelItem, ApplicationAllMessagesItem]: + return super(ApplicationModel, self).itemFromIndex(index) + + def itemFromId(self, appid: int) -> Optional[ApplicationModelItem]: + for row in range(self.rowCount()): + item = self.item(row, 0) + if not isinstance(item, ApplicationModelItem): + continue + if item.application.id == appid: + return item + return None diff --git a/gotify_tray/gui/MainWindow.py b/gotify_tray/gui/MainWindow.py new file mode 100644 index 0000000..4f461bb --- /dev/null +++ b/gotify_tray/gui/MainWindow.py @@ -0,0 +1,398 @@ +import getpass +import logging +import os +import tempfile +from typing import List + +from gotify_tray import gotify +from gotify_tray.database import Downloader, Settings +from gotify_tray.tasks import ( + DeleteApplicationMessagesTask, + DeleteMessageTask, + DeleteAllMessagesTask, + GetApplicationMessagesTask, + GetApplicationsTask, + GetMessagesTask, +) +from PyQt6 import QtCore, QtGui, QtWidgets + +from ..__version__ import __title__ +from .ApplicationModel import ( + ApplicationAllMessagesItem, + ApplicationModel, + ApplicationModelItem, +) +from .designs.widget_main import Ui_Form as Ui_Main +from .themes import set_theme +from .MessagesModel import MessagesModel, MessagesModelItem +from .MessageWidget import MessageWidget +from .SettingsDialog import SettingsDialog +from .Tray import Tray + +settings = Settings("gotify-tray") +logger = logging.getLogger("logger") +downloader = Downloader() + + +class MainWidget(QtWidgets.QWidget, Ui_Main): + def __init__( + self, application_model: ApplicationModel, messages_model: MessagesModel + ): + super(MainWidget, self).__init__() + self.setupUi(self) + + self.listView_messages.setModel(messages_model) + self.listView_applications.setModel(application_model) + self.listView_applications.setFixedWidth(180) + icon_size = settings.value("ApplicationModelItem/icon/size", type=int) + self.listView_applications.setIconSize(QtCore.QSize(icon_size, icon_size)) + + label_size = settings.value("MainWidget/status_image/size", type=int) + self.label_status.setFixedSize(QtCore.QSize(label_size, label_size)) + self.label_status_connecting() + + def label_status_active(self): + self.label_status.setToolTip("Listening for new messages") + self.label_status.setStyleSheet("QLabel {background-color: green;}") + + def label_status_connecting(self): + self.label_status.setToolTip("Connecting...") + self.label_status.setStyleSheet("QLabel {background-color: orange;}") + + def label_status_inactive(self): + self.label_status.setToolTip("Listener inactive") + self.label_status.setStyleSheet("QLabel {background-color: grey;}") + + def label_status_error(self): + self.label_status.setToolTip("Listener error") + self.label_status.setStyleSheet("QLabel {background-color: red;}") + + +class MainWindow(QtWidgets.QMainWindow): + def __init__(self, app: QtWidgets.QApplication): + super(MainWindow, self).__init__() + self.app = app + self.shutting_down = False + + def init_ui(self): + self.gotify_client = gotify.GotifyClient( + settings.value("Server/url", type=str), + settings.value("Server/client_token", type=str), + ) + + self.setWindowTitle(__title__) + self.resize(800, 600) + set_theme(self.app, settings.value("MainWindow/theme", type=str)) + + self.application_model = ApplicationModel() + self.messages_model = MessagesModel() + + self.main_widget = MainWidget(self.application_model, self.messages_model) + self.setCentralWidget(self.main_widget) + + self.refresh_applications() + + self.tray = Tray() + self.tray.show() + + self.restore_window_state() + + self.gotify_client.listen( + new_message_callback=self.new_message_callback, + opened_callback=self.listener_opened_callback, + closed_callback=self.listener_closed_callback, + ) + + self.link_callbacks() + + self.show() + self.window_state_to_restore = QtCore.Qt.WindowState.WindowNoState + + if settings.value("MainWindow/start_minimized", type=bool) and settings.value( + "tray/show", type=bool + ): + self.tray_activated_callback( + QtWidgets.QSystemTrayIcon.ActivationReason.Trigger + ) + + def refresh_applications(self): + self.application_model.clear() + self.messages_model.clear() + + self.main_widget.listView_applications.clearSelection() + self.main_widget.listView_applications.setEnabled(False) + self.application_model.setItem(0, 0, ApplicationAllMessagesItem()) + + def get_applications_callback( + applications: List[gotify.GotifyApplicationModel], + ): + for i, application in enumerate(applications): + icon = ( + QtGui.QIcon( + downloader.get_filename( + f"{self.gotify_client.url}/{application.image}" + ) + ) + if settings.value("ApplicationModelItem/icon/show", type=bool) + else None + ) + self.application_model.setItem( + i + 1, 0, ApplicationModelItem(application, icon), + ) + + self.get_applications_task = GetApplicationsTask(self.gotify_client) + self.get_applications_task.success.connect(get_applications_callback) + self.get_applications_task.finished.connect( + self.get_applications_finished_callback + ) + self.get_applications_task.start() + + def get_applications_finished_callback(self): + self.main_widget.listView_applications.setEnabled(True) + self.main_widget.listView_applications.setCurrentIndex( + self.application_model.index(0, 0) + ) + + def insert_message( + self, + row: int, + message: gotify.GotifyMessageModel, + application: gotify.GotifyApplicationModel, + ): + message_item = MessagesModelItem(message) + self.messages_model.insertRow(row, message_item) + + message_widget = MessageWidget( + message_item, + image_path=downloader.get_filename( + f"{self.gotify_client.url}/{application.image}" + ) + if settings.value("MessageWidget/image/show", type=bool) + else "", + ) + self.main_widget.listView_messages.setIndexWidget( + self.messages_model.indexFromItem(message_item), message_widget + ) + message_widget.deletion_requested.connect( + self.message_deletion_requested_callback + ) + + def listener_opened_callback(self): + self.main_widget.label_status_active() + self.tray.set_icon_ok() + + def listener_closed_callback(self, close_status_code: int, close_msg: str): + self.main_widget.label_status_connecting() + self.tray.set_icon_error() + if not self.shutting_down: + self.gotify_client.reconnect() + + def application_selection_changed( + self, current: QtCore.QModelIndex, previous: QtCore.QModelIndex + ): + if item := self.application_model.itemFromIndex(current): + self.main_widget.label_selected.setText(item.text()) + self.messages_model.clear() + + if isinstance(item, ApplicationModelItem): + + def get_application_messages_callback( + page: gotify.GotifyPagedMessagesModel, + ): + for i, message in enumerate(page.messages): + self.insert_message(i, message, item.application) + + self.get_application_messages_task = GetApplicationMessagesTask( + item.application.id, self.gotify_client + ) + self.get_application_messages_task.success.connect( + get_application_messages_callback + ) + self.get_application_messages_task.start() + + elif isinstance(item, ApplicationAllMessagesItem): + + def get_messages_callback(page: gotify.GotifyPagedMessagesModel): + for i, message in enumerate(page.messages): + if item := self.application_model.itemFromId(message.appid): + self.insert_message(i, message, item.application) + + self.get_messages_task = GetMessagesTask(self.gotify_client) + self.get_messages_task.success.connect(get_messages_callback) + self.get_messages_task.start() + + def refresh_callback(self): + self.application_model.clear() + self.messages_model.clear() + + self.refresh_applications() + if not self.gotify_client.listener.running: + self.gotify_client.listener.reset_wait_time() + else: + self.gotify_client.stop(reset_wait=True) + self.gotify_client.reconnect(increase_wait_time=False) + + def delete_all_callback(self): + selection_model = self.main_widget.listView_applications.selectionModel() + if item := self.application_model.itemFromIndex(selection_model.currentIndex()): + self.messages_model.clear() + + if isinstance(item, ApplicationModelItem): + self.delete_application_messages_task = DeleteApplicationMessagesTask( + item.application.id, self.gotify_client + ) + self.delete_application_messages_task.start() + elif isinstance(item, ApplicationAllMessagesItem): + self.delete_all_messages_task = DeleteAllMessagesTask( + self.gotify_client + ) + self.delete_all_messages_task.start() + + def new_message_callback(self, message: gotify.GotifyMessageModel): + # Show a notification + application_item = self.application_model.itemFromId(message.appid) + if not self.isActiveWindow() and message.priority >= settings.value( + "tray/notifications/priority", type=int + ): + image_url = f"{self.gotify_client.url}/{application_item.application.image}" + self.tray.showMessage( + message.title, + message.message, + QtGui.QIcon(downloader.get_filename(image_url)) + if settings.value("tray/notifications/icon/show", type=bool) + else QtWidgets.QSystemTrayIcon.Information, + msecs=settings.value("tray/notifications/duration_ms", type=int), + ) + + # Add the message to the message_model, if its corresponding application is selected + application_index = ( + self.main_widget.listView_applications.selectionModel().currentIndex() + ) + if selected_application_item := self.application_model.itemFromIndex( + application_index + ): + if isinstance(selected_application_item, ApplicationModelItem): + # A single application is selected + if message.appid == selected_application_item.application.id: + self.insert_message(0, message, application_item.application) + elif isinstance(selected_application_item, ApplicationAllMessagesItem): + # "All messages' is selected + self.insert_message(0, message, application_item.application) + + def message_deletion_requested_callback(self, message_item: MessagesModelItem): + self.messages_model.removeRow(message_item.row()) + self.delete_message_task = DeleteMessageTask( + message_item.message.id, self.gotify_client + ) + self.delete_message_task.start() + + def tray_activated_callback( + self, reason: QtWidgets.QSystemTrayIcon.ActivationReason + ): + if reason == QtWidgets.QSystemTrayIcon.ActivationReason.Trigger: + if self.windowState() & QtCore.Qt.WindowState.WindowMinimized or self.windowState() == ( + QtCore.Qt.WindowState.WindowMinimized + | QtCore.Qt.WindowState.WindowMaximized + ): + self.show() + self.setWindowState( + self.window_state_to_restore | QtCore.Qt.WindowState.WindowActive + ) # Set the window to its normal state + else: + window_state_temp = self.windowState() + self.setWindowState(QtCore.Qt.WindowState.WindowMinimized) + self.hide() + self.window_state_to_restore = window_state_temp + + def message_clicked_callback(self): + self.main_widget.listView_messages.scrollToTop() + self.setWindowState( + self.window_state_to_restore | QtCore.Qt.WindowState.WindowActive + ) + self.show() + + def settings_callback(self): + settings_dialog = SettingsDialog(self.app) + accepted = settings_dialog.exec() + + if accepted and settings_dialog.settings_changed: + settings_dialog.apply_settings() + + if settings_dialog.server_changed: + mb = QtWidgets.QMessageBox( + QtWidgets.QMessageBox.Icon.Information, + "Restart", + "Restart to apply server changes", + QtWidgets.QMessageBox.StandardButton.Yes + | QtWidgets.QMessageBox.StandardButton.Cancel, + parent=self, + ) + + r = mb.exec() + if r == QtWidgets.QMessageBox.StandardButton.Yes: + self.close() + + def link_callbacks(self): + self.main_widget.listView_applications.selectionModel().currentChanged.connect( + self.application_selection_changed + ) + self.main_widget.pb_refresh.clicked.connect(self.refresh_callback) + self.main_widget.pb_delete_all.clicked.connect(self.delete_all_callback) + + self.tray.actionQuit.triggered.connect(self.close) + self.tray.actionSettings.triggered.connect(self.settings_callback) + self.tray.actionToggleWindow.triggered.connect( + lambda: self.tray_activated_callback( + QtWidgets.QSystemTrayIcon.ActivationReason.Trigger + ) + ) + self.tray.messageClicked.connect(self.message_clicked_callback) + self.tray.activated.connect(self.tray_activated_callback) + + def acquire_lock(self) -> bool: + temp_dir = tempfile.gettempdir() + lock_filename = os.path.join( + temp_dir, __title__ + "-" + getpass.getuser() + ".lock" + ) + self.lock_file = QtCore.QLockFile(lock_filename) + self.lock_file.setStaleLockTime(0) + return self.lock_file.tryLock() + + def restore_window_state(self): + window_geometry = settings.value("MainWindow/geometry", type=QtCore.QByteArray) + window_state = settings.value("MainWindow/state", type=QtCore.QByteArray) + + if window_geometry: + self.restoreGeometry(window_geometry) + if window_state: + self.restoreState(window_state) + + def save_window_state(self): + settings.setValue("MainWindow/geometry", self.saveGeometry()) + settings.setValue("MainWindow/state", self.saveState()) + + def changeEvent(self, event: QtCore.QEvent) -> None: + if event.type() == QtCore.QEvent.Type.WindowStateChange: + if settings.value("tray/show", type=bool) and settings.value( + "tray/minimize", type=bool + ): + if self.windowState() & QtCore.Qt.WindowState.WindowMinimized: + self.window_state_to_restore = ( + self.windowState() & ~QtCore.Qt.WindowState.WindowMinimized + ) + self.hide() + + super(MainWindow, self).changeEvent(event) + + def closeEvent(self, e: QtGui.QCloseEvent) -> None: + self.save_window_state() + + if settings.value("tray/show", type=bool): + self.tray.hide() + + self.lock_file.unlock() + + self.shutting_down = True + self.gotify_client.stop() + super(MainWindow, self).closeEvent(e) + self.app.quit() diff --git a/gotify_tray/gui/MessageWidget.py b/gotify_tray/gui/MessageWidget.py new file mode 100644 index 0000000..783e984 --- /dev/null +++ b/gotify_tray/gui/MessageWidget.py @@ -0,0 +1,84 @@ +import re + +from PyQt6 import QtCore, QtGui, QtWidgets + +from .MessagesModel import MessagesModelItem +from .designs.widget_message import Ui_Form +from gotify_tray.database import Settings + + +settings = Settings("gotify-tray") + + +def convert_links(text): + _link = re.compile( + r'(?:(https://|http://)|(www\.))(\S+\b/?)([!"#$%&\'()*+,\-./:;<=>?@[\\\]^_`{|}~]*)(\s|$)', + re.I, + ) + + def replace(match): + groups = match.groups() + protocol = groups[0] or "" # may be None + www_lead = groups[1] or "" # may be None + return '{0}{1}{2}{3}{4}'.format( + protocol, www_lead, *groups[2:] + ) + + return _link.sub(replace, text) + + +class MessageWidget(QtWidgets.QWidget, Ui_Form): + deletion_requested = QtCore.pyqtSignal(MessagesModelItem) + + def __init__(self, message_item: MessagesModelItem, image_path: str = ""): + super(MessageWidget, self).__init__() + self.setupUi(self) + self.setAutoFillBackground(True) + + self.message_item = message_item + message = self.message_item.message + + # Fonts + font_title = QtGui.QFont() + font_date = QtGui.QFont() + font_content = QtGui.QFont() + font_title.fromString(settings.value("MessageWidget/font/title", type=str)) + font_date.fromString(settings.value("MessageWidget/font/date", type=str)) + font_content.fromString(settings.value("MessageWidget/font/content", type=str)) + self.label_title.setFont(font_title) + self.label_date.setFont(font_date) + self.text_message.setFont(font_content) + + self.label_title.setText(message.title) + self.label_date.setText(message.date.strftime("%Y-%m-%d, %H:%M")) + + if markdown := message.get("extras", {}).get("client::display", {}).get("contentType") == "text/markdown": + self.text_message.setTextFormat(QtCore.Qt.TextFormat.MarkdownText) + self.text_message.setText(convert_links(message.message)) + + if image_path: + image_size = settings.value("MessageWidget/image/size", type=int) + self.label_image.setFixedSize(QtCore.QSize(image_size, image_size)) + pixmap = QtGui.QPixmap(image_path).scaled(image_size, image_size, aspectRatioMode=QtCore.Qt.AspectRatioMode.KeepAspectRatioByExpanding) + self.label_image.setPixmap(pixmap) + else: + self.label_image.hide() + + # Set MessagesModelItem's size hint based on the size of this widget + self.gridLayout_frame.setContentsMargins(10, 5, 10, 5) + self.gridLayout.setContentsMargins(5, 15, 5, 15) + self.adjustSize() + size_hint = self.message_item.sizeHint() + self.message_item.setSizeHint( + QtCore.QSize( + size_hint.width(), + self.height() + ) + ) + self.pb_delete.setIcon(QtGui.QIcon("gotify_tray/gui/images/trashcan.svg")) + self.pb_delete.setIconSize(QtCore.QSize(24, 24)) + + self.link_callbacks() + + def link_callbacks(self): + self.pb_delete.clicked.connect(lambda: self.deletion_requested.emit(self.message_item)) diff --git a/gotify_tray/gui/MessagesModel.py b/gotify_tray/gui/MessagesModel.py new file mode 100644 index 0000000..ccaadc0 --- /dev/null +++ b/gotify_tray/gui/MessagesModel.py @@ -0,0 +1,17 @@ +from typing import cast +from PyQt6 import QtCore, QtGui, QtWidgets +from gotify_tray import gotify + + +class MessagesModelItem(QtGui.QStandardItem): + def __init__(self, message: gotify.GotifyMessageModel, *args, **kwargs): + super(MessagesModelItem, self).__init__() + self.message = message + + +class MessagesModel(QtGui.QStandardItemModel): + def setItem(self, row: int, column: int, item: MessagesModelItem) -> None: + super(MessagesModel, self).setItem(row, column, item) + + def itemFromIndex(self, index: QtCore.QModelIndex) -> MessagesModelItem: + return cast(MessagesModelItem, super(MessagesModel, self).itemFromIndex(index)) diff --git a/gotify_tray/gui/ServerInfoDialog.py b/gotify_tray/gui/ServerInfoDialog.py new file mode 100644 index 0000000..632aee7 --- /dev/null +++ b/gotify_tray/gui/ServerInfoDialog.py @@ -0,0 +1,54 @@ +from PyQt6 import QtWidgets + +from gotify_tray.tasks import VerifyServerInfoTask +from .designs.widget_server import Ui_Dialog + + +class ServerInfoDialog(QtWidgets.QDialog, Ui_Dialog): + def __init__(self, url: str = "", token: str = ""): + super(ServerInfoDialog, self).__init__() + self.setupUi(self) + self.setWindowTitle("Server info") + self.line_url.setText(url) + self.line_token.setText(token) + self.buttonBox.button(QtWidgets.QDialogButtonBox.StandardButton.Ok).setDisabled(True) + self.link_callbacks() + + def test_server_info(self): + self.pb_test.setStyleSheet("") + self.line_url.setStyleSheet("") + self.line_token.setStyleSheet("") + + url = self.line_url.text() + client_token = self.line_token.text() + if not url or not client_token: + return + + self.pb_test.setDisabled(True) + self.buttonBox.button(QtWidgets.QDialogButtonBox.StandardButton.Ok).setDisabled(True) + + self.task = VerifyServerInfoTask(url, client_token) + self.task.success.connect(self.server_info_success) + self.task.incorrect_token.connect(self.incorrect_token_callback) + self.task.incorrect_url.connect(self.incorrect_url_callback) + self.task.start() + + def server_info_success(self): + self.pb_test.setEnabled(True) + self.pb_test.setStyleSheet("background-color: rgba(0, 255, 0, 100);") + self.buttonBox.button(QtWidgets.QDialogButtonBox.StandardButton.Ok).setEnabled(True) + + def incorrect_token_callback(self): + self.pb_test.setEnabled(True) + self.pb_test.setStyleSheet("background-color: rgba(255, 0, 0, 100);") + self.line_token.setStyleSheet("border: 1px solid red;") + + def incorrect_url_callback(self): + self.pb_test.setEnabled(True) + self.pb_test.setStyleSheet("background-color: rgba(255, 0, 0, 100);") + self.line_url.setStyleSheet("border: 1px solid red;") + + def link_callbacks(self): + self.pb_test.clicked.connect(self.test_server_info) + self.line_url.textChanged.connect(lambda: self.buttonBox.button(QtWidgets.QDialogButtonBox.StandardButton.Ok).setDisabled(True)) + self.line_token.textChanged.connect(lambda: self.buttonBox.button(QtWidgets.QDialogButtonBox.StandardButton.Ok).setDisabled(True)) diff --git a/gotify_tray/gui/SettingsDialog.py b/gotify_tray/gui/SettingsDialog.py new file mode 100644 index 0000000..3fcdb7b --- /dev/null +++ b/gotify_tray/gui/SettingsDialog.py @@ -0,0 +1,170 @@ +from gotify_tray.database import Settings +from gotify_tray.utils import verify_server +from PyQt6 import QtCore, QtGui, QtWidgets + +from .designs.widget_settings import Ui_Dialog +from .themes import set_theme + +settings = Settings("gotify-tray") + + +class SettingsDialog(QtWidgets.QDialog, Ui_Dialog): + def __init__(self, app: QtWidgets.QApplication): + super(SettingsDialog, self).__init__() + self.setupUi(self) + self.setWindowTitle("Settings") + + self.app = app + + self.settings_changed = False + self.changes_applied = False + self.server_changed = False + + self.initUI() + + self.link_callbacks() + + def initUI(self): + self.buttonBox.button( + QtWidgets.QDialogButtonBox.StandardButton.Apply + ).setEnabled(False) + + # Fonts + self.set_font_labels() + + # Theme + self.combo_theme.addItems(["default", "dark"]) + self.combo_theme.setCurrentText(settings.value("MainWindow/theme", type=str)) + + # Icons + self.cb_icons_application.setChecked( + settings.value("ApplicationModelItem/icon/show", type=bool) + ) + self.cb_icons_message.setChecked( + settings.value("MessageWidget/image/show", type=bool) + ) + self.cb_icons_notification.setChecked( + settings.value("tray/notifications/icon/show", type=bool) + ) + + # Notifications + self.spin_priority.setValue( + settings.value("tray/notifications/priority", type=int) + ) + + self.spin_duration.setValue( + settings.value("tray/notifications/duration_ms", type=int) + ) + + def set_font_labels(self): + self.label_font_message_title.setText( + settings.value("MessageWidget/font/title", type=str) + ) + self.label_font_message_date.setText( + settings.value("MessageWidget/font/date", type=str) + ) + self.label_font_message_content.setText( + settings.value("MessageWidget/font/content", type=str) + ) + + def change_font_callback(self, key: str): + font = QtGui.QFont() + font.fromString(settings.value(key, type=str)) + font, accepted = QtWidgets.QFontDialog.getFont(font, self, "Select font") + + if not accepted: + return + + self.settings_changed_callback() + label: QtWidgets.QLabel = getattr( + self, "label_font_message_" + key.split("/")[-1] + ) + label.setText(font.toString()) + + def change_server_info_callback(self): + self.server_changed = verify_server(force_new=True) + + def settings_changed_callback(self, *args, **kwargs): + self.settings_changed = True + self.buttonBox.button( + QtWidgets.QDialogButtonBox.StandardButton.Apply + ).setEnabled(True) + + def reset_settings_callback(self): + response = QtWidgets.QMessageBox.warning( + self, + "Are you sure?", + "Reset all settings?", + QtWidgets.QMessageBox.StandardButton.Ok + | QtWidgets.QMessageBox.StandardButton.Cancel, + defaultButton=QtWidgets.QMessageBox.StandardButton.Cancel, + ) + if response == QtWidgets.QMessageBox.StandardButton.Ok: + settings.clear() + + def link_callbacks(self): + self.buttonBox.button( + QtWidgets.QDialogButtonBox.StandardButton.Apply + ).clicked.connect(self.apply_settings) + + # Fonts + self.pb_font_message_title.clicked.connect( + lambda: self.change_font_callback("MessageWidget/font/title") + ) + self.pb_font_message_date.clicked.connect( + lambda: self.change_font_callback("MessageWidget/font/date") + ) + self.pb_font_message_content.clicked.connect( + lambda: self.change_font_callback("MessageWidget/font/content") + ) + + # Theme + self.combo_theme.currentTextChanged.connect(self.settings_changed_callback) + + # Icons + self.cb_icons_application.stateChanged.connect(self.settings_changed_callback) + self.cb_icons_message.stateChanged.connect(self.settings_changed_callback) + self.cb_icons_notification.stateChanged.connect(self.settings_changed_callback) + + # Notifications + self.spin_priority.valueChanged.connect(self.settings_changed_callback) + self.spin_duration.valueChanged.connect(self.settings_changed_callback) + + # Server info + self.pb_change_server_info.clicked.connect(self.change_server_info_callback) + + def apply_settings(self): + # Fonts + settings.setValue( + "MessageWidget/font/title", self.label_font_message_title.text() + ) + settings.setValue( + "MessageWidget/font/date", self.label_font_message_date.text() + ) + settings.setValue( + "MessageWidget/font/content", self.label_font_message_content.text() + ) + + # Theme + settings.setValue("MainWindow/theme", self.combo_theme.currentText()) + set_theme(self.app, self.combo_theme.currentText()) + + # Icons + settings.setValue( + "ApplicationModelItem/icon/show", self.cb_icons_application.isChecked() + ) + settings.setValue("MessageWidget/image/show", self.cb_icons_message.isChecked()) + settings.setValue( + "tray/notifications/icon/show", self.cb_icons_notification.isChecked() + ) + + # Priority + settings.setValue("tray/notifications/priority", self.spin_priority.value()) + settings.setValue("tray/notifications/duration_ms", self.spin_duration.value()) + + self.settings_changed = False + self.buttonBox.button( + QtWidgets.QDialogButtonBox.StandardButton.Apply + ).setEnabled(False) + + self.changes_applied = True diff --git a/gotify_tray/gui/Tray.py b/gotify_tray/gui/Tray.py new file mode 100644 index 0000000..9872dc8 --- /dev/null +++ b/gotify_tray/gui/Tray.py @@ -0,0 +1,34 @@ +from PyQt6 import QtGui, QtWidgets +from gotify_tray.__version__ import __title__ + + +class Tray(QtWidgets.QSystemTrayIcon): + def __init__(self): + super(Tray, self).__init__() + + self.set_icon_error() + self.setToolTip(__title__) + + # Tray menu items + menu = QtWidgets.QMenu() + + self.actionSettings = QtGui.QAction("Settings", self) + menu.addAction(self.actionSettings) + + menu.addSeparator() + + self.actionToggleWindow = QtGui.QAction("Toggle Window", self) + menu.addAction(self.actionToggleWindow) + + menu.addSeparator() + + self.actionQuit = QtGui.QAction("Quit", self) + menu.addAction(self.actionQuit) + + self.setContextMenu(menu) + + def set_icon_ok(self): + self.setIcon(QtGui.QIcon("gotify_tray/gui/images/gotify-small.png")) + + def set_icon_error(self): + self.setIcon(QtGui.QIcon("gotify_tray/gui/images/gotify-small-error.png")) diff --git a/gotify_tray/gui/__init__.py b/gotify_tray/gui/__init__.py new file mode 100644 index 0000000..ea9cddc --- /dev/null +++ b/gotify_tray/gui/__init__.py @@ -0,0 +1,2 @@ +from .MainWindow import MainWindow +from .ServerInfoDialog import ServerInfoDialog diff --git a/gotify_tray/gui/designs/widget_main.py b/gotify_tray/gui/designs/widget_main.py new file mode 100644 index 0000000..9f10da0 --- /dev/null +++ b/gotify_tray/gui/designs/widget_main.py @@ -0,0 +1,95 @@ +# Form implementation generated from reading ui file 'gotify_tray/gui/designs\widget_main.ui' +# +# Created by: PyQt6 UI code generator 6.1.1 +# +# WARNING: Any manual changes made to this file will be lost when pyuic6 is +# run again. Do not edit this file unless you know what you are doing. + + +from PyQt6 import QtCore, QtGui, QtWidgets + + +class Ui_Form(object): + def setupUi(self, Form): + Form.setObjectName("Form") + Form.resize(809, 572) + self.gridLayout_2 = QtWidgets.QGridLayout(Form) + self.gridLayout_2.setObjectName("gridLayout_2") + self.listView_applications = QtWidgets.QListView(Form) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Expanding) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.listView_applications.sizePolicy().hasHeightForWidth()) + self.listView_applications.setSizePolicy(sizePolicy) + font = QtGui.QFont() + font.setPointSize(13) + self.listView_applications.setFont(font) + self.listView_applications.setEditTriggers(QtWidgets.QAbstractItemView.EditTrigger.NoEditTriggers) + self.listView_applications.setWordWrap(True) + self.listView_applications.setObjectName("listView_applications") + self.gridLayout_2.addWidget(self.listView_applications, 0, 0, 1, 1) + self.gridLayout = QtWidgets.QGridLayout() + self.gridLayout.setObjectName("gridLayout") + self.pb_delete_all = QtWidgets.QPushButton(Form) + self.pb_delete_all.setMinimumSize(QtCore.QSize(0, 32)) + font = QtGui.QFont() + font.setPointSize(10) + self.pb_delete_all.setFont(font) + self.pb_delete_all.setObjectName("pb_delete_all") + self.gridLayout.addWidget(self.pb_delete_all, 0, 5, 1, 1) + spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum) + self.gridLayout.addItem(spacerItem, 0, 3, 1, 1) + self.label_selected = QtWidgets.QLabel(Form) + self.label_selected.setMinimumSize(QtCore.QSize(0, 32)) + font = QtGui.QFont() + font.setPointSize(15) + font.setBold(True) + self.label_selected.setFont(font) + self.label_selected.setObjectName("label_selected") + self.gridLayout.addWidget(self.label_selected, 0, 2, 1, 1) + spacerItem1 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum) + self.gridLayout.addItem(spacerItem1, 0, 1, 1, 1) + self.pb_refresh = QtWidgets.QPushButton(Form) + self.pb_refresh.setMinimumSize(QtCore.QSize(0, 32)) + font = QtGui.QFont() + font.setPointSize(10) + self.pb_refresh.setFont(font) + self.pb_refresh.setObjectName("pb_refresh") + self.gridLayout.addWidget(self.pb_refresh, 0, 4, 1, 1) + self.label_status = QtWidgets.QLabel(Form) + self.label_status.setText("") + self.label_status.setObjectName("label_status") + self.gridLayout.addWidget(self.label_status, 0, 0, 1, 1) + self.listView_messages = QtWidgets.QListView(Form) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.MinimumExpanding, QtWidgets.QSizePolicy.Policy.Expanding) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.listView_messages.sizePolicy().hasHeightForWidth()) + self.listView_messages.setSizePolicy(sizePolicy) + self.listView_messages.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollMode.ScrollPerPixel) + self.listView_messages.setObjectName("listView_messages") + self.gridLayout.addWidget(self.listView_messages, 1, 0, 1, 6) + self.gridLayout_2.addLayout(self.gridLayout, 0, 1, 1, 1) + + self.retranslateUi(Form) + QtCore.QMetaObject.connectSlotsByName(Form) + Form.setTabOrder(self.pb_refresh, self.pb_delete_all) + Form.setTabOrder(self.pb_delete_all, self.listView_messages) + Form.setTabOrder(self.listView_messages, self.listView_applications) + + def retranslateUi(self, Form): + _translate = QtCore.QCoreApplication.translate + Form.setWindowTitle(_translate("Form", "Form")) + self.pb_delete_all.setText(_translate("Form", "Delete All")) + self.label_selected.setText(_translate("Form", "TextLabel")) + self.pb_refresh.setText(_translate("Form", "Refresh")) + + +if __name__ == "__main__": + import sys + app = QtWidgets.QApplication(sys.argv) + Form = QtWidgets.QWidget() + ui = Ui_Form() + ui.setupUi(Form) + Form.show() + sys.exit(app.exec()) diff --git a/gotify_tray/gui/designs/widget_main.ui b/gotify_tray/gui/designs/widget_main.ui new file mode 100644 index 0000000..ed0e464 --- /dev/null +++ b/gotify_tray/gui/designs/widget_main.ui @@ -0,0 +1,153 @@ + + + Form + + + + 0 + 0 + 809 + 572 + + + + Form + + + + + + + 0 + 0 + + + + + 13 + + + + QAbstractItemView::NoEditTriggers + + + true + + + + + + + + + + 0 + 32 + + + + + 10 + + + + Delete All + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + 0 + 32 + + + + + 15 + true + + + + TextLabel + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + 0 + 32 + + + + + 10 + + + + Refresh + + + + + + + + + + + + + + + 0 + 0 + + + + QAbstractItemView::ScrollPerPixel + + + + + + + + + pb_refresh + pb_delete_all + listView_messages + listView_applications + + + + diff --git a/gotify_tray/gui/designs/widget_message.py b/gotify_tray/gui/designs/widget_message.py new file mode 100644 index 0000000..66eecc1 --- /dev/null +++ b/gotify_tray/gui/designs/widget_message.py @@ -0,0 +1,94 @@ +# Form implementation generated from reading ui file 'gotify_tray/gui/designs\widget_message.ui' +# +# Created by: PyQt6 UI code generator 6.1.1 +# +# WARNING: Any manual changes made to this file will be lost when pyuic6 is +# run again. Do not edit this file unless you know what you are doing. + + +from PyQt6 import QtCore, QtGui, QtWidgets + + +class Ui_Form(object): + def setupUi(self, Form): + Form.setObjectName("Form") + Form.resize(454, 122) + self.gridLayout = QtWidgets.QGridLayout(Form) + self.gridLayout.setSizeConstraint(QtWidgets.QLayout.SizeConstraint.SetMinimumSize) + self.gridLayout.setContentsMargins(0, 0, 0, 0) + self.gridLayout.setObjectName("gridLayout") + self.frame = QtWidgets.QFrame(Form) + self.frame.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel) + self.frame.setFrameShadow(QtWidgets.QFrame.Shadow.Raised) + self.frame.setObjectName("frame") + self.gridLayout_frame = QtWidgets.QGridLayout(self.frame) + self.gridLayout_frame.setSizeConstraint(QtWidgets.QLayout.SizeConstraint.SetMinimumSize) + self.gridLayout_frame.setContentsMargins(-1, 0, -1, 0) + self.gridLayout_frame.setObjectName("gridLayout_frame") + self.label_title = QtWidgets.QLabel(self.frame) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Preferred, QtWidgets.QSizePolicy.Policy.Minimum) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.label_title.sizePolicy().hasHeightForWidth()) + self.label_title.setSizePolicy(sizePolicy) + font = QtGui.QFont() + font.setPointSize(17) + font.setBold(False) + font.setWeight(50) + self.label_title.setFont(font) + self.label_title.setTextInteractionFlags(QtCore.Qt.TextInteractionFlag.LinksAccessibleByMouse|QtCore.Qt.TextInteractionFlag.TextSelectableByMouse) + self.label_title.setObjectName("label_title") + self.gridLayout_frame.addWidget(self.label_title, 0, 1, 1, 1) + self.text_message = QtWidgets.QLabel(self.frame) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Preferred, QtWidgets.QSizePolicy.Policy.MinimumExpanding) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.text_message.sizePolicy().hasHeightForWidth()) + self.text_message.setSizePolicy(sizePolicy) + font = QtGui.QFont() + font.setPointSize(11) + self.text_message.setFont(font) + self.text_message.setWordWrap(True) + self.text_message.setOpenExternalLinks(True) + self.text_message.setTextInteractionFlags(QtCore.Qt.TextInteractionFlag.LinksAccessibleByMouse|QtCore.Qt.TextInteractionFlag.TextSelectableByMouse) + self.text_message.setObjectName("text_message") + self.gridLayout_frame.addWidget(self.text_message, 3, 1, 1, 3) + self.label_date = QtWidgets.QLabel(self.frame) + font = QtGui.QFont() + font.setPointSize(11) + self.label_date.setFont(font) + self.label_date.setTextInteractionFlags(QtCore.Qt.TextInteractionFlag.LinksAccessibleByMouse|QtCore.Qt.TextInteractionFlag.TextSelectableByMouse) + self.label_date.setObjectName("label_date") + self.gridLayout_frame.addWidget(self.label_date, 2, 1, 1, 1) + self.pb_delete = QtWidgets.QPushButton(self.frame) + self.pb_delete.setText("") + self.pb_delete.setFlat(True) + self.pb_delete.setObjectName("pb_delete") + self.gridLayout_frame.addWidget(self.pb_delete, 0, 3, 1, 1) + spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum) + self.gridLayout_frame.addItem(spacerItem, 0, 2, 1, 1) + self.label_image = QtWidgets.QLabel(self.frame) + self.label_image.setText("") + self.label_image.setObjectName("label_image") + self.gridLayout_frame.addWidget(self.label_image, 0, 0, 1, 1) + self.gridLayout.addWidget(self.frame, 0, 0, 1, 1) + + self.retranslateUi(Form) + QtCore.QMetaObject.connectSlotsByName(Form) + + def retranslateUi(self, Form): + _translate = QtCore.QCoreApplication.translate + Form.setWindowTitle(_translate("Form", "Form")) + self.label_title.setText(_translate("Form", "Title")) + self.text_message.setText(_translate("Form", "TextLabel")) + self.label_date.setText(_translate("Form", "Date")) + + +if __name__ == "__main__": + import sys + app = QtWidgets.QApplication(sys.argv) + Form = QtWidgets.QWidget() + ui = Ui_Form() + ui.setupUi(Form) + Form.show() + sys.exit(app.exec()) diff --git a/gotify_tray/gui/designs/widget_message.ui b/gotify_tray/gui/designs/widget_message.ui new file mode 100644 index 0000000..718406f --- /dev/null +++ b/gotify_tray/gui/designs/widget_message.ui @@ -0,0 +1,152 @@ + + + Form + + + + 0 + 0 + 454 + 122 + + + + Form + + + + QLayout::SetMinimumSize + + + 0 + + + 0 + + + 0 + + + 0 + + + + + QFrame::StyledPanel + + + QFrame::Raised + + + + QLayout::SetMinimumSize + + + 0 + + + 0 + + + + + + 0 + 0 + + + + + 17 + 50 + false + + + + Title + + + Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse + + + + + + + + 0 + 0 + + + + + 11 + + + + TextLabel + + + true + + + true + + + Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse + + + + + + + + 11 + + + + Date + + + Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse + + + + + + + + + + true + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + + + + + + + + diff --git a/gotify_tray/gui/designs/widget_server.py b/gotify_tray/gui/designs/widget_server.py new file mode 100644 index 0000000..63bbfa5 --- /dev/null +++ b/gotify_tray/gui/designs/widget_server.py @@ -0,0 +1,64 @@ +# Form implementation generated from reading ui file 'gotify_tray/gui/designs\widget_server.ui' +# +# Created by: PyQt6 UI code generator 6.1.1 +# +# WARNING: Any manual changes made to this file will be lost when pyuic6 is +# run again. Do not edit this file unless you know what you are doing. + + +from PyQt6 import QtCore, QtGui, QtWidgets + + +class Ui_Dialog(object): + def setupUi(self, Dialog): + Dialog.setObjectName("Dialog") + Dialog.resize(300, 124) + self.gridLayout = QtWidgets.QGridLayout(Dialog) + self.gridLayout.setObjectName("gridLayout") + self.formLayout = QtWidgets.QFormLayout() + self.formLayout.setObjectName("formLayout") + self.label = QtWidgets.QLabel(Dialog) + self.label.setObjectName("label") + self.formLayout.setWidget(0, QtWidgets.QFormLayout.ItemRole.LabelRole, self.label) + self.line_url = QtWidgets.QLineEdit(Dialog) + self.line_url.setObjectName("line_url") + self.formLayout.setWidget(0, QtWidgets.QFormLayout.ItemRole.FieldRole, self.line_url) + self.label_2 = QtWidgets.QLabel(Dialog) + self.label_2.setObjectName("label_2") + self.formLayout.setWidget(1, QtWidgets.QFormLayout.ItemRole.LabelRole, self.label_2) + self.line_token = QtWidgets.QLineEdit(Dialog) + self.line_token.setObjectName("line_token") + self.formLayout.setWidget(1, QtWidgets.QFormLayout.ItemRole.FieldRole, self.line_token) + self.gridLayout.addLayout(self.formLayout, 0, 0, 1, 2) + self.pb_test = QtWidgets.QPushButton(Dialog) + self.pb_test.setObjectName("pb_test") + self.gridLayout.addWidget(self.pb_test, 1, 1, 1, 1) + self.buttonBox = QtWidgets.QDialogButtonBox(Dialog) + self.buttonBox.setOrientation(QtCore.Qt.Orientation.Horizontal) + self.buttonBox.setStandardButtons(QtWidgets.QDialogButtonBox.StandardButton.Cancel|QtWidgets.QDialogButtonBox.StandardButton.Ok) + self.buttonBox.setObjectName("buttonBox") + self.gridLayout.addWidget(self.buttonBox, 2, 1, 1, 1) + spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum) + self.gridLayout.addItem(spacerItem, 1, 0, 1, 1) + + self.retranslateUi(Dialog) + self.buttonBox.accepted.connect(Dialog.accept) + self.buttonBox.rejected.connect(Dialog.reject) + QtCore.QMetaObject.connectSlotsByName(Dialog) + + def retranslateUi(self, Dialog): + _translate = QtCore.QCoreApplication.translate + Dialog.setWindowTitle(_translate("Dialog", "Dialog")) + self.label.setText(_translate("Dialog", "Server url:")) + self.label_2.setText(_translate("Dialog", "Client token:")) + self.pb_test.setText(_translate("Dialog", "Test")) + + +if __name__ == "__main__": + import sys + app = QtWidgets.QApplication(sys.argv) + Dialog = QtWidgets.QDialog() + ui = Ui_Dialog() + ui.setupUi(Dialog) + Dialog.show() + sys.exit(app.exec()) diff --git a/gotify_tray/gui/designs/widget_server.ui b/gotify_tray/gui/designs/widget_server.ui new file mode 100644 index 0000000..5089637 --- /dev/null +++ b/gotify_tray/gui/designs/widget_server.ui @@ -0,0 +1,108 @@ + + + Dialog + + + + 0 + 0 + 300 + 124 + + + + Dialog + + + + + + + + Server url: + + + + + + + + + + Client token: + + + + + + + + + + + + Test + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + buttonBox + accepted() + Dialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + Dialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/gotify_tray/gui/designs/widget_settings.py b/gotify_tray/gui/designs/widget_settings.py new file mode 100644 index 0000000..128e1c0 --- /dev/null +++ b/gotify_tray/gui/designs/widget_settings.py @@ -0,0 +1,175 @@ +# Form implementation generated from reading ui file 'gotify_tray/gui/designs\widget_settings.ui' +# +# Created by: PyQt6 UI code generator 6.1.1 +# +# WARNING: Any manual changes made to this file will be lost when pyuic6 is +# run again. Do not edit this file unless you know what you are doing. + + +from PyQt6 import QtCore, QtGui, QtWidgets + + +class Ui_Dialog(object): + def setupUi(self, Dialog): + Dialog.setObjectName("Dialog") + Dialog.resize(375, 540) + self.verticalLayout = QtWidgets.QVBoxLayout(Dialog) + self.verticalLayout.setObjectName("verticalLayout") + self.groupBox = QtWidgets.QGroupBox(Dialog) + self.groupBox.setObjectName("groupBox") + self.gridLayout = QtWidgets.QGridLayout(self.groupBox) + self.gridLayout.setObjectName("gridLayout") + self.groupBox_2 = QtWidgets.QGroupBox(self.groupBox) + self.groupBox_2.setObjectName("groupBox_2") + self.gridLayout_2 = QtWidgets.QGridLayout(self.groupBox_2) + self.gridLayout_2.setObjectName("gridLayout_2") + self.label_3 = QtWidgets.QLabel(self.groupBox_2) + self.label_3.setObjectName("label_3") + self.gridLayout_2.addWidget(self.label_3, 2, 0, 1, 1) + self.label_2 = QtWidgets.QLabel(self.groupBox_2) + self.label_2.setObjectName("label_2") + self.gridLayout_2.addWidget(self.label_2, 1, 0, 1, 1) + self.pb_font_message_content = QtWidgets.QPushButton(self.groupBox_2) + self.pb_font_message_content.setMaximumSize(QtCore.QSize(30, 16777215)) + self.pb_font_message_content.setObjectName("pb_font_message_content") + self.gridLayout_2.addWidget(self.pb_font_message_content, 2, 1, 1, 1) + self.pb_font_message_date = QtWidgets.QPushButton(self.groupBox_2) + self.pb_font_message_date.setMaximumSize(QtCore.QSize(30, 16777215)) + self.pb_font_message_date.setObjectName("pb_font_message_date") + self.gridLayout_2.addWidget(self.pb_font_message_date, 1, 1, 1, 1) + self.label = QtWidgets.QLabel(self.groupBox_2) + self.label.setObjectName("label") + self.gridLayout_2.addWidget(self.label, 0, 0, 1, 1) + self.label_font_message_title = QtWidgets.QLabel(self.groupBox_2) + self.label_font_message_title.setObjectName("label_font_message_title") + self.gridLayout_2.addWidget(self.label_font_message_title, 0, 2, 1, 1) + self.pb_font_message_title = QtWidgets.QPushButton(self.groupBox_2) + self.pb_font_message_title.setMaximumSize(QtCore.QSize(30, 16777215)) + self.pb_font_message_title.setObjectName("pb_font_message_title") + self.gridLayout_2.addWidget(self.pb_font_message_title, 0, 1, 1, 1) + spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum) + self.gridLayout_2.addItem(spacerItem, 0, 3, 1, 1) + self.label_font_message_date = QtWidgets.QLabel(self.groupBox_2) + self.label_font_message_date.setObjectName("label_font_message_date") + self.gridLayout_2.addWidget(self.label_font_message_date, 1, 2, 1, 1) + self.label_font_message_content = QtWidgets.QLabel(self.groupBox_2) + self.label_font_message_content.setObjectName("label_font_message_content") + self.gridLayout_2.addWidget(self.label_font_message_content, 2, 2, 1, 1) + self.gridLayout.addWidget(self.groupBox_2, 0, 0, 1, 1) + self.verticalLayout.addWidget(self.groupBox) + self.groupBox_6 = QtWidgets.QGroupBox(Dialog) + self.groupBox_6.setObjectName("groupBox_6") + self.gridLayout_5 = QtWidgets.QGridLayout(self.groupBox_6) + self.gridLayout_5.setObjectName("gridLayout_5") + self.combo_theme = QtWidgets.QComboBox(self.groupBox_6) + self.combo_theme.setObjectName("combo_theme") + self.gridLayout_5.addWidget(self.combo_theme, 0, 0, 1, 1) + spacerItem1 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum) + self.gridLayout_5.addItem(spacerItem1, 0, 1, 1, 1) + self.verticalLayout.addWidget(self.groupBox_6) + self.groupBox_3 = QtWidgets.QGroupBox(Dialog) + self.groupBox_3.setObjectName("groupBox_3") + self.verticalLayout_2 = QtWidgets.QVBoxLayout(self.groupBox_3) + self.verticalLayout_2.setObjectName("verticalLayout_2") + self.cb_icons_application = QtWidgets.QCheckBox(self.groupBox_3) + self.cb_icons_application.setObjectName("cb_icons_application") + self.verticalLayout_2.addWidget(self.cb_icons_application) + self.cb_icons_message = QtWidgets.QCheckBox(self.groupBox_3) + self.cb_icons_message.setObjectName("cb_icons_message") + self.verticalLayout_2.addWidget(self.cb_icons_message) + self.cb_icons_notification = QtWidgets.QCheckBox(self.groupBox_3) + self.cb_icons_notification.setObjectName("cb_icons_notification") + self.verticalLayout_2.addWidget(self.cb_icons_notification) + self.verticalLayout.addWidget(self.groupBox_3) + self.groupBox_5 = QtWidgets.QGroupBox(Dialog) + self.groupBox_5.setObjectName("groupBox_5") + self.gridLayout_4 = QtWidgets.QGridLayout(self.groupBox_5) + self.gridLayout_4.setObjectName("gridLayout_4") + self.spin_priority = QtWidgets.QSpinBox(self.groupBox_5) + self.spin_priority.setMinimum(1) + self.spin_priority.setMaximum(10) + self.spin_priority.setProperty("value", 5) + self.spin_priority.setObjectName("spin_priority") + self.gridLayout_4.addWidget(self.spin_priority, 0, 1, 1, 1) + spacerItem2 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum) + self.gridLayout_4.addItem(spacerItem2, 0, 2, 1, 1) + self.label_4 = QtWidgets.QLabel(self.groupBox_5) + self.label_4.setObjectName("label_4") + self.gridLayout_4.addWidget(self.label_4, 0, 0, 1, 1) + self.spin_duration = QtWidgets.QSpinBox(self.groupBox_5) + self.spin_duration.setMinimum(500) + self.spin_duration.setMaximum(30000) + self.spin_duration.setSingleStep(100) + self.spin_duration.setObjectName("spin_duration") + self.gridLayout_4.addWidget(self.spin_duration, 1, 1, 1, 1) + self.label_5 = QtWidgets.QLabel(self.groupBox_5) + self.label_5.setObjectName("label_5") + self.gridLayout_4.addWidget(self.label_5, 1, 0, 1, 1) + self.label_6 = QtWidgets.QLabel(self.groupBox_5) + self.label_6.setObjectName("label_6") + self.gridLayout_4.addWidget(self.label_6, 1, 2, 1, 1) + self.verticalLayout.addWidget(self.groupBox_5) + self.groupBox_4 = QtWidgets.QGroupBox(Dialog) + self.groupBox_4.setObjectName("groupBox_4") + self.gridLayout_3 = QtWidgets.QGridLayout(self.groupBox_4) + self.gridLayout_3.setObjectName("gridLayout_3") + self.pb_change_server_info = QtWidgets.QPushButton(self.groupBox_4) + self.pb_change_server_info.setObjectName("pb_change_server_info") + self.gridLayout_3.addWidget(self.pb_change_server_info, 0, 0, 1, 1) + spacerItem3 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum) + self.gridLayout_3.addItem(spacerItem3, 0, 1, 1, 1) + self.verticalLayout.addWidget(self.groupBox_4) + self.buttonBox = QtWidgets.QDialogButtonBox(Dialog) + self.buttonBox.setOrientation(QtCore.Qt.Orientation.Horizontal) + self.buttonBox.setStandardButtons(QtWidgets.QDialogButtonBox.StandardButton.Apply|QtWidgets.QDialogButtonBox.StandardButton.Cancel|QtWidgets.QDialogButtonBox.StandardButton.Ok) + self.buttonBox.setObjectName("buttonBox") + self.verticalLayout.addWidget(self.buttonBox) + + self.retranslateUi(Dialog) + self.buttonBox.accepted.connect(Dialog.accept) + self.buttonBox.rejected.connect(Dialog.reject) + QtCore.QMetaObject.connectSlotsByName(Dialog) + Dialog.setTabOrder(self.pb_font_message_title, self.pb_font_message_date) + Dialog.setTabOrder(self.pb_font_message_date, self.pb_font_message_content) + Dialog.setTabOrder(self.pb_font_message_content, self.cb_icons_application) + Dialog.setTabOrder(self.cb_icons_application, self.cb_icons_message) + Dialog.setTabOrder(self.cb_icons_message, self.cb_icons_notification) + Dialog.setTabOrder(self.cb_icons_notification, self.spin_priority) + Dialog.setTabOrder(self.spin_priority, self.spin_duration) + Dialog.setTabOrder(self.spin_duration, self.pb_change_server_info) + + def retranslateUi(self, Dialog): + _translate = QtCore.QCoreApplication.translate + Dialog.setWindowTitle(_translate("Dialog", "Dialog")) + self.groupBox.setTitle(_translate("Dialog", "Fonts")) + self.groupBox_2.setTitle(_translate("Dialog", "Message")) + self.label_3.setText(_translate("Dialog", "Content")) + self.label_2.setText(_translate("Dialog", "Date")) + self.pb_font_message_content.setText(_translate("Dialog", "...")) + self.pb_font_message_date.setText(_translate("Dialog", "...")) + self.label.setText(_translate("Dialog", "Title")) + self.label_font_message_title.setText(_translate("Dialog", "TextLabel")) + self.pb_font_message_title.setText(_translate("Dialog", "...")) + self.label_font_message_date.setText(_translate("Dialog", "TextLabel")) + self.label_font_message_content.setText(_translate("Dialog", "TextLabel")) + self.groupBox_6.setTitle(_translate("Dialog", "Theme")) + self.groupBox_3.setTitle(_translate("Dialog", "Icons")) + self.cb_icons_application.setText(_translate("Dialog", "Show application icons")) + self.cb_icons_message.setText(_translate("Dialog", "Show message icons")) + self.cb_icons_notification.setText(_translate("Dialog", "Show notification icons")) + self.groupBox_5.setTitle(_translate("Dialog", "Notifications")) + self.label_4.setText(_translate("Dialog", "Minimum priority to show notifications:")) + self.label_5.setText(_translate("Dialog", "Notification duration")) + self.label_6.setText(_translate("Dialog", "ms")) + self.groupBox_4.setTitle(_translate("Dialog", "Server info")) + self.pb_change_server_info.setText(_translate("Dialog", "Change server info")) + + +if __name__ == "__main__": + import sys + app = QtWidgets.QApplication(sys.argv) + Dialog = QtWidgets.QDialog() + ui = Ui_Dialog() + ui.setupUi(Dialog) + Dialog.show() + sys.exit(app.exec()) diff --git a/gotify_tray/gui/designs/widget_settings.ui b/gotify_tray/gui/designs/widget_settings.ui new file mode 100644 index 0000000..47551fe --- /dev/null +++ b/gotify_tray/gui/designs/widget_settings.ui @@ -0,0 +1,340 @@ + + + Dialog + + + + 0 + 0 + 375 + 540 + + + + Dialog + + + + + + Fonts + + + + + + Message + + + + + + Content + + + + + + + Date + + + + + + + + 30 + 16777215 + + + + ... + + + + + + + + 30 + 16777215 + + + + ... + + + + + + + Title + + + + + + + TextLabel + + + + + + + + 30 + 16777215 + + + + ... + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + TextLabel + + + + + + + TextLabel + + + + + + + + + + + + + Theme + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + Icons + + + + + + Show application icons + + + + + + + Show message icons + + + + + + + Show notification icons + + + + + + + + + + Notifications + + + + + + 1 + + + 10 + + + 5 + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Minimum priority to show notifications: + + + + + + + 500 + + + 30000 + + + 100 + + + + + + + Notification duration + + + + + + + ms + + + + + + + + + + Server info + + + + + + Change server info + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Apply|QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + pb_font_message_title + pb_font_message_date + pb_font_message_content + cb_icons_application + cb_icons_message + cb_icons_notification + spin_priority + spin_duration + pb_change_server_info + + + + + buttonBox + accepted() + Dialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + Dialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/gotify_tray/gui/images/gotify-small-error.png b/gotify_tray/gui/images/gotify-small-error.png new file mode 100644 index 0000000..39596dd Binary files /dev/null and b/gotify_tray/gui/images/gotify-small-error.png differ diff --git a/gotify_tray/gui/images/gotify-small.png b/gotify_tray/gui/images/gotify-small.png new file mode 100644 index 0000000..eb03248 Binary files /dev/null and b/gotify_tray/gui/images/gotify-small.png differ diff --git a/gotify_tray/gui/images/trashcan.svg b/gotify_tray/gui/images/trashcan.svg new file mode 100644 index 0000000..94b811f --- /dev/null +++ b/gotify_tray/gui/images/trashcan.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/gotify_tray/gui/themes/__init__.py b/gotify_tray/gui/themes/__init__.py new file mode 100644 index 0000000..41081a4 --- /dev/null +++ b/gotify_tray/gui/themes/__init__.py @@ -0,0 +1,12 @@ +from PyQt6 import QtWidgets + + +def set_theme(app: QtWidgets.QApplication, theme: str = "default"): + if theme == "default": + from . import default + + app.setPalette(default.palette()) + elif theme == "dark": + from . import dark + + app.setPalette(dark.palette()) diff --git a/gotify_tray/gui/themes/dark.py b/gotify_tray/gui/themes/dark.py new file mode 100644 index 0000000..b3f18f5 --- /dev/null +++ b/gotify_tray/gui/themes/dark.py @@ -0,0 +1,19 @@ +from PyQt6 import QtCore, QtGui + + +def palette() -> QtGui.QPalette: + palette = QtGui.QPalette() + palette.setColor(QtGui.QPalette.ColorRole.Window, QtGui.QColor(44, 44, 44)) + palette.setColor(QtGui.QPalette.ColorRole.WindowText, QtCore.Qt.GlobalColor.white) + palette.setColor(QtGui.QPalette.ColorRole.Base, QtGui.QColor(52, 52, 52)) + palette.setColor(QtGui.QPalette.ColorRole.AlternateBase, QtGui.QColor(44, 44, 44)) + palette.setColor(QtGui.QPalette.ColorRole.ToolTipBase, QtCore.Qt.GlobalColor.white) + palette.setColor(QtGui.QPalette.ColorRole.ToolTipText, QtCore.Qt.GlobalColor.white) + palette.setColor(QtGui.QPalette.ColorRole.Text, QtCore.Qt.GlobalColor.white) + palette.setColor(QtGui.QPalette.ColorRole.Button, QtGui.QColor(44, 44, 44)) + palette.setColor(QtGui.QPalette.ColorRole.ButtonText, QtCore.Qt.GlobalColor.white) + palette.setColor(QtGui.QPalette.ColorRole.BrightText, QtCore.Qt.GlobalColor.red) + palette.setColor(QtGui.QPalette.ColorRole.Link, QtGui.QColor(198, 99, 255)) + palette.setColor(QtGui.QPalette.ColorRole.Highlight, QtGui.QColor(198, 99, 255)) + palette.setColor(QtGui.QPalette.ColorRole.HighlightedText, QtCore.Qt.GlobalColor.black) + return palette diff --git a/gotify_tray/gui/themes/default.py b/gotify_tray/gui/themes/default.py new file mode 100644 index 0000000..cd45fec --- /dev/null +++ b/gotify_tray/gui/themes/default.py @@ -0,0 +1,6 @@ +from PyQt6 import QtGui + + +def palette() -> QtGui.QPalette: + palette = QtGui.QPalette() + return palette diff --git a/gotify_tray/tasks.py b/gotify_tray/tasks.py new file mode 100644 index 0000000..b7d5135 --- /dev/null +++ b/gotify_tray/tasks.py @@ -0,0 +1,148 @@ +import abc +import logging + +from PyQt6 import QtCore +from PyQt6.QtCore import pyqtSignal + +from . import gotify + + +logger = logging.getLogger("logger") + + +class BaseTask(QtCore.QThread): + failed = pyqtSignal() + + def __init__(self): + super(BaseTask, self).__init__() + self.running = False + + @abc.abstractmethod + def task(self): + ... + + def run(self): + self.running = True + try: + self.task() + except Exception as e: + logger.error(f"{self.__class__.__name__} failed: {e}") + self.failed.emit() + finally: + self.running = False + + +class DeleteMessageTask(BaseTask): + deleted = pyqtSignal(bool) + + def __init__(self, message_id: int, gotify_client: gotify.GotifyClient): + super(DeleteMessageTask, self).__init__() + self.message_id = message_id + self.gotify_client = gotify_client + + def task(self): + success = self.gotify_client.delete_message(self.message_id) + self.deleted.emit(success) + + +class DeleteApplicationMessagesTask(BaseTask): + deleted = pyqtSignal(bool) + + def __init__(self, appid: int, gotify_client: gotify.GotifyClient): + super(DeleteApplicationMessagesTask, self).__init__() + self.appid = appid + self.gotify_client = gotify_client + + def task(self): + success = self.gotify_client.delete_application_messages(self.appid) + self.deleted.emit(success) + + +class DeleteAllMessagesTask(BaseTask): + deleted = pyqtSignal(bool) + + def __init__(self, gotify_client: gotify.GotifyClient): + super(DeleteAllMessagesTask, self).__init__() + self.gotify_client = gotify_client + + def task(self): + success = self.gotify_client.delete_messages() + self.deleted.emit(success) + + +class GetApplicationsTask(BaseTask): + success = pyqtSignal(list) + error = pyqtSignal(gotify.GotifyErrorModel) + + def __init__(self, gotify_client: gotify.GotifyClient): + super(GetApplicationsTask, self).__init__() + self.gotify_client = gotify_client + + def task(self): + result = self.gotify_client.get_applications() + if isinstance(result, gotify.GotifyErrorModel): + self.error.emit(result) + else: + self.success.emit(result) + + +class GetApplicationMessagesTask(BaseTask): + success = pyqtSignal(gotify.GotifyPagedMessagesModel) + error = pyqtSignal(gotify.GotifyErrorModel) + + def __init__(self, appid: int, gotify_client: gotify.GotifyClient): + super(GetApplicationMessagesTask, self).__init__() + self.appid = appid + self.gotify_client = gotify_client + + def task(self): + result = self.gotify_client.get_application_messages(self.appid) + if isinstance(result, gotify.GotifyErrorModel): + self.error.emit(result) + else: + self.success.emit(result) + + +class GetMessagesTask(BaseTask): + success = pyqtSignal(gotify.GotifyPagedMessagesModel) + error = pyqtSignal(gotify.GotifyErrorModel) + + def __init__(self, gotify_client: gotify.GotifyClient): + super(GetMessagesTask, self).__init__() + self.gotify_client = gotify_client + + def task(self): + result = self.gotify_client.get_messages() + if isinstance(result, gotify.GotifyErrorModel): + self.error.emit(result) + else: + self.success.emit(result) + + +class VerifyServerInfoTask(BaseTask): + success = pyqtSignal() + incorrect_token = pyqtSignal() + incorrect_url = pyqtSignal() + + def __init__(self, url: str, client_token: str): + super(VerifyServerInfoTask, self).__init__() + self.url = url + self.client_token = client_token + + def task(self): + try: + gotify_client = gotify.GotifyClient(self.url, self.client_token) + result = gotify_client.get_messages(limit=1) + + if isinstance(result, gotify.GotifyPagedMessagesModel): + self.success.emit() + return + elif ( + isinstance(result, gotify.GotifyErrorModel) + and result["error"] == "Unauthorized" + ): + self.incorrect_token.emit() + return + self.incorrect_url.emit() + except Exception as e: + self.incorrect_url.emit() diff --git a/gotify_tray/utils.py b/gotify_tray/utils.py new file mode 100644 index 0000000..12bbbba --- /dev/null +++ b/gotify_tray/utils.py @@ -0,0 +1,19 @@ +def verify_server(force_new: bool = False) -> bool: + from gotify_tray.gui import ServerInfoDialog + from gotify_tray.database import Settings + + settings = Settings("gotify-tray") + + url = settings.value("Server/url", type=str) + token = settings.value("Server/client_token", type=str) + + if not url or not token or force_new: + dialog = ServerInfoDialog(url, token) + if dialog.exec(): + settings.setValue("Server/url", dialog.line_url.text()) + settings.setValue("Server/client_token", dialog.line_token.text()) + return True + else: + return False + else: + return True diff --git a/images/main_window.png b/images/main_window.png new file mode 100644 index 0000000..a7ecf85 Binary files /dev/null and b/images/main_window.png differ diff --git a/images/notification.png b/images/notification.png new file mode 100644 index 0000000..e9e16d6 Binary files /dev/null and b/images/notification.png differ diff --git a/images/notification_centre.png b/images/notification_centre.png new file mode 100644 index 0000000..519842b Binary files /dev/null and b/images/notification_centre.png differ diff --git a/logo.ico b/logo.ico new file mode 100644 index 0000000..f6607b6 Binary files /dev/null and b/logo.ico differ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3cd3315 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +requests==2.26.0 +websocket-client==1.1.0 +pyqt6==6.1.1 \ No newline at end of file diff --git a/version.py b/version.py new file mode 100644 index 0000000..77ec441 --- /dev/null +++ b/version.py @@ -0,0 +1,45 @@ +# UTF-8 +# +# For more details about fixed file info 'ffi' see: +# http://msdn.microsoft.com/en-us/library/ms646997.aspx +VSVersionInfo( + ffi=FixedFileInfo( + # filevers and prodvers should be always a tuple with four items: (1, 2, 3, 4) + # Set not needed items to zero 0. + filevers=(0, 0, 11, 0), + prodvers=(0, 0, 11, 0), + # Contains a bitmask that specifies the valid bits 'flags'r + mask=0x3f, + # Contains a bitmask that specifies the Boolean attributes of the file. + flags=0x0, + # The operating system for which this file was designed. + # 0x4 - NT and there is no need to change it. + # OS=0x40004, + OS=0x4, + # The general type of file. + # 0x1 - the file is an application. + fileType=0x1, + # The function of the file. + # 0x0 - the function is not defined for this fileType + subtype=0x0, + # Creation date and time stamp. + date=(0, 0) + ), + kids=[ + StringFileInfo( + [ + StringTable( + u'040904B0', + [StringStruct(u'Comments', u'Gotify Tray'), + StringStruct(u'CompanyName', u''), + StringStruct(u'FileDescription', u'Gotifiy Tray'), + StringStruct(u'FileVersion', u'0.0.11'), + StringStruct(u'InternalName', u'gotify-tray'), + StringStruct(u'LegalCopyright', u''), + StringStruct(u'OriginalFilename', u'gotify-tray.exe'), + StringStruct(u'ProductName', u'Gotify Tray'), + StringStruct(u'ProductVersion', u'0.0.11')]) + ]), + VarFileInfo([VarStruct(u'Translation', [0, 1200])]) + ] +) diff --git a/version.txt b/version.txt new file mode 100644 index 0000000..58682af --- /dev/null +++ b/version.txt @@ -0,0 +1 @@ +0.0.11 \ No newline at end of file