diff --git a/ClipCascade/LICENSE b/ClipCascade/LICENSE new file mode 100755 index 0000000..f288702 --- /dev/null +++ b/ClipCascade/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 +. diff --git a/ClipCascade/cli/echo.py b/ClipCascade/cli/echo.py new file mode 100755 index 0000000..35b6e8d --- /dev/null +++ b/ClipCascade/cli/echo.py @@ -0,0 +1,3 @@ +class Echo: + def __init__(self, *args, **kwargs): + print(*args, **kwargs) diff --git a/ClipCascade/cli/info.py b/ClipCascade/cli/info.py new file mode 100755 index 0000000..bc20858 --- /dev/null +++ b/ClipCascade/cli/info.py @@ -0,0 +1,23 @@ +from cli.echo import Echo + + +class CustomDialog: + def __init__(self, message, msg_type="", timeout=None): + self.message = message + self.msg_type = msg_type.lower() + self.timeout = timeout + + def mainloop(self): + if self.msg_type == "success": + Echo("✅", self.message) + elif self.msg_type == "error": + Echo("❌", self.message) + elif self.msg_type == "warning": + Echo("⚠️", self.message) + elif self.msg_type == "info": + Echo("ℹ", self.message) + else: + Echo(self.message) + + def close(self): + pass diff --git a/ClipCascade/cli/login.py b/ClipCascade/cli/login.py new file mode 100755 index 0000000..d11845b --- /dev/null +++ b/ClipCascade/cli/login.py @@ -0,0 +1,214 @@ +import getpass +import os +import re + +from core.config import Config +from cli.info import CustomDialog +from core.constants import * + + +class LoginForm: + def __init__( + self, + config: Config, + on_login_callback=None, + on_quit_callback=None, + ): + self.config = config + self.on_login_callback = on_login_callback + self.on_quit_callback = on_quit_callback + + @staticmethod + def remove_trailing_slash(entry): + return re.sub(r"/+$", "", entry) + + @staticmethod + def is_positive_integer(value): + if value.isdigit() and int(value) > 0: + return True + else: + return False + + @staticmethod + def bool_to_str(value): + return "y" if value else "n" + + @staticmethod + def str_to_bool(value): + return value.lower().strip() == "y" + + @staticmethod + def is_positive_integer(value): + if value.isdigit() and int(value) > 0: + return True + else: + return False + + def mainloop(self): + # save data to config + self.config.data["username"] = ( + input(f"username [{self.config.data['username']}]: ") + or self.config.data["username"] + ) + + self.config.data["password"] = getpass.getpass("password: ") + + server_url = ( + input(f"server url [{self.config.data['server_url']}]: ") + or self.config.data["server_url"] + ) + self.config.data["server_url"] = LoginForm.remove_trailing_slash( + server_url.strip() + ) + + self.config.data["cipher_enabled"] = LoginForm.str_to_bool( + input( + f"enabled encryption(recommended) [{LoginForm.bool_to_str(self.config.data['cipher_enabled'])}]: " + ) + or LoginForm.bool_to_str(self.config.data["cipher_enabled"]) + ) + + self.config.data["notification"] = LoginForm.str_to_bool( + input( + f"enabled notification [{LoginForm.bool_to_str(self.config.data['notification'])}]: " + ) + or LoginForm.bool_to_str(self.config.data["notification"]) + ) + + # extra fields + show_extra_fields = input(f"show extra fields [n]: ") or "n" + if not LoginForm.str_to_bool(show_extra_fields): + CustomDialog("Logging in...").mainloop() + return + + # Validate hash_rounds + while True: + hash_rounds = input( + f"hash rounds [{self.config.data['hash_rounds']}]: " + ) or str( + "" + if self.config.data["hash_rounds"] is None + else str(self.config.data["hash_rounds"]) + ) + hash_rounds = hash_rounds.strip() + + if not LoginForm.is_positive_integer(hash_rounds): + CustomDialog( + "Invalid Input\nHash Rounds must be a positive integer.", + msg_type="error", + ) + continue + + self.config.data["hash_rounds"] = int(hash_rounds) + break + + self.config.data["salt"] = ( + input(f"salt [{self.config.data['salt']}]: ") or self.config.data["salt"] + ) + + self.config.data["save_password"] = LoginForm.str_to_bool( + input( + f"store password locally(not recommended; only works if encryption is disabled) [{LoginForm.bool_to_str(self.config.data['save_password'])}]: " + ) + or LoginForm.bool_to_str(self.config.data["save_password"]) + ) + + # Validate max_clipboard_size_local_limit_bytes + while True: + saved_mcsll = str( + "" + if self.config.data["max_clipboard_size_local_limit_bytes"] is None + else str(self.config.data["max_clipboard_size_local_limit_bytes"]) + ) + + max_clipboard_size_local_limit_bytes = ( + input( + f"maximum clipboard size local limit (in bytes) [{saved_mcsll}]: " + ) + or saved_mcsll + ) + max_clipboard_size_local_limit_bytes = ( + max_clipboard_size_local_limit_bytes.strip() + ) + if max_clipboard_size_local_limit_bytes == "": + self.config.data["max_clipboard_size_local_limit_bytes"] = None + break + + if not LoginForm.is_positive_integer(max_clipboard_size_local_limit_bytes): + CustomDialog( + "Invalid Input\nMax Clipboard Size Local Limit must be a positive integer.", + msg_type="error", + ) + continue + + self.config.data["max_clipboard_size_local_limit_bytes"] = int( + max_clipboard_size_local_limit_bytes + ) + break + + self.config.data["enable_image_sharing"] = LoginForm.str_to_bool( + input( + f"enable image sharing [{LoginForm.bool_to_str(self.config.data['enable_image_sharing'])}]: " + ) + or LoginForm.bool_to_str(self.config.data["enable_image_sharing"]) + ) + + self.config.data["enable_file_sharing"] = LoginForm.str_to_bool( + input( + f"enable file sharing [{LoginForm.bool_to_str(self.config.data['enable_file_sharing'])}]: " + ) + or LoginForm.bool_to_str(self.config.data["enable_file_sharing"]) + ) + + # Validate default file download location + while True: + default_file_download_location = ( + input( + f"default file download location [{self.config.data['default_file_download_location']}]: " + ) + or self.config.data["default_file_download_location"] + ) + + default_file_download_location = default_file_download_location.strip() + if default_file_download_location == "": + self.config.data["default_file_download_location"] = "" + break + + if not os.path.isdir(default_file_download_location): + CustomDialog( + "Invalid directory path.", + msg_type="error", + ).mainloop() + continue + + self.config.data["default_file_download_location"] = ( + default_file_download_location + ) + break + + while True: + ssl_ca_bundle = ( + input( + f"ssl ca bundle path (optional) [{self.config.data.get('ssl_ca_bundle') or ''}]: " + ) + or (self.config.data.get("ssl_ca_bundle") or "") + ).strip() + if ssl_ca_bundle == "": + self.config.data["ssl_ca_bundle"] = "" + break + if not os.path.isfile(ssl_ca_bundle): + CustomDialog( + "Invalid Input\nSSL CA bundle path is not a file or does not exist.", + msg_type="error", + ).mainloop() + continue + self.config.data["ssl_ca_bundle"] = ssl_ca_bundle + break + + CustomDialog("Logging in...").mainloop() + + def on_quit(self): + pass + + def close(self): + pass diff --git a/ClipCascade/cli/message_box.py b/ClipCascade/cli/message_box.py new file mode 100755 index 0000000..1ba3434 --- /dev/null +++ b/ClipCascade/cli/message_box.py @@ -0,0 +1,4 @@ +class MessageBox: + def askquestion(self, title, message): + response = input(f"{title}\n{message} (y/n): ").strip().lower() + return "yes" if (response.lower().strip() == "y") else "no" diff --git a/ClipCascade/cli/tray.py b/ClipCascade/cli/tray.py new file mode 100755 index 0000000..a282cfb --- /dev/null +++ b/ClipCascade/cli/tray.py @@ -0,0 +1,367 @@ +import math +import os +import threading +import time +import webbrowser + + +from cli.echo import Echo +from cli.info import CustomDialog +from core.config import Config +from core.constants import * +from itertools import count + + +if PLATFORM != WINDOWS: + import subprocess + + +class TaskbarPanel: + def __init__( + self, + on_connect_callback: callable = None, + on_disconnect_callback: callable = None, + on_logoff_callback: callable = None, + new_version_available: list = None, + github_url: str = GITHUB_URL, + donation_url: str = None, + ws_interface=None, # type= interfaces.ws_interface.WSInterface + config: Config = None, + ): + self.on_connect_callback = on_connect_callback + self.on_disconnect_callback = on_disconnect_callback + self.on_logoff_callback = on_logoff_callback + self.new_version_available = new_version_available + self.github_url = github_url + self.donation_url = donation_url + self.ws_interface = ws_interface + self.config = config + + self.is_disconnecting = False + self.disconnecting_items = None + self.is_file_download_enabled = False + self.file_download_items = None + self.previous_stats: str = "" + self.previous_stats_items = None + + # Initial state: Connected + self.is_connected = True + + self.menu_items = None + self.numbered_menu = None + self.loop_terminate = False + + self.update_stats() # Start the stats update thread + + def run(self): + Echo("-" * 53) + self.menu_items, self.numbered_menu = self.create_menu() + + while not self.loop_terminate: + Echo("\n") + + self.display_menu() + + # input + choice = input("Choice: \n") + Echo("Please wait...") + if choice == "": # refresh + continue + + if choice.isdigit(): + choice = int(choice) + else: + Echo("Invalid choice. Please enter a number.") + + if choice in self.numbered_menu: + fun = self.numbered_menu[choice][1] + if fun is not None: + fun() + else: + Echo("Invalid choice.") + + def get_max_width(self): + """Determine the maximum width of menu item texts.""" + menu_texts = [ + item[0] if isinstance(item, tuple) else "" for item in self.menu_items + ] + return max(len(text) for text in menu_texts) + 1 + + def display_menu(self): + counter = count(1) + max_width = self.get_max_width() + + total_width = max_width + 8 + + # Box Top Border + print("\n╔" + "═" * (max_width + 6) + "╗") + print(f"║{' MAIN MENU ':^{max_width + 6}}║") + print("╠" + "═" * (max_width + 6) + "╣") + + # Menu Items + for item in self.menu_items: + if isinstance(item, tuple): + width = max_width + if "disconnect" in item[0].lower() or "logs" in item[0].lower(): + if "disconnecting" in item[0].lower(): + width -= 1 + else: + width += 1 + else: + width -= 1 + print(f"║ {next(counter):<2}. {item[0]:<{width}} ║") + else: + print(f"║ {'─' * (max_width + 4)} ║") + + # Box Bottom Border + print("╚" + "═" * (max_width + 6) + "╝") + + def create_menu(self, item_: tuple = None): + # Menu items + menu_items = [ + "", # Menu Separator + ("🗒️ Open Logs", self._open_logs), + ("📂 Program Files", self._open_program_location), + "", # Menu Separator + ("🏠 Homepage", self._open_homepage), + ("❓ Help", self._open_help), + ("💟 Donate", self._open_donate), + ("🌐 GitHub", self._open_github), + "", # Menu Separator + ("🔒 Logoff and Quit", self._on_logoff), + ("❌ Quit", self._on_quit), + ] + + # Add connect/disconnect option (top of the menu - 0 index) + if not self.is_connected: + menu_items.insert(0, ("🔗 Connect", self._on_connect)) + else: + if self.is_disconnecting and self.disconnecting_items is not None: + menu_items.insert( + self.disconnecting_items[1], + (self.disconnecting_items[0], self.disconnecting_items[2]), + ) + else: + menu_items.insert(0, ("⛓️‍💥 Disconnect", self._on_disconnect)) + + # Add update option (before the last 3 items) + if self.new_version_available is not None and self.new_version_available[0]: + menu_items.insert( + len(menu_items) - 3, + ( + f"🔄 Update ({self.new_version_available[2]} ➞ {self.new_version_available[1]})", + self._on_update, + ), + ) + + # Add files download option (top of the menu - 0 index) + if self.is_file_download_enabled and self.file_download_items is not None: + menu_items.insert( + self.file_download_items[1], + (self.file_download_items[0], self.file_download_items[2]), + ) + + # Add stats option (top of the menu - 0 index) + if self.previous_stats_items is not None: + menu_items.insert( + self.previous_stats_items[1], + (self.previous_stats_items[0], self.previous_stats_items[2]), + ) + + # Create numbered menu + counter = count(1) + numbered_menu = { + next(counter): item for item in menu_items if isinstance(item, tuple) + } + + return (menu_items, numbered_menu) + + def update_menu(self, item_: tuple = None, no_dialog=False): + self.menu_items, self.numbered_menu = self.create_menu(item_=item_) + if not no_dialog: + CustomDialog( + "Menu Updated! Press Enter to Refresh. ⏎", msg_type="info" + ).mainloop() + + def update_stats(self): + threading.Thread(target=self._update_stats_thread, daemon=True).start() + + def _update_stats_thread(self): + while True: + current_stats = self.ws_interface.get_stats() + if current_stats is not None and self.previous_stats != current_stats: + self.previous_stats = current_stats + self.previous_stats_items = (current_stats, 0, None) + self.update_menu(no_dialog=True) + time.sleep(1) # Sleep for 1 second + + @staticmethod + def open_webbrowser(url): + try: + webbrowser.open(url) + except Exception as e: + CustomDialog( + f"Failed to open the browser. Here is the URL: {url}\nError: {e}", + msg_type="error", + ).mainloop() + + def _on_update(self): + TaskbarPanel.open_webbrowser(self.new_version_available[3]) + + def _on_connect(self): + if self.on_connect_callback: + try: + self.on_connect_callback() + except Exception as e: + pass + self.is_connected = True + self.update_menu(no_dialog=True) + + def _on_disconnect(self): + if self.on_disconnect_callback: + self.on_disconnect_callback() + if self.ws_interface is not None and self.ws_interface.is_auto_reconnecting: + threading.Thread(target=self._wait_to_disconnect, daemon=True).start() + else: + self.is_connected = False + self.update_menu(no_dialog=True) + + def _wait_to_disconnect(self): + """ + Wait for the auto-reconnect timer to expire before disconnecting. + """ + if self.ws_interface is None: + return + + timeout = math.ceil(self.ws_interface.get_total_timeout() / 1000) + while timeout > 0: + self.is_disconnecting = True + self.disconnecting_items = ( + f"⏳ Disconnecting... ({timeout} sec)", + 0, + None, + ) # text, location, callback + self.update_menu(no_dialog=True) + time.sleep(1) # seconds + timeout -= 1 + self.is_disconnecting = False + self.disconnecting_items = None + self.ws_interface.is_auto_reconnecting = False + self.is_connected = False + self.update_menu() + + def _open_homepage(self): + TaskbarPanel.open_webbrowser(self.config.data["server_url"]) + + def _open_github(self): + TaskbarPanel.open_webbrowser(self.github_url) + + def _open_help(self): + TaskbarPanel.open_webbrowser(HELP_URL) + + def _open_donate(self): + if self.donation_url is not None: + TaskbarPanel.open_webbrowser(self.donation_url) + + def open_location(self, path): + if PLATFORM == WINDOWS: + os.startfile(path) + elif PLATFORM == MACOS: + subprocess.run(["open", path]) + elif PLATFORM.startswith(LINUX): + subprocess.run(["xdg-open", path]) + + def _open_logs(self): + log_file_path = os.path.join(get_program_files_directory(), LOG_FILE_NAME) + if os.path.exists(log_file_path): + try: + self.open_location(log_file_path) + except Exception as e: + CustomDialog( + f"Failed to open the log file '{log_file_path}'.\nError: {e}", + msg_type="error", + ).mainloop() + else: + CustomDialog( + f"Log file not found at '{log_file_path}'.", msg_type="error" + ).mainloop() + + def _open_program_location(self): + try: + program_location = get_program_files_directory() + self.open_location(program_location) + except Exception as e: + CustomDialog( + f"Failed to open the program location '{program_location}'.\nError: {e}", + msg_type="error", + ).mainloop() + + def enable_files_download(self, files): + self.is_file_download_enabled = True + self.file_download_items = ( + "📥 Download File(s)", + 0, + lambda: self._on_download(files), + ) + self.update_menu() + + def disable_files_download(self): + self.is_file_download_enabled = False + self.file_download_items = None + self.update_menu() + + def _on_download(self, files): + try: + if self.config.data["default_file_download_location"] != "": + target_directory = self.config.data["default_file_download_location"] + else: + target_directory = input("Enter the location to save file(s): ").strip() + if not target_directory: + CustomDialog( + "No input provided. Please enter a valid directory path.", + msg_type="error", + ).mainloop() + return + + if not os.path.isdir(target_directory): + CustomDialog("Invalid directory path.", msg_type="error").mainloop() + return + + if not os.access(target_directory, os.W_OK): + CustomDialog( + "Insufficient permissions to write to the directory.", + msg_type="error", + ).mainloop() + return + + CustomDialog( + f"Saving files to: {target_directory}", msg_type="info" + ).mainloop() + # Save each file to the chosen directory + for filename, file_obj in files.items(): + file_path = os.path.join(target_directory, filename) + with open(file_path, "wb") as f: + f.write(file_obj.getvalue()) + logging.debug(f"Saved: {file_path}") + CustomDialog("Done.", msg_type="success").mainloop() + + except Exception as e: + msg = f"An error occurred while downloading files. Error: {e}" + logging.error(msg) + CustomDialog( + msg, + msg_type="error", + ).mainloop() + + def _on_logoff(self): + try: + if self.on_logoff_callback: + self.on_logoff_callback() + self._on_quit() + except Exception as e: + CustomDialog( + f"An error occurred while logging off: {e}", msg_type="error" + ).mainloop() + + def _on_quit(self): + self.loop_terminate = True diff --git a/ClipCascade/clipboard/clipboard_manager.py b/ClipCascade/clipboard/clipboard_manager.py new file mode 100755 index 0000000..6a83f64 --- /dev/null +++ b/ClipCascade/clipboard/clipboard_manager.py @@ -0,0 +1,464 @@ +import base64 +import io +import json +import logging +import os +import xxhash + + +from PIL import Image +from core.constants import * +from core.config import Config + +if PLATFORM.startswith(LINUX) and LINUX_USE_CLI_UI: + from cli.tray import TaskbarPanel +else: + from gui.tray import TaskbarPanel + +if PLATFORM == WINDOWS or PLATFORM == MACOS: + import pyperclip + + +if PLATFORM == WINDOWS: + import win32clipboard + from clipboard import clipboard_monitor_win as clipboard_monitor +elif PLATFORM == MACOS: + import pasteboard + from clipboard import clipboard_monitor_mac as clipboard_monitor +elif PLATFORM.startswith(LINUX): + from clipboard import clipboard_monitor_linux as clipboard_monitor + import subprocess + + +class ClipboardManager: + def __init__(self, config: Config): + self.config = config + self.previous_clipboard_hash = 0 + self.sys_tray: TaskbarPanel = None + self.is_files_download_enabled = False + + if PLATFORM.startswith(LINUX) and XMODE: + self.is_x_clipboard_owner = clipboard_monitor.is_x_clipboard_owner() + + def set_tray_ref(self, sys_tray: TaskbarPanel): + """ + Sets the system tray reference. + """ + self.sys_tray = sys_tray + + def reset_files_download(self): + """ + Resets the files download flag and disables the download functionality in the system tray if enabled. + """ + if self.is_files_download_enabled: + self.is_files_download_enabled = False + if self.sys_tray: + self.sys_tray.disable_files_download() + + @staticmethod + def hash_clipboard(clipboard: str) -> int: + return xxhash.xxh64(clipboard).intdigest() + + def is_clipboard_size_within_limit( + self, clipboard_content: any, type_: str = "text" + ) -> bool: + if clipboard_content is None: + raise ValueError("Clipboard content cannot be None") + + content_size_in_bytes = None + if type_ == "text": + content_size_in_bytes = len(clipboard_content.encode("utf-8")) + elif type_ == "image": + content_size_in_bytes = ClipboardManager.get_image_size( + img=clipboard_content + ) + elif type_ == "files": + content_size_in_bytes = ClipboardManager.calculate_cumulative_file_size( + files=clipboard_content + ) + + # Check if the content size exceeds the server limit + max_allowed_size = self.config.data["maxsize"] + if ( + max_allowed_size is not None + and max_allowed_size >= 0 + and content_size_in_bytes > max_allowed_size + ): + logging.warning( + "Clipboard content size exceeds the maximum allowed limit. " + f"Allowed: {max_allowed_size} bytes, Found: {content_size_in_bytes} bytes." + ) + return False + + # Check if the content size exceeds the local limit + local_clipboard_size_limit = self.config.data[ + "max_clipboard_size_local_limit_bytes" + ] + if local_clipboard_size_limit is not None and local_clipboard_size_limit >= 0: + if content_size_in_bytes > local_clipboard_size_limit: + logging.warning( + "Clipboard content size exceeds the local allowed limit. " + f"Allowed: {local_clipboard_size_limit} bytes, Found: {content_size_in_bytes} bytes." + ) + return False + + return True + + def has_clipboard_changed(self, payload: str) -> bool: + """ + Check if the clipboard content has changed by comparing the current hash + with the previous clipboard hash. + + Parameters: + - payload: The current clipboard content. + + Returns: + - True if the clipboard content has changed, False otherwise. + """ + current_clipboard_hash = ClipboardManager.hash_clipboard(payload) + if current_clipboard_hash != self.previous_clipboard_hash: + self.previous_clipboard_hash = current_clipboard_hash + return True + return False + + def on_copy(self, copy_callback): + clipboard_monitor.on_update( + callback=lambda type_, content: self.clipboard_to_base64( + copy_callback, content, type_ + ), + enable_image_monitoring=self.config.data["enable_image_sharing"], + enable_file_monitoring=self.config.data["enable_file_sharing"], + ) + + def clipboard_to_base64(self, callback, content: any, type_: str = "text"): + try: + self.reset_files_download() + + type_ = type_.lower() + if type_ == "text": + if self.is_clipboard_size_within_limit(content, type_): + callback(content, type_) + + elif type_ == "image": + if isinstance(content, list): + if content is None or len(content) == 0: + raise ValueError( + "Clipboard image content cannot be None or empty" + ) + + content = Image.open(content[0]) + if self.is_clipboard_size_within_limit(content, type_): + content_str = ClipboardManager.convert_image_to_base64(img=content) + callback(content_str, type_) + + elif type_ == "files": + if PLATFORM.startswith(LINUX): + if content is not None and len(content) > 0: + temp = [] + for path in content: + if path.startswith("file:"): + temp.append(path[5:]) + else: + temp.append(path) + content = temp + + if self.is_clipboard_size_within_limit(content, type_): + content_str = ClipboardManager.convert_files_to_base64( + file_paths=content + ) + if content_str != "{}": # Check if the JSON string is empty + callback(content_str, type_) + except Exception as e: + logging.error(f"Failed to convert clipboard data to base64: {e}") + + def base64_to_clipboard(self, base64_string: str, type_: str = "text"): + try: + if type_ == "text": + txt = base64_string + if self.is_clipboard_size_within_limit(txt, type_): + self.paste(txt, type_) + elif type_ == "image": + img = ClipboardManager.convert_base64_to_image(base64_img=base64_string) + if self.is_clipboard_size_within_limit(img, type_): + self.paste(img, type_) + elif type_ == "files": + file_objects = ClipboardManager.convert_base64_to_files( + base64_json=base64_string + ) + if self.is_clipboard_size_within_limit(file_objects, type_): + self.paste(file_objects, type_) + except Exception as e: + logging.error(f"Failed to convert base64 data to clipboard: {e}") + + @staticmethod + def execute_command(*args, input_data): + """ + Execute a command with input data. + + Parameters: + - *args: Positional arguments for the command. + - input_data: Input data to be passed to the command. + """ + if PLATFORM.startswith(LINUX): + try: + subprocess.run( + args, + input=input_data, + check=True, + ) + except Exception as e: + logging.error(f"Failed to execute command: {e}") + raise + + def paste(self, payload: any, payload_type: str = "text"): + try: + self.reset_files_download() + + if payload_type == "text": + if PLATFORM == WINDOWS or PLATFORM == MACOS: + pyperclip.copy(payload) + elif PLATFORM.startswith(LINUX): + if XMODE and self.is_x_clipboard_owner: + ClipboardManager.execute_command( + "xclip", + "-selection", + "clipboard", + input_data=payload.encode("utf-8"), + ) + else: + ClipboardManager.execute_command( + "wl-copy", + input_data=payload.encode("utf-8"), + ) + + elif payload_type == "image": + if PLATFORM == WINDOWS: + # Save the image to a binary buffer in BMP format + with io.BytesIO() as output: + payload.convert("RGB").save(output, format="BMP") + bmp_data = output.getvalue()[14:] # Skip BMP header (14 bytes) + + win32clipboard.OpenClipboard() + win32clipboard.EmptyClipboard() + clipboard_monitor.enable_block_image_once() # Block image copy to prevent deadlock + win32clipboard.SetClipboardData(win32clipboard.CF_DIB, bmp_data) + win32clipboard.CloseClipboard() + elif PLATFORM == MACOS: + # Save the image to a binary buffer in TIFF format + with io.BytesIO() as output: + payload.convert("RGB").save(output, format="TIFF") + tiff_data = output.getvalue() + + clipboard_monitor.enable_block_image_once() # Block image copy to prevent deadlock + clipboard_monitor.write_to_pasteboard(tiff_data, pasteboard.TIFF) + elif PLATFORM.startswith(LINUX): + # Save the image to a binary buffer in PNG format + with io.BytesIO() as output: + payload.convert("RGB").save(output, format="PNG") + png_data = output.getvalue() + + clipboard_monitor.enable_block_image_once() # Block image copy to prevent deadlock + if XMODE and self.is_x_clipboard_owner: + ClipboardManager.execute_command( + "xclip", + "-selection", + "clipboard", + "-t", + "image/png", + input_data=png_data, + ) + else: + ClipboardManager.execute_command( + "wl-copy", + "--type", + "image/png", + input_data=png_data, + ) + + elif payload_type == "files": + if ( + payload is not None + and isinstance(payload, dict) + and len(payload) > 0 + ): + if self.sys_tray is not None: + self.is_files_download_enabled = True + self.sys_tray.enable_files_download(files=payload) + + except Exception as e: + logging.error(f"Failed to copy data to clipboard: {e}") + raise + + def stop(self): + self.reset_files_download() + clipboard_monitor.stop() + + @staticmethod + def calculate_cumulative_file_size(files: tuple | list | dict) -> int: + """ + Calculate the cumulative size of a list of files. + + Args: + files (tuple or list): A tuple of file paths. + files (dict): A dictionary of files with file names as keys and file object as values. + + Returns: + int: The cumulative size of the files in bytes. + + Raises: + IOError: If a file cannot be read or processed. + """ + cumulative_size = 0 + if isinstance(files, tuple | list): + for file_path in files: + try: + if os.path.isfile(file_path): + cumulative_size += os.path.getsize(file_path) + except Exception as e: + raise IOError( + f"Failed to calculate size for file '{file_path}' {e}." + ) from e + + if isinstance(files, dict): + for file_name, file_object in files.items(): + try: + cumulative_size += file_object.getbuffer().nbytes + except Exception as e: + raise IOError( + f"Failed to calculate size for file '{file_name}' {e}." + ) from e + + return cumulative_size + + @staticmethod + def convert_files_to_base64(file_paths: tuple | list) -> str: + """ + Converts a list of files to base64-encoded strings and returns them as a JSON string. + + Args: + file_paths (tuple or list): A tuple of file paths to be converted. + + Returns: + str: A JSON string where file names are keys and base64-encoded content is values. + + Raises: + IOError: If a file cannot be read or processed. + """ + base64_encoded_files = {} + for file_path in file_paths: + try: + if os.path.isfile(file_path): + file_name = os.path.basename(file_path) + with open(file_path, "rb") as file: + encoded_string = base64.b64encode(file.read()).decode("utf-8") + base64_encoded_files[file_name] = encoded_string + except Exception as e: + raise IOError(f"Failed to process file '{file_path}'. {e}") from e + + return json.dumps(base64_encoded_files) + + @staticmethod + def convert_base64_to_files(base64_json: dict) -> dict: + """ + Converts a JSON string with base64-encoded file content to a dictionary of file-like objects. + + Args: + base64_json (str): A JSON string where file names are keys and base64-encoded content is values. + + Returns: + dict: A dictionary where keys are file names, and values are BytesIO objects containing the decoded file data. + """ + file_objects = {} + try: + base64_data = json.loads(base64_json) + for file_name, encoded_content in base64_data.items(): + decoded_content = base64.b64decode(encoded_content) + file_objects[file_name] = io.BytesIO(decoded_content) + except Exception as e: + raise IOError(f"Error processing base64 JSON. {e}") from e + + return file_objects + + @staticmethod + def get_image_size(img: Image.Image | bytes) -> int: + """ + Calculate the size of an image in bytes. + + Args: + img (Image.Image | bytes): The image to calculate the size for. + + Returns: + int: The size of the image in bytes. + + Raises: + IOError: If the image cannot be processed or saved. + """ + try: + if isinstance(img, bytes): + size_in_bytes = len(img) + if isinstance(img, Image.Image): + with io.BytesIO() as buffer: + img.save(buffer, format=img.format or "PNG") + size_in_bytes = buffer.tell() + + return size_in_bytes + except Exception as e: + raise IOError(f"Failed to calculate the image size. {e}") from e + + @staticmethod + def convert_image_to_base64(img: Image.Image | bytes) -> str: + """ + Converts an image to a base64-encoded string. + + Args: + img (Image.Image | bytes): The image to be converted. + + Returns: + str: The base64-encoded string representation of the image. + + Raises: + IOError: If the image cannot be processed or saved. + """ + try: + if isinstance(img, bytes): + base64_string = base64.b64encode(img).decode("utf-8") + if isinstance(img, Image.Image): + with io.BytesIO() as buffered: + img.save(buffered, format=img.format or "PNG") + base64_string = base64.b64encode(buffered.getvalue()).decode( + "utf-8" + ) + + return base64_string + except Exception as e: + raise IOError(f"Failed to convert the image to base64. {e}") from e + + @staticmethod + def convert_base64_to_image(base64_img: str) -> Image.Image: + """ + Converts a base64-encoded string to a PIL Image object. + + Args: + base64_img (str): The base64-encoded string to be converted. + + Returns: + Image.Image: A PIL Image object representing the decoded image. + + Raises: + ValueError: If the base64 string is invalid. + IOError: If the image cannot be processed. + """ + try: + # Decode the base64 string + image_data = base64.b64decode(base64_img) + except base64.binascii.Error as e: + raise ValueError("Invalid base64 string.") from e + + try: + # Load the image from the decoded bytes + with io.BytesIO(image_data) as image_stream: + image = Image.open(image_stream) + image.load() + return image + except IOError as e: + raise IOError(f"Failed to process the image. {e}") from e diff --git a/ClipCascade/clipboard/clipboard_monitor_linux.py b/ClipCascade/clipboard/clipboard_monitor_linux.py new file mode 100755 index 0000000..8d7d36e --- /dev/null +++ b/ClipCascade/clipboard/clipboard_monitor_linux.py @@ -0,0 +1,486 @@ +import logging +import re +import subprocess +import threading +import time + +from core.constants import * + +_callback_update = None +_clipboard_thread = None + +_block_image_once = False + +_is_gdk_running = False +_run_poll = threading.Event() +_wl_watch_proc = None + + +def _on_clipboard_changed( + clipboard, event=None, enable_image_monitoring=False, enable_file_monitoring=False +): + global _block_image_once + + # Files + if enable_file_monitoring: + uris = clipboard.wait_for_uris() + if uris is not None and len(uris) > 0: + if _callback_update: + _callback_update("files", uris) + return + + # Text + text = clipboard.wait_for_text() + if text is not None and len(text) > 0: + if _callback_update: + _callback_update("text", text) + return + + # Image + if enable_image_monitoring: + pixbuf = clipboard.wait_for_image() + if pixbuf is not None: + if _block_image_once: + _block_image_once = False + return + if _callback_update: + success, buffer = pixbuf.save_to_bufferv("png") + if success: + _callback_update("image", bytes(buffer)) + else: + logging.error("Failed to convert image(pixbuf) to buffer") + return + + +def _monitor_x_wl_clipboard( + x_mode: bool, + enable_image_monitoring=False, + enable_file_monitoring=False, +): + global _block_image_once + last_error = None + previous_clipboard: str | bytes | None = None + ignore_patterns = [ + r"target .+ not available", # xclip pattern + r"no suitable type of content copied", # wl-clipboard pattern + ] + + if LINUX_CLIPBOARD_POLL_INTERVAL_SEC is not None: + timeout = LINUX_CLIPBOARD_POLL_INTERVAL_SEC + logging.info(f"Clipboard polling interval (--polling): {timeout}s") + elif x_mode: + timeout = 0.3 # xclip seconds + else: + timeout = 3 # wl-clipboard seconds + + while _run_poll.is_set(): + if x_mode: + success, mime_list = execute_command( + "xclip", "-selection", "clipboard", "-t", "TARGETS", "-o" + ) + else: + success, mime_list = execute_command("wl-paste", "-l") + if not success: + error_msg = f"Failed to retrieve MIME types: {mime_list}" + if error_msg != last_error: + logging.error(error_msg) + last_error = error_msg + time.sleep(timeout) + continue + + mime_list = mime_list.decode("utf-8") + mime_list = mime_list.replace("\r\n", "\n").replace("\r", "\n").split("\n") + mime_list = [m.strip() for m in mime_list if len(m.strip()) > 0] + type_ = convert_mime_to_generic_type(mime_list) + + # Text + if type_ == "text": + if x_mode: + success, text = execute_command( + "xclip", "-selection", "clipboard", "-o" + ) + else: + success, text = execute_command("wl-paste", "-n") + if success: + text = text.decode("utf-8") + if len(text) > 0 and text != previous_clipboard: + previous_clipboard = text + if _callback_update: + _callback_update("text", text) + else: + error_msg = f"Failed to retrieve text content from clipboard. {text}" + if error_msg != last_error: + if not any( + re.search(pattern, error_msg.lower()) + for pattern in ignore_patterns + ): + logging.error(error_msg) + last_error = error_msg + + # Image + if type_ == "image" and enable_image_monitoring: + if x_mode: + success, image = execute_command( + "xclip", + "-selection", + "clipboard", + "-t", + "image/png", + "-o", + ) + else: + success, image = execute_command("wl-paste", "-t", "image/png") + if success: + if image != previous_clipboard: + previous_clipboard = image + if _callback_update and not _block_image_once: + _callback_update("image", image) + else: + _block_image_once = False + else: + error_msg = f"Failed to retrieve image content from clipboard. {image}" + if error_msg != last_error: + if not any( + re.search(pattern, error_msg.lower()) + for pattern in ignore_patterns + ): + logging.error(error_msg) + last_error = error_msg + + # Files + if type_ == "files" and enable_file_monitoring: + if x_mode: + success, files = execute_command( + "xclip", + "-selection", + "clipboard", + "-t", + "text/uri-list", + "-o", + ) + else: + success, files = execute_command( + "wl-paste", "-t", "text/uri-list", "-n" + ) + if success: + files = files.decode("utf-8") + files = files.replace("\r\n", "\n").replace("\r", "\n").split("\n") + files = [f.strip() for f in files if len(f.strip()) > 0] + if files != previous_clipboard: + previous_clipboard = files + if _callback_update: + _callback_update("files", files) + else: + error_msg = f"Failed to retrieve files content from clipboard. {files}" + if error_msg != last_error: + if not any( + re.search(pattern, error_msg.lower()) + for pattern in ignore_patterns + ): + logging.error(error_msg) + last_error = error_msg + + time.sleep(timeout) + + +def _monitor_wl_watch(enable_image_monitoring=False, enable_file_monitoring=False): + """Event-driven Wayland clipboard monitoring using wl-paste --watch. + Uses the wlr-data-control-v1 protocol which does not create visible + surfaces or steal focus. Supported by wlroots-based compositors + (Sway, Hyprland, etc.) and KDE Plasma on Wayland. + Returns True if watch mode ran successfully, False to fall back to polling.""" + global _block_image_once, _wl_watch_proc + + last_error = None + previous_clipboard = None + ignore_patterns = [ + r"target .+ not available", + r"no suitable type of content copied", + ] + + try: + _wl_watch_proc = subprocess.Popen( + ["wl-paste", "--watch", "echo"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + time.sleep(0.5) + if _wl_watch_proc.poll() is not None: + stderr_out = _wl_watch_proc.stderr.read().decode("utf-8", errors="ignore") + logging.warning( + f"wl-paste --watch exited immediately: {stderr_out.strip()}" + ) + _wl_watch_proc = None + return False + + logging.info( + "Using wl-paste --watch for clipboard monitoring (no focus stealing)" + ) + + while _run_poll.is_set(): + line = _wl_watch_proc.stdout.readline() + if not line: + break + if not _run_poll.is_set(): + break + + success, mime_output = execute_command("wl-paste", "-l") + if not success: + error_msg = f"Failed to retrieve MIME types: {mime_output}" + if error_msg != last_error: + logging.error(error_msg) + last_error = error_msg + continue + + mime_list = mime_output.decode("utf-8") + mime_list = mime_list.replace("\r\n", "\n").replace("\r", "\n").split("\n") + mime_list = [m.strip() for m in mime_list if len(m.strip()) > 0] + type_ = convert_mime_to_generic_type(mime_list) + + # Text + if type_ == "text": + success, text = execute_command("wl-paste", "-n") + if success: + text = text.decode("utf-8") + if len(text) > 0 and text != previous_clipboard: + previous_clipboard = text + if _callback_update: + _callback_update("text", text) + else: + error_msg = ( + f"Failed to retrieve text content from clipboard. {text}" + ) + if error_msg != last_error: + if not any( + re.search(pattern, error_msg.lower()) + for pattern in ignore_patterns + ): + logging.error(error_msg) + last_error = error_msg + + # Image + elif type_ == "image" and enable_image_monitoring: + success, image = execute_command("wl-paste", "-t", "image/png") + if success: + if image != previous_clipboard: + previous_clipboard = image + if _callback_update and not _block_image_once: + _callback_update("image", image) + else: + _block_image_once = False + else: + error_msg = ( + f"Failed to retrieve image content from clipboard. {image}" + ) + if error_msg != last_error: + if not any( + re.search(pattern, error_msg.lower()) + for pattern in ignore_patterns + ): + logging.error(error_msg) + last_error = error_msg + + # Files + elif type_ == "files" and enable_file_monitoring: + success, files = execute_command( + "wl-paste", "-t", "text/uri-list", "-n" + ) + if success: + files = files.decode("utf-8") + files = ( + files.replace("\r\n", "\n").replace("\r", "\n").split("\n") + ) + files = [f.strip() for f in files if len(f.strip()) > 0] + if files != previous_clipboard: + previous_clipboard = files + if _callback_update: + _callback_update("files", files) + else: + error_msg = ( + f"Failed to retrieve files content from clipboard. {files}" + ) + if error_msg != last_error: + if not any( + re.search(pattern, error_msg.lower()) + for pattern in ignore_patterns + ): + logging.error(error_msg) + last_error = error_msg + + return True + except FileNotFoundError: + logging.warning("wl-paste not found, cannot use --watch mode") + return False + except Exception as e: + logging.warning(f"wl-paste --watch failed: {e}") + return False + finally: + if _wl_watch_proc is not None: + _wl_watch_proc.terminate() + try: + _wl_watch_proc.wait(timeout=2) + except subprocess.TimeoutExpired: + _wl_watch_proc.kill() + _wl_watch_proc = None + + +def convert_mime_to_generic_type(mime_list): + if "text/uri-list" in mime_list: + return "files" + + if any(mime.startswith("image/") for mime in mime_list): + return "image" + + text_mime = [ + "text/plain", + "text/plain;charset=utf-8", + "STRING", + "TEXT", + "COMPOUND_TEXT", + "UTF8_STRING", + ] + if any(t_mime in mime_list for t_mime in text_mime): + return "text" + + return "unknown" + + +def execute_command(*args) -> tuple: + """ + Executes a command with the given arguments and returns the output or error. + + Parameters: + *args: Variable-length argument list to be passed as the command and arguments. + + Returns: + tuple: (success: bool, result: str) + success is True if the command executed successfully, False otherwise. + result is the output or error of the command. + """ + process = subprocess.Popen( + args, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + output, error = process.communicate() + if process.returncode == 0: # Success + return (True, output) + else: # Failure + return (False, error.decode()) + + +def is_x_clipboard_owner(): + # Check if the X clipboard is owned by the current user + return execute_command("xclip", "-selection", "clipboard", "-t", "TARGETS", "-o")[0] + + +def _start_clipboard_polling(enable_image_monitoring, enable_file_monitoring): + if XMODE: + x_clipboard_owner = is_x_clipboard_owner() + if not x_clipboard_owner: + logging.warning( + "x-clip is not owned by the current user. Switching to wl-clipboard." + ) + _monitor_x_wl_clipboard( + x_mode=x_clipboard_owner, + enable_image_monitoring=enable_image_monitoring, + enable_file_monitoring=enable_file_monitoring, + ) + else: + if not _monitor_wl_watch( + enable_image_monitoring=enable_image_monitoring, + enable_file_monitoring=enable_file_monitoring, + ): + logging.info( + "Falling back to wl-paste polling mode for clipboard monitoring" + ) + _monitor_x_wl_clipboard( + x_mode=False, + enable_image_monitoring=enable_image_monitoring, + enable_file_monitoring=enable_file_monitoring, + ) + + +def _runner(enable_image_monitoring=False, enable_file_monitoring=False): + global _is_gdk_running, _run_poll + logging.info(f"XMODE: {XMODE}") + try: + _run_poll.set() + import gi + + gi.require_version("Gtk", "3.0") + gi.require_version("Gdk", "3.0") + from gi.repository import Gtk, Gdk + + if "x11" in str(type(Gdk.Display.get_default())).lower(): # X11 + logging.info("Starting GTK clipboard monitoring for X11 display server.") + clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) + clipboard.connect( + "owner-change", + lambda clip, event: _on_clipboard_changed( + clip, event, enable_image_monitoring, enable_file_monitoring + ), + ) + _is_gdk_running = True + Gtk.main() + else: + logging.warning( + f"Unsupported display server detected ${str(type(Gdk.Display.get_default())).lower()}. Starting polling mode for {detect_linux_display_server()} server as fallback." + ) + _start_clipboard_polling(enable_image_monitoring, enable_file_monitoring) + except Exception as e: + logging.error( + f"Failed to start clipboard monitor: Error {e}\nStarting polling mode for {detect_linux_display_server()} server as fallback." + ) + _start_clipboard_polling(enable_image_monitoring, enable_file_monitoring) + + +def _start(enable_image_monitoring=False, enable_file_monitoring=False): + global _clipboard_thread + if not _clipboard_thread: + _clipboard_thread = threading.Thread( + target=_runner, + args=(enable_image_monitoring, enable_file_monitoring), + daemon=True, + ) + _clipboard_thread.start() + + +def stop(): + global _clipboard_thread, _callback_update, _block_image_once, _run_poll, _is_gdk_running, _wl_watch_proc + if _clipboard_thread: + if _is_gdk_running: + import gi + + gi.require_version("Gtk", "3.0") + from gi.repository import Gtk + + Gtk.main_quit() + _is_gdk_running = False + _run_poll.clear() + if _wl_watch_proc is not None: + _wl_watch_proc.terminate() + _clipboard_thread.join() # Wait for the thread to finish + _clipboard_thread = None + _callback_update = None + _block_image_once = False + _wl_watch_proc = None + logging.info("Clipboard monitor stopped") + + +def wait(): + global _clipboard_thread + if _clipboard_thread: + _clipboard_thread.join() + + +def enable_block_image_once(): + global _block_image_once + _block_image_once = True + + +def on_update(callback, enable_image_monitoring=False, enable_file_monitoring=False): + global _callback_update + _callback_update = callback + _start(enable_image_monitoring, enable_file_monitoring) diff --git a/ClipCascade/clipboard/clipboard_monitor_mac.py b/ClipCascade/clipboard/clipboard_monitor_mac.py new file mode 100755 index 0000000..fd01398 --- /dev/null +++ b/ClipCascade/clipboard/clipboard_monitor_mac.py @@ -0,0 +1,149 @@ +from io import BytesIO +import logging +import threading +import time +import pasteboard + +_clipboard_thread = None +_callback_update = None +_run = False +_first_run = False +_block_image_once = False +_pasteboard_lock = threading.Lock() +_pb_writer = None + + +def write_to_pasteboard(data, pb_type): + global _pb_writer + with _pasteboard_lock: + if _pb_writer is None: + _pb_writer = pasteboard.Pasteboard() + _pb_writer.set_contents(data, pb_type) + + +def _runner(enable_image_monitoring=False, enable_file_monitoring=False): + global _first_run, _block_image_once + try: + pb_text = pasteboard.Pasteboard() + if enable_image_monitoring: + pb_image_png = pasteboard.Pasteboard() + pb_image_tiff = pasteboard.Pasteboard() + if enable_file_monitoring: + pb_files = pasteboard.Pasteboard() + image_processed = False + files_processed = False + while _run: + # don't change the execution order (files,text,image or files,image,text) + + if enable_file_monitoring: + # Files + with _pasteboard_lock: + clipboard_files = pb_files.get_file_urls(diff=True) + if ( + clipboard_files is not None + and type(clipboard_files) is tuple + and len(clipboard_files) > 0 + ): + if _callback_update and not _first_run: + files_processed = True + _callback_update("files", clipboard_files) + + # Text + with _pasteboard_lock: + clipboard_text = pb_text.get_contents( + type=pasteboard.String, diff=True + ) # If True, retrieves and returns the content only if it has changed since the last call. + # This approach is efficient even in cases of frequent polling. + if ( + clipboard_text is not None + and type(clipboard_text) is str + and len(clipboard_text) > 0 + ): + if _callback_update and not _first_run and not files_processed: + _callback_update("text", clipboard_text) + + if enable_image_monitoring: + # Image (PNG) + with _pasteboard_lock: + clipboard_image_png = pb_image_png.get_contents( + type=pasteboard.PNG, diff=True + ) + if ( + clipboard_image_png is not None + and type(clipboard_image_png) is bytes + ): + if _callback_update and not _first_run and not image_processed: + image_processed = True + if _block_image_once: + _block_image_once = False + else: + if not files_processed: + _callback_update("image", clipboard_image_png) + + # Image (TIFF) + with _pasteboard_lock: + clipboard_image_tiff = pb_image_tiff.get_contents( + type=pasteboard.TIFF, diff=True + ) + if ( + clipboard_image_tiff is not None + and type(clipboard_image_tiff) is bytes + ): + if _callback_update and not _first_run and not image_processed: + image_processed = True + if _block_image_once: + _block_image_once = False + else: + if not files_processed: + _callback_update("image", clipboard_image_tiff) + + files_processed = False + image_processed = False + _first_run = False + time.sleep(0.3) # seconds + + except Exception as e: + logging.error(f"Error processing clipboard update: {e}") + + +def _start(enable_image_monitoring=False, enable_file_monitoring=False): + global _clipboard_thread, _run, _first_run + if not _clipboard_thread: + _run = True + _first_run = True + _clipboard_thread = threading.Thread( + target=_runner, + args=(enable_image_monitoring, enable_file_monitoring), + daemon=True, + ) + _clipboard_thread.start() + + +def stop(): + global _clipboard_thread, _callback_update, _run, _first_run, _block_image_once, _pb_writer + if _clipboard_thread: + _run = False + _clipboard_thread.join() # Wait for the thread to finish + _first_run = False + _clipboard_thread = None + _callback_update = None + _block_image_once = False + _pb_writer = None + logging.info("Clipboard monitor stopped") + + +def wait(): + global _clipboard_thread + if _clipboard_thread: + _clipboard_thread.join() + + +def enable_block_image_once(): + global _block_image_once + _block_image_once = True + + +def on_update(callback, enable_image_monitoring=False, enable_file_monitoring=False): + global _callback_update + _callback_update = callback + _start(enable_image_monitoring, enable_file_monitoring) diff --git a/ClipCascade/clipboard/clipboard_monitor_win.py b/ClipCascade/clipboard/clipboard_monitor_win.py new file mode 100755 index 0000000..dd6e493 --- /dev/null +++ b/ClipCascade/clipboard/clipboard_monitor_win.py @@ -0,0 +1,163 @@ +import logging +import win32gui +import win32api +import win32con +import win32clipboard +import threading +import ctypes +import time +from PIL import ImageGrab + + +_clipboard_thread = None +_hwnd = None # Store the window handle +_callback_update = None +_block_image_once = False + + +def _get_clipboard_content(enable_image_monitoring=False, enable_file_monitoring=False): + """ + Get the content of the clipboard. + + Image: + PNG -> PngImagePlugin.PngImageFile + DIB -> BmpImagePlugin.DibImageFile + PNG, DIB, JPG, etc. -> [file_path1, file_path2, ...] + + Text: + CF_UNICODETEXT, CF_TEXT -> str + + Files: + CF_HDROP -> (file_path1, file_path2, ...) + """ + # sleep 0.5 to avoid clipboard not ready for read + time.sleep(0.5) + clipboard_type = None + clipboard_content = None + + if enable_image_monitoring and win32clipboard.IsClipboardFormatAvailable( + win32con.CF_BITMAP + ): + clipboard_type = "image" + clipboard_content = ImageGrab.grabclipboard() + else: + win32clipboard.OpenClipboard() + try: + if win32clipboard.IsClipboardFormatAvailable(win32con.CF_UNICODETEXT): + text = win32clipboard.GetClipboardData(win32con.CF_UNICODETEXT) + clipboard_type = "text" + clipboard_content = text + elif win32clipboard.IsClipboardFormatAvailable(win32con.CF_TEXT): + text_bytes = win32clipboard.GetClipboardData(win32con.CF_TEXT) + text = text_bytes.decode() + clipboard_type = "text" + clipboard_content = text + elif enable_file_monitoring and win32clipboard.IsClipboardFormatAvailable( + win32con.CF_HDROP + ): + files = win32clipboard.GetClipboardData(win32con.CF_HDROP) + clipboard_type = "files" + clipboard_content = files + finally: + win32clipboard.CloseClipboard() + + return (clipboard_type, clipboard_content) + + +def _process_message( + hwnd: int, + msg: int, + wparam: int, + lparam: int, + enable_image_monitoring=False, + enable_file_monitoring=False, +): + global _block_image_once + WM_CLIPBOARDUPDATE = 0x031D + if msg == WM_CLIPBOARDUPDATE: + clip = _get_clipboard_content(enable_image_monitoring, enable_file_monitoring) + + try: + if clip[0] == "text" and _callback_update: + _callback_update(clip[0], clip[1]) + + if enable_image_monitoring and clip[0] == "image" and _callback_update: + if _block_image_once: + _block_image_once = False + else: + _callback_update(clip[0], clip[1]) + + if enable_file_monitoring and clip[0] == "files" and _callback_update: + _callback_update(clip[0], clip[1]) + except Exception as e: + logging.error(f"Error processing clipboard update: {e}") + return 0 + + +def _create_window(enable_image_monitoring=False, enable_file_monitoring=False): + global _hwnd + className = "ClipboardHook" + wc = win32gui.WNDCLASS() + wc.lpfnWndProc = lambda hwnd, msg, wparam, lparam: _process_message( + hwnd, msg, wparam, lparam, enable_image_monitoring, enable_file_monitoring + ) + wc.lpszClassName = className + wc.hInstance = win32api.GetModuleHandle(None) + class_atom = win32gui.RegisterClass(wc) + _hwnd = win32gui.CreateWindow( + class_atom, className, 0, 0, 0, 0, 0, 0, 0, wc.hInstance, None + ) + + +def _runner(enable_image_monitoring=False, enable_file_monitoring=False): + global _hwnd + _create_window(enable_image_monitoring, enable_file_monitoring) + ctypes.windll.user32.AddClipboardFormatListener(_hwnd) + try: + win32gui.PumpMessages() + finally: + ctypes.windll.user32.RemoveClipboardFormatListener(_hwnd) + win32gui.DestroyWindow(_hwnd) + win32gui.UnregisterClass("ClipboardHook", win32api.GetModuleHandle(None)) + + +def _start(enable_image_monitoring=False, enable_file_monitoring=False): + global _clipboard_thread + if not _clipboard_thread: + _clipboard_thread = threading.Thread( + target=_runner, + args=(enable_image_monitoring, enable_file_monitoring), + daemon=True, + ) + _clipboard_thread.start() + + +def stop(): + global _clipboard_thread, _hwnd, _callback_update, _block_image_once + if _clipboard_thread and _hwnd: + win32gui.PostMessage( + _hwnd, win32con.WM_QUIT, 0, 0 + ) # Send WM_QUIT to the window + _clipboard_thread.join() # Wait for the thread to finish + _clipboard_thread = None + _hwnd = None + _callback_update = None + _block_image_once = False + logging.info("Clipboard monitor stopped") + + +def wait(): + global _clipboard_thread + if _clipboard_thread: + _clipboard_thread.join() + + +def enable_block_image_once(): + global _block_image_once + _block_image_once = True + + +def on_update(callback, enable_image_monitoring=False, enable_file_monitoring=False): + global _callback_update + _callback_update = callback + _start(enable_image_monitoring, enable_file_monitoring) diff --git a/ClipCascade/core/application.py b/ClipCascade/core/application.py new file mode 100755 index 0000000..0424ca0 --- /dev/null +++ b/ClipCascade/core/application.py @@ -0,0 +1,309 @@ +import logging +import sys + + +from core.constants import * + +from core.config import Config +from utils.request_manager import RequestManager +from utils.cipher_manager import CipherManager +from stomp_ws.stomp_manager import STOMPManager +from p2p.p2p_manager import P2PManager + +if PLATFORM == WINDOWS: + import ctypes +elif PLATFORM == MACOS or PLATFORM.startswith(LINUX): + import fcntl + + +if PLATFORM.startswith(LINUX) and LINUX_USE_CLI_UI: + import pyfiglet + from cli.login import LoginForm + from cli.info import CustomDialog + from cli.tray import TaskbarPanel + from cli.message_box import MessageBox + from cli.echo import Echo +else: + from gui.login import LoginForm + from gui.info import CustomDialog + from gui.tray import TaskbarPanel + from gui.message_box import MessageBox + + +class Application: + def __init__( + self, + log_file_path=LOG_FILE_NAME, + data_file_path=DATA_FILE_NAME, + mutex_identifier=MUTEX_NAME, + ): + try: + self.log_file_path = os.path.join( + get_program_files_directory(), log_file_path + ) + self.data_file_path = os.path.join( + get_program_files_directory(), data_file_path + ) + self.mutex_identifier = mutex_identifier + + if PLATFORM == MACOS or PLATFORM.startswith(LINUX): + self.lock_file = None # File(lock) object + self.mutex_identifier = os.path.join( + get_program_files_directory(), self.mutex_identifier + ) + + self.config = Config( + file_name=self.data_file_path + ) # Maintain a single configuration instance for the entire application lifecycle. + + self.request_manager = RequestManager(self.config) + self.stomp_manager = STOMPManager(self.config) + self.p2p_manager = P2PManager(self.config) + self.cipher_manager = CipherManager(self.config) + except Exception as e: + CustomDialog( + f"An error occurred during application initialization: {e}", + msg_type="error", + ).mainloop() + + def setup_logging(self): + LOG_FORMAT = "%(asctime)s - %(levelname)s - %(message)s" + logging.basicConfig( + level=LOG_LEVEL, + format=LOG_FORMAT, + filename=self.log_file_path, + filemode="w", + ) + + def ensure_single_instance(self): + if PLATFORM == WINDOWS: + ctypes.windll.kernel32.CreateMutexW(None, False, self.mutex_identifier) + if ctypes.windll.kernel32.GetLastError() == 183: # ERROR_ALREADY_EXISTS + CustomDialog( + "Another instance of ClipCascade is already running.", + msg_type="warning", + ).mainloop() + sys.exit(0) + elif PLATFORM == MACOS or PLATFORM.startswith(LINUX): + if PLATFORM == MACOS: + app_dir = get_program_files_directory() + if not os.path.exists(app_dir): + try: + os.makedirs(app_dir) + except Exception as e: + CustomDialog( + f"An error occurred while creating the directory '{app_dir}'. Error: {e}", + msg_type="error", + ).mainloop() + sys.exit(1) + + # Create the lock file + try: + self.create_lock_file() + except IOError: + run_anyway = MessageBox().askquestion( + "ClipCascade", + "Another instance of ClipCascade is already running. Do you want to run anyway?", + ) + if run_anyway == "yes": + os.remove(self.mutex_identifier) + self.create_lock_file() + else: + self.lock_file = None + sys.exit(0) + + def create_lock_file(self, path=None): + if path is None: + path = self.mutex_identifier + + if PLATFORM == MACOS or PLATFORM.startswith(LINUX): + self.lock_file = open(path, "w") + fcntl.flock(self.lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB) + + def authenticate_and_connect(self): + # Attempt to connect with existing cookie + if self.config.data.get("cookie"): + ws_conn_successful, msg = self._get_ws_manager().connect() + if ws_conn_successful: + self._get_ws_manager().is_login_phase = False + return + + # enable login form + used_saved_credentials = False + display_login_success_dialog = False + if PLATFORM.startswith(LINUX) and LINUX_USE_CLI_UI: + Echo("═" * 14 + "\n║ LOGIN FORM ║\n" + "═" * 14) + while True: + if ( + self.config.data.get("cookie") is not None + and self.config.data["save_password"] + and self.config.data["cipher_enabled"] == False + and not used_saved_credentials + ): + # Attempt to connect with password when using saved credentials + used_saved_credentials = True + else: + display_login_success_dialog = True + self.config.data["password"] = "" # Clear the password + login_form = LoginForm( + self.config, + on_quit_callback=( + None + if (PLATFORM.startswith(LINUX) and LINUX_USE_CLI_UI) + else lambda: sys.exit(0) + ), + ) + login_form.mainloop() # wait until login form is closed + raw_password = self.config.data[ + "password" + ] # Store the raw password temporarily for hashing + self.config.data["password"] = ( + CipherManager.string_to_sha3_512_lowercase_hex(raw_password) + ) # Hash the password + + login_successful, msg_login, self.config.data["cookie"] = ( + self.request_manager.login() + ) + if login_successful: + self.config.data["csrf_token"] = self.request_manager.get_csrf_token() + self.config.data["server_mode"] = self.request_manager.get_server_mode() + if self.config.data["server_mode"] == "P2P": + self.config.data["stun_url"] = self.request_manager.get_stun_url() + self.config.data["maxsize"] = -1 + self.config.data["websocket_url"] = Config.convert_to_websocket_url( + self.config.data["server_url"], WEBSOCKET_ENDPOINT_P2P + ) + else: + self.config.data["stun_url"] = "" + self.config.data["maxsize"] = self.request_manager.maxsize() + self.config.data["websocket_url"] = Config.convert_to_websocket_url( + self.config.data["server_url"], WEBSOCKET_ENDPOINT + ) + ws_conn_successful, msg = self._get_ws_manager().connect() + if ws_conn_successful: + self._get_ws_manager().is_login_phase = False + if self.config.data["cipher_enabled"]: + self.config.data["hashed_password"] = ( + self.cipher_manager.hash_password(raw_password) + ) + if not self.config.data["save_password"]: + self.config.data["password"] = "" + if display_login_success_dialog: + CustomDialog( + "Success! ClipCascade will now run in the task bar/menu bar.", + msg_type="success", + timeout=5000, + ).mainloop() + break + else: + CustomDialog( + "Login successful but websocket connection failed. \nPlease check websocket-url\n" + + msg, + msg_type="error", + ).mainloop() + else: + CustomDialog("Login Failed\n" + msg_login, msg_type="error").mainloop() + + raw_password = None # Clear the raw password + if PLATFORM.startswith(LINUX) and LINUX_USE_CLI_UI: + Echo("-" * 53) + + def _get_ws_manager(self): + if self.config.data["server_mode"] == "P2P": + return self.p2p_manager + else: + return self.stomp_manager + + def get_version_update_status(self) -> list: + """ + Checks for a new version of the application by comparing the current version + with the one available in a remote JSON file. + + Returns: + list: [bool, str, str, str] - [Is new version available, latest version, current version, release URL] + """ + try: + response = RequestManager.get(VERSION_URL) + response_data = response.json() + if PLATFORM == WINDOWS: + key = "windows" + elif PLATFORM == MACOS: + key = "macos" + elif PLATFORM.startswith(LINUX): + if not LINUX_USE_CLI_UI: + key = "linux_gui" + else: + key = "linux_non_gui" + + if response_data[key] != APP_VERSION: + return [True, response_data[key], APP_VERSION, RELEASE_URL] + except Exception as e: + logging.error(f"Error checking for new version: {e}") + return [False, "", APP_VERSION, RELEASE_URL] + + def get_donation_url(self) -> str: + try: + metadata = self.request_manager.get_metadata() + if metadata is not None: + return metadata.get("funding", None) + except Exception as e: + logging.error(f"Error fetching metadata: {e}") + return None + + def logoff_and_exit(self): + try: + self._get_ws_manager().disconnect() + self.request_manager.logout() + self.config.data["hashed_password"] = None + self.config.data["cookie"] = None + self.config.data["maxsize"] = None + self.config.data["password"] = "" + self.config.data["csrf_token"] = "" + self.config.save() + except Exception as e: + raise Exception(f"Error during logging off: {e}") + + def banner(self): + if PLATFORM.startswith(LINUX) and LINUX_USE_CLI_UI: + Echo(pyfiglet.figlet_format(APP_NAME)) + Echo("*" * 53) + Echo("Real-Time Clipboard Syncing".center(53)) + Echo(GITHUB_URL.center(53)) + Echo("*" * 53) + + def run(self): + try: + self.banner() + self.setup_logging() + self.ensure_single_instance() + self.config.load() + self.authenticate_and_connect() + self.config.save() + update_available = self.get_version_update_status() + donation_url = self.get_donation_url() + + sys_tray = TaskbarPanel( + on_connect_callback=self._get_ws_manager().manual_reconnect, + on_disconnect_callback=self._get_ws_manager().disconnect, + on_logoff_callback=self.logoff_and_exit, + new_version_available=update_available, + github_url=GITHUB_URL, + donation_url=donation_url, + ws_interface=self._get_ws_manager(), + config=self.config, + ) + self._get_ws_manager().set_tray_ref(sys_tray) + sys_tray.run() + except Exception as e: + msg = f"An unexpected error has occurred: {e}" + logging.error(msg) + CustomDialog( + msg + "\nCheck logs in project directory", msg_type="error" + ).mainloop() + finally: + self._get_ws_manager().disconnect() + if PLATFORM == MACOS or PLATFORM.startswith(LINUX): + if self.lock_file is not None: + fcntl.flock(self.lock_file, fcntl.LOCK_UN) + self.lock_file.close() + os.remove(self.mutex_identifier) diff --git a/ClipCascade/core/config.py b/ClipCascade/core/config.py new file mode 100755 index 0000000..b91a1e2 --- /dev/null +++ b/ClipCascade/core/config.py @@ -0,0 +1,92 @@ +import base64 +import json +import os +import re +from core.constants import * + + +class Config: + def __init__(self, file_name=DATA_FILE_NAME): + self.file_name = file_name + self.data = { + "cipher_enabled": True, + "server_url": "http://localhost:8080", + "websocket_url": "", + "username": "", + "hashed_password": None, + "cookie": None, + "maxsize": None, + "hash_rounds": 664937, + "salt": "", + "csrf_token": "", + "notification": True, + "save_password": False, + "password": "", + "max_clipboard_size_local_limit_bytes": None, + "enable_image_sharing": True, + "enable_file_sharing": True, + "default_file_download_location": "", + "server_mode": "P2S", + "stun_url": "", + "ssl_ca_bundle": "", + } + + def save(self): + """ + Save data to file + """ + try: + temp = self.data.copy() + if self.data.get("cipher_enabled") and self.data.get("hashed_password"): + temp["hashed_password"] = base64.b64encode( + temp["hashed_password"] + ).decode("utf-8") + with open(self.file_name, "w") as f: + json.dump(temp, f, indent=4) + except Exception as e: + logging.error(f"Failed to save data: {e}") + + def load(self): + """ + Load data from file + """ + if os.path.isfile(self.file_name): + try: + with open(self.file_name, "r") as f: + file_data = json.load(f) + self.data.update(file_data) + # Decode hashed_password if present + if self.data.get("hashed_password"): + self.data["hashed_password"] = base64.b64decode( + self.data["hashed_password"] + ) + return True + except Exception as e: + logging.error(f"Failed to load data: {e}") + logging.error( + "Try deleting DATA file in the program directory, and re-run the program again" + ) + return False + + @staticmethod + def convert_to_websocket_url(input_url: str, endpoint: str = None) -> str: + if not input_url or not isinstance(input_url, str): + raise ValueError("Invalid URL provided") + + # Trim whitespace, remove trailing slashes, and convert to lowercase + input_url = re.sub(r"/+$", "", input_url.strip()).lower() + + # Determine protocol and convert + if input_url.startswith("https://"): + ws_url = input_url.replace("https://", "wss://", 1) + elif input_url.startswith("http://"): + ws_url = input_url.replace("http://", "ws://", 1) + else: + raise ValueError(f"Unsupported protocol in URL: {input_url}") + + if endpoint is not None: + # Append the WebSocket endpoint and remove any trailing slash + ws_url += endpoint + ws_url = re.sub(r"/+$", "", ws_url) + + return ws_url diff --git a/ClipCascade/core/constants.py b/ClipCascade/core/constants.py new file mode 100755 index 0000000..78c357a --- /dev/null +++ b/ClipCascade/core/constants.py @@ -0,0 +1,262 @@ +import logging +import platform +import os +import sys + +# platform constants +WINDOWS = "Windows" +MACOS = "macOS" +LINUX = "Linux" +LINUX_X11 = f"{LINUX}_X11" +LINUX_WAYLAND = f"{LINUX}_Wayland" +LINUX_HEADLESS = f"{LINUX}_Headless" + + +# OS detection +def get_os_and_display_server(): + system = platform.system().lower().strip() + if system == "windows": + return WINDOWS + elif system == "darwin": + return MACOS + elif system == "linux": + session_type = os.environ.get("XDG_SESSION_TYPE", "").lower().strip() + if session_type == "wayland": + return LINUX_WAYLAND + elif session_type == "x11": + return LINUX_X11 + else: + # Could be a headless session or another type of session + return LINUX_HEADLESS + else: + return "Unknown OS" + + +def detect_linux_display_server(): + session_type = os.environ.get("XDG_SESSION_TYPE", "").lower().strip() + session_desktop = os.environ.get("XDG_SESSION_DESKTOP", "").lower().strip() + wayland_display = os.environ.get("WAYLAND_DISPLAY") + x_display = os.environ.get("DISPLAY") + + # Priority detection order: X11 > XWayland > Hyprland > Wayland > Unknown + + if session_type == "x11": + return "X11" + + if session_type == "wayland" and x_display and wayland_display: + return "XWayland" + + if session_type == "wayland" and session_desktop == "hyprland": + return "Hyprland" + + if session_type == "wayland": + return "Wayland" + + return "Unknown" + + +def _parse_linux_bool_flag_token(raw): + if raw is None or not str(raw).strip(): + raise ValueError("empty value") + s = str(raw).strip().lower() + if s == "true": + return True + if s == "false": + return False + raise ValueError(raw) + + +def _parse_linux_positive_float(raw): + if raw is None or not str(raw).strip(): + raise ValueError("empty value") + try: + value = float(str(raw).strip()) + except ValueError: + raise ValueError(f"not a number: {raw!r}") + if value <= 0: + raise ValueError("must be a positive number") + return value + + +def _strip_linux_cli_overrides(argv): + """ + Linux-only: parse --gui / --xmode (true|false), --polling (seconds), remove them from argv. + Last occurrence wins if a flag is repeated. + """ + gui_override = None + xmode_override = None + polling_override = None + i = 1 + kept = [argv[0]] if argv else [] + while i < len(argv): + item = argv[i] + if item.startswith("--gui="): + gui_override = _parse_linux_bool_flag_token(item.split("=", 1)[1]) + i += 1 + continue + if item == "--gui": + if i + 1 >= len(argv): + raise ValueError("--gui requires true or false") + gui_override = _parse_linux_bool_flag_token(argv[i + 1]) + i += 2 + continue + if item.startswith("--xmode="): + xmode_override = _parse_linux_bool_flag_token(item.split("=", 1)[1]) + i += 1 + continue + if item == "--xmode": + if i + 1 >= len(argv): + raise ValueError("--xmode requires true or false") + xmode_override = _parse_linux_bool_flag_token(argv[i + 1]) + i += 2 + continue + if item.startswith("--polling="): + polling_override = _parse_linux_positive_float(item.split("=", 1)[1]) + i += 1 + continue + if item == "--polling": + if i + 1 >= len(argv): + raise ValueError("--polling requires a positive number (seconds)") + polling_override = _parse_linux_positive_float(argv[i + 1]) + i += 2 + continue + kept.append(item) + i += 1 + argv[:] = kept + return gui_override, xmode_override, polling_override + + +PLATFORM = get_os_and_display_server() + +# Linux: CLI UI vs GTK tray — False on other platforms (unused except behind LINUX checks). +LINUX_USE_CLI_UI = False +# Linux: optional override for xclip/wl-paste polling sleep (seconds); None = use built-ins (0.3 / 3). +LINUX_CLIPBOARD_POLL_INTERVAL_SEC = None + +if PLATFORM.startswith(LINUX): + if ( + detect_linux_display_server() == "X11" + or detect_linux_display_server() == "XWayland" + or detect_linux_display_server() == "Unknown" + ): + XMODE = True + else: + XMODE = False + + try: + gui_override, xmode_override, polling_override = _strip_linux_cli_overrides( + sys.argv + ) + except ValueError as e: + print(f"clipcascade (Linux CLI): {e}", file=sys.stderr) + sys.exit(2) + + if xmode_override is not None: + XMODE = xmode_override + + if gui_override is not None: + LINUX_USE_CLI_UI = not gui_override + else: + LINUX_USE_CLI_UI = not XMODE + + if polling_override is not None: + LINUX_CLIPBOARD_POLL_INTERVAL_SEC = polling_override + + +# App version +if PLATFORM == WINDOWS: + APP_VERSION = "3.2.0" +elif PLATFORM == MACOS: + APP_VERSION = "3.2.0" +elif PLATFORM.startswith(LINUX): + if XMODE: + APP_VERSION = "3.2.0" # gui version + else: + APP_VERSION = "3.2.0" # non-gui(cli) version + + +# core constants +RECONNECT_WS_TIMER = 10 # seconds +WEBSOCKET_TIMEOUT = 3000 # milliseconds + +# P2P signaling WebSocket keepalive (RFC 6455 ping/pong). +P2P_WS_PING_INTERVAL_SEC = 25 +P2P_WS_PING_TIMEOUT_SEC = 20 +# Data-channel keepalive (JSON envelope with _cc_keepalive); helps idle sessions and mobile radios. +P2P_DC_HEARTBEAT_INTERVAL_SEC = 20 +# After sleep, aiortc RTCPeerConnection.close() can block; cap wait so the asyncio +# thread does not stall (which would also block processing ASSIGNED_ID / PEER_LIST). +P2P_PC_CLOSE_TIMEOUT_SEC = 5.0 + +LOG_FILE_NAME = "clipcascade_log.log" +LOG_LEVEL = logging.INFO # Use valid levels: DEBUG, INFO, WARNING, ERROR, CRITICAL +DATA_FILE_NAME = "DATA" +MAX_SIZE = 1048576 # 1 MiB +FRAGMENT_SIZE = 15360 # 15 KiB +SUBSCRIPTION_DESTINATION = "/user/queue/cliptext" +SEND_DESTINATION = "/app/cliptext" +LOGIN_URL = "/login" +LOGOUT_URL = "/logout" +MAXSIZE_URL = "/max-size" +CSRF_URL = "/csrf-token" +SERVER_MODE_URL = "/server-mode" +WEBSOCKET_ENDPOINT = "/clipsocket" +WEBSOCKET_ENDPOINT_P2P = "/p2psignaling" +STUN_URL = "/stun-url" + +VERSION_URL = "https://raw.githubusercontent.com/Sathvik-Rao/ClipCascade/main/version.json" +RELEASE_URL = "https://github.com/Sathvik-Rao/ClipCascade/releases/latest" +GITHUB_URL = "https://github.com/Sathvik-Rao/ClipCascade" +APP_NAME = "ClipCascade" +HELP_URL = f"{GITHUB_URL}/blob/main/README.md" +METADATA_URL = "https://raw.githubusercontent.com/Sathvik-Rao/ClipCascade/main/metadata.json" + +if PLATFORM == WINDOWS: + MUTEX_NAME = "Global\\ClipCascade_Mutex_PSSR" +elif PLATFORM == MACOS or PLATFORM.startswith(LINUX): + MUTEX_NAME = "clipcascade.lock" + + +# helper functions +def get_user_home_directory(): + """ + Get the user's home directory in a cross-platform manner. + On Windows, this resolves to something like C:\\Users\\ + On macOS and Linux, this typically resolves to /Users/ or /home/, respectively. + """ + return os.path.expanduser("~") + + +def get_program_files_directory(): + """ + Get the directory containing the program files. + """ + if PLATFORM == MACOS: + app_dir = os.path.join( + get_user_home_directory(), "Library", "Application Support", "ClipCascade" + ) + if not os.path.exists(app_dir): + try: + os.makedirs(app_dir) + except Exception as e: + raise e + + return app_dir + else: + if getattr(sys, "frozen", False): # Running as a PyInstaller executable + return os.path.dirname(sys.executable) + else: # Running as a regular Python script + running_dir = os.path.dirname(os.path.abspath(__file__)) + parent_dir = os.path.dirname(running_dir) # Go one folder up + return parent_dir + + +def get_downloads_folder(): + """ + Get the path to the user's Downloads folder in a cross-platform manner. + By default, the Downloads directory is commonly located as: + - Windows: C:\\Users\\\\Downloads + - macOS: /Users//Downloads + - Linux: /home//Downloads + """ + return os.path.join(get_user_home_directory(), "Downloads") diff --git a/ClipCascade/gui/info.py b/ClipCascade/gui/info.py new file mode 100755 index 0000000..942f9b6 --- /dev/null +++ b/ClipCascade/gui/info.py @@ -0,0 +1,120 @@ +import tkinter as tk +from tkinter import ttk +from tkinter import scrolledtext +import gc +import time + +from utils.window_manager import center_window +from core.constants import * + + +class CustomDialog(tk.Tk): + def __init__(self, message, msg_type="info", timeout=None): + super().__init__() + self.message = message + self.msg_type = msg_type.lower() + self.timeout = timeout + self.after_id = None + + self.style = ttk.Style(self) + self.style.configure("TFrame", background="#f2f2f2") + self.style.configure( + "TLabel", background="#f2f2f2", foreground="#333333", font=("Arial", 11) + ) + self.style.configure("Header.TLabel", font=("Arial", 14, "bold")) + self.style.configure("TButton", font=("Arial", 11)) + + self._configure_window() + self._show_dialog() + + if self.timeout: + self.after_id = self.after(self.timeout, self.close) + + # Allow closing with keyboard shortcuts + self.bind("", lambda e: self.close()) + self.bind("", lambda e: self.close()) + + def _configure_window(self): + """Configure the dialog window properties.""" + self.title("ClipCascade") + self.geometry( + "600x300+{}+{}".format( + (self.winfo_screenwidth() - 600) // 2, + (self.winfo_screenheight() - 300) // 2, + ) + ) + self.update_idletasks() + center_window(self) + self.attributes("-topmost", True) + self.resizable(False, False) + self.focus_force() + + if PLATFORM == MACOS: + self.after(100, self._macos_restore_focus) + + def _show_dialog(self): + """Display the appropriate dialog based on the message type.""" + type_config = { + "success": ("Success", "green", "✔"), + "error": ("Error", "red", "✖"), + "warning": ("Warning", "orange", "⚠"), + "info": ("Information", "blue", "ℹ"), + } + title, color, symbol = type_config.get(self.msg_type, ("Message", "black", "•")) + + # Create a main frame + main_frame = ttk.Frame(self, padding="20 20 20 20") + main_frame.pack(fill="both", expand=True) + + # Title frame with symbol and title + title_frame = ttk.Frame(main_frame) + title_frame.pack(pady=(0, 10), fill="x") + + symbol_label = ttk.Label( + title_frame, text=symbol, font=("Arial", 18, "bold"), foreground=color + ) + symbol_label.pack(side="left", padx=(0, 10)) + + title_label = ttk.Label(title_frame, text=title, style="Header.TLabel", foreground=color) + title_label.pack(side="left") + + # Message frame for scrolled text + message_frame = ttk.Frame(main_frame) + message_frame.pack(fill="both", expand=True, pady=10) + + # ScrolledText widget for the message + self.text_widget = scrolledtext.ScrolledText( + message_frame, wrap="word", font=("Arial", 11), width=70, height=8 + ) + self.text_widget.pack(fill="both", expand=True) + + # Insert the message and disable editing + self.text_widget.insert("1.0", self.message) + self.text_widget.config(state="disabled") + + # Button frame + button_frame = ttk.Frame(main_frame) + button_frame.pack(pady=(10, 0)) + + ok_button = ttk.Button(button_frame, text="OK", command=self.close, width=10) + ok_button.pack() + ok_button.focus_set() + + def _macos_restore_focus(self): + """Force macOS to properly activate and focus the dialog.""" + try: + from AppKit import NSApplication + app = NSApplication.sharedApplication() + app.activateIgnoringOtherApps_(True) + except ImportError: + pass + self.lift() + self.focus_force() + + def close(self): + if self.after_id: + self.after_cancel(self.after_id) # Cancel the after event if it exists + self.after_id = None + self.destroy() + gc.collect() + time.sleep(0.5) diff --git a/ClipCascade/gui/login.py b/ClipCascade/gui/login.py new file mode 100755 index 0000000..65b0310 --- /dev/null +++ b/ClipCascade/gui/login.py @@ -0,0 +1,648 @@ +import re +import tkinter as tk +from tkinter import ttk, font +import gc +import time +import os + +from utils.window_manager import center_window +from core.config import Config +from gui.info import CustomDialog +from core.constants import * + + +class HoverTooltip: + """Simple hover tooltip for Tk widgets.""" + + def __init__(self, widget, text, wraplength=520): + self.widget = widget + self.text = text + self.wraplength = wraplength + self.tooltip_window = None + self.widget.bind("", self._show, add="+") + self.widget.bind("", self._hide, add="+") + self.widget.bind("", self._hide, add="+") + + def _show(self, _event=None): + if self.tooltip_window or not self.text.strip(): + return + + # Position tooltip near the widget with an offset. + x = self.widget.winfo_rootx() + 18 + y = self.widget.winfo_rooty() + self.widget.winfo_height() + 8 + + self.tooltip_window = tw = tk.Toplevel(self.widget) + tw.wm_overrideredirect(True) + tw.wm_geometry(f"+{x}+{y}") + + label = tk.Label( + tw, + text=self.text, + justify=tk.LEFT, + relief=tk.SOLID, + borderwidth=1, + background="#ffffe0", + foreground="#202020", + font=("Helvetica", 10), + wraplength=self.wraplength, + padx=8, + pady=6, + ) + label.pack() + + def _hide(self, _event=None): + if self.tooltip_window: + self.tooltip_window.destroy() + self.tooltip_window = None + + +class LoginForm(tk.Tk): + def __init__( + self, + config: Config, + on_login_callback=None, + on_quit_callback=None, + ): + super().__init__() + self.config = config + self.on_login_callback = on_login_callback + self.on_quit_callback = on_quit_callback + + if PLATFORM == MACOS or PLATFORM.startswith(LINUX): + # Set an initial size to avoid 0 size issue + # self.geometry("800x450") + self.update_idletasks() + + self.withdraw() # Hide the root window + self.title("ClipCascade") + self._tooltips = [] + + # Make the window appear on top and grab focus + self.attributes("-topmost", True) + self.focus_force() + + # Configure styles + style = ttk.Style(self) + # Use default theme (may vary by OS); consider 'clam', 'alt', or 'default' + style.configure("TLabel", font=("Helvetica", 13), padding=5) + style.configure("TEntry", font=("Helvetica", 13), padding=5) + style.configure("TButton", font=("Helvetica", 13), padding=5) + style.configure("TCheckbutton", font=("Helvetica", 13), padding=5) + + # Main frame with padding + main_frame = ttk.Frame(self, padding=20) + main_frame.pack(fill=tk.BOTH, expand=True) + + # Title Label + title_label = ttk.Label( + main_frame, + text="Please Log In", + font=("Helvetica", 16, "bold"), + takefocus=False, + ) + title_label.pack(pady=(0, 10)) + + # Create a frame for the fields + self.field_frame = ttk.Frame(main_frame) + self.field_frame.pack(padx=10, pady=10, fill=tk.X) + + # Username + user_label = ttk.Label(self.field_frame, text="Username:") + user_label.grid(row=0, column=0, padx=(0, 10), pady=5, sticky=tk.W) + self.username_entry = ttk.Entry(self.field_frame, width=50, font=("Helvetica", 13)) + self.username_entry.insert(0, self.config.data["username"]) + self.username_entry.grid(row=0, column=1, padx=10, pady=5, sticky=tk.W + tk.E) + self._add_tooltip( + [user_label, self.username_entry], + "Username used to log in to your ClipCascade account.\n\n" + "Use the same account name on all devices that should share clipboard data.", + ) + + # Password + pass_label = ttk.Label(self.field_frame, text="Password:") + pass_label.grid(row=1, column=0, padx=(0, 10), pady=5, sticky=tk.W) + self.password_frame = ttk.Frame(self.field_frame) + self.password_frame.grid(row=1, column=1, padx=10, pady=5, sticky=tk.W + tk.E) + self.password_entry = ttk.Entry( + self.password_frame, show="*", width=47, font=("Helvetica", 13) + ) + self.password_entry.insert(0, self.config.data["password"]) + self.password_entry.pack(side=tk.LEFT, fill=tk.X, expand=True) + + self.eye_icon = ttk.Label( + self.password_frame, text="🙈", cursor="hand2", takefocus=False + ) + self.eye_icon.pack(side=tk.RIGHT, padx=(5, 0)) + self.eye_icon.bind("", self._toggle_password) + self._add_tooltip( + [pass_label, self.password_entry, self.eye_icon], + "Account password used for server authentication.\n\n" + "If encryption is enabled, this password is also used to derive your encryption key.\n" + "Use the same password on all devices for successful decrypt/sync.", + ) + + # Server URL + server_label = ttk.Label(self.field_frame, text="Server URL:") + server_label.grid(row=2, column=0, padx=(0, 10), pady=5, sticky=tk.W) + self.server_url_entry = ttk.Entry(self.field_frame, width=50, font=("Helvetica", 13)) + self.server_url_entry.insert(0, self.config.data["server_url"]) + self.server_url_entry.grid(row=2, column=1, padx=10, pady=5, sticky=tk.W + tk.E) + self._add_tooltip( + [server_label, self.server_url_entry], + "Address of your ClipCascade server.\n\n" + "Examples:\n" + "- Local server: http://localhost:8080\n" + "- LAN server: http://192.168.1.50:8080\n" + "- Reverse proxy/domain: https://clipcascade.example.com\n\n" + "Include protocol (http/https). Do not add /login or /clipsocket.", + ) + + # Configure grid weights to ensure entries expand + self.field_frame.columnconfigure(1, weight=1) + + # Checkboxes frame + checks_frame = ttk.Frame(main_frame) + checks_frame.pack(pady=(5, 10), fill=tk.X) + + # Encryption Checkbox + self.cipher_var = tk.BooleanVar(value=self.config.data["cipher_enabled"]) + self.cipher_checkbox = ttk.Checkbutton( + checks_frame, + text="Enable Encryption (recommended)", + variable=self.cipher_var, + takefocus=False, + ) + self.cipher_checkbox.pack(pady=3, anchor=tk.W) + self._add_tooltip( + [self.cipher_checkbox], + "Enables end-to-end encryption for clipboard content (recommended).\n\n" + "When enabled, clipboard data is encrypted on-device before sending.\n" + "All devices in the same account must use the same encryption settings " + "(password, salt, and hash rounds) to decrypt data correctly.", + ) + + # Notification Checkbox + self.notification_var = tk.BooleanVar(value=self.config.data["notification"]) + self.notification_checkbox = ttk.Checkbutton( + checks_frame, + text="Enable Notification", + variable=self.notification_var, + takefocus=False, + ) + self.notification_checkbox.pack(pady=3, anchor=tk.W) + self._add_tooltip( + [self.notification_checkbox], + "Shows local notifications for connection status events.\n\n" + "Useful to know when WebSocket disconnects/reconnects happen.\n" + "Turn this off if you prefer a quieter experience with fewer popups.", + ) + + # Button frame + button_frame = ttk.Frame(main_frame) + button_frame.pack(pady=(10, 0)) + + # Login Button + self.login_button = ttk.Button(button_frame, text="Login", command=self.on_login) + self.login_button.pack() + + # Extra Configurations Toggle Label + # macOS: show advanced fields by default + self.show_extra = PLATFORM == MACOS + self.toggle_normal_color = "#007acc" # neutral blue with strong contrast + self.toggle_hover_color = "#005a9e" # slightly darker for hover feedback + self.toggle_label = tk.Label( + main_frame, + text="Hide Extra Config" if PLATFORM == MACOS else "Enable Extra Config", + fg=self.toggle_normal_color, + cursor="hand2", + font=font.Font(family="Helvetica", size=13, underline=True), + takefocus=False, + ) + self.toggle_label.pack(pady=(5, 10)) + self.toggle_label.bind("", self._toggle_extra_config) + self._add_tooltip( + [self.toggle_label], + "Shows advanced settings for encryption and clipboard behavior.\n\n" + "Most users can keep defaults. Open this section only if you need custom tuning.", + ) + + # hover effects for the toggle label + self.toggle_label.bind("", self._on_hover) + self.toggle_label.bind("", self._on_leave) + + # Extra Configuration Frame with Scrollbar + self.extra_frame_container = ttk.Frame(main_frame) + if PLATFORM == MACOS: + self.extra_frame_container.pack(fill=tk.BOTH, expand=True) + else: + self.extra_frame_container.pack_forget() + + # Create a canvas inside the extra_frame_container + self.extra_canvas = tk.Canvas( + self.extra_frame_container, + borderwidth=0, + background="#f0f0f0", + takefocus=False, + ) + self.extra_canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + + # Add a vertical scrollbar to the canvas + self.scrollbar = ttk.Scrollbar( + self.extra_frame_container, + orient="vertical", + command=self.extra_canvas.yview, + takefocus=False, + ) + self.scrollbar.pack(side=tk.RIGHT, fill=tk.Y) + + # Configure the canvas to use the scrollbar + self.extra_canvas.configure(yscrollcommand=self.scrollbar.set) + + # Create a frame inside the canvas to hold the extra configuration widgets + self.extra_frame = ttk.Frame(self.extra_canvas, padding=10) + self.extra_frame_window = self.extra_canvas.create_window( + (0, 0), window=self.extra_frame, anchor="nw" + ) + + # Bind the configure event to update the scrollregion + self.extra_frame.bind( + "", + lambda event: self.extra_canvas.configure(scrollregion=self.extra_canvas.bbox("all")), + ) + + # Bind a configure event to the container to adjust the width + self.extra_frame_container.bind("", self._on_extra_container_configure) + + # Hash Rounds + hash_rounds_label = ttk.Label(self.extra_frame, text="Hash Rounds:") + hash_rounds_label.grid(row=0, column=0, padx=(0, 10), pady=5, sticky=tk.W) + self.hash_rounds_entry = ttk.Entry(self.extra_frame, width=50, font=("Helvetica", 13)) + self.hash_rounds_entry.insert(0, str(self.config.data["hash_rounds"])) + self.hash_rounds_entry.grid(row=0, column=1, padx=10, pady=5, sticky=tk.W + tk.E) + self._add_tooltip( + [hash_rounds_label, self.hash_rounds_entry], + "Number of hash iterations used for encryption key derivation.\n\n" + "Higher value = stronger brute-force resistance, but slower processing.\n" + "Default is tuned for balance. Keep default unless you know why to change.\n" + "Must be a positive integer and must match on every device.", + ) + + # Salt + salt_label = ttk.Label(self.extra_frame, text="Salt:") + salt_label.grid(row=1, column=0, padx=(0, 10), pady=5, sticky=tk.W) + self.salt_entry = ttk.Entry(self.extra_frame, width=50, font=("Helvetica", 13)) + self.salt_entry.insert(0, self.config.data["salt"]) + self.salt_entry.grid(row=1, column=1, padx=10, pady=5, sticky=tk.W + tk.E) + self._add_tooltip( + [salt_label, self.salt_entry], + "Optional extra text mixed into encryption key generation.\n\n" + "It can be any combination of letters and numbers (you can also include symbols/spaces if desired).\n" + "Use the same exact salt on all devices if encryption is enabled.\n" + "If left blank, encryption still works using password + hash rounds.\n" + "Changing salt later will prevent old devices from decrypting new clipboard data.", + ) + + # Save Password Locally Checkbox + save_password_label = ttk.Label( + self.extra_frame, + text="Store Password Locally\n(not recommended; \nonly works if encryption is disabled):", + ) + save_password_label.grid(row=2, column=0, padx=(0, 10), pady=5, sticky=tk.W) + self.save_password_var = tk.BooleanVar(value=self.config.data["save_password"]) + self.save_password_checkbox = ttk.Checkbutton( + self.extra_frame, + variable=self.save_password_var, + takefocus=False, + ) + self.save_password_checkbox.grid(row=2, column=1, padx=10, pady=5, sticky=tk.W) + self._add_tooltip( + [save_password_label, self.save_password_checkbox], + "Stores your plain password locally for automatic login convenience.\n\n" + "Security trade-off: avoid this on shared or untrusted machines.\n" + "This only works when encryption is disabled.", + ) + + # Maximum Clipboard Size - Local Limit + local_clipboard_size_label = ttk.Label( + self.extra_frame, text="Maximum Clipboard Size\nLocal Limit (in bytes):" + ) + local_clipboard_size_label.grid(row=3, column=0, padx=(0, 10), pady=5, sticky=tk.W) + self.local_clipboard_size_entry = ttk.Entry( + self.extra_frame, width=50, font=("Helvetica", 13) + ) + self.local_clipboard_size_entry.insert( + 0, str(self.config.data["max_clipboard_size_local_limit_bytes"] or "") + ) + self.local_clipboard_size_entry.grid(row=3, column=1, padx=10, pady=5, sticky=tk.W + tk.E) + self._add_tooltip( + [local_clipboard_size_label, self.local_clipboard_size_entry], + "Optional local safety limit for clipboard payload size (incoming and outgoing) in bytes.\n\n" + "Example: 1 MiB = 1048576 bytes.\n" + "Leave empty for no local limit.\n" + "Use a lower value if your device struggles with very large clipboard data " + "(large text/images/files). Must be a positive integer.", + ) + + # Enable Image Sharing Checkbox + enable_image_sharing_label = ttk.Label(self.extra_frame, text="Enable Image Sharing:") + enable_image_sharing_label.grid(row=4, column=0, padx=(0, 10), pady=5, sticky=tk.W) + self.enable_image_sharing_var = tk.BooleanVar( + value=self.config.data["enable_image_sharing"] + ) + self.enable_image_sharing_checkbox = ttk.Checkbutton( + self.extra_frame, + variable=self.enable_image_sharing_var, + takefocus=False, + ) + self.enable_image_sharing_checkbox.grid(row=4, column=1, padx=10, pady=5, sticky=tk.W) + self._add_tooltip( + [enable_image_sharing_label, self.enable_image_sharing_checkbox], + "Controls whether this device sends copied images to other devices.\n\n" + "If disabled, this device can still receive images sent from other devices.", + ) + + # Enable File Sharing Checkbox + enable_file_sharing_label = ttk.Label(self.extra_frame, text="Enable File Sharing:") + enable_file_sharing_label.grid(row=5, column=0, padx=(0, 10), pady=5, sticky=tk.W) + self.enable_file_sharing_var = tk.BooleanVar(value=self.config.data["enable_file_sharing"]) + self.enable_file_sharing_checkbox = ttk.Checkbutton( + self.extra_frame, + variable=self.enable_file_sharing_var, + takefocus=False, + ) + self.enable_file_sharing_checkbox.grid(row=5, column=1, padx=10, pady=5, sticky=tk.W) + self._add_tooltip( + [enable_file_sharing_label, self.enable_file_sharing_checkbox], + "Controls whether this device sends copied/shared files to other devices.\n\n" + "If disabled, this device can still receive files sent from other devices.", + ) + + # Default File Download Location + default_file_download_location_label = ttk.Label( + self.extra_frame, text="Default File Download Location:" + ) + default_file_download_location_label.grid( + row=6, column=0, padx=(0, 10), pady=5, sticky=tk.W + ) + self.default_file_download_location_entry = ttk.Entry( + self.extra_frame, width=50, font=("Helvetica", 13) + ) + self.default_file_download_location_entry.insert( + 0, self.config.data["default_file_download_location"] + ) + self.default_file_download_location_entry.grid( + row=6, column=1, padx=10, pady=5, sticky=tk.W + tk.E + ) + self._add_tooltip( + [default_file_download_location_label, self.default_file_download_location_entry], + "Optional default folder to save incoming files automatically.\n\n" + "Examples:\n" + "- Windows: C:\\Users\\YourName\\Downloads\n" + "- macOS/Linux: /Users/yourname/Downloads\n\n" + "Leave blank to choose a location manually when downloading files.\n" + "Path must already exist on this device.", + ) + + # SSL CA bundle (private / corporate PKI) + ssl_ca_label = ttk.Label(self.extra_frame, text="SSL CA bundle (optional):") + ssl_ca_label.grid(row=7, column=0, padx=(0, 10), pady=5, sticky=tk.W) + self.ssl_ca_bundle_entry = ttk.Entry( + self.extra_frame, width=50, font=("Helvetica", 13) + ) + self.ssl_ca_bundle_entry.insert( + 0, self.config.data.get("ssl_ca_bundle") or "" + ) + self.ssl_ca_bundle_entry.grid(row=7, column=1, padx=10, pady=5, sticky=tk.W + tk.E) + self._add_tooltip( + [ssl_ca_label, self.ssl_ca_bundle_entry], + "Path to a PEM file that contains your root CA (or full chain) for HTTPS/WSS.\n\n" + "Leave empty for default verification (public CAs / OS defaults).\n" + "Use this when your server uses a certificate from a private or internal CA.\n\n" + "Example: /etc/ssl/corp-root.pem or C:\\\\certs\\\\corp-root.pem", + ) + + # Configure grid weights for extra_frame + self.extra_frame.columnconfigure(1, weight=1) + + # Bind mouse wheel events for scrolling + self._bind_mousewheel() + + # Handle window close + self.protocol("WM_DELETE_WINDOW", self.on_quit) + + # Update idle tasks to calculate widget sizes + self.update_idletasks() + + # Set a maximum height for the window + self.max_window_height = 600 + + # on press enter key + self.bind("", lambda event: self.on_login()) + + self._sync_login_tab_order() + + self.deiconify() # Show window + self.update_idletasks() + center_window(self, self.max_window_height) + + # Drop always-on-top after showing + self.after(300, lambda: self.attributes("-topmost", False)) + + if PLATFORM == MACOS: + self.after(400, self._macos_restore_focus) + + def _sync_login_tab_order(self): + """Tab cycles username → password → server → Login → [extra entries if shown] → repeat. + Checkboxes and chrome are excluded from keyboard focus.""" + extra_entries = ( + self.hash_rounds_entry, + self.salt_entry, + self.local_clipboard_size_entry, + self.default_file_download_location_entry, + self.ssl_ca_bundle_entry, + ) + for w in extra_entries: + w.configure(takefocus=bool(self.show_extra)) + + def _add_tooltip(self, widgets, text): + for widget in widgets: + self._tooltips.append(HoverTooltip(widget, text)) + + def _macos_restore_focus(self): + """Force macOS to properly activate and focus the window. + Needed after sequential tk.Tk() creation/destruction cycles.""" + try: + from AppKit import NSApplication + app = NSApplication.sharedApplication() + app.activateIgnoringOtherApps_(True) + except ImportError: + pass + self.lift() + self.focus_force() + self.username_entry.focus_set() + + def _toggle_password(self, event=None): + if self.password_entry.cget("show") == "*": + self.password_entry.config(show="") + self.eye_icon.config(text="🐵") + else: + self.password_entry.config(show="*") + self.eye_icon.config(text="🙈") + + def _toggle_extra_config(self, event=None): + """Toggle the visibility of the extra configuration fields.""" + extra_entries = ( + self.hash_rounds_entry, + self.salt_entry, + self.local_clipboard_size_entry, + self.default_file_download_location_entry, + self.ssl_ca_bundle_entry, + ) + if self.show_extra: + focused = self.focus_get() + if focused in extra_entries: + self.login_button.focus_set() + self.extra_frame_container.pack_forget() + self.toggle_label.config(text="Enable Extra Config") + else: + self.extra_frame_container.pack(fill=tk.BOTH, expand=True) + self.toggle_label.config(text="Hide Extra Config") + self.show_extra = not self.show_extra + self._sync_login_tab_order() + + self.update_idletasks() + self.geometry("") + self.update_idletasks() + center_window(self) + + def _on_hover(self, event=None): + """Change text color on hover.""" + self.toggle_label.config(fg=self.toggle_hover_color) + + def _on_leave(self, event=None): + """Revert text color when not hovered.""" + self.toggle_label.config(fg=self.toggle_normal_color) + + def _bind_mousewheel(self): + """Bind mouse wheel events to the canvas for scrolling.""" + if PLATFORM == WINDOWS: + self.extra_canvas.bind_all("", self._on_mousewheel_windows) + elif PLATFORM == MACOS: + self.extra_canvas.bind_all("", self._on_mousewheel_mac) + elif PLATFORM.startswith(LINUX): + # Linux uses Button-4 and Button-5 for scroll up/down + self.extra_canvas.bind_all("", self._on_mousewheel_linux) + self.extra_canvas.bind_all("", self._on_mousewheel_linux) + + def _on_mousewheel_windows(self, event): + """Handle mouse wheel scrolling on Windows.""" + self.extra_canvas.yview_scroll(int(-1 * (event.delta / 120)), "units") + + def _on_mousewheel_mac(self, event): + """Handle mouse wheel scrolling on macOS.""" + self.extra_canvas.yview_scroll(int(-1 * event.delta), "units") + + def _on_mousewheel_linux(self, event): + """Handle mouse wheel scrolling on Linux.""" + if event.num == 4: + self.extra_canvas.yview_scroll(-1, "units") + elif event.num == 5: + self.extra_canvas.yview_scroll(1, "units") + + def _on_extra_container_configure(self, event): + """Adjust the width of the extra_frame as the container is resized.""" + canvas_width = event.width - self.scrollbar.winfo_width() + self.extra_canvas.itemconfigure(self.extra_frame_window, width=canvas_width) + + @staticmethod + def remove_trailing_slash(entry): + return re.sub(r"/+$", "", entry) + + @staticmethod + def is_positive_integer(value): + if value.isdigit() and int(value) > 0: + return True + else: + return False + + def on_login(self): + # save data to config + self.config.data["username"] = self.username_entry.get() + self.config.data["password"] = self.password_entry.get() + self.config.data["server_url"] = LoginForm.remove_trailing_slash( + self.server_url_entry.get().strip() + ) + self.config.data["cipher_enabled"] = self.cipher_var.get() + self.config.data["notification"] = self.notification_var.get() + + # extra fields + + # Validate hash_rounds + hash_rounds_input = self.hash_rounds_entry.get().strip() + if not LoginForm.is_positive_integer(hash_rounds_input): + CustomDialog( + "Invalid Input\nHash Rounds must be a positive integer.", + msg_type="error", + ).mainloop() + return # retry login + self.config.data["hash_rounds"] = int(hash_rounds_input) + + self.config.data["salt"] = self.salt_entry.get() + self.config.data["save_password"] = self.save_password_var.get() + + # Validate max_clipboard_size_local_limit_bytes + max_clipboard_size_local_limit_bytes_input = self.local_clipboard_size_entry.get().strip() + if max_clipboard_size_local_limit_bytes_input == "": + self.config.data["max_clipboard_size_local_limit_bytes"] = None + else: + if not LoginForm.is_positive_integer(max_clipboard_size_local_limit_bytes_input): + CustomDialog( + "Invalid Input\nMax Clipboard Size Local Limit must be a positive integer.", + msg_type="error", + ).mainloop() + return # retry login + self.config.data["max_clipboard_size_local_limit_bytes"] = int( + max_clipboard_size_local_limit_bytes_input + ) + + self.config.data["enable_image_sharing"] = self.enable_image_sharing_var.get() + self.config.data["enable_file_sharing"] = self.enable_file_sharing_var.get() + + # Validate file download location + default_file_download_location = self.default_file_download_location_entry.get().strip() + if default_file_download_location == "": + self.config.data["default_file_download_location"] = "" + else: + if not os.path.exists(default_file_download_location): + CustomDialog( + "Invalid Input\nDefault File Download Location does not exist.", + msg_type="error", + ).mainloop() + return # retry login + self.config.data["default_file_download_location"] = default_file_download_location + + ssl_ca_bundle = self.ssl_ca_bundle_entry.get().strip() + if ssl_ca_bundle: + if not os.path.isfile(ssl_ca_bundle): + CustomDialog( + "Invalid Input\nSSL CA bundle path is not a file or does not exist.", + msg_type="error", + ).mainloop() + return + self.config.data["ssl_ca_bundle"] = ssl_ca_bundle + + # call login callback + if self.on_login_callback: + self.on_login_callback() + + self.close() + + def on_quit(self): + if self.on_quit_callback: + self.on_quit_callback() + self.close() + + def close(self): + self.destroy() + gc.collect() + time.sleep(0.5) diff --git a/ClipCascade/gui/message_box.py b/ClipCascade/gui/message_box.py new file mode 100755 index 0000000..1d22900 --- /dev/null +++ b/ClipCascade/gui/message_box.py @@ -0,0 +1,6 @@ +from tkinter import messagebox + + +class MessageBox: + def askquestion(self, title, message): + return messagebox.askquestion(title, message) diff --git a/ClipCascade/gui/tray.py b/ClipCascade/gui/tray.py new file mode 100755 index 0000000..d16a25a --- /dev/null +++ b/ClipCascade/gui/tray.py @@ -0,0 +1,397 @@ +import math +import os +import threading +import time +import tkinter as tk +from tkinter import filedialog +import webbrowser +from pystray import Icon, MenuItem as item, Menu +from PIL import Image, ImageDraw + +from core.config import Config +from gui.info import CustomDialog +from core.constants import * + +if PLATFORM != WINDOWS: + import subprocess + + +class TaskbarPanel: + def __init__( + self, + on_connect_callback: callable = None, + on_disconnect_callback: callable = None, + on_logoff_callback: callable = None, + new_version_available: list = None, + github_url: str = GITHUB_URL, + donation_url: str = None, + ws_interface=None, # type= interfaces.ws_interface.WSInterface + config: Config = None, + ): + self.on_connect_callback = on_connect_callback + self.on_disconnect_callback = on_disconnect_callback + self.on_logoff_callback = on_logoff_callback + self.new_version_available = new_version_available + self.github_url = github_url + self.donation_url = donation_url + self.ws_interface = ws_interface + self.config = config + + self.is_disconnecting = False + self.disconnecting_items = None + self.is_file_download_enabled = False + self.file_download_items = None + self.previous_stats: str = "" + self.previous_stats_items = None + + self.root = tk.Tk() + self.root.withdraw() # Hide the root window + + # Hide dock icon on macOS after creating tkinter window + if PLATFORM == MACOS: + try: + from AppKit import NSApplication, NSApplicationActivationPolicyAccessory + NSApplication.sharedApplication().setActivationPolicy_(NSApplicationActivationPolicyAccessory) + except ImportError: + pass + + # Initial state: Connected + self.is_connected = True + + # Create the tray icon + self.icon = Icon( + "ClipCascade", self.create_clipboard_icon(), menu=self.create_menu() + ) + + self.icon.title = "ClipCascade" + + self.update_stats() # Start the stats update thread + + def run(self): + self.icon.run() + + def _create_clipboard_base_image(self): + """Shared clipboard artwork for normal tray icon and file-download badge variant.""" + width, height = 64, 64 + image = Image.new("RGBA", (width, height), (0, 0, 0, 0)) + draw = ImageDraw.Draw(image) + + fill_color = (220, 220, 220) + outline_color = (255, 255, 255) + + board_coords = (12, 12, 52, 57) + try: + draw.rounded_rectangle( + board_coords, radius=5, fill=None, outline=outline_color, width=3 + ) + except (AttributeError, TypeError): + draw.rectangle(board_coords, fill=None, outline=outline_color) + + clip_coords = (22, 7, 42, 17) + try: + draw.rounded_rectangle( + clip_coords, radius=3, fill=fill_color, outline=outline_color, width=3 + ) + except (AttributeError, TypeError): + draw.rectangle(clip_coords, fill=fill_color, outline=outline_color) + + return image + + def create_clipboard_icon(self): + return self._create_clipboard_base_image() + + def create_clipboard_icon_with_dot(self): + """Same clipboard as normal state, plus a blue notification dot for pending file download.""" + image = self._create_clipboard_base_image().copy() + draw = ImageDraw.Draw(image) + width, height = 64, 64 + + dot_radius = 8 + dot_center_x = width - dot_radius - 5 + dot_center_y = dot_radius + 5 + dot_bbox = [ + dot_center_x - dot_radius, + dot_center_y - dot_radius, + dot_center_x + dot_radius, + dot_center_y + dot_radius, + ] + draw.ellipse(dot_bbox, fill=(0, 128, 255, 255)) + + highlight_radius = dot_radius // 2 + highlight_center_x = dot_center_x - highlight_radius // 2 + highlight_center_y = dot_center_y - highlight_radius // 2 + highlight_bbox = [ + highlight_center_x - highlight_radius, + highlight_center_y - highlight_radius, + highlight_center_x + highlight_radius, + highlight_center_y + highlight_radius, + ] + draw.ellipse(highlight_bbox, fill=(255, 255, 255, 180)) + + return image + + def create_menu(self, item_: tuple = None): + """Create the menu for the tray icon. + + Args: + item_ (tuple, optional): The item that triggered the menu. Defaults to None. + item_ = (text, location, callback) + Returns: + Menu: The menu for the tray icon. + """ + # Menu items + menu_items = [ + Menu.SEPARATOR, + item("🗒️ Open Logs", self._open_logs), + item("📂 Program Files", self._open_program_location), + Menu.SEPARATOR, + item("🏠 Homepage", self._open_homepage), + item("❓ Help", self._open_help), + item("💟 Donate", self._open_donate), + item("🌐 GitHub", self._open_github), + Menu.SEPARATOR, + item("🔒 Logoff and Quit", self._on_logoff), + item("❌ Quit", self._on_quit), + ] + + # Add connect/disconnect option (top of the menu - 0 index) + if not self.is_connected: + menu_items.insert(0, item("🔗 Connect", self._on_connect, default=True)) + else: + if self.is_disconnecting and self.disconnecting_items is not None: + menu_items.insert( + self.disconnecting_items[1], + item(self.disconnecting_items[0], self.disconnecting_items[2]), + ) + else: + menu_items.insert(0, item("⛓️‍💥 Disconnect", self._on_disconnect)) + + # Add update option (before the last 3 items) + if self.new_version_available is not None and self.new_version_available[0]: + menu_items.insert( + len(menu_items) - 3, + item( + f"🔄 Update ({self.new_version_available[2]} ➞ {self.new_version_available[1]})", + self._on_update, + ), + ) + + # Add files download option (top of the menu - 0 index) + if self.is_file_download_enabled and self.file_download_items is not None: + menu_items.insert( + self.file_download_items[1], + item( + self.file_download_items[0], + self.file_download_items[2], + default=True, + ), + ) + + # Add stats option (top of the menu - 0 index) + if self.previous_stats_items is not None: + menu_items.insert( + self.previous_stats_items[1], + item( + self.previous_stats_items[0], + self.previous_stats_items[2], + enabled=self.previous_stats_items[2] is not None, + ), + ) + + return Menu(*menu_items) + + def update_menu(self, item_: tuple = None): + self.icon.menu = self.create_menu(item_=item_) + + def update_stats(self): + threading.Thread(target=self._update_stats_thread, daemon=True).start() + + def _update_stats_thread(self): + while True: + current_stats = self.ws_interface.get_stats() + if current_stats is not None and self.previous_stats != current_stats: + self.previous_stats = current_stats + self.previous_stats_items = (current_stats, 0, None) + self.update_menu() + time.sleep(1) # Sleep for 1 second + + @staticmethod + def open_webbrowser(url): + try: + webbrowser.open(url) + except Exception as e: + CustomDialog( + f"Failed to open the browser. Here is the URL: {url}\nError: {e}", + msg_type="error", + ).mainloop() + + def _on_update(self, icon, item): + TaskbarPanel.open_webbrowser(self.new_version_available[3]) + + def _on_connect(self, icon, item): + if self.on_connect_callback: + try: + self.on_connect_callback() + except Exception as e: + pass + self.is_connected = True + self.update_menu() + + def _on_disconnect(self, icon, item): + if self.on_disconnect_callback: + self.on_disconnect_callback() + if self.ws_interface is not None and self.ws_interface.is_auto_reconnecting: + threading.Thread( + target=self._wait_to_disconnect, args=(icon, item), daemon=True + ).start() + else: + self.is_connected = False + self.update_menu() + + def _wait_to_disconnect(self, icon, item): + """ + Wait for the auto-reconnect timer to expire before disconnecting. + """ + if self.ws_interface is None: + return + + timeout = math.ceil(self.ws_interface.get_total_timeout() / 1000) + while timeout > 0: + self.is_disconnecting = True + self.disconnecting_items = ( + f"⏳ Disconnecting... ({timeout} sec)", + 0, + None, + ) # text, location, callback + self.update_menu() + time.sleep(1) # seconds + timeout -= 1 + self.is_disconnecting = False + self.disconnecting_items = None + self.ws_interface.is_auto_reconnecting = False + self.is_connected = False + self.update_menu() + + def _open_homepage(self, icon, item): + TaskbarPanel.open_webbrowser(self.config.data["server_url"]) + + def _open_github(self, icon, item): + TaskbarPanel.open_webbrowser(self.github_url) + + def _open_help(self, icon, item): + TaskbarPanel.open_webbrowser(HELP_URL) + + def _open_donate(self, icon, item): + if self.donation_url is not None: + TaskbarPanel.open_webbrowser(self.donation_url) + + def open_location(self, path): + if PLATFORM == WINDOWS: + os.startfile(path) + elif PLATFORM == MACOS: + subprocess.run(["open", path]) + elif PLATFORM.startswith(LINUX): + subprocess.run(["xdg-open", path]) + + def _open_logs(self, icon, item): + log_file_path = os.path.join(get_program_files_directory(), LOG_FILE_NAME) + if os.path.exists(log_file_path): + try: + self.open_location(log_file_path) + except Exception as e: + CustomDialog( + f"Failed to open the log file '{log_file_path}'.\nError: {e}", + msg_type="error", + ).mainloop() + else: + CustomDialog( + f"Log file not found at '{log_file_path}'.", msg_type="error" + ).mainloop() + + def _open_program_location(self, icon, item): + try: + program_location = get_program_files_directory() + self.open_location(program_location) + except Exception as e: + CustomDialog( + f"Failed to open the program location '{program_location}'.\nError: {e}", + msg_type="error", + ).mainloop() + + def enable_files_download(self, files): + self.is_file_download_enabled = True + self.icon.icon = self.create_clipboard_icon_with_dot() + self.file_download_items = ( + "📥 Download File(s)", + 0, + lambda icon, item: self._on_download(icon, item, files), + ) + self.update_menu() + + def disable_files_download(self): + self.is_file_download_enabled = False + self.file_download_items = None + self.icon.icon = self.create_clipboard_icon() + self.update_menu() + + def _on_download(self, icon, item, files): + """Download the files to the user's Downloads folder.""" + try: + try: + if self.config.data["default_file_download_location"] != "": + target_directory = self.config.data[ + "default_file_download_location" + ] + else: + target_directory = filedialog.askdirectory( + title="Select location to Save File(s)", + initialdir=get_downloads_folder(), + ) + if not target_directory: + logging.debug("No directory selected. Exiting.") + return + except RuntimeError as re: + target_directory = os.path.join( + get_program_files_directory(), "downloads" + ) + if not os.path.exists(target_directory): + os.makedirs(target_directory) + logging.error( + f"A runtime error occurred while starting filedialog to select a directory. Error: {re}.\n" + + f"Setting the default location to the program directory '{target_directory}'." + ) + CustomDialog( + f"ClipCascade 📥: Saving files to the program directory '{target_directory}'.", + msg_type="info", + timeout=5000, + ).mainloop() + + # Save each file to the chosen directory + for filename, file_obj in files.items(): + file_path = os.path.join(target_directory, filename) + with open(file_path, "wb") as f: + f.write(file_obj.getvalue()) + logging.debug(f"Saved: {file_path}") + + except Exception as e: + msg = f"An error occurred while downloading files. Error: {e}" + logging.error(msg) + CustomDialog( + msg, + msg_type="error", + ).mainloop() + + def _on_logoff(self, icon, item): + try: + if self.on_logoff_callback: + self.on_logoff_callback() + self.icon.stop() + self.root.quit() + except Exception as e: + CustomDialog( + f"An error occurred while logging off: {e}", msg_type="error" + ).mainloop() + + def _on_quit(self, icon, item): + self.icon.stop() + self.root.quit() diff --git a/ClipCascade/help.txt b/ClipCascade/help.txt new file mode 100755 index 0000000..8fb5c9b --- /dev/null +++ b/ClipCascade/help.txt @@ -0,0 +1 @@ +https://github.com/Sathvik-Rao/ClipCascade diff --git a/ClipCascade/interfaces/ws_interface.py b/ClipCascade/interfaces/ws_interface.py new file mode 100755 index 0000000..4483b06 --- /dev/null +++ b/ClipCascade/interfaces/ws_interface.py @@ -0,0 +1,46 @@ +from abc import ABC, abstractmethod + +from cli.tray import TaskbarPanel +from core.config import Config + + +class WSInterface(ABC): + ############################ + # ABSTRACT VARIABLES # + ############################ + is_auto_reconnecting: bool = False + + @abstractmethod + def __init__(self, config: Config, is_login_phase=True): + """This method must be implemented in subclasses.""" + pass + + @abstractmethod + def get_total_timeout(self): + """This method must be implemented in subclasses.""" + pass + + @abstractmethod + def get_stats(self): + """This method must be implemented in subclasses.""" + pass + + @abstractmethod + def connect(self): + """This method must be implemented in subclasses.""" + pass + + @abstractmethod + def set_tray_ref(self, sys_tray: TaskbarPanel): + """This method must be implemented in subclasses.""" + pass + + @abstractmethod + def manual_reconnect(self): + """This method must be implemented in subclasses.""" + pass + + @abstractmethod + def disconnect(self): + """This method must be implemented in subclasses.""" + pass diff --git a/ClipCascade/main.py b/ClipCascade/main.py new file mode 100755 index 0000000..0f53712 --- /dev/null +++ b/ClipCascade/main.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 + +# ClipCascade - A seamless clipboard syncing utility +# Repository: https://github.com/Sathvik-Rao/ClipCascade +# +# Author: Sathvik Rao Poladi +# License: GPL-3.0 +# +# This script serves as the entry point for the ClipCascade application, +# initializing and running the core application logic. + +from core.application import Application + + +class Main: + def __init__(self): + Application().run() + +def main(): + Main() + +if __name__ == "__main__": + main() diff --git a/ClipCascade/p2p/p2p_manager.py b/ClipCascade/p2p/p2p_manager.py new file mode 100755 index 0000000..da99d71 --- /dev/null +++ b/ClipCascade/p2p/p2p_manager.py @@ -0,0 +1,1014 @@ +import json +import logging +import time +import websocket +import asyncio +import uuid + +from threading import Lock, Thread +from typing import Dict, List, Optional +from core.config import Config +from interfaces.ws_interface import WSInterface +from utils.cipher_manager import CipherManager +from clipboard.clipboard_manager import ClipboardManager +from utils.notification_manager import NotificationManager +from utils.request_manager import RequestManager +from utils.ssl_helper import websocket_sslopt_for_config +from core.constants import * +from aiortc import ( + RTCPeerConnection, + RTCIceServer, + RTCIceCandidate, + RTCSessionDescription, + RTCConfiguration, + RTCDataChannel, +) + +if PLATFORM.startswith(LINUX) and LINUX_USE_CLI_UI: + from cli.tray import TaskbarPanel +else: + from gui.tray import TaskbarPanel + +class P2PManager(WSInterface): + def __init__(self, config: Config, is_login_phase=True): + self.config = config + self.clipboard_manager = ClipboardManager(self.config) + self.cipher_manager = CipherManager(self.config) + self.notification_manager = NotificationManager(self.config) + self.sys_tray: TaskbarPanel = None + self.first_conn_lost = True + self.is_login_phase = is_login_phase + self.ws_client = None + self.is_connected = False + self.disconnected = False + self.is_auto_reconnecting = False + self._suppress_auto_reconnect_once = False + self.is_clipboard_monitoring_on = False + + # Fragment variables + self.sending_fragment_id = "" # The id of the fragment currently being sent + self.receiving_fragments: dict = {} # Mapping: fragmentid:str -> fragment:list[str] + self.sending_fragment_stats: str = None + self.receiving_fragment_stats: str = None + + # p2p variables + self.my_peer_id: str = None # Own peer id assigned by the server + self.peers: set[str] = set() # All peers in this 'room' + self.peer_connections: dict[str, RTCPeerConnection] = ( + {} + ) # Mapping: peer_id -> RTCPeerConnection + self.data_channels: dict[str, RTCDataChannel] = {} # Mapping: peer_id -> DataChannel + self.live_connections: int = 0 # Number of open data channels (derived; see _sync) + self._live_connections_lock = Lock() + # If PEER_LIST arrives before ASSIGNED_ID (e.g. right after signaling reconnect), mesh setup waits. + self._pending_peer_list: Optional[List[str]] = None + + # True during full P2P teardown (logout / signaling reconnect prep); suppresses ICE recovery. + self._p2p_shutting_down: bool = False + # Serialize recover + offer handling per peer (same asyncio loop as signaling handlers). + self._peer_recovery_locks: Dict[str, asyncio.Lock] = {} + self._dc_heartbeat_handle: Optional[asyncio.TimerHandle] = None + + # Event loop for asyncio + self.loop = asyncio.new_event_loop() + self.loop_thread = Thread( + target=self.loop.run_forever, name="P2PManagerEventLoopThread", daemon=True + ) + self.loop_thread.start() + + def schedule_task(self, coro): + """Submit an async function to the manager's event loop thread.""" + return asyncio.run_coroutine_threadsafe(coro, self.loop) + + def _cancel_dc_heartbeat(self) -> None: + if self._dc_heartbeat_handle is not None: + self._dc_heartbeat_handle.cancel() + self._dc_heartbeat_handle = None + + def _dc_heartbeat_tick(self) -> None: + self._dc_heartbeat_handle = None + if self.disconnected or self._p2p_shutting_down or not self.is_connected: + return + ping = json.dumps({"_cc_keepalive": True}) + for ch in list(self.data_channels.values()): + try: + if getattr(ch, "readyState", "") == "open": + ch.send(ping) + except Exception: + pass + self._schedule_next_dc_heartbeat() + + def _schedule_next_dc_heartbeat(self) -> None: + if self.disconnected or self._p2p_shutting_down or not self.is_connected: + return + self._cancel_dc_heartbeat() + self._dc_heartbeat_handle = self.loop.call_later( + P2P_DC_HEARTBEAT_INTERVAL_SEC, self._dc_heartbeat_tick + ) + + def _restart_dc_heartbeat(self) -> None: + self._cancel_dc_heartbeat() + if not self.disconnected and not self._p2p_shutting_down and self.is_connected: + self._schedule_next_dc_heartbeat() + + def set_tray_ref(self, sys_tray: TaskbarPanel): + """ + Sets the system tray reference. + """ + self.sys_tray = sys_tray + self.clipboard_manager.set_tray_ref(sys_tray) + + def _sync_live_connections_count(self): + """Set live_connections from open data channels (avoids +/- drift and negatives).""" + with self._live_connections_lock: + self.live_connections = sum( + 1 for ch in self.data_channels.values() if getattr(ch, "readyState", "") == "open" + ) + + def get_stats(self) -> str: + self._sync_live_connections_count() + stats = "📊" + with self._live_connections_lock: + n = self.live_connections + stats += f" Peers: {n}" + if self.sending_fragment_stats is not None: + stats += f" | Sending: {self.sending_fragment_stats}" + if self.receiving_fragment_stats is not None: + stats += f" | Receiving: {self.receiving_fragment_stats}" + return stats + + def get_total_timeout(self): + """ + Returns the total timeout value in milliseconds.""" + return (RECONNECT_WS_TIMER * 1000) + WEBSOCKET_TIMEOUT + + def connect(self) -> tuple[bool, str]: + """ + Connects to the P2P websocket signaling server. + """ + try: + if self.ws_client is not None: + if self.is_connected: + return True, "" + try: + self.ws_client.close() + except Exception: + pass + self.ws_client = None + + self.ws_client = websocket.WebSocketApp( + url=self.config.data["websocket_url"], + header={"Cookie": RequestManager.format_cookie(self.config.data["cookie"])}, + on_open=self._on_ws_open, + on_error=self._on_ws_error, + on_message=self._on_ws_message, + on_close=self._on_ws_close, + ) + + ws_run_kw = { + "ping_interval": P2P_WS_PING_INTERVAL_SEC, + "ping_timeout": P2P_WS_PING_TIMEOUT_SEC, + } + ws_sslopt = websocket_sslopt_for_config(self.config) + if ws_sslopt: + ws_run_kw["sslopt"] = ws_sslopt + Thread( + target=self.ws_client.run_forever, + kwargs=ws_run_kw, + daemon=True, + ).start() + + if not self.is_clipboard_monitoring_on: + # Start clipboard monitoring + self.is_clipboard_monitoring_on = True + self.clipboard_manager.on_copy(self.send) + + if not self.is_login_phase: + return True, "" + + total_ms = 0 + while self.is_connected is False: + time.sleep(0.25) + total_ms += 250 + if 0 < WEBSOCKET_TIMEOUT < total_ms: + self.ws_client.close() + raise TimeoutError( + f"Connection to {self.config.data['websocket_url']} timed out" + ) + + return True, "Websocket connected" + except Exception as e: + msg = f"Failed to connect websocket: {e}" + logging.error(msg) + try: + self.ws_close() + except Exception: + pass + return False, msg + + def _on_ws_close(self, ws, *args): + self.ws_client = None + self.is_connected = False + if self._suppress_auto_reconnect_once: + self._suppress_auto_reconnect_once = False + return + # Auto Reconnect + if not self.is_login_phase and not self.disconnected: + self.is_auto_reconnecting = True + if self.first_conn_lost: + self.notification_manager.notify( + title=f"{APP_NAME}: WebSocket Connection Lost ⛓️‍💥", + message="Check your internet connection. Retrying...", + ) + self.first_conn_lost = False + time.sleep(RECONNECT_WS_TIMER) # seconds + self.connect() + + def manual_reconnect(self): + if not self.is_auto_reconnecting: + self.disconnected = False + self._suppress_auto_reconnect_once = self.ws_client is not None + self.ws_close() + self.is_connected = False + self.connect() + + def _on_ws_message(self, ws, message): + self.schedule_task(self._on_ws_message_async(message)) + + async def _on_ws_message_async(self, message): + try: + logging.debug("\n<<< " + str(message)) + data = json.loads(message) + msg_type = data.get("type") + + if msg_type == "ASSIGNED_ID": + if self.my_peer_id is not None and self.my_peer_id != data["peerId"]: + await self._cleanup_peer_connections() + self.my_peer_id = data["peerId"] + if self._pending_peer_list is not None: + pending = self._pending_peer_list + self._pending_peer_list = None + await self._handle_peer_list(pending) + elif msg_type == "PEER_LIST": + await self._handle_peer_list(data["peers"]) + elif msg_type == "OFFER": + await self._handle_offer(data["fromPeerId"], data["offer"]) + + elif msg_type == "ANSWER": + await self._handle_answer(data["fromPeerId"], data["answer"]) + + elif msg_type == "ICE_CANDIDATE": + await self._handle_ice_candidate(data["fromPeerId"], data["candidate"]) + + except Exception as e: + logging.error(f"Failed to handle websocket message: {e}") + + async def _prepare_reconnect_bootstrap(self): + """ + Runs on the asyncio signaling loop thread. + Resets bootstrap state and tears down old mesh without clobbering newer + ASSIGNED_ID / PEER_LIST values that may arrive during cleanup. + """ + self.my_peer_id = None + self._pending_peer_list = None + await self._cleanup_peer_connections(clear_bootstrap_state=False) + + def _on_ws_open(self, ws, *args): + try: + if self.disconnected: + self.disconnect() + return + + # Tear down WebRTC after any signaling reconnect so PEER_LIST recreates PCs. + try: + fut = asyncio.run_coroutine_threadsafe( + self._prepare_reconnect_bootstrap(), self.loop + ) + # Upper bound: N peers * P2P_PC_CLOSE_TIMEOUT + margin; + fut.result(timeout=90) + except Exception as e: + logging.warning( + "P2P peer cleanup on signaling open: %s", + e if str(e) else type(e).__name__, + ) + + self.is_connected = True + self.is_auto_reconnecting = False + self.loop.call_soon_threadsafe(self._restart_dc_heartbeat) + if not self.first_conn_lost: + self.first_conn_lost = True + self.notification_manager.notify( + title=f"{APP_NAME}: WebSocket Connection Restored 🔗", + message="Connection re-established", + ) + except Exception as e: + logging.error(f"Failed to open P2P signaling session: {e}") + + def _on_ws_error(self, ws, error, *args): + logging.debug(error) + + def ws_send(self, data: dict): + try: + if self.ws_client is None or not self.is_connected: + return + + message = json.dumps(data) + logging.debug("\n>>> " + message) + self.ws_client.send(message) + except Exception as e: + logging.error(f"Failed to send websocket message: {e}") + + def ws_close(self): + if self.ws_client is not None: + self.ws_client.close() + self.ws_client = None + + def disconnect(self): + self.schedule_task(self._disconnect()) + + async def _disconnect(self): + """ + Stops the clipboard manager, closes the WebSocket, + closes all data channels, clears all peer-related data, + and resets P2P tracking variables. + """ + try: + self.clipboard_manager.previous_clipboard_hash = 0 + self.disconnected = True + self.first_conn_lost = True + + # Close the websocket + try: + self.ws_close() + self.is_connected = False + logging.info("Websocket disconnected") + except Exception as e: + pass # silent catch + + # Cleanup peer connections + await self._cleanup_peer_connections() + + # Stop the clipboard manager + self.clipboard_manager.stop() + self.is_clipboard_monitoring_on = False + except Exception as e: + logging.error(f"Failed to disconnect websocket: {e}") + + async def _cleanup_peer_connections(self, clear_bootstrap_state: bool = True): + self._p2p_shutting_down = True + self._cancel_dc_heartbeat() + try: + try: + for dc in list(self.data_channels.values()): + try: + dc.close() + except Exception: + pass + + for pc in list(self.peer_connections.values()): + try: + await asyncio.wait_for(pc.close(), timeout=P2P_PC_CLOSE_TIMEOUT_SEC) + except (asyncio.TimeoutError, asyncio.CancelledError, Exception): + pass + finally: + if clear_bootstrap_state: + self.my_peer_id = None + self.peers.clear() + self.peer_connections.clear() + self.data_channels.clear() + if clear_bootstrap_state: + self._pending_peer_list = None + self._peer_recovery_locks.clear() + with self._live_connections_lock: + self.live_connections = 0 + finally: + self._p2p_shutting_down = False + + async def _handle_peer_list(self, peer_list): + """ + The server has told us about the entire set of peer IDs in this 'room'. + We create connections or data channels accordingly. + """ + updated_peers = set(peer_list) + # Keep room membership in sync before closing stale PCs so ICE recovery does not + # treat removed peers as still present when data-channel close handlers run. + self.peers = updated_peers + await self._remove_stale_peers(updated_peers) + + if self.my_peer_id is None: + self._pending_peer_list = list(peer_list) + return + + # 3) For each peer, if we have no RTCPeerConnection, create one + for pid in self.peers: + if pid == self.my_peer_id: + continue # Ignore self + + if pid not in self.peer_connections: + pc = self._create_peer_connection(pid) + self.peer_connections[pid] = pc + + # TIE-BREAKER logic: + # We only createOffer if we are the "lesser" peer ID (i.e. myPeerId < pid). + # This helps avoid collisions where both sides create an offer simultaneously. + if self.my_peer_id < pid: + # We are the "offerer" + channel = pc.createDataChannel("cliptext") + self.data_channels[pid] = channel + self._setup_data_channel(pid, channel) + await self._create_offer(pid) + + async def _remove_stale_peers(self, updated_peers): + """ + Close connections and data channels for peers that no longer exist in updated_peers. + """ + # Find which peers are no longer present + stale_ids = set(self.peer_connections.keys()) - updated_peers + for old_pid in stale_ids: + logging.debug(f"Removing stale peer: {old_pid}") + self._peer_recovery_locks.pop(old_pid, None) + # Close data channel + dc = self.data_channels.pop(old_pid, None) + if dc is not None: + try: + dc.close() + except Exception: + pass + + # Close RTCPeerConnection + pc = self.peer_connections.pop(old_pid, None) + if pc is not None: + try: + await asyncio.wait_for(pc.close(), timeout=P2P_PC_CLOSE_TIMEOUT_SEC) + except (asyncio.TimeoutError, asyncio.CancelledError, Exception): + pass + + self._sync_live_connections_count() + + def _get_peer_lock(self, peer_id: str) -> asyncio.Lock: + lock = self._peer_recovery_locks.get(peer_id) + if lock is None: + lock = asyncio.Lock() + self._peer_recovery_locks[peer_id] = lock + return lock + + async def _dispose_peer_connection(self, peer_id: str) -> None: + """Close and drop one peer's DC/PC without changing self.peers (used by recovery and offer handling).""" + dc = self.data_channels.pop(peer_id, None) + if dc is not None: + try: + dc.close() + except Exception: + pass + pc = self.peer_connections.pop(peer_id, None) + if pc is not None: + try: + await asyncio.wait_for(pc.close(), timeout=P2P_PC_CLOSE_TIMEOUT_SEC) + except (asyncio.TimeoutError, asyncio.CancelledError, Exception): + pass + self._sync_live_connections_count() + + async def _recover_peer_transport( + self, remote_peer_id: str, dead_pc: Optional[RTCPeerConnection] = None + ) -> None: + """ + Re-establish WebRTC to a peer that is still in the room after ICE failure or data-channel loss. + Signaling WebSocket stays up; no server change required. + """ + if self._p2p_shutting_down or self.disconnected or self.is_login_phase: + return + if self.my_peer_id is None or remote_peer_id not in self.peers: + return + if dead_pc is not None and self.peer_connections.get(remote_peer_id) is not dead_pc: + return + + lock = self._get_peer_lock(remote_peer_id) + async with lock: + if self._p2p_shutting_down or self.disconnected or self.my_peer_id is None: + return + if remote_peer_id not in self.peers: + return + if dead_pc is not None and self.peer_connections.get(remote_peer_id) is not dead_pc: + return + if dead_pc is None: + ch = self.data_channels.get(remote_peer_id) + if ch is not None and getattr(ch, "readyState", "") == "open": + return + + logging.info("P2P: recovering WebRTC transport to peer %s", remote_peer_id) + + await self._dispose_peer_connection(remote_peer_id) + + if ( + self._p2p_shutting_down + or self.my_peer_id is None + or remote_peer_id not in self.peers + ): + return + + pc = self._create_peer_connection(remote_peer_id) + self.peer_connections[remote_peer_id] = pc + + if self.my_peer_id < remote_peer_id: + channel = pc.createDataChannel("cliptext") + self.data_channels[remote_peer_id] = channel + self._setup_data_channel(remote_peer_id, channel) + await self._create_offer(remote_peer_id) + + def _create_peer_connection(self, remote_peer_id: str) -> RTCPeerConnection: + """ + Create and return a new RTCPeerConnection, wired up with ICE-candidate and data-channel handling. + """ + pc = RTCPeerConnection( + configuration=RTCConfiguration( + iceServers=[RTCIceServer(urls=[self.config.data["stun_url"]])] + ) + ) + + @pc.on("icecandidate") + def on_icecandidate(candidate: RTCIceCandidate): + # This fires when the local side gathers an ICE candidate + if candidate is not None: + candidate_line = P2PManager.dict_to_ice_candidate_line( + { + "component": candidate.component, + "foundation": candidate.foundation, + "ip": candidate.ip, + "port": candidate.port, + "priority": candidate.priority, + "protocol": candidate.protocol, + "type": candidate.type, + "raddr": candidate.relatedAddress, + "rport": candidate.relatedPort, + "tcptype": candidate.tcpType, + } + ) + self.ws_send( + { + "type": "ICE_CANDIDATE", + "fromPeerId": self.my_peer_id, + "toPeerId": remote_peer_id, + "candidate": { + "candidate": candidate_line, + "sdpMid": candidate.sdpMid, + "sdpMLineIndex": candidate.sdpMLineIndex, + }, + } + ) + + @pc.on("connectionstatechange") + def on_connectionstatechange(): + try: + state = pc.connectionState + except Exception: + return + if state in ("failed", "closed"): + self.schedule_task(self._recover_peer_transport(remote_peer_id, pc)) + + @pc.on("datachannel") + def on_datachannel(channel): + # This fires when the remote side creates a datachannel + self.data_channels[remote_peer_id] = channel + self._setup_data_channel(remote_peer_id, channel) + + return pc + + async def _create_offer(self, remote_peer_id: str): + """ + Create an SDP offer for the specified remote peer and send it out via signaling. + """ + pc = self.peer_connections[remote_peer_id] + offer = await pc.createOffer() + await pc.setLocalDescription(offer) + + self.ws_send( + { + "type": "OFFER", + "fromPeerId": self.my_peer_id, + "toPeerId": remote_peer_id, + "offer": { + "type": pc.localDescription.type, + "sdp": pc.localDescription.sdp, + }, + } + ) + + async def _handle_offer(self, from_peer_id: str, offer: dict): + """ + Respond to an incoming OFFER from a remote peer. + Always replaces any existing RTCPeerConnection for this peer before applying + the SDP: recovery sends a full new offer; applying it on an already-connected + PC leaves ICE/datachannel mismatched (remote shows Peers: 1, local 0). + """ + if self._p2p_shutting_down: + return + # Ignore delayed/stale OFFERs for peers no longer present in the latest PEER_LIST. + # Startup guard: allow early signaling before ASSIGNED_ID/PEER_LIST is established. + if self.my_peer_id is not None and self.peers and from_peer_id not in self.peers: + logging.debug("Ignoring stale OFFER for removed peer: %s", from_peer_id) + return + lock = self._get_peer_lock(from_peer_id) + async with lock: + # TOCTOU guard: PEER_LIST can change after the outer fast-path check. + if self.my_peer_id is not None and self.peers and from_peer_id not in self.peers: + logging.debug("Ignoring stale OFFER for removed peer (in-lock): %s", from_peer_id) + return + if from_peer_id in self.peer_connections: + await self._dispose_peer_connection(from_peer_id) + pc = self._create_peer_connection(from_peer_id) + self.peer_connections[from_peer_id] = pc + + desc = RTCSessionDescription(sdp=offer["sdp"], type=offer["type"]) + await pc.setRemoteDescription(desc) + + answer = await pc.createAnswer() + await pc.setLocalDescription(answer) + + self.ws_send( + { + "type": "ANSWER", + "fromPeerId": self.my_peer_id, + "toPeerId": from_peer_id, + "answer": { + "type": pc.localDescription.type, + "sdp": pc.localDescription.sdp, + }, + } + ) + + async def _handle_answer(self, from_peer_id: str, answer: dict): + """ + Handle an incoming ANSWER to our previously sent OFFER. + """ + pc = self.peer_connections.get(from_peer_id) + if not pc: + logging.warning(f"No peer connection exists for id={from_peer_id}, ignoring ANSWER.") + return + if getattr(pc, "connectionState", "") in ("failed", "closed"): + logging.debug("Ignoring ANSWER for dead peer connection: %s", from_peer_id) + return + desc = RTCSessionDescription(sdp=answer["sdp"], type=answer["type"]) + await pc.setRemoteDescription(desc) + + async def _handle_ice_candidate(self, from_peer_id: str, candidate_data: dict): + """ + Handle an incoming ICE candidate from the remote peer. + """ + pc = self.peer_connections.get(from_peer_id) + if not pc: + logging.warning( + f"No peer connection exists for id={from_peer_id}, ignoring ICE_CANDIDATE." + ) + return + if getattr(pc, "connectionState", "") in ("failed", "closed"): + logging.debug("Ignoring ICE candidate for dead peer connection: %s", from_peer_id) + return + + parsed_candidate = P2PManager.parse_ice_candidate_line(candidate_data.get("candidate")) + ice_candidate = RTCIceCandidate( + component=parsed_candidate.get("component"), + foundation=parsed_candidate.get("foundation"), + ip=parsed_candidate.get("ip"), + port=parsed_candidate.get("port"), + priority=parsed_candidate.get("priority"), + protocol=parsed_candidate.get("protocol"), + type=parsed_candidate.get("type"), + relatedAddress=parsed_candidate.get("raddr"), + relatedPort=parsed_candidate.get("rport"), + sdpMid=candidate_data.get("sdpMid"), + sdpMLineIndex=candidate_data.get("sdpMLineIndex"), + tcpType=parsed_candidate.get("tcptype"), + ) + await pc.addIceCandidate(ice_candidate) + + def _setup_data_channel(self, remote_peer_id: str, channel: RTCDataChannel): + """ + Set up handlers for an RTCDataChannel (open, message, close, error). + """ + + @channel.on("open") + def on_open(): + self._sync_live_connections_count() + + if channel.readyState == "open": + self._sync_live_connections_count() + + @channel.on("message") + def on_message(message): + self._receive(message) + + @channel.on("close") + def on_close(): + self._sync_live_connections_count() + self.schedule_task(self._recover_peer_transport(remote_peer_id, None)) + + @channel.on("error") + def on_error(e): + logging.error(f"[data] Data channel error with {remote_peer_id}: {e}") + self._sync_live_connections_count() + + def send(self, payload: str, payload_type: str = "text"): + self.schedule_task(self._send(payload, payload_type)) + + def reset_sending_fragment_id(self): + self.sending_fragment_id = "" + self.sending_fragment_stats = None + + async def _send(self, payload: str, payload_type: str = "text"): + try: + if self.clipboard_manager.has_clipboard_changed(payload): + self.reset_sending_fragment_id() + self.reset_receiving_fragments() + + raw_payload_size_in_bytes = len(payload.encode("utf-8")) + + if self.config.data["cipher_enabled"]: + payload = CipherManager.encode_to_json_string( + **self.cipher_manager.encrypt(payload) + ) + + fragments = P2PManager.fragment_string(payload) + metadata = { + "id": str(uuid.uuid4()), + "isFragmented": len(fragments) > 1, + "index": 0, + "totalFragments": len(fragments), + "combinedRawPayloadSizeInBytes": raw_payload_size_in_bytes, + } + + self.sending_fragment_id = metadata["id"] + for fragment in fragments: + if self.sending_fragment_id != metadata["id"]: + break + + body = json.dumps( + { + "payload": fragment, + "type": payload_type, + "metadata": metadata, + } + ) + metadata["index"] += 1 + + # Send to all open DataChannels + for peer_id, channel in self.data_channels.items(): + if channel.readyState == "open": + channel.send(body) + + if metadata["isFragmented"]: + self.sending_fragment_stats = ( + f"{metadata['index']}/{metadata['totalFragments']}" + ) + else: + self.reset_sending_fragment_id() + + except Exception as e: + logging.error(f"Failed to send data: {e}") + + def reset_receiving_fragments(self): + self.receiving_fragments = {} + self.receiving_fragment_stats = None + + def _receive(self, frame: any) -> str: + try: + body = json.loads(frame) + if isinstance(body, dict) and body.get("_cc_keepalive") is True: + return + self.reset_sending_fragment_id() + payload = body["payload"] + payload_type = body.get("type", "text") + metadata = body.get("metadata") + + # Check if the payload exceeds the maximum size: first layer protection + if ( + metadata is not None + and self.config.data["max_clipboard_size_local_limit_bytes"] is not None + and metadata["combinedRawPayloadSizeInBytes"] + > self.config.data["max_clipboard_size_local_limit_bytes"] + ): + self.reset_receiving_fragments() + logging.debug( + f"Payload size limit exceeded: {metadata['combinedRawPayloadSizeInBytes']} bytes exceeds {self.config.data['max_clipboard_size_local_limit_bytes']} bytes" + ) + return + + # Fragmented message handling + if metadata is not None and metadata["isFragmented"]: + self.receiving_fragment_stats = ( + f"{metadata['index'] + 1}/{metadata['totalFragments']}" + ) + if metadata["id"] in self.receiving_fragments: + self.receiving_fragments[metadata["id"]][metadata["index"]] = payload + if metadata["index"] == metadata["totalFragments"] - 1: + if all(s != "" for s in self.receiving_fragments[metadata["id"]]): + payload = "".join(self.receiving_fragments[metadata["id"]]) + else: + self.reset_receiving_fragments() + logging.error( + "Failed to receive: One or more fragments are missing or clipboard has changed." + ) + return + else: + return + else: + self.reset_receiving_fragments() + self.receiving_fragments[metadata["id"]] = [""] * metadata["totalFragments"] + self.receiving_fragments[metadata["id"]][metadata["index"]] = payload + return + + if self.config.data["cipher_enabled"]: + payload = self.cipher_manager.decrypt( + **CipherManager.decode_from_json_string(payload) + ) + + if self.clipboard_manager.has_clipboard_changed(payload): + self.reset_receiving_fragments() + self.clipboard_manager.base64_to_clipboard( + base64_string=payload, type_=payload_type + ) + except json.decoder.JSONDecodeError: + logging.error("If cipher is enabled, please make sure it is enabled on all devices") + except Exception as e: + logging.error(f"Failed to receive data: {e}") + + @staticmethod + def fragment_string(s: str, fragment_size: int = FRAGMENT_SIZE) -> list[str]: + """ + Splits a string into a list of fragments, each with a maximum size of `fragment_size` bytes. + + Args: + s (str): The string to fragment. + fragment_size (int): The maximum size of each fragment in bytes. + + Returns: + list[str]: A list of string fragments. + """ + # Encode the string to bytes to accurately split by byte size + s_bytes = s.encode("utf-8") + fragments = [ + s_bytes[i : i + fragment_size].decode("utf-8", errors="ignore") + for i in range(0, len(s_bytes), fragment_size) + ] + return fragments + + @staticmethod + def parse_ice_candidate_line(candidate_line: str) -> dict: + """ + Parse a full ICE candidate line into its constituent fields. + + Returns a dict with keys: + foundation, component, protocol, priority, + ip, port, type, + raddr, rport, tcptype, generation, ufrag, network-id, network-cost, + plus any other unrecognized "key value" pairs. + + Example ICE candidate line: + "candidate:3365442476 1 udp 2113939711 4c76248e-9690-49db-9043-6cd01cc3a9b4.local 59478 typ host generation 0 ufrag HoZP network-cost 999" + """ + candidate_line = candidate_line.strip() + tokens = candidate_line.split() + + # 1) Must start with something like "candidate:..." + if not tokens or not tokens[0].startswith("candidate:"): + raise ValueError("Not a valid ICE candidate line (must start with 'candidate:').") + + # 2) Basic mandatory fields (RFC 5245) + # + # candidate: typ + # e.g.: + # "candidate:3365442476 1 udp 2113939711 192.168.1.10 5000 typ host ..." + foundation = tokens[0].split(":", 1)[1] + component = int(tokens[1]) + protocol = tokens[2].lower() + priority = int(tokens[3]) + ip = tokens[4] + port = int(tokens[5]) + + # Next token should be "typ" + if tokens[6] != "typ": + raise ValueError( + f"Expected 'typ' after port but found '{tokens[6]}' in ICE candidate line." + ) + + candidate_type = tokens[7] + + # 3) Optional fields come after index 8, usually as "key value" pairs + # e.g. "raddr 1.2.3.4 rport 1234 tcptype active generation 0 ufrag ABCD network-cost 999" + extras = {} + idx = 8 + while idx < len(tokens): + key = tokens[idx] + # If there's no "value" after this key, store None and break. + if idx + 1 >= len(tokens): + extras[key] = None + break + value = tokens[idx + 1] + + # Store it + extras[key] = value + idx += 2 + + # 4) Pull out commonly known keys if they exist in extras + raddr = extras.pop("raddr", None) + rport = extras.pop("rport", None) + tcptype = extras.pop("tcptype", None) + generation = extras.pop("generation", None) + ufrag = extras.pop("ufrag", None) + network_id = extras.pop("network-id", None) + network_cost = extras.pop("network-cost", None) + + # 5) Build final dictionary + candidate_dict = { + "foundation": foundation, + "component": component, + "protocol": protocol, + "priority": priority, + "ip": ip, + "port": port, + "type": candidate_type, + "raddr": raddr, + "rport": rport, + "tcptype": tcptype, + "generation": generation, + "ufrag": ufrag, + "network-id": network_id, + "network-cost": network_cost, + # Put anything else we didn't explicitly handle into 'extras' + "extras": extras, + } + + return candidate_dict + + @staticmethod + def dict_to_ice_candidate_line(candidate_dict: dict) -> str: + """ + Convert a dictionary of ICE candidate fields back into the ICE candidate line string. + + Expects a dict with at least the following keys: + - foundation (str) + - component (int) + - protocol (str) + - priority (int) + - ip (str) + - port (int) + - type (str) + + The known optional keys are: + - raddr, rport, tcptype, generation, ufrag + - network-id, network-cost + These will be appended as "key value" pairs if they exist (and are not None). + + Additionally, any leftover entries in candidate_dict["extras"] are appended at the end. + + Returns a line starting with "candidate:..." + """ + foundation = candidate_dict["foundation"] + component = candidate_dict["component"] + protocol = candidate_dict["protocol"] + priority = candidate_dict["priority"] + ip = candidate_dict["ip"] + port = candidate_dict["port"] + candidate_type = candidate_dict["type"] + + line_parts = [ + f"candidate:{foundation}", + str(component), + protocol.lower(), + str(priority), + ip, + str(port), + "typ", + candidate_type, + ] + + # Append known optional fields if present. + # (Only add if the value is not None) + if candidate_dict.get("raddr") is not None: + line_parts += ["raddr", str(candidate_dict["raddr"])] + + if candidate_dict.get("rport") is not None: + line_parts += ["rport", str(candidate_dict["rport"])] + + if candidate_dict.get("tcptype") is not None: + line_parts += ["tcptype", str(candidate_dict["tcptype"])] + + if candidate_dict.get("generation") is not None: + line_parts += ["generation", str(candidate_dict["generation"])] + + if candidate_dict.get("ufrag") is not None: + line_parts += ["ufrag", str(candidate_dict["ufrag"])] + + if candidate_dict.get("network-id") is not None: + line_parts += ["network-id", str(candidate_dict["network-id"])] + + if candidate_dict.get("network-cost") is not None: + line_parts += ["network-cost", str(candidate_dict["network-cost"])] + + # Finally, any 'extras' are appended at the end + extras = candidate_dict.get("extras", {}) + for k, v in extras.items(): + line_parts.append(str(k)) + if v is not None: + line_parts.append(str(v)) + + return " ".join(line_parts) diff --git a/ClipCascade/pyproject.toml b/ClipCascade/pyproject.toml new file mode 100755 index 0000000..28281f9 --- /dev/null +++ b/ClipCascade/pyproject.toml @@ -0,0 +1,84 @@ +[build-system] +requires = ["setuptools>=65.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "ClipCascade" +version = "3.2.0" +description = "ClipCascade is a lightweight utility that automatically syncs the clipboard across devices, no key press required." +authors = [{ name = "Sathvik Rao", email = "sathvik.poladi@gmail.com" }] +license = { file = "LICENSE" } +requires-python = ">=3.8" +keywords = [ + "clipboard", + "sync", + "cross-platform", + "open-source", + "utility", + "encryption", +] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: End Users/Desktop", + "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: System :: Monitoring", +] + +dependencies = [ + "Pillow>=10.4.0", + "plyer==2.1.0", + "pycryptodome==3.20.0", + "pystray==0.19.5", + "Requests==2.32.3", + "websocket_client==1.8.0", + "xxhash==3.5.0", + "beautifulsoup4==4.12.3", + "aiortc==1.10.0", + # Platform-specific dependencies + "pyfiglet==1.0.2; sys_platform == 'linux'", + "pyperclip==1.8.2; sys_platform == 'win32' or sys_platform == 'darwin'", + "pywin32==306; sys_platform == 'win32'", + "certifi; sys_platform == 'darwin'", + "pyobjus==1.2.3; sys_platform == 'darwin'", + "pasteboard==0.4.0; sys_platform == 'darwin'", +] + +[project.scripts] +clipcascade = "main:main" + +[tool.setuptools] +py-modules = ["main"] +packages = [ + "cli", + "clipboard", + "core", + "gui", + "interfaces", + "p2p", + "stomp_ws", + "utils", +] + +[tool.black] +line-length = 100 +target-version = ["py38", "py39", "py310", "py311", "py312"] +include = "\\.pyi?$" + +[tool.isort] +profile = "black" +line_length = 100 +multi_line_mode = 3 + +[project.urls] +Homepage = "https://clipcascade.sathvik.dev/" +Repository = "https://github.com/Sathvik-Rao/ClipCascade" +Documentation = "https://github.com/Sathvik-Rao/ClipCascade#readme" +Issues = "https://github.com/Sathvik-Rao/ClipCascade/issues" +Discussions = "https://github.com/Sathvik-Rao/ClipCascade/discussions" diff --git a/ClipCascade/requirements.txt b/ClipCascade/requirements.txt new file mode 100755 index 0000000..b613c30 --- /dev/null +++ b/ClipCascade/requirements.txt @@ -0,0 +1,10 @@ +Pillow==10.4.0 +plyer==2.1.0 +pycryptodome==3.20.0 +pystray==0.19.5 +Requests==2.32.3 +websocket_client==1.8.0 +xxhash==3.5.0 +pyfiglet==1.0.2 +beautifulsoup4==4.12.3 +aiortc==1.10.0 diff --git a/ClipCascade/stomp_ws/client.py b/ClipCascade/stomp_ws/client.py new file mode 100755 index 0000000..e09f32f --- /dev/null +++ b/ClipCascade/stomp_ws/client.py @@ -0,0 +1,206 @@ +import time +from threading import Thread + +from .frame import Frame +import websocket +import logging + +from core.constants import * + +VERSIONS = "1.0,1.1" + + +class Client: + + def __init__(self, url, headers={}, on_close_callback=None, sslopt=None): + + self.url = url + self._ws_sslopt = sslopt + self.ws = websocket.WebSocketApp(self.url, headers) + self.ws.on_open = self._on_open + self.ws.on_message = self._on_message + self.ws.on_error = self._on_error + self.ws.on_close = self._on_close + self.on_close_callback = on_close_callback + + self.opened = False + + self.connected = False + + self.counter = 0 + self.subscriptions = {} + + self._connectCallback = None + self.errorCallback = None + + def _connect(self, timeout=0): + if self._ws_sslopt: + thread = Thread( + target=lambda: self.ws.run_forever(sslopt=self._ws_sslopt) + ) + else: + thread = Thread(target=self.ws.run_forever) + thread.daemon = True + thread.start() + + total_ms = 0 + while self.opened is False: + time.sleep(0.25) + total_ms += 250 + if 0 < timeout < total_ms: + raise TimeoutError(f"Connection to {self.url} timed out") + + def _on_open(self, ws_app, *args): + self.opened = True + + def _on_close(self, ws_app, *args): + self.connected = False + logging.debug("Whoops! Lost connection to " + self.ws.url) + if self.on_close_callback is not None: + self.on_close_callback() + self._clean_up() + + def _on_error(self, ws_app, error, *args): + logging.debug(error) + + def _on_message(self, ws_app, message, *args): + if message == "\n": # If message is a newline, it's a heartbeat frame + logging.debug("Received heartbeat frame") + self.ws.send("\n") # Send a heartbeat back to the server + logging.debug("Sent heartbeat frame") + return + + logging.debug("\n<<< " + str(message)) + frame = Frame.unmarshall_single(message) + _results = [] + if frame.command == "CONNECTED": + self.connected = True + logging.debug("connected to server " + self.url) + if self._connectCallback is not None: + _results.append(self._connectCallback(frame)) + elif frame.command == "MESSAGE": + + subscription = frame.headers["subscription"] + + if subscription in self.subscriptions: + onreceive = self.subscriptions[subscription] + messageID = frame.headers["message-id"] + + def ack(headers): + if headers is None: + headers = {} + return self.ack(messageID, subscription, headers) + + def nack(headers): + if headers is None: + headers = {} + return self.nack(messageID, subscription, headers) + + frame.ack = ack + frame.nack = nack + + _results.append(onreceive(frame)) + else: + info = "Unhandled received MESSAGE: " + str(frame) + logging.debug(info) + _results.append(info) + elif frame.command == "RECEIPT": + pass + elif frame.command == "ERROR": + if self.errorCallback is not None: + _results.append(self.errorCallback(frame)) + else: + info = "Unhandled received MESSAGE: " + frame.command + logging.debug(info) + _results.append(info) + + return _results + + def _transmit(self, command, headers, body=None): + out = Frame.marshall(command, headers, body) + logging.debug("\n>>> " + out) + self.ws.send(out) + + def connect( + self, + login=None, + passcode=None, + headers=None, + connectCallback=None, + errorCallback=None, + timeout=0, + ): + + logging.debug("Opening web socket...") + self._connect(timeout) + + headers = headers if headers is not None else {} + headers["host"] = self.url + headers["accept-version"] = VERSIONS + headers["heart-beat"] = "0,20000" + + if login is not None: + headers["login"] = login + if passcode is not None: + headers["passcode"] = passcode + + self._connectCallback = connectCallback + self.errorCallback = errorCallback + + self._transmit("CONNECT", headers) + + def disconnect(self, disconnectCallback=None, headers=None): + if headers is None: + headers = {} + + # self._transmit("DISCONNECT", headers) # comment this + self.ws.on_close = None + self.ws.close() + self._clean_up() + + if disconnectCallback is not None: + disconnectCallback() + + def _clean_up(self): + self.connected = False + + def send(self, destination, headers=None, body=None): + if headers is None: + headers = {} + if body is None: + body = "" + headers["destination"] = destination + return self._transmit("SEND", headers, body) + + def subscribe(self, destination, callback=None, headers=None): + if headers is None: + headers = {} + if "id" not in headers: + headers["id"] = "sub-" + str(self.counter) + self.counter += 1 + headers["destination"] = destination + self.subscriptions[headers["id"]] = callback + self._transmit("SUBSCRIBE", headers) + + def unsubscribe(): + self.unsubscribe(headers["id"]) + + return headers["id"], unsubscribe + + def unsubscribe(self, id): + del self.subscriptions[id] + return self._transmit("UNSUBSCRIBE", {"id": id}) + + def ack(self, message_id, subscription, headers): + if headers is None: + headers = {} + headers["message-id"] = message_id + headers["subscription"] = subscription + return self._transmit("ACK", headers) + + def nack(self, message_id, subscription, headers): + if headers is None: + headers = {} + headers["message-id"] = message_id + headers["subscription"] = subscription + return self._transmit("NACK", headers) diff --git a/ClipCascade/stomp_ws/frame.py b/ClipCascade/stomp_ws/frame.py new file mode 100755 index 0000000..4327558 --- /dev/null +++ b/ClipCascade/stomp_ws/frame.py @@ -0,0 +1,51 @@ +from urllib.parse import urlparse + +Byte = {"LF": "\x0A", "NULL": "\x00"} + + +class Frame: + + def __init__(self, command, headers, body): + self.command = command + self.headers = headers + self.body = "" if body is None else body + + def __str__(self): + lines = [self.command] + skipContentLength = "content-length" in self.headers + if skipContentLength: + del self.headers["content-length"] + + for name in self.headers: + value = self.headers[name] + lines.append("" + name + ":" + value) + + if self.body is not None and not skipContentLength: + lines.append("content-length:" + str(len(self.body))) + + lines.append(Byte["LF"] + self.body) + return Byte["LF"].join(lines) + + @staticmethod + def unmarshall_single(data): + lines = data.split(Byte["LF"]) + + command = lines[0].strip() + headers = {} + + # get all headers + i = 1 + while lines[i] != "": + # get key, value from raw header + (key, value) = lines[i].split(":", 1) + headers[key] = value + i += 1 + + # set body to None if there is no body + body = None if lines[i + 1] == Byte["NULL"] else lines[i + 1][:-1] + + return Frame(command, headers, body) + + @staticmethod + def marshall(command, headers, body): + return str(Frame(command, headers, body)) + Byte["NULL"] diff --git a/ClipCascade/stomp_ws/stomp_manager.py b/ClipCascade/stomp_ws/stomp_manager.py new file mode 100755 index 0000000..48a78c4 --- /dev/null +++ b/ClipCascade/stomp_ws/stomp_manager.py @@ -0,0 +1,161 @@ +import json +import logging +import time + + +from interfaces.ws_interface import WSInterface +from stomp_ws.client import Client +from core.config import Config +from utils.cipher_manager import CipherManager +from clipboard.clipboard_manager import ClipboardManager +from utils.notification_manager import NotificationManager +from utils.request_manager import RequestManager +from utils.ssl_helper import websocket_sslopt_for_config +from core.constants import * + +if PLATFORM.startswith(LINUX) and LINUX_USE_CLI_UI: + from cli.tray import TaskbarPanel +else: + from gui.tray import TaskbarPanel + + +class STOMPManager(WSInterface): + def __init__(self, config: Config, is_login_phase=True): + self.config = config + self.clipboard_manager = ClipboardManager(self.config) + self.cipher_manager = CipherManager(self.config) + self.notification_manager = NotificationManager(self.config) + self.sys_tray: TaskbarPanel = None + self.first_conn_lost = True + self.is_login_phase = is_login_phase + self.client = None + self.is_connected = False + self.disconnected = False + self.is_auto_reconnecting = False + + def set_tray_ref(self, sys_tray: TaskbarPanel): + """ + Sets the system tray reference. + """ + self.sys_tray = sys_tray + self.clipboard_manager.set_tray_ref(sys_tray) + + def get_total_timeout(self): + """ + Returns the total timeout value in milliseconds.""" + return (RECONNECT_WS_TIMER * 1000) + WEBSOCKET_TIMEOUT + + def get_stats(self): + return None + + def connect(self) -> tuple[bool, str]: + try: + if self.is_connected: + return True, "" + self.client = Client( + self.config.data["websocket_url"], + headers={ + "Cookie": RequestManager.format_cookie( + self.config.data["cookie"] + ) + }, + on_close_callback=self._on_close, + sslopt=websocket_sslopt_for_config(self.config), + ) + self.client.connect( + timeout=WEBSOCKET_TIMEOUT, + connectCallback=lambda _: self.client.subscribe( # receive event + destination=SUBSCRIPTION_DESTINATION, + callback=self._receive, + ), + ) + if self.disconnected: + self.disconnect() + return False, "Websocket disconnected" + + # logging.info("Websocket connected") + self.is_connected = True + self.is_auto_reconnecting = False + if not self.first_conn_lost: + self.first_conn_lost = True + self.notification_manager.notify( + title=f"{APP_NAME}: WebSocket Connection Restored 🔗", + message="Connection re-established", + ) + + # send event + self.clipboard_manager.on_copy(self.send) + return True, "Websocket connected" + except Exception as e: + msg = f"Failed to connect websocket: {e}" + logging.error(msg) + return False, msg + + def _on_close(self): + self.is_connected = False + # Auto Reconnect + if not self.is_login_phase and not self.disconnected: + self.is_auto_reconnecting = True + if self.first_conn_lost: + self.notification_manager.notify( + title=f"{APP_NAME}: WebSocket Connection Lost ⛓️‍💥", + message="Check your internet connection. Retrying...", + ) + self.first_conn_lost = False + time.sleep(RECONNECT_WS_TIMER) # seconds + self.connect() + + def send(self, payload: str, payload_type: str = "text"): + try: + if self.is_connected: + if self.clipboard_manager.has_clipboard_changed(payload): + if self.config.data["cipher_enabled"]: + payload = CipherManager.encode_to_json_string( + **self.cipher_manager.encrypt(payload) + ) + body = json.dumps({"payload": payload, "type": payload_type}) + self.client.send(destination=SEND_DESTINATION, body=body) + except Exception as e: + logging.error(f"Failed to send data: {e}") + + def _receive(self, frame: any) -> str: + try: + if self.is_connected: + body = json.loads(frame.body) + payload = body["payload"] + payload_type = body.get("type", "text") + if self.config.data["cipher_enabled"]: + payload = self.cipher_manager.decrypt( + **CipherManager.decode_from_json_string(payload) + ) + + if self.clipboard_manager.has_clipboard_changed(payload): + self.clipboard_manager.base64_to_clipboard( + base64_string=payload, type_=payload_type + ) + except json.decoder.JSONDecodeError: + logging.error( + "If cipher is enabled, please make sure it is enabled on all devices" + ) + except Exception as e: + logging.error(f"Failed to receive data: {e}") + + def manual_reconnect(self): + if not self.is_auto_reconnecting: + self.disconnected = False + self.connect() + + def disconnect(self): + try: + self.clipboard_manager.previous_clipboard_hash = 0 + self.disconnected = True + self.first_conn_lost = True + try: + self.client.disconnect() + self.is_connected = False + logging.info("Websocket disconnected") + except Exception as e: + pass # silent catch + self.clipboard_manager.stop() + except Exception as e: + logging.error(f"Failed to disconnect websocket: {e}") diff --git a/ClipCascade/utils/cipher_manager.py b/ClipCascade/utils/cipher_manager.py new file mode 100755 index 0000000..b76157a --- /dev/null +++ b/ClipCascade/utils/cipher_manager.py @@ -0,0 +1,109 @@ +import base64 +import json +import hashlib + +from Crypto.Cipher import AES +from core.constants import * +from core.config import Config + + +class CipherManager: + def __init__(self, config: Config): + self.config = config + + # hash + self.hash_name = "sha256" + self.dklen = 32 # 256 bits for AES-256 + + # encryption + self.mode = AES.MODE_GCM + + def hash_password(self, password: str) -> bytes: + return hashlib.pbkdf2_hmac( + hash_name=self.hash_name, + password=password.encode(), + salt=( + self.config.data["username"] + password + self.config.data["salt"] + ).encode("utf-8"), + iterations=self.config.data["hash_rounds"], + dklen=self.dklen, + ) + + def encrypt(self, plaintext: str) -> dict: + key = self.config.data["hashed_password"] + plaintext_bytes = plaintext.encode("utf-8") + cipher = AES.new(key, self.mode) + ciphertext, tag = cipher.encrypt_and_digest(plaintext_bytes) + return {"nonce": cipher.nonce, "ciphertext": ciphertext, "tag": tag} + + def decrypt(self, nonce: bytes, ciphertext: bytes, tag: bytes) -> str: + key = self.config.data["hashed_password"] + cipher = AES.new(key, self.mode, nonce=nonce) + return cipher.decrypt_and_verify(ciphertext, tag).decode() + + @staticmethod + def encode_to_json_string(**kwargs: bytes) -> str: + """ + Convert bytes values to Base64 and create a JSON string. + + Args: + **kwargs: Key-value pairs where values must be of type `bytes`. + + Returns: + str: A JSON string with all `bytes` values Base64-encoded. + + Raises: + ValueError: If a value is not of type `bytes`. + """ + json_data = {} + for key, value in kwargs.items(): + if isinstance(value, bytes): + json_data[key] = base64.b64encode(value).decode("utf-8") + else: + raise ValueError( + f"Unsupported value type for key '{key}': {type(value)}. " + f"This method only supports 'bytes'." + ) + return json.dumps(json_data) + + @staticmethod + def decode_from_json_string(json_string: str) -> dict: + """ + Decode a JSON string where all values are Base64-encoded back to their original bytes. + + Args: + json_string (str): A JSON string with Base64-encoded values. + + Returns: + dict: A dictionary with the original keys and `bytes` values decoded from Base64. + + Raises: + ValueError: If the JSON string is not valid or if decoding fails. + """ + # Parse the JSON string into a dictionary + json_data = json.loads(json_string) + decoded_data = {} + + # Decode each Base64-encoded value back to bytes + for key, value in json_data.items(): + if isinstance(value, str): + decoded_data[key] = base64.b64decode(value) + else: + raise ValueError( + f"Unsupported value type for key '{key}': {type(value)}. " + + f"Expected 'str' for Base64 decoding." + ) + return decoded_data + + @staticmethod + def string_to_sha3_512_lowercase_hex(input_string: str) -> str: + """ + Convert a string to its lowercase hexadecimal SHA3-512 hash. + + Args: + input_string (str): The input string to hash. + + Returns: + str: The lowercase hexadecimal representation of the SHA3-512 hash. + """ + return hashlib.sha3_512(input_string.encode("utf-8")).hexdigest() diff --git a/ClipCascade/utils/notification_manager.py b/ClipCascade/utils/notification_manager.py new file mode 100755 index 0000000..89cf636 --- /dev/null +++ b/ClipCascade/utils/notification_manager.py @@ -0,0 +1,42 @@ +from core.constants import * +from core.config import Config + +if PLATFORM == WINDOWS or ( + PLATFORM.startswith(LINUX) and not LINUX_USE_CLI_UI +): + # WINDOWS: When creating the executable with pyinstaller, add --hidden-import plyer.platforms.win.notification + from plyer import notification +elif PLATFORM.startswith(LINUX) and LINUX_USE_CLI_UI: + from cli.info import CustomDialog +elif PLATFORM == MACOS: + import subprocess + + +class NotificationManager: + def __init__(self, config: Config): + self.config = config + + def notify(self, title: str, message: str, app_name: str = APP_NAME, timeout=10): + if self.config.data["notification"]: # Check if notifications are enabled + try: + if PLATFORM == WINDOWS or ( + PLATFORM.startswith(LINUX) and not LINUX_USE_CLI_UI + ): + notification.notify( + title=title, + message=message, + app_name=app_name, + timeout=timeout, # seconds + ) + elif PLATFORM.startswith(LINUX) and LINUX_USE_CLI_UI: + CustomDialog(f"{title} : {message}").mainloop() + elif PLATFORM == MACOS: + subprocess.run( + [ + "osascript", + "-e", + f'display notification "{message}" with title "{title}"', + ] + ) + except Exception as e: + logging.error(f"Failed to handle notification: {e}") diff --git a/ClipCascade/utils/request_manager.py b/ClipCascade/utils/request_manager.py new file mode 100755 index 0000000..6918974 --- /dev/null +++ b/ClipCascade/utils/request_manager.py @@ -0,0 +1,201 @@ +import json +import logging +import requests +from core.constants import * +from core.config import Config +from bs4 import BeautifulSoup +from utils.ssl_helper import requests_verify_arg + + +class RequestManager: + def __init__(self, config: Config): + self.config = config + + def _verify(self): + return requests_verify_arg(self.config) + + @staticmethod + def format_cookie(cookie: dict) -> str: + """ + Format the cookie string for headers. + """ + return f"JSESSIONID={cookie.get('JSESSIONID', '')};" + + def login(self) -> tuple[bool, str, dict]: + try: + session = requests.Session() + + # Fetch the login page to get the CSRF token + response = session.get( + self.config.data["server_url"] + LOGIN_URL, + verify=self._verify(), + ) + + if response.status_code != 200: + msg = f"Failed to fetch login page: {response.status_code}" + logging.error(msg) + return False, msg, None + + soup = BeautifulSoup(response.text, "html.parser") + csrf_token = soup.find("input", {"name": "_csrf"})["value"] + + # Login with the credentials + form_data = { + "username": self.config.data["username"], + "password": self.config.data["password"], + "_csrf": csrf_token, + } + response = session.post( + self.config.data["server_url"] + LOGIN_URL, + data=form_data, + verify=self._verify(), + ) + if ( + response.status_code == 200 + and "bad credentials" not in response.text.lower() + ): + # login successful + cookie = session.cookies.get_dict() + logging.info(f"Login successful: {response.status_code}") + return True, "Login successful", cookie + else: + # login failed + msg = f"Login failed: {response.status_code}" + logging.error(msg) + return False, msg, None + except Exception as e: + msg = f"An error occurred during login: {e}" + logging.error(msg) + return False, msg, None + + def maxsize(self) -> int: + try: + response = RequestManager.get( + url=self.config.data["server_url"] + MAXSIZE_URL, + headers={ + "Cookie": RequestManager.format_cookie(self.config.data["cookie"]) + }, + verify=self._verify(), + ) + if response.status_code == 200: + # maxsize request successful + maxsize = response.json().get("maxsize", MAX_SIZE) + logging.info(f"Max size: {maxsize}") + return maxsize + except Exception as e: + logging.error( + f"Error fetching max size: {e}, defaulting to {MAX_SIZE} Bytes" + ) + return MAX_SIZE + + def get_server_mode(self) -> str: + try: + response = RequestManager.get( + url=self.config.data["server_url"] + SERVER_MODE_URL, + headers={ + "Cookie": RequestManager.format_cookie(self.config.data["cookie"]) + }, + verify=self._verify(), + ) + if response.status_code == 200: + # server mode request successful + server_mode = response.json().get("mode") + logging.info(f"Server mode: {server_mode}") + return server_mode + except Exception as e: + logging.error(f"Error fetching server mode: {e}") + raise + + def get_stun_url(self) -> str: + try: + response = RequestManager.get( + url=self.config.data["server_url"] + STUN_URL, + headers={ + "Cookie": RequestManager.format_cookie(self.config.data["cookie"]) + }, + verify=self._verify(), + ) + if response.status_code == 200: + # stun url request successful + stun_url = response.json().get("url") + logging.info(f"STUN URL: {stun_url}") + return stun_url + except Exception as e: + logging.error(f"Error fetching STUN URL: {e}") + raise + + def get_metadata(self) -> dict: + try: + response = RequestManager.get( + url=METADATA_URL, + headers={ + "Cookie": RequestManager.format_cookie(self.config.data["cookie"]) + }, + verify=True, + ) + if response.status_code == 200: + # metadata request successful + return response.json() + except Exception as e: + logging.error(f"Error fetching metadata: {e}") + raise + + def logout(self): + try: + response = RequestManager.post( + url=self.config.data["server_url"] + LOGOUT_URL, + data={"_csrf": self.config.data["csrf_token"]}, + headers={ + "Cookie": RequestManager.format_cookie(self.config.data["cookie"]) + }, + verify=self._verify(), + ) + if response.status_code == 204: + logging.info(f"Logout successful: {response.status_code}") + except Exception as e: + logging.error(f"Error during logout: {e}") + + def get_csrf_token(self) -> str: + try: + response = RequestManager.get( + url=self.config.data["server_url"] + CSRF_URL, + headers={ + "Cookie": RequestManager.format_cookie(self.config.data["cookie"]) + }, + verify=self._verify(), + ) + + if response.status_code == 200: + # CSRF token request successful + return json.loads(response.text).get("token", "") + except Exception as e: + logging.error(f"Error fetching CSRF token: {e}") + return "" + + @staticmethod + def get(url: str, headers: dict = None, verify=True) -> requests.Response: + """ + A generic GET mapper for handling GET requests. + """ + try: + response = requests.get(url, headers=headers, verify=verify) + response.raise_for_status() # Will raise an HTTPError if the HTTP request returned an unsuccessful status code + return response + except Exception as e: + logging.error(f"Error during GET request to {url}: {e}") + raise + + @staticmethod + def post( + url: str, data: dict, headers: dict = None, verify=True + ) -> requests.Response: + """ + A generic POST mapper for handling POST requests. + """ + try: + response = requests.post(url, data=data, headers=headers, verify=verify) + response.raise_for_status() # Will raise an HTTPError if the HTTP request returned an unsuccessful status code + return response + except Exception as e: + logging.error(f"Error during POST request to {url}: {e}") + raise diff --git a/ClipCascade/utils/ssl_helper.py b/ClipCascade/utils/ssl_helper.py new file mode 100755 index 0000000..482aae1 --- /dev/null +++ b/ClipCascade/utils/ssl_helper.py @@ -0,0 +1,32 @@ +import os +import ssl +from typing import Any, Dict, Optional, Union + +from core.constants import MACOS, PLATFORM + +if PLATFORM == MACOS: + import certifi + + +def requests_verify_arg(config) -> Union[bool, str]: + """Argument for requests' verify= when calling the user's server.""" + path = (config.data.get("ssl_ca_bundle") or "").strip() + if not path: + return True + if not os.path.isfile(path): + raise FileNotFoundError(f"SSL CA bundle file not found: {path}") + return path + + +def websocket_sslopt_for_config(config) -> Optional[Dict[str, Any]]: + """sslopt for websocket_client run_forever; None uses the library/OS default.""" + path = (config.data.get("ssl_ca_bundle") or "").strip() + if path: + if not os.path.isfile(path): + raise FileNotFoundError(f"SSL CA bundle file not found: {path}") + ctx = ssl.create_default_context(cafile=path) + return {"context": ctx} + if PLATFORM == MACOS: + ctx = ssl.create_default_context(cafile=certifi.where()) + return {"context": ctx} + return None diff --git a/ClipCascade/utils/window_manager.py b/ClipCascade/utils/window_manager.py new file mode 100755 index 0000000..cb2daa5 --- /dev/null +++ b/ClipCascade/utils/window_manager.py @@ -0,0 +1,45 @@ +import logging +import re +from core.constants import * + + +def center_window(win, max_height=None): + """Center a Tk/Toplevel window on the primary monitor.""" + logging.debug(f"Centering window with max_height={max_height}") + win.update_idletasks() + window_width = win.winfo_width() + window_height = win.winfo_height() + if max_height: + window_height = min(window_height, max_height) + logging.debug(f"Window dimensions: {window_width}x{window_height}") + primary_x, primary_y = 0, 0 + primary_w, primary_h = win.winfo_screenwidth(), win.winfo_screenheight() + try: + if PLATFORM.startswith(LINUX): + logging.debug("Detecting monitors on Linux platform") + import subprocess + + result = subprocess.run( + ["xrandr", "--query"], capture_output=True, text=True, timeout=2 + ) + for line in result.stdout.splitlines(): + if " connected primary" in line: + m = re.search(r"(\d+)x(\d+)\+(\d+)\+(\d+)", line) + if m: + primary_w = int(m.group(1)) + primary_h = int(m.group(2)) + primary_x = int(m.group(3)) + primary_y = int(m.group(4)) + logging.debug( + f"Primary monitor: position=({primary_x}, {primary_y}), size={primary_w}x{primary_h}" + ) + break + except Exception as e: + logging.warning(f"Failed to detect primary monitor: {e}. Using fallback dimensions.") + x = primary_x + (primary_w - window_width) // 2 + y = primary_y + (primary_h - window_height) // 2 + x = max(primary_x, x) + y = max(primary_y, y) + logging.debug(f"Calculated window position: ({x}, {y})") + win.geometry(f"{window_width}x{window_height}+{x}+{y}") + logging.debug(f"Applied geometry: {window_width}x{window_height}+{x}+{y}")