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}")