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