added clipcascade
This commit is contained in:
Executable
+674
@@ -0,0 +1,674 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
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.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
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
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
class Echo:
|
||||
def __init__(self, *args, **kwargs):
|
||||
print(*args, **kwargs)
|
||||
Executable
+23
@@ -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
|
||||
Executable
+214
@@ -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
|
||||
Executable
+4
@@ -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"
|
||||
Executable
+367
@@ -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
|
||||
Executable
+464
@@ -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
|
||||
+486
@@ -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)
|
||||
+149
@@ -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)
|
||||
+163
@@ -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)
|
||||
Executable
+309
@@ -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)
|
||||
Executable
+92
@@ -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
|
||||
Executable
+262
@@ -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\\<Username>
|
||||
On macOS and Linux, this typically resolves to /Users/<Username> or /home/<Username>, 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\\<Username>\\Downloads
|
||||
- macOS: /Users/<Username>/Downloads
|
||||
- Linux: /home/<Username>/Downloads
|
||||
"""
|
||||
return os.path.join(get_user_home_directory(), "Downloads")
|
||||
Executable
+120
@@ -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("<Return>", lambda e: self.close())
|
||||
self.bind("<Escape>", 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)
|
||||
Executable
+648
@@ -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("<Enter>", self._show, add="+")
|
||||
self.widget.bind("<Leave>", self._hide, add="+")
|
||||
self.widget.bind("<ButtonPress>", 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("<Button-1>", 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("<Button-1>", 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("<Enter>", self._on_hover)
|
||||
self.toggle_label.bind("<Leave>", 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(
|
||||
"<Configure>",
|
||||
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("<Configure>", 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("<Return>", 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("<MouseWheel>", self._on_mousewheel_windows)
|
||||
elif PLATFORM == MACOS:
|
||||
self.extra_canvas.bind_all("<MouseWheel>", self._on_mousewheel_mac)
|
||||
elif PLATFORM.startswith(LINUX):
|
||||
# Linux uses Button-4 and Button-5 for scroll up/down
|
||||
self.extra_canvas.bind_all("<Button-4>", self._on_mousewheel_linux)
|
||||
self.extra_canvas.bind_all("<Button-5>", 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)
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
from tkinter import messagebox
|
||||
|
||||
|
||||
class MessageBox:
|
||||
def askquestion(self, title, message):
|
||||
return messagebox.askquestion(title, message)
|
||||
Executable
+397
@@ -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()
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
https://github.com/Sathvik-Rao/ClipCascade
|
||||
Executable
+46
@@ -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
|
||||
Executable
+23
@@ -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()
|
||||
Executable
+1014
File diff suppressed because it is too large
Load Diff
Executable
+84
@@ -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"
|
||||
Executable
+10
@@ -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
|
||||
Executable
+206
@@ -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)
|
||||
Executable
+51
@@ -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"]
|
||||
Executable
+161
@@ -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}")
|
||||
Executable
+109
@@ -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()
|
||||
Executable
+42
@@ -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}")
|
||||
Executable
+201
@@ -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
|
||||
Executable
+32
@@ -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
|
||||
Executable
+45
@@ -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}")
|
||||
Reference in New Issue
Block a user