diff --git a/LICENSE b/LICENSE index 02bbb60..f288702 100644 --- a/LICENSE +++ b/LICENSE @@ -1,165 +1,674 @@ - GNU LESSER GENERAL PUBLIC LICENSE + GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 - Copyright (C) 2007 Free Software Foundation, Inc. + Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. + Preamble - This version of the GNU Lesser General Public License incorporates -the terms and conditions of version 3 of the GNU General Public -License, supplemented by the additional permissions listed below. + The GNU General Public License is a free, copyleft license for +software and other kinds of works. - 0. Additional Definitions. + 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. - As used herein, "this License" refers to version 3 of the GNU Lesser -General Public License, and the "GNU GPL" refers to version 3 of the GNU -General Public License. + 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. - "The Library" refers to a covered work governed by this License, -other than an Application or a Combined Work as defined below. + 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. - An "Application" is any work that makes use of an interface provided -by the Library, but which is not otherwise based on the Library. -Defining a subclass of a class defined by the Library is deemed a mode -of using an interface provided by the Library. + 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. - A "Combined Work" is a work produced by combining or linking an -Application with the Library. The particular version of the Library -with which the Combined Work was made is also called the "Linked -Version". + 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. - The "Minimal Corresponding Source" for a Combined Work means the -Corresponding Source for the Combined Work, excluding any source code -for portions of the Combined Work that, considered in isolation, are -based on the Application, and not on the Linked Version. + 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. - The "Corresponding Application Code" for a Combined Work means the -object code and/or source code for the Application, including any data -and utility programs needed for reproducing the Combined Work from the -Application, but excluding the System Libraries of the Combined Work. + 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. - 1. Exception to Section 3 of the GNU GPL. + 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. - You may convey a covered work under sections 3 and 4 of this License -without being bound by section 3 of the GNU GPL. + The precise terms and conditions for copying, distribution and +modification follow. - 2. Conveying Modified Versions. + TERMS AND CONDITIONS - If you modify a copy of the Library, and, in your modifications, a -facility refers to a function or data to be supplied by an Application -that uses the facility (other than as an argument passed when the -facility is invoked), then you may convey a copy of the modified -version: + 0. Definitions. - a) under this License, provided that you make a good faith effort to - ensure that, in the event an Application does not supply the - function or data, the facility still operates, and performs - whatever part of its purpose remains meaningful, or + "This License" refers to version 3 of the GNU General Public License. - b) under the GNU GPL, with none of the additional permissions of - this License applicable to that copy. + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. - 3. Object Code Incorporating Material from Library Header Files. + "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. - The object code form of an Application may incorporate material from -a header file that is part of the Library. You may convey such object -code under terms of your choice, provided that, if the incorporated -material is not limited to numerical parameters, data structure -layouts and accessors, or small macros, inline functions and templates -(ten or fewer lines in length), you do both of the following: + 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) Give prominent notice with each copy of the object code that the - Library is used in it and that the Library and its use are - covered by this License. + A "covered work" means either the unmodified Program or a work based +on the Program. - b) Accompany the object code with a copy of the GNU GPL and this license - document. + 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. - 4. Combined Works. + 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. - You may convey a Combined Work under terms of your choice that, -taken together, effectively do not restrict modification of the -portions of the Library contained in the Combined Work and reverse -engineering for debugging such modifications, if you also do each of -the following: + 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. - a) Give prominent notice with each copy of the Combined Work that - the Library is used in it and that the Library and its use are - covered by this License. + 1. Source Code. - b) Accompany the Combined Work with a copy of the GNU GPL and this license - document. + 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. - c) For a Combined Work that displays copyright notices during - execution, include the copyright notice for the Library among - these notices, as well as a reference directing the user to the - copies of the GNU GPL and this license document. + 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. - d) Do one of the following: + 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. - 0) Convey the Minimal Corresponding Source under the terms of this - License, and the Corresponding Application Code in a form - suitable for, and under terms that permit, the user to - recombine or relink the Application with a modified version of - the Linked Version to produce a modified Combined Work, in the - manner specified by section 6 of the GNU GPL for conveying - Corresponding Source. + 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. - 1) Use a suitable shared library mechanism for linking with the - Library. A suitable mechanism is one that (a) uses at run time - a copy of the Library already present on the user's computer - system, and (b) will operate properly with a modified version - of the Library that is interface-compatible with the Linked - Version. + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. - e) Provide Installation Information, but only if you would otherwise - be required to provide such information under section 6 of the - GNU GPL, and only to the extent that such information is - necessary to install and execute a modified version of the - Combined Work produced by recombining or relinking the - Application with a modified version of the Linked Version. (If - you use option 4d0, the Installation Information must accompany - the Minimal Corresponding Source and Corresponding Application - Code. If you use option 4d1, you must provide the Installation - Information in the manner specified by section 6 of the GNU GPL - for conveying Corresponding Source.) + The Corresponding Source for a work in source code form is that +same work. - 5. Combined Libraries. + 2. Basic Permissions. - You may place library facilities that are a work based on the -Library side by side in a single library together with other library -facilities that are not Applications and are not covered by this -License, and convey such a combined library under terms of your -choice, if you do both of the following: + 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. - a) Accompany the combined library with a copy of the same work based - on the Library, uncombined with any other library facilities, - conveyed under the terms of this License. + 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. - b) Give prominent notice with the combined library that part of it - is a work based on the Library, and explaining where to find the - accompanying uncombined form of the same work. + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. - 6. Revised Versions of the GNU Lesser General Public License. + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - The Free Software Foundation may publish revised and/or new versions -of the GNU Lesser 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. + 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. - Each version is given a distinguishing version number. If the -Library as you received it specifies that a certain numbered version -of the GNU Lesser General Public License "or any later version" -applies to it, you have the option of following the terms and -conditions either of that published version or of any later version -published by the Free Software Foundation. If the Library as you -received it does not specify a version number of the GNU Lesser -General Public License, you may choose any version of the GNU Lesser -General Public License ever published by the Free Software Foundation. + 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. - If the Library as you received it specifies that a proxy can decide -whether future versions of the GNU Lesser General Public License shall -apply, that proxy's public statement of acceptance of any version is -permanent authorization for you to choose that version for the -Library. \ No newline at end of file + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md index 77e1766..d63e401 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Featured|HelloGitHub [![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20Linux%20%7C%20macOS-brightgreen.svg)]() -[![License: LGPL v3](https://img.shields.io/badge/License-LGPL%20v3-blue.svg)](https://www.gnu.org/licenses/lgpl-3.0) +[![License: GPL v3](https://img.shields.io/badge/License-GPL%20v3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0) [![GitHub last commit](https://img.shields.io/github/last-commit/kunkundi/crossdesk)](https://github.com/kunkundi/crossdesk/commits/web-client) [![Build Status](https://github.com/kunkundi/crossdesk/actions/workflows/build.yml/badge.svg)](https://github.com/kunkundi/crossdesk/actions) [![Docker Pulls](https://img.shields.io/docker/pulls/crossdesk/crossdesk-server)](https://hub.docker.com/r/crossdesk/crossdesk-server/tags) @@ -82,34 +82,77 @@ Windows 安装包会自动打包 `crossdesk_service.exe` 和 `crossdesk_session_ - [xmake](https://xmake.io/#/guide/installation) - [cmake](https://cmake.org/download/) -Linux环境下需安装以下包: +### Linux -``` -sudo apt-get install -y software-properties-common git curl unzip build-essential libx11-dev libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev libxcb-randr0-dev libxcb-xtest0-dev libxcb-xinerama0-dev libxcb-shape0-dev libxcb-xkb-dev libxcb-xfixes0-dev libxfixes-dev libxv-dev libxtst-dev libasound2-dev libsndio-dev libxcb-shm0-dev libasound2-dev libpulse-dev +Linux 构建目前支持 Ubuntu 22.04 及以上版本的 amd64 和 arm64。先安装基础编译依赖: + +```bash +sudo apt-get update +sudo apt-get install -y \ + git curl unzip build-essential cmake pkg-config binutils dpkg-dev \ + libx11-dev libxext-dev libxrender-dev libxft-dev libxrandr-dev \ + libxinerama-dev libxcursor-dev libxi-dev libxfixes-dev libxv-dev \ + libxtst-dev libxcb-randr0-dev libxcb-xtest0-dev libxcb-xinerama0-dev \ + libxcb-shape0-dev libxcb-xkb-dev libxcb-xfixes0-dev libxcb-shm0-dev \ + libasound2-dev libsndio-dev libpulse-dev ``` -编译 -``` -git clone https://github.com/kunkundi/crossdesk.git +需要启用 Wayland 捕获或 DRM 捕获时,再安装对应依赖: +```bash +sudo apt-get install -y libdbus-1-dev libpipewire-0.3-dev libdrm-dev +``` + +下载子模块并编译 Release 版本: + +```bash +git clone --recurse-submodules https://github.com/kunkundi/crossdesk.git cd crossdesk -git submodule init - -git submodule update +# 已经克隆过仓库时执行这一行 +git submodule update --init --recursive +xmake f -m release --USE_CUDA=false -y xmake b -vy crossdesk ``` -编译选项 + +amd64 构建结果位于 `build/linux/x86_64/release/crossdesk`,arm64 构建结果位于 `build/linux/arm64/release/crossdesk`。 + +启用 Wayland 和 DRM 的配置示例: + +```bash +xmake f -m release --USE_WAYLAND=true --USE_DRM=true --USE_CUDA=false -y +xmake b -vy crossdesk ``` + +生成 Debian 安装包(必须在对应架构上先完成 Release 编译): + +```bash +# amd64 +./scripts/linux/pkg_amd64.sh 1.0.0 + +# arm64 +./scripts/linux/pkg_arm64.sh 1.0.0 +``` + +打包脚本会将 Slint 共享运行库安装到软件包私有目录 `/usr/lib/crossdesk`,无需用户另外安装 `libslint_cpp.so`。 + +### 通用编译选项 + +```text --USE_CUDA=true/false: 启用 CUDA 硬件编解码,默认不启用 +--USE_WAYLAND=true/false: 在 Linux 上启用 Wayland/PipeWire 捕获,默认不启用 +--USE_DRM=true/false: 在 Linux 上启用 DRM 捕获,默认不启用 +--CROSSDESK_PORTABLE=true/false: 构建便携版本,默认不启用 --CROSSDESK_VERSION=xxx: 指定 CrossDesk 的版本 # 示例 xmake f --CROSSDESK_VERSION=1.0.0 --USE_CUDA=true ``` -运行 -``` + +运行: + +```bash xmake r crossdesk ``` diff --git a/README_EN.md b/README_EN.md index 8837a5d..0e7dd17 100644 --- a/README_EN.md +++ b/README_EN.md @@ -2,7 +2,7 @@ Featured|HelloGitHub [![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20Linux%20%7C%20macOS-brightgreen.svg)]() -[![License: LGPL v3](https://img.shields.io/badge/License-LGPL%20v3-blue.svg)](https://www.gnu.org/licenses/lgpl-3.0) +[![License: GPL v3](https://img.shields.io/badge/License-GPL%20v3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0) [![GitHub last commit](https://img.shields.io/github/last-commit/kunkundi/crossdesk)](https://github.com/kunkundi/crossdesk/commits/web-client) [![Build Status](https://github.com/kunkundi/crossdesk/actions/workflows/build.yml/badge.svg)](https://github.com/kunkundi/crossdesk/actions) [![Docker Pulls](https://img.shields.io/docker/pulls/crossdesk/crossdesk-server)](https://hub.docker.com/r/crossdesk/crossdesk-server/tags) @@ -87,34 +87,77 @@ Requirements: - [xmake](https://xmake.io/#/guide/installation) - [cmake](https://cmake.org/download/) -Following packages need to be installed on Linux: +### Linux -``` -sudo apt-get install -y software-properties-common git curl unzip build-essential libx11-dev libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev libxcb-randr0-dev libxcb-xtest0-dev libxcb-xinerama0-dev libxcb-shape0-dev libxcb-xkb-dev libxcb-xfixes0-dev libxfixes-dev libxv-dev libxtst-dev libasound2-dev libsndio-dev libxcb-shm0-dev libasound2-dev libpulse-dev +Linux builds currently support Ubuntu 22.04 or later on amd64 and arm64. Install the base build dependencies first: + +```bash +sudo apt-get update +sudo apt-get install -y \ + git curl unzip build-essential cmake pkg-config binutils dpkg-dev \ + libx11-dev libxext-dev libxrender-dev libxft-dev libxrandr-dev \ + libxinerama-dev libxcursor-dev libxi-dev libxfixes-dev libxv-dev \ + libxtst-dev libxcb-randr0-dev libxcb-xtest0-dev libxcb-xinerama0-dev \ + libxcb-shape0-dev libxcb-xkb-dev libxcb-xfixes0-dev libxcb-shm0-dev \ + libasound2-dev libsndio-dev libpulse-dev ``` -Build: -``` -git clone https://github.com/kunkundi/crossdesk.git +Install the optional dependencies when Wayland capture or DRM capture is enabled: +```bash +sudo apt-get install -y libdbus-1-dev libpipewire-0.3-dev libdrm-dev +``` + +Clone the submodules and build a release binary: + +```bash +git clone --recurse-submodules https://github.com/kunkundi/crossdesk.git cd crossdesk -git submodule init - -git submodule update +# Run this when the repository has already been cloned +git submodule update --init --recursive +xmake f -m release --USE_CUDA=false -y xmake b -vy crossdesk ``` -Build options: -``` ---USE_CUDA=true/false: enable CUDA acceleration codec, default: false ---CROSSDESK_VERSION=xxx: set the version number -# example: +The amd64 binary is written to `build/linux/x86_64/release/crossdesk`; the arm64 binary is written to `build/linux/arm64/release/crossdesk`. + +Example configuration with Wayland and DRM capture enabled: + +```bash +xmake f -m release --USE_WAYLAND=true --USE_DRM=true --USE_CUDA=false -y +xmake b -vy crossdesk +``` + +Build a Debian package after compiling a release binary on the matching architecture: + +```bash +# amd64 +./scripts/linux/pkg_amd64.sh 1.0.0 + +# arm64 +./scripts/linux/pkg_arm64.sh 1.0.0 +``` + +The package scripts install the shared Slint runtime in the private `/usr/lib/crossdesk` directory, so users do not need to install `libslint_cpp.so` separately. + +### Common build options + +```text +--USE_CUDA=true/false: Enable CUDA hardware codec acceleration, disabled by default +--USE_WAYLAND=true/false: Enable Wayland/PipeWire capture on Linux, disabled by default +--USE_DRM=true/false: Enable DRM capture on Linux, disabled by default +--CROSSDESK_PORTABLE=true/false: Build the portable variant, disabled by default +--CROSSDESK_VERSION=xxx: Set the CrossDesk version + +# Example xmake f --CROSSDESK_VERSION=1.0.0 --USE_CUDA=true ``` + Run: -``` + +```bash xmake r crossdesk ``` diff --git a/scripts/linux/package_deb.sh b/scripts/linux/package_deb.sh new file mode 100755 index 0000000..c067281 --- /dev/null +++ b/scripts/linux/package_deb.sh @@ -0,0 +1,183 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -lt 3 || $# -gt 4 ]]; then + echo "Usage: $0 [extra-recommendation]" >&2 + exit 2 +fi + +DEBIAN_ARCH="$1" +XMAKE_ARCH="$2" +INPUT_VERSION="$3" +EXTRA_RECOMMENDATION="${4:-}" + +PKG_NAME="crossdesk" +APP_NAME="CrossDesk" +MAINTAINER="Junkun Di " +DESCRIPTION="A simple cross-platform remote desktop client." +ALSA_RUNTIME_DEP="libasound2 | libasound2t64" +PORTAL_RUNTIME_RECOMMENDS="xdg-desktop-portal, xdg-desktop-portal-gtk | xdg-desktop-portal-kde | xdg-desktop-portal-wlr" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +normalize_app_version() { + local input="$1" + local prefix="" + local body="$input" + + if [[ "$body" == v* ]]; then + prefix="v" + body="${body#v}" + fi + + if [[ "$body" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]{8})-([0-9]+)$ ]]; then + echo "${prefix}${BASH_REMATCH[1]}-${BASH_REMATCH[4]}-${BASH_REMATCH[3]}" + else + echo "$input" + fi +} + +find_slint_runtime() { + local needed="$1" + local resolved="" + + resolved="$(ldd "$BUILD_BINARY" 2>/dev/null | awk -v needed="$needed" \ + '$1 == needed && $2 == "=>" && $3 ~ /^\// { print $3; exit }')" + if [[ -n "$resolved" && -f "$resolved" ]]; then + echo "$resolved" + return 0 + fi + + local package_roots=() + if [[ -n "${XMAKE_GLOBALDIR:-}" ]]; then + package_roots+=("$XMAKE_GLOBALDIR/.xmake/packages") + fi + package_roots+=("$HOME/.xmake/packages" "/data/.xmake/packages") + + local package_root="" + local candidate="" + for package_root in "${package_roots[@]}"; do + [[ -d "$package_root/s/slint" ]] || continue + candidate="$(find "$package_root/s/slint" -path "*/lib/$needed" -print -quit 2>/dev/null || true)" + if [[ -n "$candidate" ]]; then + echo "$candidate" + return 0 + fi + done + + return 1 +} + +APP_VERSION="$(normalize_app_version "$INPUT_VERSION")" +DEB_VERSION="${APP_VERSION#v}" +if [[ ! "$DEB_VERSION" =~ ^[0-9][0-9A-Za-z.+:~_-]*$ ]]; then + echo "Invalid Debian version: $DEB_VERSION" >&2 + exit 2 +fi + +BUILD_BINARY="$PROJECT_ROOT/build/linux/$XMAKE_ARCH/release/crossdesk" +if [[ ! -x "$BUILD_BINARY" ]]; then + echo "Missing release binary: $BUILD_BINARY" >&2 + echo "Build it first with: xmake f -m release -y && xmake b -vy crossdesk" >&2 + exit 1 +fi + +for command_name in readelf ldd dpkg-deb; do + if ! command -v "$command_name" >/dev/null 2>&1; then + echo "Required packaging command is missing: $command_name" >&2 + exit 1 + fi +done + +WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/crossdesk-deb.XXXXXX")" +trap 'rm -rf "$WORK_DIR"' EXIT + +DEB_DIR="$WORK_DIR/${PKG_NAME}-${DEB_VERSION}" +DEBIAN_DIR="$DEB_DIR/DEBIAN" +BIN_DIR="$DEB_DIR/usr/bin" +PRIVATE_DIR="$DEB_DIR/usr/lib/$PKG_NAME" +ICON_BASE_DIR="$DEB_DIR/usr/share/icons/hicolor" +DESKTOP_DIR="$DEB_DIR/usr/share/applications" +OUTPUT_FILE="$PROJECT_ROOT/${PKG_NAME}-linux-${DEBIAN_ARCH}-${APP_VERSION}.deb" + +install -d "$DEBIAN_DIR" "$BIN_DIR" "$PRIVATE_DIR" "$DESKTOP_DIR" +install -m 0755 "$BUILD_BINARY" "$PRIVATE_DIR/crossdesk-bin" + +# Slint is intentionally built as a shared library. Keep that runtime private +# to CrossDesk instead of relying on a non-existent distribution package. +SLINT_NEEDED="$(readelf -d "$BUILD_BINARY" | sed -n \ + 's/.*Shared library: \[\(libslint_cpp[^]]*\)\].*/\1/p' | head -n 1)" +if [[ -n "$SLINT_NEEDED" ]]; then + SLINT_LIBRARY="$(find_slint_runtime "$SLINT_NEEDED" || true)" + if [[ -z "$SLINT_LIBRARY" ]]; then + echo "Unable to locate the required Slint runtime: $SLINT_NEEDED" >&2 + exit 1 + fi + install -m 0755 "$SLINT_LIBRARY" "$PRIVATE_DIR/$SLINT_NEEDED" +fi + +cat > "$BIN_DIR/$PKG_NAME" <<'EOF' +#!/bin/sh +private_lib_dir="/usr/lib/crossdesk" +if [ -n "${LD_LIBRARY_PATH:-}" ]; then + export LD_LIBRARY_PATH="$private_lib_dir:$LD_LIBRARY_PATH" +else + export LD_LIBRARY_PATH="$private_lib_dir" +fi +exec /usr/lib/crossdesk/crossdesk-bin "$@" +EOF +chmod 0755 "$BIN_DIR/$PKG_NAME" +ln -s "$PKG_NAME" "$BIN_DIR/$APP_NAME" + +for size in 16 24 32 48 64 96 128 256; do + install -d "$ICON_BASE_DIR/${size}x${size}/apps" + install -m 0644 "$PROJECT_ROOT/icons/linux/crossdesk_${size}x${size}.png" \ + "$ICON_BASE_DIR/${size}x${size}/apps/${PKG_NAME}.png" +done + +RECOMMENDS="$PORTAL_RUNTIME_RECOMMENDS" +if [[ -n "$EXTRA_RECOMMENDATION" ]]; then + RECOMMENDS="$RECOMMENDS, $EXTRA_RECOMMENDATION" +fi + +cat > "$DEBIAN_DIR/control" <= 2.29), libstdc++6 (>= 9), libx11-6, libxext6, + libxrender1, libxft2, libxrandr2, libxfixes3, libxcb1, libxcb-randr0, + libxcb-xtest0, libxcb-xinerama0, libxcb-shape0, libxcb-xkb1, + libxcb-xfixes0, libxv1, libxtst6, $ALSA_RUNTIME_DEP, libsndio7.0, + libxcb-shm0, libpulse0, libdrm2, libdbus-1-3 +Recommends: $RECOMMENDS +Priority: optional +Section: utils +EOF + +cat > "$DESKTOP_DIR/$PKG_NAME.desktop" </dev/null +dpkg-deb --contents "$OUTPUT_FILE" > "$WORK_DIR/package-contents.txt" + +if [[ -n "$SLINT_NEEDED" ]] && \ + ! grep -q "usr/lib/$PKG_NAME/$SLINT_NEEDED" "$WORK_DIR/package-contents.txt"; then + echo "Packaged artifact is missing $SLINT_NEEDED" >&2 + exit 1 +fi + +echo "Deb package created: $OUTPUT_FILE" diff --git a/scripts/linux/pkg_amd64.sh b/scripts/linux/pkg_amd64.sh old mode 100644 new mode 100755 index be9fab2..60b33b4 --- a/scripts/linux/pkg_amd64.sh +++ b/scripts/linux/pkg_amd64.sh @@ -1,116 +1,11 @@ -#!/bin/bash -set -e +#!/usr/bin/env bash +set -euo pipefail -PKG_NAME="crossdesk" -APP_NAME="CrossDesk" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -ARCHITECTURE="amd64" -MAINTAINER="Junkun Di " -DESCRIPTION="A simple cross-platform remote desktop client." -ALSA_RUNTIME_DEP="libasound2 | libasound2t64" -PORTAL_RUNTIME_RECOMMENDS="xdg-desktop-portal, xdg-desktop-portal-gtk | xdg-desktop-portal-kde | xdg-desktop-portal-wlr" - -normalize_app_version() { - local input="$1" - local prefix="" - local body="$input" - - if [[ "$body" == v* ]]; then - prefix="v" - body="${body#v}" - fi - - if [[ "$body" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]{8})-([0-9]+)$ ]]; then - echo "${prefix}${BASH_REMATCH[1]}-${BASH_REMATCH[4]}-${BASH_REMATCH[3]}" - else - echo "$input" - fi -} - -APP_VERSION="$(normalize_app_version "$1")" - -# Remove 'v' prefix from version for Debian package (Debian version must start with digit) -DEB_VERSION="${APP_VERSION#v}" - -DEB_DIR="${PKG_NAME}-${DEB_VERSION}" -DEBIAN_DIR="$DEB_DIR/DEBIAN" -BIN_DIR="$DEB_DIR/usr/bin" -ICON_BASE_DIR="$DEB_DIR/usr/share/icons/hicolor" -DESKTOP_DIR="$DEB_DIR/usr/share/applications" - -rm -rf "$DEB_DIR" - -mkdir -p "$DEBIAN_DIR" "$BIN_DIR" "$DESKTOP_DIR" - -cp build/linux/x86_64/release/crossdesk "$BIN_DIR/$PKG_NAME" -chmod +x "$BIN_DIR/$PKG_NAME" - -ln -s "$PKG_NAME" "$BIN_DIR/$APP_NAME" - -for size in 16 24 32 48 64 96 128 256; do - mkdir -p "$ICON_BASE_DIR/${size}x${size}/apps" - cp "icons/linux/crossdesk_${size}x${size}.png" \ - "$ICON_BASE_DIR/${size}x${size}/apps/${PKG_NAME}.png" -done - -cat > "$DEBIAN_DIR/control" << EOF -Package: $PKG_NAME -Version: $DEB_VERSION -Architecture: $ARCHITECTURE -Maintainer: $MAINTAINER -Description: $DESCRIPTION -Depends: libc6 (>= 2.29), libstdc++6 (>= 9), libx11-6, libxcb1, - libxcb-randr0, libxcb-xtest0, libxcb-xinerama0, libxcb-shape0, - libxcb-xkb1, libxcb-xfixes0, libxv1, libxtst6, $ALSA_RUNTIME_DEP, - libsndio7.0, libxcb-shm0, libpulse0, libdrm2, libdbus-1-3 -Recommends: $PORTAL_RUNTIME_RECOMMENDS, nvidia-cuda-toolkit -Priority: optional -Section: utils -EOF - -cat > "$DESKTOP_DIR/$PKG_NAME.desktop" << EOF -[Desktop Entry] -Version=$DEB_VERSION -Name=$APP_NAME -Comment=$DESCRIPTION -Exec=/usr/bin/$PKG_NAME -Icon=$PKG_NAME -Terminal=false -Type=Application -Categories=Utility; -EOF - -cat > "$DEBIAN_DIR/postrm" << EOF -#!/bin/bash -set -e - -if [ "\$1" = "remove" ] || [ "\$1" = "purge" ]; then - rm -f /usr/bin/$PKG_NAME || true - rm -f /usr/bin/$APP_NAME || true - rm -f /usr/share/applications/$PKG_NAME.desktop || true - for size in 16 24 32 48 64 96 128 256; do - rm -f /usr/share/icons/hicolor/\${size}x\${size}/apps/$PKG_NAME.png || true - done +if [[ $# -ne 1 ]]; then + echo "Usage: $0 " >&2 + exit 2 fi -exit 0 -EOF -chmod +x "$DEBIAN_DIR/postrm" - -cat > "$DEBIAN_DIR/postinst" << 'EOF' -#!/bin/bash -set -e - -exit 0 -EOF - -chmod +x "$DEBIAN_DIR/postinst" - -dpkg-deb --build "$DEB_DIR" - -OUTPUT_FILE="${PKG_NAME}-linux-${ARCHITECTURE}-${APP_VERSION}.deb" -mv "$DEB_DIR.deb" "$OUTPUT_FILE" - -rm -rf "$DEB_DIR" - -echo "✅ Deb package created: $OUTPUT_FILE" +exec bash "$SCRIPT_DIR/package_deb.sh" amd64 x86_64 "$1" nvidia-cuda-toolkit diff --git a/scripts/linux/pkg_arm64.sh b/scripts/linux/pkg_arm64.sh old mode 100644 new mode 100755 index ff97436..ab335e6 --- a/scripts/linux/pkg_arm64.sh +++ b/scripts/linux/pkg_arm64.sh @@ -1,116 +1,11 @@ -#!/bin/bash -set -e +#!/usr/bin/env bash +set -euo pipefail -PKG_NAME="crossdesk" -APP_NAME="CrossDesk" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -ARCHITECTURE="arm64" -MAINTAINER="Junkun Di " -DESCRIPTION="A simple cross-platform remote desktop client." -ALSA_RUNTIME_DEP="libasound2 | libasound2t64" -PORTAL_RUNTIME_RECOMMENDS="xdg-desktop-portal, xdg-desktop-portal-gtk | xdg-desktop-portal-kde | xdg-desktop-portal-wlr" - -normalize_app_version() { - local input="$1" - local prefix="" - local body="$input" - - if [[ "$body" == v* ]]; then - prefix="v" - body="${body#v}" - fi - - if [[ "$body" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]{8})-([0-9]+)$ ]]; then - echo "${prefix}${BASH_REMATCH[1]}-${BASH_REMATCH[4]}-${BASH_REMATCH[3]}" - else - echo "$input" - fi -} - -APP_VERSION="$(normalize_app_version "$1")" - -# Remove 'v' prefix from version for Debian package (Debian version must start with digit) -DEB_VERSION="${APP_VERSION#v}" - -DEB_DIR="${PKG_NAME}-${DEB_VERSION}" -DEBIAN_DIR="$DEB_DIR/DEBIAN" -BIN_DIR="$DEB_DIR/usr/bin" -ICON_BASE_DIR="$DEB_DIR/usr/share/icons/hicolor" -DESKTOP_DIR="$DEB_DIR/usr/share/applications" - -rm -rf "$DEB_DIR" - -mkdir -p "$DEBIAN_DIR" "$BIN_DIR" "$DESKTOP_DIR" - -cp build/linux/arm64/release/crossdesk "$BIN_DIR" -chmod +x "$BIN_DIR/$PKG_NAME" - -ln -s "$PKG_NAME" "$BIN_DIR/$APP_NAME" - -for size in 16 24 32 48 64 96 128 256; do - mkdir -p "$ICON_BASE_DIR/${size}x${size}/apps" - cp "icons/linux/crossdesk_${size}x${size}.png" \ - "$ICON_BASE_DIR/${size}x${size}/apps/${PKG_NAME}.png" -done - -cat > "$DEBIAN_DIR/control" << EOF -Package: $PKG_NAME -Version: $DEB_VERSION -Architecture: $ARCHITECTURE -Maintainer: $MAINTAINER -Description: $DESCRIPTION -Depends: libc6 (>= 2.29), libstdc++6 (>= 9), libx11-6, libxcb1, - libxcb-randr0, libxcb-xtest0, libxcb-xinerama0, libxcb-shape0, - libxcb-xkb1, libxcb-xfixes0, libxv1, libxtst6, $ALSA_RUNTIME_DEP, - libsndio7.0, libxcb-shm0, libpulse0, libdrm2, libdbus-1-3 -Recommends: $PORTAL_RUNTIME_RECOMMENDS -Priority: optional -Section: utils -EOF - -cat > "$DESKTOP_DIR/$PKG_NAME.desktop" << EOF -[Desktop Entry] -Version=$DEB_VERSION -Name=$APP_NAME -Comment=$DESCRIPTION -Exec=/usr/bin/$PKG_NAME -Icon=$PKG_NAME -Terminal=false -Type=Application -Categories=Utility; -EOF - -cat > "$DEBIAN_DIR/postrm" << EOF -#!/bin/bash -set -e - -if [ "\$1" = "remove" ] || [ "\$1" = "purge" ]; then - rm -f /usr/bin/$PKG_NAME || true - rm -f /usr/bin/$APP_NAME || true - rm -f /usr/share/applications/$PKG_NAME.desktop || true - for size in 16 24 32 48 64 96 128 256; do - rm -f /usr/share/icons/hicolor/\${size}x\${size}/apps/$PKG_NAME.png || true - done +if [[ $# -ne 1 ]]; then + echo "Usage: $0 " >&2 + exit 2 fi -exit 0 -EOF -chmod +x "$DEBIAN_DIR/postrm" - -cat > "$DEBIAN_DIR/postinst" << 'EOF' -#!/bin/bash -set -e - -exit 0 -EOF - -chmod +x "$DEBIAN_DIR/postinst" - -dpkg-deb --build "$DEB_DIR" - -OUTPUT_FILE="crossdesk-linux-arm64-$APP_VERSION.deb" -mv "$DEB_DIR.deb" "$OUTPUT_FILE" - -rm -rf "$DEB_DIR" - -echo "✅ Deb package created: $OUTPUT_FILE" +exec bash "$SCRIPT_DIR/package_deb.sh" arm64 arm64 "$1" diff --git a/src/common/filesystem_utf8.h b/src/common/filesystem_utf8.h new file mode 100644 index 0000000..8e31fef --- /dev/null +++ b/src/common/filesystem_utf8.h @@ -0,0 +1,21 @@ +#ifndef CROSSDESK_COMMON_FILESYSTEM_UTF8_H_ +#define CROSSDESK_COMMON_FILESYSTEM_UTF8_H_ + +#include +#include +#include + +namespace crossdesk { + +inline std::filesystem::path PathFromUtf8(std::string_view value) { +#if defined(__cpp_char8_t) + const auto *begin = reinterpret_cast(value.data()); + return std::filesystem::path(std::u8string(begin, begin + value.size())); +#else + return std::filesystem::u8path(value.begin(), value.end()); +#endif +} + +} // namespace crossdesk + +#endif // CROSSDESK_COMMON_FILESYSTEM_UTF8_H_ diff --git a/src/gui/application/application_state.h b/src/gui/application/application_state.h index b3dc83e..da63bad 100644 --- a/src/gui/application/application_state.h +++ b/src/gui/application/application_state.h @@ -1,24 +1,12 @@ #ifndef CROSSDESK_GUI_APPLICATION_STATE_H_ #define CROSSDESK_GUI_APPLICATION_STATE_H_ -#include - #include #include #include #include #include -#include "imgui.h" - -#if _WIN32 -#include "platform/tray/win_tray.h" -#elif defined(__APPLE__) -#include "platform/tray/mac_tray.h" -#elif defined(__linux__) -#include "platform/tray/linux_tray.h" -#endif - namespace crossdesk::gui_detail { struct MainWindowState { @@ -27,25 +15,10 @@ struct MainWindowState { float title_bar_button_width_ = 30; float title_bar_button_height_ = 30; - SDL_Window *main_window_ = nullptr; - SDL_Renderer *main_renderer_ = nullptr; - ImGuiContext *main_ctx_ = nullptr; - ImFont *main_windows_system_chinese_font_ = nullptr; - ImFont *stream_windows_system_chinese_font_ = nullptr; - ImFont *server_windows_system_chinese_font_ = nullptr; bool exit_ = false; const int sdl_refresh_ms_ = 16; -#if _WIN32 - std::unique_ptr tray_; -#elif defined(__APPLE__) - std::unique_ptr tray_; -#elif defined(__linux__) - std::unique_ptr tray_; -#endif bool main_window_minimized_ = false; - uint32_t last_main_minimize_request_tick_ = 0; - uint32_t last_stream_minimize_request_tick_ = 0; int main_window_width_real_ = 720; int main_window_height_real_ = 540; float main_window_dpi_scaling_w_ = 1.0f; @@ -77,8 +50,6 @@ struct MainWindowState { float about_window_height_ = 170; float update_notification_window_width_ = 400; float update_notification_window_height_ = 320; - uint32_t STREAM_REFRESH_EVENT = 0; - uint32_t APP_EXIT_EVENT = 0; }; // Application-global interaction flags that coordinate SDL events with media @@ -116,14 +87,15 @@ struct InteractionState { std::string controlled_remote_id_; std::string focused_remote_id_; std::string remote_client_id_; - SDL_Event last_mouse_event{}; }; struct UpdateState { nlohmann::json latest_version_info_ = nlohmann::json{}; bool update_available_ = false; std::string latest_version_; + std::string release_name_; std::string release_notes_; + std::string release_date_; bool show_new_version_icon_ = false; bool show_new_version_icon_in_menu_ = true; double new_version_icon_last_trigger_time_ = 0.0; @@ -131,20 +103,15 @@ struct UpdateState { }; struct StreamWindowState { - SDL_Window *stream_window_ = nullptr; - SDL_Renderer *stream_renderer_ = nullptr; - ImGuiContext *stream_ctx_ = nullptr; bool need_to_create_stream_window_ = false; bool stream_window_created_ = false; bool stream_window_inited_ = false; bool window_maximized_ = false; - bool stream_window_grabbed_ = false; bool control_mouse_ = false; int stream_window_width_default_ = 1280; int stream_window_height_default_ = 720; float stream_window_width_ = 1280; float stream_window_height_ = 720; - SDL_PixelFormat stream_pixformat_ = SDL_PIXELFORMAT_NV12; int stream_window_width_real_ = 1280; int stream_window_height_real_ = 720; float stream_window_dpi_scaling_w_ = 1.0f; @@ -152,9 +119,6 @@ struct StreamWindowState { }; struct ServerWindowState { - SDL_Window *server_window_ = nullptr; - SDL_Renderer *server_renderer_ = nullptr; - ImGuiContext *server_ctx_ = nullptr; bool need_to_create_server_window_ = false; bool need_to_destroy_server_window_ = false; bool server_window_created_ = false; @@ -164,7 +128,6 @@ struct ServerWindowState { float server_window_width_ = 250; float server_window_height_ = 150; float server_window_title_bar_height_ = 30.0f; - SDL_PixelFormat server_pixformat_ = SDL_PIXELFORMAT_NV12; int server_window_normal_width_ = 250; int server_window_normal_height_ = 150; float server_window_dpi_scaling_w_ = 1.0f; diff --git a/src/gui/application/gui_application.cpp b/src/gui/application/gui_application.cpp index 844bc87..abe51a8 100644 --- a/src/gui/application/gui_application.cpp +++ b/src/gui/application/gui_application.cpp @@ -1,51 +1,692 @@ #include "application/gui_application.h" -#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include #include -#include +#include +#include #include #include +#include +#include +#include #include +#include +#include +#include #include +#include "crossdesk_ui.h" +#include "fa_solid_900.h" #include "localization.h" +#if _WIN32 +#include "platform/tray/win_tray.h" +#elif defined(__APPLE__) +#include "platform/tray/mac_tray.h" +#include "platform/window_drag.h" +#elif defined(__linux__) +#include "platform/tray/linux_tray.h" +#endif #include "rd_log.h" #include "version_checker.h" namespace crossdesk { +namespace { + +using namespace std::chrono_literals; + +#if _WIN32 +HICON LoadSlintTrayIcon() { + HMODULE module = GetModuleHandleW(nullptr); + HICON icon = reinterpret_cast( + LoadImageW(module, L"IDI_ICON1", IMAGE_ICON, 0, 0, LR_DEFAULTSIZE)); + return icon ? icon : LoadIconW(nullptr, IDI_APPLICATION); +} +#endif + +slint::SharedString UiText(std::string_view value) { + return slint::SharedString(value); +} + +void RegisterFontAwesome(slint::Window &window) { + if (const auto error = window.window_handle().register_font_from_data( + fa_solid_900_ttf, fa_solid_900_ttf_len)) { + LOG_ERROR("Failed to register Font Awesome for Slint window: {}", + error->data()); + } +} + +std::string FormatPeerId(const char *id) { + if (!id) { + return {}; + } + std::string compact(id); + if (compact.size() != 9) { + return compact; + } + compact.insert(3, " "); + compact.insert(7, " "); + return compact; +} + +std::string FormatRemotePeerIdInput(std::string_view input) { + std::string digits; + digits.reserve(9); + for (const unsigned char ch : input) { + if (std::isdigit(ch) != 0 && digits.size() < 9) { + digits.push_back(static_cast(ch)); + } + } + if (digits.size() > 3) { + digits.insert(3, " "); + } + if (digits.size() > 7) { + digits.insert(7, " "); + } + return digits; +} + +std::string CompactPeerId(std::string id) { + id.erase(std::remove_if(id.begin(), id.end(), [](unsigned char ch) { + return std::isspace(ch) != 0; + }), + id.end()); + return id; +} + +std::string Trim(std::string value) { + const auto first = std::find_if_not(value.begin(), value.end(), + [](unsigned char ch) { + return std::isspace(ch) != 0; + }); + const auto last = std::find_if_not(value.rbegin(), value.rend(), + [](unsigned char ch) { + return std::isspace(ch) != 0; + }) + .base(); + if (first >= last) { + return {}; + } + return std::string(first, last); +} + +std::string CleanReleaseNotesForUi(std::string markdown) { + size_t line_start = 0; + while (line_start < markdown.size()) { + size_t content_start = line_start; + while (content_start < markdown.size() && markdown[content_start] == '#') { + ++content_start; + } + if (content_start > line_start && content_start < markdown.size() && + markdown[content_start] == ' ') { + ++content_start; + } + if (content_start > line_start) { + markdown.erase(line_start, content_start - line_start); + } + const size_t newline = markdown.find('\n', line_start); + if (newline == std::string::npos) { + break; + } + line_start = newline + 1; + } + size_t marker = 0; + while ((marker = markdown.find("**", marker)) != std::string::npos) { + markdown.erase(marker, 2); + } + return markdown; +} + +std::string DecodeDroppedPath(std::string text) { + const size_t line_end = text.find_first_of("\r\n"); + if (line_end != std::string::npos) { + text.resize(line_end); + } + text = Trim(std::move(text)); + if (text.rfind("file://localhost/", 0) == 0) { + text.erase(0, 16); + text.insert(text.begin(), '/'); + } else if (text.rfind("file://", 0) == 0) { + text.erase(0, 7); + } + + std::string decoded; + decoded.reserve(text.size()); + auto hex_value = [](char value) -> int { + if (value >= '0' && value <= '9') return value - '0'; + if (value >= 'a' && value <= 'f') return value - 'a' + 10; + if (value >= 'A' && value <= 'F') return value - 'A' + 10; + return -1; + }; + for (size_t i = 0; i < text.size(); ++i) { + if (text[i] == '%' && i + 2 < text.size()) { + const int high = hex_value(text[i + 1]); + const int low = hex_value(text[i + 2]); + if (high >= 0 && low >= 0) { + decoded.push_back(static_cast((high << 4) | low)); + i += 2; + continue; + } + } + decoded.push_back(text[i]); + } +#if defined(_WIN32) + if (decoded.size() >= 3 && decoded[0] == '/' && + std::isalpha(static_cast(decoded[1])) && + decoded[2] == ':') { + decoded.erase(decoded.begin()); + } +#endif + return decoded; +} + +std::optional ParsePort(const slint::SharedString &text) { + const std::string value(text); + int port = 0; + const auto [end, error] = + std::from_chars(value.data(), value.data() + value.size(), port); + if (error != std::errc{} || end != value.data() + value.size() || port <= 0 || + port > 65535) { + return std::nullopt; + } + return port; +} + +std::string ConnectionStatusText(ConnectionStatus status, int language) { + switch (status) { + case ConnectionStatus::Connected: + return localization::p2p_connected[language]; + case ConnectionStatus::Connecting: + return localization::p2p_connecting[language]; + case ConnectionStatus::Gathering: + return localization::p2p_gathering[language]; + case ConnectionStatus::Disconnected: + return localization::p2p_disconnected[language]; + case ConnectionStatus::NoSuchTransmissionId: + return localization::no_such_id[language]; + case ConnectionStatus::IncorrectPassword: + return localization::reinput_password[language]; + case ConnectionStatus::Closed: + return localization::p2p_closed[language]; + case ConnectionStatus::RemoteUnavailable: + return localization::device_offline[language]; + case ConnectionStatus::Failed: + default: + return localization::p2p_failed[language]; + } +} + +std::string FormatBitrate(uint32_t bits_per_second) { + char text[48]; + if (bits_per_second < 1000) { + std::snprintf(text, sizeof(text), "%u bps", bits_per_second); + } else if (bits_per_second < 1000000) { + std::snprintf(text, sizeof(text), "%u kbps", bits_per_second / 1000); + } else { + std::snprintf(text, sizeof(text), "%.1f mbps", + bits_per_second / 1000000.0); + } + return text; +} + +std::string FormatTransferRate(uint32_t bits_per_second) { + return bits_per_second == 0 ? std::string{} : FormatBitrate(bits_per_second); +} + +std::string FormatLossRate(float loss_rate) { + if (loss_rate < 0.01f) { + return "0%"; + } + char text[24]; + std::snprintf(text, sizeof(text), "%.0f%%", loss_rate * 100.0f); + return text; +} + +std::string FormatFileSize(uint64_t bytes) { + char text[48]; + if (bytes < 1024) { + std::snprintf(text, sizeof(text), "%llu B", + static_cast(bytes)); + } else if (bytes < 1024 * 1024) { + std::snprintf(text, sizeof(text), "%.2f KB", bytes / 1024.0); + } else if (bytes < 1024ULL * 1024ULL * 1024ULL) { + std::snprintf(text, sizeof(text), "%.2f MB", + bytes / (1024.0 * 1024.0)); + } else { + std::snprintf(text, sizeof(text), "%.2f GB", + bytes / (1024.0 * 1024.0 * 1024.0)); + } + return text; +} + +uint32_t DecodeFirstUtf8CodePoint(const std::string &text) { + if (text.empty()) { + return 0; + } + const auto *bytes = reinterpret_cast(text.data()); + if (bytes[0] < 0x80) { + return bytes[0]; + } + if ((bytes[0] & 0xE0) == 0xC0 && text.size() >= 2) { + return ((bytes[0] & 0x1F) << 6) | (bytes[1] & 0x3F); + } + if ((bytes[0] & 0xF0) == 0xE0 && text.size() >= 3) { + return ((bytes[0] & 0x0F) << 12) | ((bytes[1] & 0x3F) << 6) | + (bytes[2] & 0x3F); + } + if ((bytes[0] & 0xF8) == 0xF0 && text.size() >= 4) { + return ((bytes[0] & 0x07) << 18) | ((bytes[1] & 0x3F) << 12) | + ((bytes[2] & 0x3F) << 6) | (bytes[3] & 0x3F); + } + return 0; +} + +int SlintKeyToWindowsVk(const std::string &text) { + const uint32_t key = DecodeFirstUtf8CodePoint(text); + if (key >= 'a' && key <= 'z') { + return static_cast(key - 'a' + 'A'); + } + if ((key >= 'A' && key <= 'Z') || (key >= '0' && key <= '9')) { + return static_cast(key); + } + if (key >= 0xF704 && key <= 0xF71B) { + return 0x70 + static_cast(key - 0xF704); + } + + switch (key) { + case 0x08: + return 0x08; + case 0x09: + case 0x19: + return 0x09; + case 0x0A: + return 0x0D; + case 0x1B: + return 0x1B; + case 0x7F: + return 0x2E; + case 0x10: + return 0xA0; + case 0x15: + return 0xA1; + case 0x11: + return 0xA2; + case 0x16: + return 0xA3; + case 0x12: + return 0xA4; + case 0x13: + return 0xA5; + case 0x17: + return 0x5B; + case 0x18: + return 0x5C; + case 0x14: + return 0x14; + case 0x20: + return 0x20; + case 0xF700: + return 0x26; + case 0xF701: + return 0x28; + case 0xF702: + return 0x25; + case 0xF703: + return 0x27; + case 0xF727: + return 0x2D; + case 0xF729: + return 0x24; + case 0xF72B: + return 0x23; + case 0xF72C: + return 0x21; + case 0xF72D: + return 0x22; + case 0xF72F: + return 0x91; + case 0xF730: + return 0x13; + case 0xF731: + return 0x2C; + case 0xF734: + return 0xB2; + case 0xF735: + return 0x5D; + case 0xF748: + return 0xA6; + case ';': + case ':': + return 0xBA; + case '\'': + case '"': + return 0xDE; + case '`': + case '~': + return 0xC0; + case ',': + case '<': + return 0xBC; + case '.': + case '>': + return 0xBE; + case '/': + case '?': + return 0xBF; + case '\\': + case '|': + return 0xDC; + case '[': + case '{': + return 0xDB; + case ']': + case '}': + return 0xDD; + case '-': + case '_': + return 0xBD; + case '=': + case '+': + return 0xBB; + case '!': + return '1'; + case '@': + return '2'; + case '#': + return '3'; + case '$': + return '4'; + case '%': + return '5'; + case '^': + return '6'; + case '&': + return '7'; + case '*': + return '8'; + case '(': + return '9'; + case ')': + return '0'; + default: + return -1; + } +} + +struct WindowDragState { + bool active = false; + bool use_global_pointer = false; + float pointer_start_x = 0; + float pointer_start_y = 0; + float last_local_x = 0; + float last_local_y = 0; + float global_to_physical_scale = 1; + slint::PhysicalPosition window_start; + slint::PhysicalPosition last_target; +}; + +bool CanUseGlobalPointerPosition() { + const char *driver = SDL_GetCurrentVideoDriver(); + if (driver == nullptr) { + return false; + } +#if defined(__linux__) && !defined(__APPLE__) + // Wayland deliberately does not expose global pointer coordinates or allow + // clients to position top-level windows. + return std::strcmp(driver, "wayland") != 0; +#else + return true; +#endif +} + +float ServerWindowLogicalWidth(int language) { + return language == 0 ? 250.0f : language == 1 ? 330.0f : 430.0f; +} + +float ServerWindowLogicalHeight(bool collapsed) { + return collapsed ? 30.0f : 150.0f; +} + +constexpr float kStreamWindowLogicalWidth = 1280.0f; +constexpr float kStreamWindowLogicalHeight = 720.0f; + +SDL_DisplayID DisplayForSlintWindow(const slint::Window &window) { + const float scale = std::max(window.scale_factor(), 0.01f); + const auto position = window.position(); + const auto size = window.size(); + const SDL_Point center{ + static_cast(std::lround(position.x / scale + + size.width / (2.0f * scale))), + static_cast(std::lround(position.y / scale + + size.height / (2.0f * scale)))}; + const SDL_DisplayID display = SDL_GetDisplayForPoint(¢er); + return display != 0 ? display : SDL_GetPrimaryDisplay(); +} + +bool PositionWindowAtCenter(slint::Window &window, + const slint::Window &anchor, + float logical_width, float logical_height) { + if (!CanUseGlobalPointerPosition()) { + return false; + } + + const SDL_DisplayID display = DisplayForSlintWindow(anchor); + SDL_Rect usable_bounds{}; + if (display == 0 || + !SDL_GetDisplayUsableBounds(display, &usable_bounds)) { + LOG_WARN("Unable to obtain usable display bounds for stream window: {}", + SDL_GetError()); + return false; + } + + const float target_x = static_cast(usable_bounds.x) + + (static_cast(usable_bounds.w) - logical_width) / + 2.0f; + const float target_y = static_cast(usable_bounds.y) + + (static_cast(usable_bounds.h) - logical_height) / + 2.0f; + window.set_position(slint::LogicalPosition( + slint::Point{target_x, target_y})); + return true; +} + +bool PositionWindowAtBottomRight(slint::Window &window, float logical_width, + float logical_height) { + if (!CanUseGlobalPointerPosition()) { + return false; + } + + SDL_DisplayID display = 0; + const float scale = std::max(window.scale_factor(), 0.01f); + const auto position = window.position(); + const auto size = window.size(); + const SDL_Point center{ + static_cast(std::lround(position.x / scale + + size.width / (2.0f * scale))), + static_cast(std::lround(position.y / scale + + size.height / (2.0f * scale)))}; + display = SDL_GetDisplayForPoint(¢er); + if (display == 0) { + display = SDL_GetPrimaryDisplay(); + } + + SDL_Rect usable_bounds{}; + if (display == 0 || + !SDL_GetDisplayUsableBounds(display, &usable_bounds)) { + LOG_WARN("Unable to obtain usable display bounds for server window: {}", + SDL_GetError()); + return false; + } + + const float target_x = + static_cast(usable_bounds.x) + + std::max(0.0f, static_cast(usable_bounds.w) - logical_width); + const float target_y = + static_cast(usable_bounds.y) + + std::max(0.0f, static_cast(usable_bounds.h) - logical_height); + window.set_position(slint::LogicalPosition( + slint::Point{target_x, target_y})); + return true; +} + +template +void DragWindow(WindowHandle &component, int phase, float mouse_x, + float mouse_y, WindowDragState &state) { + if (phase == 1) { +#if defined(__APPLE__) + // Let AppKit run the native move loop. This preserves macOS window + // snapping, Spaces, multi-display, and high-DPI behavior without manually + // updating the window position for every pointer event. + if (!StartNativeWindowDrag()) { + LOG_WARN("Unable to start native macOS window drag"); + } + state.active = false; + return; +#endif + auto &window = component->window(); + state.active = true; + state.last_local_x = mouse_x; + state.last_local_y = mouse_y; + state.window_start = window.position(); + state.last_target = state.window_start; + state.use_global_pointer = CanUseGlobalPointerPosition(); + if (state.use_global_pointer) { + SDL_GetGlobalMouseState(&state.pointer_start_x, &state.pointer_start_y); +#if defined(__APPLE__) + // Cocoa reports global pointer positions in logical screen points, + // while Slint/winit accepts physical window positions. + state.global_to_physical_scale = window.scale_factor(); +#else + state.global_to_physical_scale = 1.0f; +#endif + } + return; + } + if (phase == 0) { + state.active = false; + return; + } + if (!state.active || phase != 2) { + return; + } + + auto &window = component->window(); + if (state.use_global_pointer) { + float pointer_x = 0; + float pointer_y = 0; + SDL_GetGlobalMouseState(&pointer_x, &pointer_y); + const int delta_x = static_cast(std::lround( + (pointer_x - state.pointer_start_x) * + state.global_to_physical_scale)); + const int delta_y = static_cast(std::lround( + (pointer_y - state.pointer_start_y) * + state.global_to_physical_scale)); + const slint::PhysicalPosition target(slint::Point{ + state.window_start.x + delta_x, state.window_start.y + delta_y}); + if (target.x != state.last_target.x || target.y != state.last_target.y) { + window.set_position(target); + state.last_target = target; + } + return; + } + + // Retain the previous behavior for backends without global pointer access. + const float scale = window.scale_factor(); + const auto position = window.position(); + const int delta_x = static_cast( + std::lround((mouse_x - state.last_local_x) * scale)); + const int delta_y = static_cast( + std::lround((mouse_y - state.last_local_y) * scale)); + if (delta_x != 0 || delta_y != 0) { + window.set_position( + slint::PhysicalPosition(slint::Point{ + position.x + delta_x, position.y + delta_y})); + } + state.last_local_x = mouse_x; + state.last_local_y = mouse_y; +} + +} // namespace + +struct GuiApplication::SlintUi { + slint::ComponentHandle main = ui::MainWindow::create(); + std::optional> stream; + std::optional> server; + std::shared_ptr> recent_model = + std::make_shared>(); + std::shared_ptr> tab_model = + std::make_shared>(); + std::shared_ptr> display_model = + std::make_shared>(); + std::shared_ptr> transfer_model = + std::make_shared>(); + std::shared_ptr> stats_model = + std::make_shared>(); + std::shared_ptr> controller_model = + std::make_shared>(); + std::shared_ptr> controller_name_model = + std::make_shared>(); + std::unordered_map displayed_frame_sequence; + std::vector tab_order; + std::vector tab_ids; + std::string tab_model_signature; + std::vector controller_ids; + std::string connection_dialog_remote_id; + bool connection_password_initialized = false; + bool portable_service_dialog_initialized = false; + std::string recent_model_signature; + slint::Timer timer; + WindowDragState server_drag; + int main_native_titlebar_attempts = 30; + int stream_live_resize_configuration_attempts = 0; + int stream_initial_position_attempts = 0; + int server_initial_position_attempts = 0; + int localized_language = -1; + bool capture_mode = false; + std::string capture_page; + std::chrono::steady_clock::time_point last_clipboard_poll{}; +#if _WIN32 + std::unique_ptr tray; +#elif defined(__APPLE__) + std::unique_ptr tray; +#elif defined(__linux__) + std::unique_ptr tray; +#endif +}; GuiApplication::GuiApplication() = default; - GuiApplication::~GuiApplication() = default; int GuiApplication::Run() { path_manager_ = std::make_unique("CrossDesk"); - if (path_manager_) { - exec_log_path_ = path_manager_->GetLogPath().string(); - dll_log_path_ = path_manager_->GetLogPath().string(); - cache_path_ = path_manager_->GetCachePath().string(); - config_center_ = - std::make_unique(cache_path_ + "/config.ini"); - strncpy(signal_server_ip_self_, - config_center_->GetSignalServerHost().c_str(), - sizeof(signal_server_ip_self_) - 1); - signal_server_ip_self_[sizeof(signal_server_ip_self_) - 1] = '\0'; - int signal_port_init = config_center_->GetSignalServerPort(); - if (signal_port_init > 0) { - strncpy(signal_server_port_self_, - std::to_string(signal_port_init).c_str(), - sizeof(signal_server_port_self_) - 1); - signal_server_port_self_[sizeof(signal_server_port_self_) - 1] = '\0'; - } else { - signal_server_port_self_[0] = '\0'; - } - } else { - std::cerr << "Failed to create PathManager" << std::endl; + if (!path_manager_) { return -1; } + exec_log_path_ = path_manager_->GetLogPath().string(); + dll_log_path_ = exec_log_path_; + cache_path_ = path_manager_->GetCachePath().string(); + config_center_ = + std::make_unique(cache_path_ + "/config.ini"); InitializeLogger(); - LOG_INFO("CrossDesk version: {}", CROSSDESK_VERSION); + LOG_INFO("CrossDesk version: {} (Slint UI)", CROSSDESK_VERSION); + + strncpy(signal_server_ip_self_, config_center_->GetSignalServerHost().c_str(), + sizeof(signal_server_ip_self_) - 1); + const int signal_port = config_center_->GetSignalServerPort(); + if (signal_port > 0) { + std::snprintf(signal_server_port_self_, sizeof(signal_server_port_self_), + "%d", signal_port); + } latest_version_info_ = CheckUpdate(); if (!latest_version_info_.empty()) { @@ -57,48 +698,45 @@ int GuiApplication::Run() { latest_version_info_["version"].is_string()) { version = latest_version_info_["version"].get(); } - - if (!version.empty()) { - latest_version_ = 'v' + version; - } else { - latest_version_ = ""; - } + latest_version_ = version.empty() ? std::string{} : "v" + version; if (latest_version_info_.contains("releaseNotes") && latest_version_info_["releaseNotes"].is_string()) { - release_notes_ = latest_version_info_["releaseNotes"].get(); - } else { - release_notes_ = ""; + release_notes_ = + latest_version_info_["releaseNotes"].get(); + } + if (latest_version_info_.contains("releaseName") && + latest_version_info_["releaseName"].is_string()) { + release_name_ = latest_version_info_["releaseName"].get(); + } + if (latest_version_info_.contains("releaseDate") && + latest_version_info_["releaseDate"].is_string()) { + release_date_ = latest_version_info_["releaseDate"].get(); } update_available_ = !version.empty() && IsNewerVersion(CROSSDESK_VERSION, latest_version_); - LOG_INFO("Update check: current={}, latest={}, available={}", - CROSSDESK_VERSION, latest_version_, update_available_); - if (update_available_) { - show_update_notification_window_ = true; - } - } else { - latest_version_ = ""; - update_available_ = false; - LOG_WARN("Update check skipped: version.json is empty or missing " - "latest_version"); } InitializeSettings(); - InitializeSDL(); + if (!InitializeSDL()) { + return -1; + } InitializeModules(); - InitializeMainWindow(); - -#if _WIN32 && CROSSDESK_PORTABLE - CheckPortableWindowsService(); -#endif - - const int scaled_video_width_ = 160; - const int scaled_video_height_ = 90; - - MainLoop(); + InitializeUi(); + ui_->timer.start(slint::TimerMode::Repeated, 16ms, [this] { Tick(); }); + if (ui_->capture_mode && + (ui_->capture_page == "stream" || ui_->capture_page == "server")) { + slint::run_event_loop(); + } else if (ui_->capture_mode) { + ui_->main->run(); + } else { + // The native tray icon is managed outside Slint, so Slint cannot count it + // when deciding whether the last hidden window should stop the event loop. + // Keep the loop alive until the tray's explicit Exit action requests quit. + ui_->main->show(); + slint::run_event_loop(slint::EventLoopMode::RunUntilQuit); + } Cleanup(); - return 0; } @@ -107,189 +745,1926 @@ void GuiApplication::InitializeLogger() { InitLogger(exec_log_path_); } void GuiApplication::InitializeSettings() { settings_.Load(); settings_.LoadRecentConnectionAliases(); - localization_language_index_ = localization::detail::ClampLanguageIndex(language_button_value_); language_button_value_ = localization_language_index_; - - if (localization_language_index_ == 0) { - localization_language_ = ConfigCenter::LANGUAGE::CHINESE; - } else if (localization_language_index_ == 1) { - localization_language_ = ConfigCenter::LANGUAGE::ENGLISH; - } else { - localization_language_ = ConfigCenter::LANGUAGE::RUSSIAN; - } + localization_language_ = + static_cast(localization_language_index_); } -void GuiApplication::InitializeSDL() { +bool GuiApplication::InitializeSDL() { #if defined(__linux__) && !defined(__APPLE__) if (!getenv("SDL_AUDIODRIVER")) { - // Prefer PulseAudio first on Linux to avoid hard ALSA plugin dependency. setenv("SDL_AUDIODRIVER", "pulseaudio", 0); } #endif - if (!SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO)) { - LOG_ERROR("Error: {}", SDL_GetError()); - return; + LOG_ERROR("SDL initialization failed: {}", SDL_GetError()); + return false; } - const SDL_DisplayMode *dm = SDL_GetCurrentDisplayMode(0); - if (dm) { - screen_width_ = dm->w; - screen_height_ = dm->h; + if (const SDL_DisplayMode *mode = SDL_GetCurrentDisplayMode(0)) { + screen_width_ = mode->w; + screen_height_ = mode->h; } - - const uint32_t custom_event_base = SDL_RegisterEvents(3); - if (custom_event_base == static_cast(-1)) { - LOG_ERROR("Failed to register custom SDL events"); - } else { - STREAM_REFRESH_EVENT = custom_event_base; - APP_EXIT_EVENT = custom_event_base + 1; - clipboard_.SetEventType(custom_event_base + 2); - } - - LOG_INFO("Screen resolution: [{}x{}]", screen_width_, screen_height_); + clipboard_.Initialize(); + return true; } void GuiApplication::InitializeModules() { - if (!modules_inited_) { - devices_.Initialize(); - CreateConnectionPeer(); + if (modules_inited_) { + return; + } + devices_.Initialize(); + CreateConnectionPeer(); + modules_inited_ = true; +} - modules_inited_ = true; +void GuiApplication::InitializeUi() { + ui_ = std::make_unique(); + RegisterFontAwesome(ui_->main->window()); +#ifdef CROSSDESK_DEBUG + auto read_capture_override = [](const char *path) -> std::string { + std::ifstream input(path); + std::string value; + std::getline(input, value); + return value; + }; + if (const char *capture = std::getenv("CROSSDESK_UI_CAPTURE")) { + ui_->capture_mode = std::string_view(capture) != "0"; + } + if (const char *page = std::getenv("CROSSDESK_UI_CAPTURE_PAGE")) { + ui_->capture_page = page; + } + std::string capture_language_override; + if (ui_->capture_mode) { + if (const auto page = read_capture_override( + "/tmp/crossdesk-ui-capture-page"); + !page.empty()) { + ui_->capture_page = page; + } + capture_language_override = read_capture_override( + "/tmp/crossdesk-ui-capture-language"); + } + const char *language = capture_language_override.empty() + ? std::getenv("CROSSDESK_UI_CAPTURE_LANGUAGE") + : capture_language_override.c_str(); + if (language) { + int capture_language = 0; + const auto [end, error] = std::from_chars( + language, language + std::strlen(language), capture_language); + if (error == std::errc{} && end == language + std::strlen(language)) { + localization_language_index_ = + localization::detail::ClampLanguageIndex(capture_language); + language_button_value_ = localization_language_index_; + localization_language_ = static_cast( + localization_language_index_); + } + } +#endif + ui_->main->set_recent_connections(ui_->recent_model); + ResetSettingsUi(); + BindMainCallbacks(); + InitializeSystemTray(); + UpdateLocalization(); + if (ui_->capture_mode) { + const bool detected_update = update_available_; + update_available_ = false; + if (ui_->capture_page == "settings") { + ui_->main->set_settings_open(true); + } else if (ui_->capture_page == "self-hosted") { + ui_->main->set_settings_open(true); + ui_->main->set_self_host_settings_open(true); + } else if (ui_->capture_page == "about") { + update_available_ = detected_update; + ui_->main->set_update_open(false); + ui_->main->set_about_open(true); + } else if (ui_->capture_page == "update") { + update_available_ = detected_update; + ui_->main->set_update_open(true); + } else if (ui_->capture_page == "reset-password") { + ui_->main->set_reset_password_open(true); + } else if (ui_->capture_page == "reset-password-invalid") { + ui_->main->set_reset_password_open(true); + ui_->main->set_reset_password_invalid(true); + ui_->main->set_new_password_input("123"); + } else if (ui_->capture_page == "alias") { + ui_->main->set_alias_input("Office workstation"); + ui_->main->set_alias_open(true); + } else if (ui_->capture_page == "delete") { + ui_->main->set_delete_open(true); + } else if (ui_->capture_page == "offline") { + ui_->main->set_offline_warning( + UiText(localization::device_offline[localization_language_index_])); + } else if (ui_->capture_page == "connection") { + ui_->main->set_connection_status_text( + UiText(localization::p2p_connecting[localization_language_index_])); + ui_->main->set_connection_pending(true); + ui_->main->set_connection_dialog_open(true); + } else if (ui_->capture_page == "connection-password") { + ui_->main->set_connection_status_text( + UiText(localization::reinput_password[localization_language_index_])); + ui_->main->set_connection_password_required(true); + ui_->main->set_connection_dialog_open(true); + } else if (ui_->capture_page == "service") { + ui_->main->set_portable_service_dialog_open(true); + } else if (ui_->capture_page == "service-notice") { + ui_->main->set_portable_service_suppressed_notice_open(true); + } + } + SyncMainWindow(); + + if (ui_->capture_mode && ui_->capture_page == "stream") { + ui_->stream.emplace(ui::StreamWindow::create()); + RegisterFontAwesome((*ui_->stream)->window()); + (*ui_->stream)->set_tabs(ui_->tab_model); + (*ui_->stream)->set_displays(ui_->display_model); + (*ui_->stream)->set_file_transfers(ui_->transfer_model); + (*ui_->stream)->set_stats_rows(ui_->stats_model); + ui::StreamTab preview_tab; + preview_tab.remote_id = "589173341"; + preview_tab.title = "Mac"; + preview_tab.connected = true; + ui_->tab_model->set_vector({preview_tab}); + ui_->display_model->set_vector({slint::SharedString("Display 1")}); + ui_->localized_language = -1; + UpdateLocalization(); + (*ui_->stream)->set_status_text( + UiText(localization::p2p_connected[localization_language_index_])); +#ifdef CROSSDESK_DEBUG + // A bordered 16:9 frame makes stream scaling/cropping regressions visible + // in the existing capture mode without requiring a live remote session. + constexpr int preview_width = 640; + constexpr int preview_height = 360; + slint::SharedPixelBuffer preview_pixels(preview_width, + preview_height); + auto *preview_data = + reinterpret_cast(preview_pixels.begin()); + for (int y = 0; y < preview_height; ++y) { + for (int x = 0; x < preview_width; ++x) { + const bool border = x < 8 || y < 8 || x >= preview_width - 8 || + y >= preview_height - 8; + const size_t offset = + (static_cast(y) * preview_width + x) * 3; + preview_data[offset] = border ? 240 : static_cast( + 35 + 150 * x / + preview_width); + preview_data[offset + 1] = border ? 70 : static_cast( + 35 + 150 * y / + preview_height); + preview_data[offset + 2] = border ? 70 : 90; + } + } + (*ui_->stream)->set_frame(slint::Image(std::move(preview_pixels))); + (*ui_->stream)->set_has_frame(true); + (*ui_->stream)->set_status_text(""); +#endif + BindStreamCallbacks(); + (*ui_->stream)->show(); + PositionWindowAtCenter((*ui_->stream)->window(), ui_->main->window(), + kStreamWindowLogicalWidth, + kStreamWindowLogicalHeight); + ui_->main->hide(); + } else if (ui_->capture_mode && ui_->capture_page == "server") { + ui_->server.emplace(ui::ServerWindow::create()); + RegisterFontAwesome((*ui_->server)->window()); + (*ui_->server)->set_controllers(ui_->controller_model); + (*ui_->server)->set_controller_names(ui_->controller_name_model); + (*ui_->server)->set_language_index(localization_language_index_); + const float server_width = + ServerWindowLogicalWidth(localization_language_index_); + const float server_height = ServerWindowLogicalHeight(false); + (*ui_->server)->window().set_size(slint::LogicalSize( + slint::Size{server_width, server_height})); + ui::ControllerEntry preview_controller; + preview_controller.remote_id = "589173341"; + preview_controller.display_name = "Mac"; + ui_->controller_model->set_vector({preview_controller}); + ui_->controller_name_model->set_vector({slint::SharedString("Mac")}); + (*ui_->server)->set_controller_label( + UiText(localization::controller[localization_language_index_])); + (*ui_->server)->set_connection_label( + UiText(localization::connection_status[localization_language_index_])); + (*ui_->server)->set_connection_status( + UiText(localization::p2p_connected[localization_language_index_])); + (*ui_->server)->set_file_transfer_label( + UiText(localization::file_transfer[localization_language_index_])); + (*ui_->server)->set_select_file_label( + UiText(localization::select_file[localization_language_index_])); + BindServerCallbacks(); + (*ui_->server)->show(); + PositionWindowAtBottomRight((*ui_->server)->window(), server_width, + server_height); + ui_->main->hide(); } } -void GuiApplication::InitializeMainWindow() { - CreateMainWindow(); - clipboard_.Initialize(); - if (SDL_WINDOW_HIDDEN & SDL_GetWindowFlags(main_window_)) { - SDL_ShowWindow(main_window_); +void GuiApplication::InitializeSystemTray() { +#if defined(_WIN32) || defined(__APPLE__) || defined(__linux__) + if (!ui_) { + return; } -} - -void GuiApplication::MainLoop() { - while (!exit_) { - if (!peer_) { - CreateConnectionPeer(); + auto show_window = [this] { + if (!ui_) { + return; } - - SDL_Event event; - if (SDL_WaitEventTimeout(&event, sdl_refresh_ms_)) { - ProcessSdlEvent(event); + ui_->main->show(); + ui_->main->window().set_minimized(false); +#if defined(__APPLE__) + // A window restored from the tray may only obtain its NSWindow after the + // callback returns, so let the regular UI tick configure it. + ui_->main_native_titlebar_attempts = 30; +#endif + }; + auto hide_window = [this] { + if (ui_) { + ui_->main->hide(); } - // Also drain the pending value after a timeout so a full SDL event queue - // cannot indefinitely delay a clipboard update received from the network. - clipboard_.ApplyPendingRemoteText(); - + }; + auto open_settings = [this] { + if (!ui_) { + return; + } + ui_->main->show(); + ui_->main->window().set_minimized(false); + ui_->main->set_settings_open(true); +#if defined(__APPLE__) + ui_->main_native_titlebar_attempts = 30; +#endif + }; + auto exit_app = [this] { + exit_ = true; + slint::quit_event_loop(); + }; #if _WIN32 - MSG msg; - while (PeekMessage(&msg, nullptr, 0, 0, PM_REMOVE)) { - TranslateMessage(&msg); - DispatchMessage(&msg); + ui_->tray = std::make_unique( + std::move(show_window), std::move(hide_window), std::move(exit_app), + LoadSlintTrayIcon(), L"CrossDesk", localization_language_index_); +#elif defined(__APPLE__) + ui_->tray = std::make_unique( + std::move(show_window), std::move(hide_window), + std::move(open_settings), std::move(exit_app), "CrossDesk", + localization_language_index_); +#elif defined(__linux__) + ui_->tray = std::make_unique( + std::move(show_window), std::move(hide_window), std::move(exit_app), + "CrossDesk", localization_language_index_); +#endif +#endif +} + +bool GuiApplication::MinimizeMainWindowToTray() { + if (!enable_minimize_to_tray_ || !ui_) { + return false; + } +#if _WIN32 || defined(__APPLE__) + if (!ui_->tray) { + return false; + } + ui_->tray->MinimizeToTray(); + return true; +#elif defined(__linux__) + return ui_->tray && ui_->tray->MinimizeToTray(); +#else + return false; +#endif +} + +void GuiApplication::ResetSettingsUi() { + if (!ui_) { + return; + } + ui_->main->set_language_index(language_button_value_); + ui_->main->set_video_quality_index(video_quality_button_value_); + ui_->main->set_frame_rate_index(video_frame_rate_button_value_); + ui_->main->set_codec_index(video_encode_format_button_value_); + ui_->main->set_hardware_codec_enabled(enable_hardware_video_codec_); + ui_->main->set_turn_enabled(enable_turn_); + ui_->main->set_srtp_enabled(enable_srtp_); + ui_->main->set_self_hosted_enabled(enable_self_hosted_); + ui_->main->set_autostart_enabled(enable_autostart_); + ui_->main->set_daemon_enabled(enable_daemon_); + ui_->main->set_minimize_to_tray_enabled(enable_minimize_to_tray_); + ui_->main->set_file_save_path(file_transfer_save_path_buf_); + ui_->main->set_server_host(signal_server_ip_self_); + ui_->main->set_server_port(signal_server_port_self_); + ui_->main->set_coturn_port(coturn_server_port_self_); +} + +void GuiApplication::BindMainCallbacks() { + auto &main = ui_->main; + main->window().on_close_requested([this] { + if (MinimizeMainWindowToTray()) { + // MinimizeMainWindowToTray() ensures the tray icon exists. Let Slint + // complete the close request by hiding the main window as well. + return slint::CloseRequestResponse::HideWindow; } -#elif defined(__linux__) && !defined(__APPLE__) - if (tray_) { - tray_->ProcessEvents(); + exit_ = true; + return slint::CloseRequestResponse::HideWindow; + }); + main->on_copy_local_id([this] { + if (!SDL_SetClipboardText(client_id_)) { + LOG_WARN("Copy local id failed: {}", SDL_GetError()); } + }); + main->on_toggle_password_visibility([this] { + show_password_ = !show_password_; + ui_->main->set_password_visible(show_password_); + }); + main->on_reset_password([this](slint::SharedString value) { + const std::string password(value); + if (password.size() != 6) { + return false; + } + std::memset(password_saved_, 0, sizeof(password_saved_)); + std::strncpy(password_saved_, password.c_str(), + sizeof(password_saved_) - 1); + + if (config_center_->IsSelfHosted()) { + std::string identity = self_hosted_id_; + if (const auto at = identity.find('@'); at != std::string::npos) { + identity.resize(at); + } + if (identity.empty()) { + identity = client_id_; + } + identity += "@" + password; + std::memset(self_hosted_id_, 0, sizeof(self_hosted_id_)); + std::strncpy(self_hosted_id_, identity.c_str(), + sizeof(self_hosted_id_) - 1); + } else { + const std::string identity = std::string(client_id_) + "@" + password; + std::memset(client_id_with_password_, 0, + sizeof(client_id_with_password_)); + std::strncpy(client_id_with_password_, identity.c_str(), + sizeof(client_id_with_password_) - 1); + } + settings_.Save(); + if (peer_) { + LeaveConnection(peer_, client_id_); + DestroyPeer(&peer_); + } + return true; + }); + main->on_connect_requested([this](slint::SharedString id) { + ConnectFromUi(std::string(id)); + }); + main->on_format_remote_id([](slint::SharedString value) { + return UiText(FormatRemotePeerIdInput(std::string_view(value))); + }); + main->on_recent_connect([this](slint::SharedString id) { + ConnectFromUi(std::string(id)); + }); + main->on_recent_edit_alias( + [this](slint::SharedString id, slint::SharedString alias) { + const std::string remote_id(id); + const std::string trimmed = Trim(std::string(alias)); + if (trimmed.empty()) { + settings_.EraseRecentConnectionAlias(remote_id); + } else { + settings_.SetRecentConnectionAlias(remote_id, trimmed); + } + settings_.SaveRecentConnectionAliases(); + reload_recent_connections_ = true; + }); + main->on_recent_delete([this](slint::SharedString id) { + const std::string remote_id(id); + const auto connection = std::find_if( + recent_connections_.begin(), recent_connections_.end(), + [&](const auto &entry) { return entry.second.remote_id == remote_id; }); + if (connection != recent_connections_.end()) { + thumbnail_->DeleteThumbnail(connection->first); + settings_.EraseRecentConnectionAlias(remote_id); + settings_.SaveRecentConnectionAliases(); + reload_recent_connections_ = true; + } + }); + main->on_browse_save_path([this] { + const char *folder = tinyfd_selectFolderDialog( + localization::file_transfer_save_path[localization_language_index_] + .c_str(), + file_transfer_save_path_buf_[0] ? file_transfer_save_path_buf_ : nullptr); + if (folder) { + ui_->main->set_file_save_path(folder); + } + }); + main->on_save_settings([this] { SaveSettingsFromUi(); }); + main->on_cancel_settings([this] { ResetSettingsUi(); }); + main->on_save_self_hosted_settings([this] { + const std::string host(ui_->main->get_server_host()); + const auto signal_port = ParsePort(ui_->main->get_server_port()); + const auto coturn_port = ParsePort(ui_->main->get_coturn_port()); + if (!host.empty()) { + config_center_->SetServerHost(host); + std::memset(signal_server_ip_self_, 0, sizeof(signal_server_ip_self_)); + std::strncpy(signal_server_ip_self_, host.c_str(), + sizeof(signal_server_ip_self_) - 1); + std::memset(signal_server_ip_, 0, sizeof(signal_server_ip_)); + std::strncpy(signal_server_ip_, host.c_str(), + sizeof(signal_server_ip_) - 1); + } + if (signal_port) { + config_center_->SetServerPort(*signal_port); + std::snprintf(signal_server_port_self_, sizeof(signal_server_port_self_), + "%d", *signal_port); + std::snprintf(signal_server_port_, sizeof(signal_server_port_), "%d", + *signal_port); + } + if (coturn_port) { + config_center_->SetCoturnServerPort(*coturn_port); + std::snprintf(coturn_server_port_self_, + sizeof(coturn_server_port_self_), "%d", *coturn_port); + std::snprintf(coturn_server_port_, sizeof(coturn_server_port_), "%d", + *coturn_port); + } + }); + main->on_cancel_self_hosted_settings([this] { + const std::string host = config_center_->GetSignalServerHost(); + ui_->main->set_server_host(UiText(host)); + const int signal_port = config_center_->GetSignalServerPort(); + const int coturn_port = config_center_->GetCoturnServerPort(); + ui_->main->set_server_port( + signal_port > 0 ? UiText(std::to_string(signal_port)) + : slint::SharedString{}); + ui_->main->set_coturn_port( + coturn_port > 0 ? UiText(std::to_string(coturn_port)) + : slint::SharedString{}); + }); + main->on_open_download([this] { OpenUrl("https://crossdesk.cn"); }); + main->on_connection_cancel([this] { + const auto props = FindRemoteSession(ui_->connection_dialog_remote_id); + if (!props) { + show_connection_status_window_ = false; + return; + } + const ConnectionStatus status = props->connection_status_.load(); + if (status == ConnectionStatus::IncorrectPassword) { + std::memset(props->remote_password_, 0, sizeof(props->remote_password_)); + password_validating_ = false; + } else { + CloseStreamTab(props->remote_id_); + re_enter_remote_id_ = true; + } + show_connection_status_window_ = false; + }); + main->on_connection_acknowledge([this] { + const auto props = FindRemoteSession(ui_->connection_dialog_remote_id); + if (props) { + const ConnectionStatus status = props->connection_status_.load(); + if (status == ConnectionStatus::NoSuchTransmissionId || + status == ConnectionStatus::RemoteUnavailable) { + CloseStreamTab(props->remote_id_); + re_enter_remote_id_ = true; + } + } + show_connection_status_window_ = false; + }); + main->on_connection_submit_password( + [this](slint::SharedString value, bool remember) { + const auto props = FindRemoteSession(ui_->connection_dialog_remote_id); + const std::string password(value); + if (!props || password.empty() || password.size() > 6) { + return; + } + std::memset(props->remote_password_, 0, sizeof(props->remote_password_)); + std::memcpy(props->remote_password_, password.data(), password.size()); + props->remember_password_ = remember; + props->rejoin_ = true; + password_validating_ = true; + need_to_rejoin_ = true; + }); + main->on_request_screen_recording_permission([this] { +#ifdef __APPLE__ + OpenScreenRecordingPreferences(); + mac_screen_recording_permission_requested_ = true; + RefreshMacPermissionStatus(true); +#endif + }); + main->on_request_accessibility_permission([this] { +#ifdef __APPLE__ + OpenAccessibilityPreferences(); + mac_accessibility_permission_requested_ = true; + RefreshMacPermissionStatus(true); +#endif + }); + main->on_install_portable_service([this] { +#if _WIN32 && CROSSDESK_PORTABLE + portable_service_do_not_remind_ = false; + ui_->main->set_portable_service_do_not_remind(false); + StartPortableWindowsServiceInstall(); +#endif + }); + main->on_cancel_portable_service([this] { +#if _WIN32 && CROSSDESK_PORTABLE + if (portable_service_install_state_.load(std::memory_order_acquire) == + PortableServiceInstallState::installing) { + return; + } + portable_service_do_not_remind_ = + ui_->main->get_portable_service_do_not_remind(); + if (portable_service_do_not_remind_) { + portable_service_prompt_suppressed_ = true; + config_center_->SetPortableServicePromptSuppressed(true); + show_portable_service_prompt_suppressed_window_ = true; + } + show_portable_service_install_window_ = false; +#endif + }); + main->on_acknowledge_portable_service([this] { +#if _WIN32 && CROSSDESK_PORTABLE + show_portable_service_install_window_ = false; + portable_service_installed_ = + portable_service_install_state_.load(std::memory_order_acquire) == + PortableServiceInstallState::succeeded; + JoinPortableWindowsServiceInstallThread(); +#endif + }); + main->on_acknowledge_portable_service_suppressed([this] { +#if _WIN32 && CROSSDESK_PORTABLE + show_portable_service_prompt_suppressed_window_ = false; +#endif + }); +} + +void GuiApplication::BindStreamCallbacks() { + if (!ui_->stream) { + return; + } + auto &stream = *ui_->stream; + stream->window().on_close_requested([this] { + std::vector ids; + { + std::shared_lock lock(remote_sessions_mutex_); + for (const auto &[id, _] : remote_sessions_) { + ids.push_back(id); + } + } + for (const auto &id : ids) { + CloseStreamTab(id); + } + return slint::CloseRequestResponse::HideWindow; + }); + stream->on_select_tab([this](int index) { SelectStreamTab(index); }); + stream->on_reorder_tab([this](int from, float drop_x, float tab_width) { + ReorderStreamTab(from, drop_x, tab_width); + }); + stream->on_close_tab([this](slint::SharedString id) { + CloseStreamTab(std::string(id)); + }); + stream->on_switch_display([this](int index) { + auto props = SelectedSession(); + if (!props || index < 0 || + index >= static_cast(props->display_info_list_.size())) { + return; + } + props->selected_display_ = index; + RemoteAction action{}; + action.type = ControlType::display_id; + action.d = index; + const std::string message = action.to_json(); + SendReliableDataFrame(props->peer_, message.c_str(), message.size(), + props->control_data_label_.c_str()); + }); + stream->on_send_shortcut([this](slint::SharedString shortcut) { + auto props = SelectedSession(); + if (!props || !props->peer_) { + return; + } + RemoteAction action{}; + action.type = ControlType::service_command; + action.c.flag = std::string(shortcut) == "Ctrl+Alt+Del" + ? ServiceCommandFlag::send_sas + : ServiceCommandFlag::lock_workstation; + const std::string message = action.to_json(); + SendReliableDataFrame(props->peer_, message.c_str(), message.size(), + props->control_data_label_.c_str()); + }); + stream->on_toggle_mouse_control([this] { + auto props = SelectedSession(); + if (!props || !props->connection_established_) { + return; + } + props->control_mouse_ = !props->control_mouse_; + props->enable_mouse_control_ = props->control_mouse_; + start_keyboard_capturer_ = props->control_mouse_; + if (!props->control_mouse_) { + keyboard_.ForceReleasePressedKeys(); + } + }); + stream->on_toggle_audio([this] { + auto props = SelectedSession(); + if (!props || !props->connection_established_) { + return; + } + props->audio_capture_button_pressed_ = + !props->audio_capture_button_pressed_; + RemoteAction action{}; + action.type = ControlType::audio_capture; + action.a = props->audio_capture_button_pressed_; + const std::string message = action.to_json(); + SendReliableDataFrame(props->peer_, message.c_str(), message.size(), + props->control_data_label_.c_str()); + }); + stream->on_select_file([this] { + auto props = SelectedSession(); + if (!props) { + return; + } + transfers_.ProcessSelectedFile( + OpenFileDialog(localization::select_file[localization_language_index_]), + props, props->file_label_); + }); + stream->on_close_file_transfer([this] { + if (auto props = SelectedSession()) { + props->file_transfer_.file_transfer_window_visible_ = false; + } + }); + stream->on_can_drop_file([](const slint::DataTransfer &data) { + return data.has_plain_text(); + }); + stream->on_file_dropped([this](const slint::DataTransfer &data) { + const auto text = data.plain_text(); + auto props = SelectedSession(); + if (!text || !props) { + return; + } + const std::string path = DecodeDroppedPath(std::string(*text)); + if (!path.empty()) { + transfers_.ProcessSelectedFile(path, props, props->file_label_, + props->remote_id_); + } + }); + stream->on_toggle_stats([this] { + if (auto props = SelectedSession()) { + props->net_traffic_stats_button_pressed_ = + !props->net_traffic_stats_button_pressed_; + } + }); + stream->on_toggle_fullscreen([this] { + auto &stream = *ui_->stream; +#if defined(__APPLE__) + const bool enter_fullscreen = !IsStreamWindowFullscreen(); + // Update Slint's fullscreen layout before AppKit applies the new frame so + // the first fullscreen draw cannot briefly contain the windowed tab strip. + stream->set_fullscreen_enabled(enter_fullscreen); + if (!SetStreamWindowFullscreen(enter_fullscreen)) { + stream->set_fullscreen_enabled(!enter_fullscreen); + return; + } +#else + auto &window = stream->window(); + const bool enter_fullscreen = !window.is_fullscreen(); +#endif + fullscreen_button_pressed_ = enter_fullscreen; + +#if !defined(__APPLE__) + stream->set_fullscreen_enabled(enter_fullscreen); + window.request_redraw(); + window.set_fullscreen(enter_fullscreen); +#endif + }); + stream->on_disconnect([this] { + if (auto props = SelectedSession()) { + CloseStreamTab(props->remote_id_); + } + }); + stream->on_pointer_input([this](slint::PointerEventButton button, + slint::PointerEventKind kind, float x, + float y) { + SendPointerInput(static_cast(button), static_cast(kind), x, y); + }); + stream->on_scroll_input([this](float dx, float dy, float x, float y) { + SendScrollInput(dx, dy, x, y); + }); + stream->on_key_input( + [this](slint::SharedString text, bool pressed, bool control, bool alt, + bool shift, bool meta) { + SendKeyInput(std::string(text), pressed, control, alt, shift, meta); + }); +} + +void GuiApplication::BindServerCallbacks() { + if (!ui_->server) { + return; + } + auto &server = *ui_->server; + server->on_title_drag([this](int phase, float x, float y) { + auto &drag = ui_->server_drag; + DragWindow(*ui_->server, phase, x, y, drag); + }); + server->on_toggle_collapsed([this](bool collapsed) { + server_window_collapsed_ = collapsed; + const int language = (*ui_->server)->get_language_index(); + const float width = ServerWindowLogicalWidth(language); + (*ui_->server)->window().set_size( + slint::LogicalSize(slint::Size{ + width, ServerWindowLogicalHeight(collapsed)})); + }); + server->on_controller_selected([this](int index) { + if (index < 0 || index >= static_cast(ui_->controller_ids.size())) { + return; + } + selected_server_remote_id_ = ui_->controller_ids[index]; + }); + server->on_select_file([this] { + if (selected_server_remote_id_.empty()) { + return; + } + transfers_.ProcessSelectedFile( + OpenFileDialog(localization::select_file[localization_language_index_]), + nullptr, file_label_, selected_server_remote_id_); + }); + server->on_disconnect_controller([this] { + if (peer_ && !selected_server_remote_id_.empty()) { + LeaveConnection(peer_, selected_server_remote_id_.c_str()); + } + }); +} + +void GuiApplication::Tick() { + if (exit_) { + slint::quit_event_loop(); + return; + } + if (!peer_) { + CreateConnectionPeer(); + } + + SDL_Event event; +#if defined(__APPLE__) + // Slint's winit backend owns the macOS AppKit event loop. SDL_PollEvent() + // implicitly pumps Cocoa events, so calling it from this Slint timer would + // re-enter winit's event handler and abort the process. Only drain events + // that are already in SDL's queue; clipboard changes are polled below and + // tray/window actions use direct Slint callbacks on macOS. + while (SDL_PeepEvents(&event, 1, SDL_GETEVENT, SDL_EVENT_FIRST, + SDL_EVENT_LAST) > 0) { +#else + while (SDL_PollEvent(&event)) { +#endif + if (event.type == SDL_EVENT_QUIT) { + exit_ = true; + } else if (event.type == SDL_EVENT_CLIPBOARD_UPDATE) { + clipboard_.HandleLocalUpdate(); + } + } + if (ui_->capture_mode && + (ui_->capture_page == "stream" || ui_->capture_page == "server")) { + return; + } + clipboard_.ApplyPendingRemoteText(); +#if defined(__linux__) && !defined(__APPLE__) + if (ui_->tray) { + ui_->tray->ProcessEvents(); + } +#endif + const auto now = std::chrono::steady_clock::now(); + if (now - ui_->last_clipboard_poll >= 250ms) { + clipboard_.HandleLocalUpdate(); + ui_->last_clipboard_poll = now; + } + + if (need_to_rejoin_ && now - last_rejoin_check_time_ >= 1s) { + last_rejoin_check_time_ = now; + need_to_rejoin_ = false; + for (const auto &[_, props] : remote_sessions_) { + if (props && props->rejoin_) { + ConnectTo(props->remote_id_, props->remote_password_, + props->remember_password_); + } + } + } + + UpdateLabels(); + const bool was_reloading_recent_connections = reload_recent_connections_; + HandleRecentConnections(); + if (was_reloading_recent_connections && !reload_recent_connections_) { + ui_->recent_model_signature.clear(); + } + HandleConnectionStatusChange(); + HandlePendingPresenceProbe(); + HandleConnectionTimeouts(); + HandleWindowsServiceIntegration(); + devices_.UpdateInteractions(); + + UpdateLocalization(); + SyncMainWindow(); + SyncConnectionDialog(); + SyncPlatformDialogs(); + SyncStreamWindow(); + SyncServerWindow(); +} + +void GuiApplication::UpdateLocalization() { + const int language = + localization::detail::ClampLanguageIndex(localization_language_index_); + if (!ui_ || ui_->localized_language == language) { + return; + } + auto &strings = ui_->main->global(); + strings.set_local_desktop(UiText(localization::local_desktop[language])); + strings.set_remote_desktop(UiText(localization::remote_desktop[language])); + strings.set_recent_connections( + UiText(localization::recent_connections[language])); + strings.set_local_id(UiText(localization::local_id[language])); + strings.set_device_name(UiText(localization::device_name[language])); + strings.set_remote_id(UiText(localization::remote_id[language])); + strings.set_online(UiText(localization::online[language])); + strings.set_offline(UiText(localization::offline[language])); + strings.set_password(UiText(localization::password[language])); + strings.set_copied( + UiText(localization::local_id_copied_to_clipboard[language])); + strings.set_connect(UiText(localization::connect[language])); + strings.set_settings(UiText(localization::settings[language])); + strings.set_about(UiText(localization::about[language])); + strings.set_ok(UiText(localization::ok[language])); + strings.set_cancel(UiText(localization::cancel[language])); + strings.set_new_password(UiText(localization::new_password[language])); + strings.set_invalid_password(UiText(localization::max_password_len[language])); + strings.set_edit_alias( + UiText(localization::input_connection_alias[language])); + strings.set_delete_connection( + UiText(localization::delete_connection[language])); + strings.set_confirm_delete( + UiText(localization::confirm_delete_connection[language])); + strings.set_language(UiText(localization::language[language])); + strings.set_video_quality(UiText(localization::video_quality[language])); + strings.set_frame_rate(UiText(localization::video_frame_rate[language])); + strings.set_codec(UiText(localization::video_encode_format[language])); + strings.set_hardware_codec( + UiText(localization::enable_hardware_video_codec[language])); + strings.set_turn_relay(UiText(localization::enable_turn[language])); + strings.set_srtp(UiText(localization::enable_srtp[language])); + strings.set_self_hosted( + UiText(localization::self_hosted_server_config[language])); + strings.set_autostart(UiText(localization::enable_autostart[language])); + strings.set_daemon(UiText(localization::enable_daemon[language])); + strings.set_minimize_to_tray( + UiText(localization::minimize_to_tray[language])); + strings.set_file_save_path( + UiText(localization::file_transfer_save_path[language])); + strings.set_default_desktop(UiText(localization::default_desktop[language])); + strings.set_server_host( + UiText(localization::self_hosted_server_address[language])); + strings.set_server_port( + UiText(localization::self_hosted_server_port[language])); + strings.set_coturn_port( + UiText(localization::self_hosted_server_coturn_server_port[language])); + strings.set_version(UiText(localization::version[language])); + strings.set_signal_connected( + UiText(localization::signal_connected[language])); + strings.set_signal_disconnected( + UiText(localization::signal_disconnected[language])); + strings.set_tls_error(UiText(localization::signal_tls_cert_error[language])); + strings.set_update_available( + UiText(localization::new_version_available[language])); + strings.set_download(UiText(localization::update[language])); + strings.set_input_password(UiText(localization::input_password[language])); + strings.set_reinput_password( + UiText(localization::reinput_password[language])); + strings.set_remember_password( + UiText(localization::remember_password[language])); + strings.set_validate_password( + UiText(localization::validate_password[language])); + strings.set_request_permissions( + UiText(localization::request_permissions[language])); + strings.set_permission_required( + UiText(localization::permission_required_message[language])); + strings.set_screen_recording_permission( + UiText(localization::screen_recording_permission[language])); + strings.set_accessibility_permission( + UiText(localization::accessibility_permission[language])); + strings.set_service_setup_title( + UiText(localization::windows_service_setup_title[language])); + strings.set_service_setup_message( + UiText(localization::windows_service_setup_message[language])); + strings.set_service_settings_label( + UiText(localization::windows_service_settings_label[language])); + strings.set_install_service( + UiText(localization::install_windows_service[language])); + strings.set_service_installed( + UiText(localization::windows_service_installed[language])); + strings.set_do_not_remind( + UiText(localization::do_not_remind_again[language])); + strings.set_notification(UiText(localization::notification[language])); + strings.set_service_suppressed_message( + UiText(localization::windows_service_prompt_suppressed_message[language])); + strings.set_quality_low(UiText(localization::video_quality_low[language])); + strings.set_quality_medium( + UiText(localization::video_quality_medium[language])); + strings.set_quality_high(UiText(localization::video_quality_high[language])); + strings.set_codec_h264(UiText(localization::h264[language])); + strings.set_codec_av1(UiText(localization::av1[language])); + strings.set_self_hosted_settings( + UiText(localization::self_hosted_server_settings[language])); + strings.set_access_website(UiText(localization::access_website[language])); + strings.set_release_date_label(UiText(localization::release_date[language])); + strings.set_later(UiText(localization::cancel[language])); + const auto apply_stream_strings = [language](auto &stream_strings) { + stream_strings.set_select_display( + UiText(localization::select_display[language])); + stream_strings.set_send_shortcut( + UiText(localization::send_shortcut[language])); + stream_strings.set_control_mouse( + UiText(localization::control_mouse[language])); + stream_strings.set_release_mouse( + UiText(localization::release_mouse[language])); + stream_strings.set_audio(UiText(localization::audio_capture[language])); + stream_strings.set_mute(UiText(localization::mute[language])); + stream_strings.set_select_file( + UiText(localization::select_file[language])); + stream_strings.set_show_stats( + UiText(localization::show_net_traffic_stats[language])); + stream_strings.set_hide_stats( + UiText(localization::hide_net_traffic_stats[language])); + stream_strings.set_fullscreen(UiText(localization::fullscreen[language])); + stream_strings.set_exit_fullscreen( + UiText(localization::exit_fullscreen[language])); + stream_strings.set_disconnect( + UiText(localization::disconnect[language])); + stream_strings.set_file_transfer( + UiText(localization::file_transfer_progress[language])); + stream_strings.set_expand_control( + UiText(localization::expand_control_bar[language])); + stream_strings.set_collapse_control( + UiText(localization::collapse_control_bar[language])); + stream_strings.set_stats_in(UiText(localization::in[language])); + stream_strings.set_stats_out(UiText(localization::out[language])); + stream_strings.set_stats_loss_rate( + UiText(localization::loss_rate[language])); + stream_strings.set_stats_resolution( + UiText(localization::resolution[language])); + stream_strings.set_stats_connection_mode( + UiText(localization::connection_mode[language])); + }; + auto &main_stream_strings = ui_->main->global(); + apply_stream_strings(main_stream_strings); + if (ui_->stream) { + auto &active_stream_strings = + (*ui_->stream)->global(); + apply_stream_strings(active_stream_strings); + } + ui_->localized_language = language; +} + +void GuiApplication::SyncMainWindow() { + if (!ui_) { + return; + } +#if defined(__APPLE__) + if (ui_->main_native_titlebar_attempts > 0) { + if (HideDisabledMainWindowZoomButton()) { + ui_->main_native_titlebar_attempts = 0; + } else { + --ui_->main_native_titlebar_attempts; + } + } +#endif + if (re_enter_remote_id_) { + ui_->main->invoke_reset_remote_id(); + re_enter_remote_id_ = false; + } + ui_->main->set_local_id(UiText(FormatPeerId(client_id_))); + ui_->main->set_local_password(password_saved_); + ui_->main->set_password_visible(show_password_); + ui_->main->set_signal_connected(signal_connected_); + ui_->main->set_signal_tls_error( + signal_status_ == SignalStatus::SignalTlsCertError); + ui_->main->set_update_available(update_available_); + ui_->main->set_current_version(CROSSDESK_VERSION); + ui_->main->set_latest_version(UiText(latest_version_)); + ui_->main->set_release_name(UiText(release_name_)); + ui_->main->set_release_notes(UiText(CleanReleaseNotesForUi(release_notes_))); + ui_->main->set_release_date(UiText(release_date_)); + ui_->main->set_settings_session_active(stream_window_inited_); +#if (((defined(_WIN32) || defined(__linux__)) && !defined(__aarch64__) && \ + !defined(__arm__) && USE_CUDA) || \ + defined(__APPLE__)) + ui_->main->set_hardware_codec_visible(true); +#else + ui_->main->set_hardware_codec_visible(false); #endif - UpdateLabels(); - HandleRecentConnections(); - HandleConnectionStatusChange(); - HandlePendingPresenceProbe(); - HandleConnectionTimeouts(); - HandleStreamWindow(); - HandleServerWindow(); - HandleWindowsServiceIntegration(); + if (show_offline_warning_window_) { + ui_->main->set_offline_warning(UiText(offline_warning_text_)); + show_offline_warning_window_ = false; + } - const bool main_window_visible = - main_window_ && !(SDL_GetWindowFlags(main_window_) & SDL_WINDOW_HIDDEN); - if (main_window_visible) { - DrawMainWindow(); - } - if (stream_window_inited_) { - DrawStreamWindow(); - } + std::ostringstream signature_builder; + for (const auto &[key, connection] : recent_connections_) { + signature_builder << key << '\0' << connection.remote_id << '\0' + << connection.remote_host_name << '\0' + << settings_.RecentConnectionDisplayName(connection) + << '\0' + << device_presence_cache_.IsOnline(connection.remote_id) + << '\n'; + } + const std::string signature = signature_builder.str(); + if (ui_->recent_model_signature == signature) { + return; + } + ui_->recent_model_signature = signature; - if (is_server_mode_) { - DrawServerWindow(); + std::vector model; + model.reserve(recent_connections_.size()); + for (const auto &[_, connection] : recent_connections_) { + ui::RecentConnection item; + item.remote_id = UiText(connection.remote_id); + item.host_name = UiText(connection.remote_host_name); + item.display_name = + UiText(settings_.RecentConnectionDisplayName(connection)); + item.online = device_presence_cache_.IsOnline(connection.remote_id); + std::vector rgba; + int image_width = 0; + int image_height = 0; + if (!connection.image_path.empty() && + thumbnail_->DecodeImage(connection.image_path, &rgba, &image_width, + &image_height)) { + slint::SharedPixelBuffer pixels(image_width, + image_height); + std::memcpy(pixels.begin(), rgba.data(), rgba.size()); + item.thumbnail = slint::Image(std::move(pixels)); } + model.push_back(std::move(item)); + } + ui_->recent_model->set_vector(std::move(model)); +} - devices_.UpdateInteractions(); +void GuiApplication::SyncConnectionDialog() { +#ifdef CROSSDESK_DEBUG + if (ui_ && ui_->capture_mode && + (ui_->capture_page == "connection" || + ui_->capture_page == "connection-password")) { + return; + } +#endif + if (!ui_ || !show_connection_status_window_) { + if (ui_) { + ui_->main->set_connection_dialog_open(false); + ui_->connection_password_initialized = false; + } + return; + } + + auto props = FindRemoteSession(focused_remote_id_); + if (!props) { + ui_->main->set_connection_dialog_open(false); + return; + } + + if (ui_->connection_dialog_remote_id != props->remote_id_) { + ui_->connection_password_initialized = false; + } + ui_->connection_dialog_remote_id = props->remote_id_; + const ConnectionStatus status = props->connection_status_.load(); + const bool password_required = status == ConnectionStatus::IncorrectPassword; + const bool pending = status == ConnectionStatus::Connecting || + status == ConnectionStatus::Gathering; + std::string text; + if (password_required) { + text = password_validating_ + ? localization::validate_password[localization_language_index_] + : password_validating_time_ <= 1 + ? localization::input_password[localization_language_index_] + : localization::reinput_password[localization_language_index_]; + } else { + text = ConnectionStatusText(status, localization_language_index_); + } + + ui_->main->set_connection_dialog_open(true); + ui_->main->set_connection_status_text(UiText(text)); + ui_->main->set_connection_pending(pending); + ui_->main->set_connection_password_required(password_required); + ui_->main->set_connection_validating(password_validating_); + if (password_required && !password_validating_ && + !ui_->connection_password_initialized) { + ui_->main->set_connection_password(props->remote_password_); + ui_->main->set_connection_remember_password(props->remember_password_); + ui_->connection_password_initialized = true; + } else if (!password_required) { + ui_->connection_password_initialized = false; } } -void GuiApplication::HandleStreamWindow() { - if (need_to_create_stream_window_) { - CreateStreamWindow(); +void GuiApplication::SyncPlatformDialogs() { +#ifdef CROSSDESK_DEBUG + if (ui_ && ui_->capture_mode && + (ui_->capture_page == "service" || + ui_->capture_page == "service-notice")) { + return; + } +#endif +#if _WIN32 && CROSSDESK_PORTABLE + CheckPortableWindowsService(); + const PortableServiceInstallState state = + portable_service_install_state_.load(std::memory_order_acquire); + if (state == PortableServiceInstallState::succeeded) { + portable_service_installed_ = true; + } + ui_->main->set_portable_service_settings_visible(true); + ui_->main->set_portable_service_dialog_open( + show_portable_service_install_window_); + ui_->main->set_portable_service_suppressed_notice_open( + show_portable_service_prompt_suppressed_window_); + ui_->main->set_portable_service_installed(portable_service_installed_); + ui_->main->set_portable_service_installing( + state == PortableServiceInstallState::installing); + ui_->main->set_portable_service_succeeded( + state == PortableServiceInstallState::succeeded); + const std::string *status = nullptr; + if (state == PortableServiceInstallState::installing) { + status = &localization::installing_windows_service + [localization_language_index_]; + } else if (state == PortableServiceInstallState::succeeded) { + status = &localization::windows_service_install_success + [localization_language_index_]; + } else if (state == PortableServiceInstallState::failed) { + status = &localization::windows_service_install_failed + [localization_language_index_]; + } + ui_->main->set_portable_service_status( + status ? UiText(*status) : slint::SharedString{}); + if (show_portable_service_install_window_) { + if (!ui_->portable_service_dialog_initialized) { + ui_->main->set_portable_service_do_not_remind( + portable_service_do_not_remind_); + ui_->portable_service_dialog_initialized = true; + } + } else { + ui_->portable_service_dialog_initialized = false; + } +#else + ui_->main->set_portable_service_settings_visible(false); + ui_->main->set_portable_service_dialog_open(false); + ui_->main->set_portable_service_suppressed_notice_open(false); +#endif +#ifdef __APPLE__ + if (ui_->capture_mode) { + const bool show_permission = ui_->capture_page == "permission"; + ui_->main->set_permission_dialog_open(show_permission); + ui_->main->set_screen_recording_granted(!show_permission); + ui_->main->set_accessibility_granted(!show_permission); + return; + } + RefreshMacPermissionStatus(false); + show_request_permission_window_ = + !mac_screen_recording_permission_granted_ || + !mac_accessibility_permission_granted_; + ui_->main->set_permission_dialog_open(show_request_permission_window_); + ui_->main->set_screen_recording_granted( + mac_screen_recording_permission_granted_); + ui_->main->set_accessibility_granted( + mac_accessibility_permission_granted_); +#else + ui_->main->set_permission_dialog_open(false); +#endif +} + +void GuiApplication::SyncStreamWindow() { + bool has_sessions = false; + { + std::shared_lock lock(remote_sessions_mutex_); + has_sessions = !remote_sessions_.empty(); + } + if (need_to_create_stream_window_ && !ui_->stream) { + ui_->stream.emplace(ui::StreamWindow::create()); + RegisterFontAwesome((*ui_->stream)->window()); + // Slint globals belong to a component tree. Force the next localization + // pass to initialize the newly-created, independent stream window tree. + ui_->localized_language = -1; + (*ui_->stream)->set_tabs(ui_->tab_model); + (*ui_->stream)->set_displays(ui_->display_model); + (*ui_->stream)->set_file_transfers(ui_->transfer_model); + (*ui_->stream)->set_stats_rows(ui_->stats_model); + BindStreamCallbacks(); + (*ui_->stream)->show(); + // Repeat after the next layout pass because the backend resolves the + // initial DPI and preferred size asynchronously on some platforms. + ui_->stream_initial_position_attempts = 2; +#if defined(__APPLE__) + ui_->stream_live_resize_configuration_attempts = 30; +#endif + stream_window_created_ = true; + stream_window_inited_ = true; need_to_create_stream_window_ = false; } + if (!ui_->stream) { + return; + } + (*ui_->stream)->set_srtp_enabled(enable_srtp_); +#if defined(__APPLE__) + if (ui_->stream_live_resize_configuration_attempts > 0) { + if (ConfigureStreamWindowLiveResize()) { + ui_->stream_live_resize_configuration_attempts = 0; + } else { + --ui_->stream_live_resize_configuration_attempts; + } + } +#endif + if (ui_->stream_initial_position_attempts > 0) { + if (PositionWindowAtCenter((*ui_->stream)->window(), ui_->main->window(), + kStreamWindowLogicalWidth, + kStreamWindowLogicalHeight)) { + --ui_->stream_initial_position_attempts; + } else { + ui_->stream_initial_position_attempts = 0; + } + } + if (!has_sessions) { +#if defined(__APPLE__) + SetStreamWindowFullscreen(false); +#endif + (*ui_->stream)->hide(); + ui_->stream.reset(); + stream_window_created_ = false; + stream_window_inited_ = false; + ui_->stream_initial_position_attempts = 0; + ui_->stream_live_resize_configuration_attempts = 0; + focused_remote_id_.clear(); + controlled_remote_id_.clear(); + ui_->tab_order.clear(); + ui_->tab_ids.clear(); + ui_->tab_model_signature.clear(); + return; + } - if (stream_window_inited_) { - if (!stream_window_grabbed_ && control_mouse_) { - SDL_SetWindowMouseGrab(stream_window_, true); - stream_window_grabbed_ = true; - } else if (stream_window_grabbed_ && !control_mouse_) { - SDL_SetWindowMouseGrab(stream_window_, false); - stream_window_grabbed_ = false; + std::vector tabs; + std::unordered_map tabs_by_id; + { + std::shared_lock lock(remote_sessions_mutex_); + tabs_by_id.reserve(remote_sessions_.size()); + for (const auto &[id, props] : remote_sessions_) { + if (!props || !props->tab_opened_) { + continue; + } + ui::StreamTab tab; + tab.remote_id = UiText(id); + tab.title = UiText(props->remote_host_name_.empty() + ? id + : props->remote_host_name_); + tab.connected = props->connection_status_.load() == + ConnectionStatus::Connected; + tabs_by_id.emplace(id, std::move(tab)); + } + } + std::erase_if(ui_->tab_order, [&tabs_by_id](const std::string &id) { + return !tabs_by_id.contains(id); + }); + for (const auto &[id, _] : tabs_by_id) { + if (std::find(ui_->tab_order.begin(), ui_->tab_order.end(), id) == + ui_->tab_order.end()) { + ui_->tab_order.push_back(id); + } + } + ui_->tab_ids = ui_->tab_order; + tabs.reserve(ui_->tab_ids.size()); + std::ostringstream tab_signature_builder; + for (const auto &id : ui_->tab_ids) { + if (auto found = tabs_by_id.find(id); found != tabs_by_id.end()) { + tab_signature_builder << id << '\0' << std::string(found->second.title) + << '\0' << found->second.connected << '\n'; + tabs.push_back(std::move(found->second)); + } + } + const std::string tab_signature = tab_signature_builder.str(); + if (ui_->tab_model_signature != tab_signature) { + ui_->tab_model_signature = tab_signature; + ui_->tab_model->set_vector(std::move(tabs)); + } + + int selected_index = 0; + if (focused_remote_id_.empty() && !ui_->tab_ids.empty()) { + focused_remote_id_ = ui_->tab_ids.front(); + } + for (int i = 0; i < static_cast(ui_->tab_ids.size()); ++i) { + if (ui_->tab_ids[i] == focused_remote_id_) { + selected_index = i; + break; + } + } + (*ui_->stream)->set_selected_tab(selected_index); + SelectStreamTab(selected_index); + + auto props = SelectedSession(); + if (!props) { + return; + } + const ConnectionStatus status = props->connection_status_.load(); + // Once the peer is connected, the frame-waiting hint replaces the + // connection status. Keeping these states mutually exclusive also avoids a + // transient overlap when both properties are synchronized in one tick. + (*ui_->stream)->set_status_text(UiText( + status == ConnectionStatus::Connected + ? std::string{} + : ConnectionStatusText(status, localization_language_index_))); + (*ui_->stream)->set_receiving_text( + UiText(status == ConnectionStatus::Connected + ? localization::receiving_screen[localization_language_index_] + : std::string{})); + (*ui_->stream)->set_mouse_control_enabled(props->control_mouse_); + (*ui_->stream)->set_audio_enabled(props->audio_capture_button_pressed_); +#if defined(__APPLE__) + fullscreen_button_pressed_ = IsStreamWindowFullscreen(); +#else + fullscreen_button_pressed_ = (*ui_->stream)->window().is_fullscreen(); +#endif + (*ui_->stream)->set_fullscreen_enabled(fullscreen_button_pressed_); + (*ui_->stream)->set_stats_visible( + props->net_traffic_stats_button_pressed_); + + std::vector displays; + displays.reserve(props->display_info_list_.size()); + for (const auto &display : props->display_info_list_) { + displays.emplace_back(UiText(display.name)); + } + ui_->display_model->set_vector(std::move(displays)); + (*ui_->stream)->set_selected_display(props->selected_display_); + + const auto &net = props->net_traffic_stats_; + std::vector stats_rows; + stats_rows.reserve(4); + const auto append_stats_row = [&](const std::string &label, + const auto &inbound, + const auto &outbound) { + ui::NetworkStatsRow row; + row.label = UiText(label); + row.inbound = UiText(FormatBitrate(inbound.bitrate)); + row.outbound = UiText(FormatBitrate(outbound.bitrate)); + row.loss_rate = UiText(FormatLossRate(inbound.loss_rate)); + stats_rows.push_back(std::move(row)); + }; + append_stats_row(localization::video[localization_language_index_], + net.video_inbound_stats, net.video_outbound_stats); + append_stats_row(localization::audio[localization_language_index_], + net.audio_inbound_stats, net.audio_outbound_stats); + append_stats_row(localization::data[localization_language_index_], + net.data_inbound_stats, net.data_outbound_stats); + append_stats_row(localization::total[localization_language_index_], + net.total_inbound_stats, net.total_outbound_stats); + ui_->stats_model->set_vector(std::move(stats_rows)); + (*ui_->stream)->set_stats_fps(UiText(std::to_string(props->fps_))); + (*ui_->stream)->set_stats_resolution(UiText( + std::to_string(props->video_width_) + "x" + + std::to_string(props->video_height_))); + (*ui_->stream)->set_stats_connection_mode(UiText( + props->traversal_mode_ == TraversalMode::P2P + ? localization::connection_mode_direct[localization_language_index_] + : localization::connection_mode_relay[localization_language_index_])); + + std::vector file_list; + { + std::lock_guard lock(props->file_transfer_.file_transfer_list_mutex_); + file_list = props->file_transfer_.file_transfer_list_; + } + const auto transfer_priority = [](RemoteSession::FileTransferStatus status) { + switch (status) { + case RemoteSession::FileTransferStatus::Sending: + return 0; + case RemoteSession::FileTransferStatus::Completed: + return 1; + case RemoteSession::FileTransferStatus::Queued: + return 2; + case RemoteSession::FileTransferStatus::Failed: + return 3; + } + return 3; + }; + std::stable_sort(file_list.begin(), file_list.end(), + [&](const auto &left, const auto &right) { + return transfer_priority(left.status) < + transfer_priority(right.status); + }); + + std::vector transfers; + transfers.reserve(file_list.size()); + for (const auto &info : file_list) { + ui::FileTransferEntry item; + item.name = UiText(info.file_name); + const auto status_index = static_cast(info.status); + if (status_index == 0) + item.status = UiText(localization::queued[localization_language_index_]); + if (status_index == 1) + item.status = UiText(localization::sending[localization_language_index_]); + if (status_index == 2) + item.status = UiText(localization::completed[localization_language_index_]); + if (status_index == 3) + item.status = UiText(localization::failed[localization_language_index_]); + item.progress = status_index == 2 + ? 1.0f + : info.file_size == 0 + ? 0.0f + : std::clamp( + static_cast(info.sent_bytes) / + static_cast(info.file_size), + 0.0f, 1.0f); + item.speed = UiText(FormatTransferRate(info.rate_bps)); + item.size = UiText(FormatFileSize(info.file_size)); + transfers.push_back(std::move(item)); + } + ui_->transfer_model->set_vector(std::move(transfers)); + (*ui_->stream)->set_file_transfer_visible( + props->file_transfer_.file_transfer_window_visible_); + + std::shared_ptr> frame; + int width = 0; + int height = 0; + uint64_t sequence = 0; + { + std::lock_guard lock(props->video_frame_mutex_); + frame = props->front_frame_; + width = props->video_width_; + height = props->video_height_; + sequence = props->video_frame_sequence_; + } + const size_t nv12_size = static_cast(width) * height * 3 / 2; + if (frame && width > 0 && height > 0 && frame->size() >= nv12_size && + ui_->displayed_frame_sequence[props->remote_id_] != sequence) { + slint::SharedPixelBuffer pixels(width, height); + const int result = libyuv::NV12ToRAW( + frame->data(), width, frame->data() + static_cast(width) * height, + width, reinterpret_cast(pixels.begin()), width * 3, width, + height); + if (result == 0) { + (*ui_->stream)->set_frame(slint::Image(std::move(pixels))); + (*ui_->stream)->set_has_frame(true); + (*ui_->stream)->set_receiving_text(""); + ui_->displayed_frame_sequence[props->remote_id_] = sequence; + } + } else if (!frame) { + (*ui_->stream)->set_has_frame(false); + } +} + +void GuiApplication::SyncServerWindow() { + if (need_to_create_server_window_ && !ui_->server) { + ui_->server.emplace(ui::ServerWindow::create()); + RegisterFontAwesome((*ui_->server)->window()); + (*ui_->server)->set_controllers(ui_->controller_model); + (*ui_->server)->set_controller_names(ui_->controller_name_model); + (*ui_->server)->set_language_index(localization_language_index_); + server_window_collapsed_ = false; + (*ui_->server)->window().set_size(slint::LogicalSize(slint::Size{ + ServerWindowLogicalWidth(localization_language_index_), + ServerWindowLogicalHeight(false)})); + BindServerCallbacks(); + (*ui_->server)->show(); + // Apply once immediately and once after the next layout pass. This keeps + // the right and bottom edges correct when the backend resolves DPI and + // language-dependent preferred width asynchronously on first show. + ui_->server_initial_position_attempts = 2; + server_window_created_ = true; + server_window_inited_ = true; + need_to_create_server_window_ = false; + } + if (need_to_destroy_server_window_ && ui_->server) { + (*ui_->server)->hide(); + ui_->server.reset(); + server_window_created_ = false; + server_window_inited_ = false; + ui_->server_initial_position_attempts = 0; + need_to_destroy_server_window_ = false; + } + if (!ui_->server) { + return; + } + + std::vector controllers; + std::vector names; + ui_->controller_ids.clear(); + { + std::shared_lock lock(connection_status_mutex_); + controllers.reserve(connection_status_.size()); + for (const auto &[id, _] : connection_status_) { + const auto host = connection_host_names_.find(id); + const std::string name = + host != connection_host_names_.end() && !host->second.empty() + ? host->second + : id; + ui::ControllerEntry entry; + entry.remote_id = UiText(id); + entry.display_name = UiText(name); + controllers.push_back(entry); + names.emplace_back(UiText(name)); + ui_->controller_ids.push_back(id); + } + } + ui_->controller_model->set_vector(std::move(controllers)); + ui_->controller_name_model->set_vector(std::move(names)); + if (selected_server_remote_id_.empty() && !ui_->controller_ids.empty()) { + selected_server_remote_id_ = ui_->controller_ids.front(); + } + int selected = 0; + for (int i = 0; i < static_cast(ui_->controller_ids.size()); ++i) { + if (ui_->controller_ids[i] == selected_server_remote_id_) { + selected = i; + break; + } + } + (*ui_->server)->set_selected_controller(selected); + (*ui_->server)->set_language_index(localization_language_index_); + if (ui_->server_initial_position_attempts > 0) { + const float server_width = + ServerWindowLogicalWidth(localization_language_index_); + const float server_height = + ServerWindowLogicalHeight(server_window_collapsed_); + (*ui_->server)->window().set_size(slint::LogicalSize( + slint::Size{server_width, server_height})); + if (PositionWindowAtBottomRight((*ui_->server)->window(), server_width, + server_height)) { + --ui_->server_initial_position_attempts; + } else { + ui_->server_initial_position_attempts = 0; + } + } + (*ui_->server)->set_controller_label( + UiText(localization::controller[localization_language_index_])); + (*ui_->server)->set_connection_label( + UiText(localization::connection_status[localization_language_index_])); + (*ui_->server)->set_file_transfer_label( + UiText(localization::file_transfer[localization_language_index_])); + (*ui_->server)->set_select_file_label( + UiText(localization::select_file[localization_language_index_])); + + ConnectionStatus status = ConnectionStatus::Closed; + { + std::shared_lock lock(connection_status_mutex_); + if (const auto found = connection_status_.find(selected_server_remote_id_); + found != connection_status_.end()) { + status = found->second; + } + } + (*ui_->server)->set_connection_status(UiText( + ConnectionStatusText(status, localization_language_index_))); + + auto &transfer = transfers_.global_state(); + const auto sent = transfer.file_sent_bytes_.load(); + const auto total = transfer.file_total_bytes_.load(); + (*ui_->server)->set_file_transfer_visible( + transfer.file_transfer_window_visible_); + (*ui_->server)->set_sending_file(transfer.file_sending_.load()); + (*ui_->server)->set_file_progress( + total == 0 ? 0.0f + : std::clamp(static_cast(sent) / + static_cast(total), + 0.0f, 1.0f)); + (*ui_->server)->set_file_progress_text( + UiText(FormatTransferRate(transfer.file_send_rate_bps_.load()))); + std::string current_file_name; + const uint32_t current_file_id = transfer.current_file_id_.load(); + if (current_file_id != 0) { + std::lock_guard lock(transfer.file_transfer_list_mutex_); + for (const auto &info : transfer.file_transfer_list_) { + if (info.file_id == current_file_id) { + current_file_name = info.file_name; + break; + } + } + } + (*ui_->server)->set_current_file_name(UiText(current_file_name)); + (*ui_->server)->set_file_size_text( + total == 0 ? slint::SharedString{} + : UiText(FormatFileSize(sent) + " / " + + FormatFileSize(total))); +} + +void GuiApplication::SaveSettingsFromUi() { + auto &main = ui_->main; + language_button_value_ = localization::detail::ClampLanguageIndex( + main->get_language_index()); + video_quality_button_value_ = + std::clamp(main->get_video_quality_index(), 0, 2); + video_frame_rate_button_value_ = + std::clamp(main->get_frame_rate_index(), 0, 1); + video_encode_format_button_value_ = + std::clamp(main->get_codec_index(), 0, 1); + enable_hardware_video_codec_ = main->get_hardware_codec_enabled(); + enable_turn_ = main->get_turn_enabled(); + enable_srtp_ = main->get_srtp_enabled(); + enable_self_hosted_ = main->get_self_hosted_enabled(); + enable_autostart_ = main->get_autostart_enabled(); + enable_daemon_ = main->get_daemon_enabled(); + enable_minimize_to_tray_ = main->get_minimize_to_tray_enabled(); + + localization_language_ = + static_cast(language_button_value_); + localization_language_index_ = language_button_value_; + config_center_->SetLanguage(localization_language_); + config_center_->SetVideoQuality( + static_cast(video_quality_button_value_)); + config_center_->SetVideoFrameRate(static_cast( + video_frame_rate_button_value_)); + config_center_->SetVideoEncodeFormat( + static_cast( + video_encode_format_button_value_)); + config_center_->SetHardwareVideoCodec(enable_hardware_video_codec_); + config_center_->SetTurn(enable_turn_); + config_center_->SetSrtp(enable_srtp_); + config_center_->SetSelfHosted(enable_self_hosted_); + config_center_->SetAutostart(enable_autostart_); + config_center_->SetDaemon(enable_daemon_); + config_center_->SetMinimizeToTray(enable_minimize_to_tray_); + + const std::string path(main->get_file_save_path()); + std::memset(file_transfer_save_path_buf_, 0, + sizeof(file_transfer_save_path_buf_)); + std::strncpy(file_transfer_save_path_buf_, path.c_str(), + sizeof(file_transfer_save_path_buf_) - 1); + config_center_->SetFileTransferSavePath(path); + + const std::string host(main->get_server_host()); + if (!host.empty()) { + config_center_->SetServerHost(host); + } + if (const auto port = ParsePort(main->get_server_port())) { + config_center_->SetServerPort(*port); + } + if (const auto port = ParsePort(main->get_coturn_port())) { + config_center_->SetCoturnServerPort(*port); + } + + language_button_value_last_ = language_button_value_; + video_quality_button_value_last_ = video_quality_button_value_; + video_frame_rate_button_value_last_ = video_frame_rate_button_value_; + video_encode_format_button_value_last_ = + video_encode_format_button_value_; + enable_hardware_video_codec_last_ = enable_hardware_video_codec_; + enable_turn_last_ = enable_turn_; + enable_srtp_last_ = enable_srtp_; + enable_self_hosted_last_ = enable_self_hosted_; + enable_autostart_last_ = enable_autostart_; + enable_daemon_last_ = enable_daemon_; + enable_minimize_to_tray_last_ = enable_minimize_to_tray_; + file_transfer_save_path_last_ = path; + ui_->localized_language = -1; + + if (!stream_window_inited_) { + CloseAllRemoteSessions(); + CreateConnectionPeer(); + } +} + +void GuiApplication::ConnectFromUi(const std::string &input) { + const std::string remote_id = CompactPeerId(input); + if (remote_id.empty()) { + return; + } + if (auto existing = FindRemoteSession(remote_id); + existing && existing->connection_established_) { + focused_remote_id_ = remote_id; + need_to_create_stream_window_ = true; + return; + } + + for (const auto &[_, connection] : recent_connections_) { + if (connection.remote_id == remote_id) { + ConnectTo(remote_id, connection.password.c_str(), + connection.remember_password); + return; + } + } + ConnectTo(remote_id, "", false); +} + +void GuiApplication::SelectStreamTab(int index) { + if (!ui_ || index < 0 || index >= static_cast(ui_->tab_ids.size())) { + return; + } + focused_remote_id_ = ui_->tab_ids[index]; + controlled_remote_id_ = focused_remote_id_; + std::shared_lock lock(remote_sessions_mutex_); + for (auto &[id, props] : remote_sessions_) { + if (props) { + props->tab_selected_ = id == focused_remote_id_; } } } -void GuiApplication::HandleServerWindow() { - if (need_to_create_server_window_) { - CreateServerWindow(); - need_to_create_server_window_ = false; +void GuiApplication::ReorderStreamTab(int from, float drop_x, + float tab_width) { + if (!ui_ || from < 0 || + from >= static_cast(ui_->tab_order.size()) || tab_width <= 0.0f) { + return; + } + const float slot_width = tab_width + 1.0f; + const int target = std::clamp(static_cast(std::floor(drop_x / slot_width)), + 0, + static_cast(ui_->tab_order.size()) - 1); + if (target == from) { + return; + } + std::string moved = std::move(ui_->tab_order[from]); + ui_->tab_order.erase(ui_->tab_order.begin() + from); + ui_->tab_order.insert(ui_->tab_order.begin() + target, std::move(moved)); + ui_->tab_ids = ui_->tab_order; + SelectStreamTab(target); +} + +void GuiApplication::CloseStreamTab(const std::string &remote_id) { + std::shared_ptr props; + { + std::shared_lock lock(remote_sessions_mutex_); + if (const auto found = remote_sessions_.find(remote_id); + found != remote_sessions_.end()) { + props = found->second; + } + } + if (!props) { + return; + } + keyboard_.ReleaseRemotePressedKeys(remote_id, "stream_tab_closed"); + CloseRemoteSession(props); + WaitForThumbnailSaveTasks(); + reload_recent_connections_ = true; + recent_connection_image_save_time_ = 0; + ResetRemoteSessionResources(props); + { + std::unique_lock lock(remote_sessions_mutex_); + remote_sessions_.erase(remote_id); + } + ui_->displayed_frame_sequence.erase(remote_id); + if (focused_remote_id_ == remote_id) { + focused_remote_id_.clear(); + controlled_remote_id_.clear(); + } +} + +std::shared_ptr +GuiApplication::SelectedSession() { + std::shared_lock lock(remote_sessions_mutex_); + auto found = remote_sessions_.find(focused_remote_id_); + if (found != remote_sessions_.end()) { + return found->second; + } + for (const auto &[_, props] : remote_sessions_) { + if (props && props->tab_selected_) { + return props; + } + } + return nullptr; +} + +void GuiApplication::SendPointerInput(int button, int kind, float x, float y) { + auto props = SelectedSession(); + if (!props || !props->peer_ || !props->control_mouse_ || !ui_->stream) { + return; } - if (need_to_destroy_server_window_) { - DestroyServerWindow(); - DestroyServerWindowContext(); - need_to_destroy_server_window_ = false; + const auto size = (*ui_->stream)->window().size(); + const float scale = (*ui_->stream)->window().scale_factor(); + const float available_width = size.width / scale; + const float available_height = + size.height / scale - + (fullscreen_button_pressed_ + ? 0.0f + : (ui_->tab_ids.size() > 1 ? 30.0f : 0.0f)); + float render_width = available_width; + float render_height = available_height; + float offset_x = 0; + float offset_y = 0; + if (props->video_width_ > 0 && props->video_height_ > 0 && + available_width > 0 && available_height > 0) { + const float video_aspect = + static_cast(props->video_width_) / props->video_height_; + const float area_aspect = available_width / available_height; + if (video_aspect > area_aspect) { + render_height = available_width / video_aspect; + offset_y = (available_height - render_height) * 0.5f; + } else { + render_width = available_height * video_aspect; + offset_x = (available_width - render_width) * 0.5f; + } } + if (x < offset_x || x > offset_x + render_width || y < offset_y || + y > offset_y + render_height) { + return; + } + + RemoteAction action{}; + action.type = ControlType::mouse; + action.m.x = std::clamp((x - offset_x) / render_width, 0.0f, 1.0f); + action.m.y = std::clamp((y - offset_y) / render_height, 0.0f, 1.0f); + // Slint enum order: Cancel=0, Down=1, Up=2, Move=3 and + // Other=0, Left=1, Right=2, Middle=3. + if (kind == 3) { + action.m.flag = MouseFlag::move; + } else if (kind == 1) { + if (button == 1) action.m.flag = MouseFlag::left_down; + else if (button == 2) action.m.flag = MouseFlag::right_down; + else if (button == 3) action.m.flag = MouseFlag::middle_down; + else return; + } else if (kind == 2) { + if (button == 1) action.m.flag = MouseFlag::left_up; + else if (button == 2) action.m.flag = MouseFlag::right_up; + else if (button == 3) action.m.flag = MouseFlag::middle_up; + else return; + } else { + return; + } + + controlled_remote_id_ = props->remote_id_; + const std::string message = action.to_json(); + SendDataFrame(props->peer_, message.c_str(), message.size(), + props->mouse_label_.c_str()); +} + +void GuiApplication::SendScrollInput(float delta_x, float delta_y, float x, + float y) { + auto props = SelectedSession(); + if (!props || !props->peer_ || !props->control_mouse_ || !ui_->stream) { + return; + } + const auto size = (*ui_->stream)->window().size(); + const float scale = (*ui_->stream)->window().scale_factor(); + const float width = size.width / scale; + const float height = + size.height / scale - + (fullscreen_button_pressed_ + ? 0.0f + : (ui_->tab_ids.size() > 1 ? 30.0f : 0.0f)); + if (width <= 0 || height <= 0) { + return; + } + RemoteAction action{}; + action.type = ControlType::mouse; + action.m.x = std::clamp(x / width, 0.0f, 1.0f); + action.m.y = std::clamp(y / height, 0.0f, 1.0f); + if (std::abs(delta_y) >= std::abs(delta_x)) { + action.m.flag = MouseFlag::wheel_vertical; + action.m.s = delta_y > 0 ? 1 : delta_y < 0 ? -1 : 0; + } else { + action.m.flag = MouseFlag::wheel_horizontal; + action.m.s = delta_x > 0 ? 1 : delta_x < 0 ? -1 : 0; + } + if (action.m.s == 0) { + return; + } + controlled_remote_id_ = props->remote_id_; + const std::string message = action.to_json(); + SendDataFrame(props->peer_, message.c_str(), message.size(), + props->mouse_label_.c_str()); +} + +void GuiApplication::SendKeyInput(const std::string &text, bool pressed, + bool control, bool alt, bool shift, + bool meta) { + (void)control; + (void)alt; + (void)shift; + (void)meta; + if (auto props = SelectedSession()) { + controlled_remote_id_ = props->remote_id_; + focused_remote_id_ = props->remote_id_; + } + const int virtual_key = SlintKeyToWindowsVk(text); + if (virtual_key >= 0) { + keyboard_.SendKeyCommand(virtual_key, pressed); + } +} + +std::string GuiApplication::OpenFileDialog(const std::string &title) { +#if defined(__APPLE__) + return OpenNativeFileDialog(title); +#else + const char *path = + tinyfd_openFileDialog(title.c_str(), "", 0, nullptr, nullptr, 0); + return path ? path : ""; +#endif +} + +bool GuiApplication::OpenUrl(const std::string &url) { + return SDL_OpenURL(url.c_str()); } void GuiApplication::Cleanup() { - clipboard_.Shutdown(); - - devices_.DestroyDevices(); - devices_.DestroyFactories(); - CloseAllRemoteSessions(); - + if (!ui_) { + SDL_Quit(); + return; + } + ui_->timer.stop(); #if _WIN32 && CROSSDESK_PORTABLE JoinPortableWindowsServiceInstallThread(); #endif - + clipboard_.Shutdown(); + keyboard_.ForceReleasePressedKeys(); + devices_.DestroyDevices(); + devices_.DestroyFactories(); + CloseAllRemoteSessions(); WaitForThumbnailSaveTasks(); - devices_.DestroyAudioOutput(); + if (ui_->stream) { + (*ui_->stream)->hide(); + } + if (ui_->server) { + (*ui_->server)->hide(); + } #if defined(_WIN32) || defined(__APPLE__) || defined(__linux__) - tray_.reset(); + if (ui_->tray) { + ui_->tray->RemoveTrayIcon(); + ui_->tray.reset(); + } #endif - - if (stream_window_created_) { - if (stream_window_) { - SDL_SetWindowMouseGrab(stream_window_, false); - } - DestroyStreamWindow(); - } - if (stream_ctx_) { - DestroyStreamWindowContext(); - } - - if (server_window_created_) { - DestroyServerWindow(); - } - if (server_ctx_) { - DestroyServerWindowContext(); - } - - DestroyMainWindowContext(); - DestroyMainWindow(); + ui_.reset(); SDL_Quit(); } - } // namespace crossdesk diff --git a/src/gui/application/gui_application.h b/src/gui/application/gui_application.h index 1785f19..f39f96c 100644 --- a/src/gui/application/gui_application.h +++ b/src/gui/application/gui_application.h @@ -1,20 +1,15 @@ #ifndef CROSSDESK_GUI_APPLICATION_H_ #define CROSSDESK_GUI_APPLICATION_H_ -#include +#include #include -#include -#include "IconsFontAwesome6.h" -#include "imgui_impl_sdl3.h" -#include "imgui_impl_sdlrenderer3.h" -#include "imgui_internal.h" #include "runtime/gui_runtime.h" namespace crossdesk { -// SDL/ImGui application shell. Feature state and protocol behavior live in -// GuiRuntime and its composed controllers. +// Slint application shell. GuiRuntime remains the owner of transport, media, +// device, clipboard, transfer and settings behavior. class GuiApplication final : private GuiRuntime { public: GuiApplication(); @@ -23,88 +18,47 @@ public: int Run(); private: - // Native window integration. - static SDL_HitTestResult HitTestCallback(SDL_Window *window, - const SDL_Point *area, void *data); + struct SlintUi; - // Application lifecycle. void InitializeLogger(); void InitializeSettings(); - void InitializeSDL(); + bool InitializeSDL(); void InitializeModules(); - void InitializeMainWindow(); - void MainLoop(); - void HandleStreamWindow(); - void HandleServerWindow(); - void Cleanup(); - - // Window lifecycle and rendering. - int CreateMainWindow(); - int DestroyMainWindow(); - int CreateStreamWindow(); - int DestroyStreamWindow(); - int CreateServerWindow(); - int DestroyServerWindow(); - int SetupFontAndStyle(ImFont **system_chinese_font_out); - int DestroyMainWindowContext(); - int DestroyStreamWindowContext(); - int DestroyServerWindowContext(); - int DrawMainWindow(); - int DrawStreamWindow(); - int DrawServerWindow(); - - // Views and dialogs. - int TitleBar(bool main_window); - int MainWindow(); - int UpdateNotificationWindow(); - int StreamWindow(); - int ServerWindow(); - int RemoteClientInfoWindow(); - int LocalWindow(); - int RemoteWindow(); - int RecentConnectionsWindow(); - int SettingWindow(); - int SelfHostedServerWindow(); - int ControlWindow(std::shared_ptr &props); - int ControlBar(std::shared_ptr &props); - int AboutWindow(); - int StatusBar(); - bool - ConnectionStatusWindow(std::shared_ptr &props); - int ShowRecentConnections(); - int ConfirmDeleteConnection(); - int EditRecentConnectionAliasWindow(); - int OfflineWarningWindow(); - int NetTrafficStats(std::shared_ptr &props); - int FileTransferWindow(std::shared_ptr &props); - void - DrawConnectionStatusText(std::shared_ptr &props); - void - DrawReceivingScreenText(std::shared_ptr &props); - bool OpenUrl(const std::string &url); - void Hyperlink(const std::string &label, const std::string &url, - float window_width); - std::string OpenFileDialog(std::string title); + void InitializeUi(); + void InitializeSystemTray(); bool MinimizeMainWindowToTray(); - - // SDL event dispatch and input translation. - void UpdateRenderRect(); - void ProcessSdlEvent(const SDL_Event &event); - int ProcessKeyboardEvent(const SDL_Event &event); - int ProcessMouseEvent(const SDL_Event &event); - void CloseTab(decltype(remote_sessions_)::iterator &it); - + void BindMainCallbacks(); + void BindStreamCallbacks(); + void BindServerCallbacks(); + void Tick(); + void SyncMainWindow(); + void SyncConnectionDialog(); + void SyncPlatformDialogs(); + void SyncStreamWindow(); + void SyncServerWindow(); + void UpdateLocalization(); + void ResetSettingsUi(); + void SaveSettingsFromUi(); #if _WIN32 && CROSSDESK_PORTABLE void CheckPortableWindowsService(); - int PortableServiceInstallWindow(); void StartPortableWindowsServiceInstall(); void JoinPortableWindowsServiceInstallThread(); #endif + void ConnectFromUi(const std::string &remote_id); + void SelectStreamTab(int index); + void ReorderStreamTab(int from, float drop_x, float tab_width); + void CloseStreamTab(const std::string &remote_id); + void SendPointerInput(int button, int kind, float x, float y); + void SendScrollInput(float delta_x, float delta_y, float x, float y); + void SendKeyInput(const std::string &text, bool pressed, bool control, + bool alt, bool shift, bool meta); + void Cleanup(); -#ifdef __APPLE__ - int RequestPermissionWindow(); - bool DrawToggleSwitch(const char *id, bool active, bool enabled); -#endif + std::shared_ptr SelectedSession(); + std::string OpenFileDialog(const std::string &title); + bool OpenUrl(const std::string &url); + + std::unique_ptr ui_; }; } // namespace crossdesk diff --git a/src/gui/application/portable_service_integration.cpp b/src/gui/application/portable_service_integration.cpp new file mode 100644 index 0000000..d355d9c --- /dev/null +++ b/src/gui/application/portable_service_integration.cpp @@ -0,0 +1,144 @@ +#include "application/gui_application.h" + +#if _WIN32 && CROSSDESK_PORTABLE + +#include +#include + +#include +#include + +#include "rd_log.h" +#include "service_host.h" + +namespace crossdesk { +namespace { + +std::filesystem::path GetCurrentExecutablePath() { + std::vector buffer(MAX_PATH); + while (true) { + const DWORD length = GetModuleFileNameW( + nullptr, buffer.data(), static_cast(buffer.size())); + if (length == 0) { + return {}; + } + if (length < buffer.size()) { + return std::filesystem::path(buffer.data(), buffer.data() + length); + } + if (buffer.size() >= 32768) { + return {}; + } + buffer.resize(buffer.size() * 2); + } +} + +bool InstallServiceWithElevation() { + const std::filesystem::path executable_path = GetCurrentExecutablePath(); + if (executable_path.empty()) { + LOG_ERROR("Portable service install failed: current executable not found"); + return false; + } + + const std::filesystem::path service_path = + executable_path.parent_path() / L"crossdesk_service.exe"; + const std::filesystem::path helper_path = + executable_path.parent_path() / L"crossdesk_session_helper.exe"; + if (!std::filesystem::exists(service_path) || + !std::filesystem::exists(helper_path)) { + LOG_ERROR("Portable service install failed: service binaries missing, " + "service={}, helper={}", + service_path.string(), helper_path.string()); + return false; + } + + const std::wstring executable = executable_path.wstring(); + const std::wstring working_dir = executable_path.parent_path().wstring(); + constexpr wchar_t parameters[] = L"--service-install"; + + SHELLEXECUTEINFOW execute_info{}; + execute_info.cbSize = sizeof(execute_info); + execute_info.fMask = SEE_MASK_NOCLOSEPROCESS; + execute_info.lpVerb = L"runas"; + execute_info.lpFile = executable.c_str(); + execute_info.lpParameters = parameters; + execute_info.lpDirectory = working_dir.c_str(); + execute_info.nShow = SW_HIDE; + + if (!ShellExecuteExW(&execute_info)) { + LOG_ERROR("Portable service install failed: ShellExecuteExW error={}", + GetLastError()); + return false; + } + + const DWORD wait_result = + WaitForSingleObject(execute_info.hProcess, INFINITE); + DWORD exit_code = 1; + if (wait_result == WAIT_OBJECT_0) { + GetExitCodeProcess(execute_info.hProcess, &exit_code); + } else { + LOG_ERROR("Portable service install wait failed, result={}", wait_result); + } + CloseHandle(execute_info.hProcess); + + if (exit_code != 0) { + LOG_ERROR("Portable service install command failed, exit_code={}", + exit_code); + return false; + } + + const bool started = StartCrossDeskService(); + if (!started) { + LOG_WARN("Portable service installed but start failed"); + } + return IsCrossDeskServiceInstalled() && started; +} + +} // namespace + +void GuiApplication::CheckPortableWindowsService() { + if (portable_service_prompt_checked_) { + return; + } + portable_service_prompt_checked_ = true; + portable_service_installed_ = IsCrossDeskServiceInstalled(); + if (portable_service_installed_ || portable_service_prompt_suppressed_) { + return; + } + + portable_service_install_state_.store(PortableServiceInstallState::idle, + std::memory_order_relaxed); + show_portable_service_install_window_ = true; +} + +void GuiApplication::StartPortableWindowsServiceInstall() { + portable_service_do_not_remind_ = false; + PortableServiceInstallState expected = PortableServiceInstallState::idle; + if (!portable_service_install_state_.compare_exchange_strong( + expected, PortableServiceInstallState::installing, + std::memory_order_acq_rel)) { + if (expected != PortableServiceInstallState::failed) { + return; + } + portable_service_install_state_.store( + PortableServiceInstallState::installing, std::memory_order_release); + } + + JoinPortableWindowsServiceInstallThread(); + portable_service_install_thread_ = std::thread([this]() { + const bool installed = InstallServiceWithElevation(); + portable_service_install_state_.store( + installed ? PortableServiceInstallState::succeeded + : PortableServiceInstallState::failed, + std::memory_order_release); + }); +} + +void GuiApplication::JoinPortableWindowsServiceInstallThread() { + if (portable_service_install_thread_.joinable()) { + portable_service_install_thread_.join(); + } +} + +} // namespace crossdesk + +#endif diff --git a/src/gui/assets/localization/localization.h b/src/gui/assets/localization/localization.h index acb6560..78fbd12 100644 --- a/src/gui/assets/localization/localization.h +++ b/src/gui/assets/localization/localization.h @@ -58,7 +58,7 @@ using TranslationTable = inline std::unordered_map MakeLocalizedValues( const TranslationRow& row) { return {{"zh-CN", reinterpret_cast(row.zh)}, - {"en-US", row.en}, + {"en-US", reinterpret_cast(row.en)}, {"ru-RU", reinterpret_cast(row.ru)}}; } diff --git a/src/gui/assets/localization/localization_data.h b/src/gui/assets/localization/localization_data.h index 8e6997d..49021e9 100644 --- a/src/gui/assets/localization/localization_data.h +++ b/src/gui/assets/localization/localization_data.h @@ -13,9 +13,9 @@ namespace detail { struct TranslationRow { const char* key; - const char* zh; - const char* en; - const char* ru; + const void* zh; + const void* en; + const void* ru; }; // Single source of truth for all UI strings. diff --git a/src/gui/features/clipboard/clipboard_controller.cpp b/src/gui/features/clipboard/clipboard_controller.cpp index 6ceaeae..8c4f70a 100644 --- a/src/gui/features/clipboard/clipboard_controller.cpp +++ b/src/gui/features/clipboard/clipboard_controller.cpp @@ -33,11 +33,6 @@ void ClipboardController::Initialize() { SDL_free(clipboard_text); } - if (event_type_ == 0) { - LOG_ERROR("Clipboard synchronization disabled: SDL event is unavailable"); - return; - } - events_enabled_.store(true, std::memory_order_release); } @@ -75,7 +70,7 @@ void ClipboardController::QueueRemoteText(const char *data, size_t size) { SDL_Event event{}; event.type = event_type_; - if (!SDL_PushEvent(&event)) { + if (event_type_ != 0 && !SDL_PushEvent(&event)) { // MainLoop also drains the pending value after its wait timeout. LOG_WARN("Failed to wake SDL loop for remote clipboard text: {}", SDL_GetError()); diff --git a/src/gui/features/file_transfer/file_transfer_manager.cpp b/src/gui/features/file_transfer/file_transfer_manager.cpp index ca198d4..24b023e 100644 --- a/src/gui/features/file_transfer/file_transfer_manager.cpp +++ b/src/gui/features/file_transfer/file_transfer_manager.cpp @@ -13,6 +13,7 @@ #include #include "file_transfer.h" +#include "filesystem_utf8.h" #include "rd_log.h" #include "runtime/gui_runtime.h" @@ -40,7 +41,7 @@ void FileTransferManager::ProcessSelectedFile( FileTransferState &state = state_for(props); LOG_INFO("Selected file: {}", path.c_str()); - const std::filesystem::path file_path = std::filesystem::u8path(path); + const std::filesystem::path file_path = PathFromUtf8(path); std::error_code ec; if (!std::filesystem::is_regular_file(file_path, ec)) { LOG_ERROR("Selected path is not a regular file: {}", path); @@ -55,7 +56,9 @@ void FileTransferManager::ProcessSelectedFile( { std::lock_guard lock(state.file_transfer_list_mutex_); FileTransferState::FileTransferInfo info; - info.file_name = file_path.filename().u8string(); + const auto utf8_name = file_path.filename().u8string(); + info.file_name.assign(reinterpret_cast(utf8_name.data()), + utf8_name.size()); info.file_path = file_path; info.file_size = file_size; info.status = FileTransferState::FileTransferStatus::Queued; @@ -341,39 +344,4 @@ void FileTransferManager::HandleAck(const char *data, size_t size) { ProcessQueue(props); } -void FileTransferManager::HandleDropEvent(const SDL_Event &event) { - if (event.type != SDL_EVENT_DROP_FILE || - !((owner_.stream_window_ && - SDL_GetWindowID(owner_.stream_window_) == event.window.windowID) || - (owner_.server_window_ && - SDL_GetWindowID(owner_.server_window_) == event.window.windowID))) { - return; - } - - if (owner_.stream_window_ && - SDL_GetWindowID(owner_.stream_window_) == event.window.windowID) { - if (!owner_.stream_window_inited_ || !event.drop.data) { - return; - } - std::shared_lock lock(owner_.remote_sessions_mutex_); - for (const auto &[remote_id, props] : owner_.remote_sessions_) { - if (props && props->tab_selected_ && props->peer_) { - ProcessSelectedFile(static_cast(event.drop.data), props, - props->file_label_); - break; - } - } - return; - } - - if (owner_.server_window_inited_ && event.drop.data) { - const auto file_path = std::filesystem::u8path(event.drop.data); - std::error_code ec; - if (std::filesystem::is_regular_file(file_path, ec)) { - LOG_INFO("Drop file [{}] on server window (size: {} bytes)", - event.drop.data, std::filesystem::file_size(file_path, ec)); - } - } -} - } // namespace crossdesk diff --git a/src/gui/features/file_transfer/file_transfer_manager.h b/src/gui/features/file_transfer/file_transfer_manager.h index 807a17a..748d02d 100644 --- a/src/gui/features/file_transfer/file_transfer_manager.h +++ b/src/gui/features/file_transfer/file_transfer_manager.h @@ -1,8 +1,6 @@ #ifndef CROSSDESK_GUI_FILE_TRANSFER_MANAGER_H_ #define CROSSDESK_GUI_FILE_TRANSFER_MANAGER_H_ -#include - #include #include #include @@ -33,7 +31,6 @@ public: const std::shared_ptr &props, const std::string &file_label, const std::string &remote_id = ""); - void HandleDropEvent(const SDL_Event &event); void HandleAck(const char *data, size_t size); private: diff --git a/src/gui/platform/tray/linux_tray.cpp b/src/gui/platform/tray/linux_tray.cpp index e734462..cb9e647 100644 --- a/src/gui/platform/tray/linux_tray.cpp +++ b/src/gui/platform/tray/linux_tray.cpp @@ -190,6 +190,18 @@ struct LinuxTrayImpl { menu_label(GetMenuLabel(language_index_value)), menu_ascii_label(IsAsciiPrintable(menu_label) ? menu_label : "Exit") {} + explicit LinuxTrayImpl(std::function show_window_callback, + std::function hide_window_callback, + std::function exit_callback, + std::string tray_tooltip, int language_index_value) + : show_window(std::move(show_window_callback)), + hide_window(std::move(hide_window_callback)), + exit_app(std::move(exit_callback)), + tooltip(std::move(tray_tooltip)), + language_index(language_index_value), + menu_label(GetMenuLabel(language_index_value)), + menu_ascii_label(IsAsciiPrintable(menu_label) ? menu_label : "Exit") {} + ~LinuxTrayImpl() { RemoveTrayIcon(); } bool MinimizeToTray() { @@ -197,7 +209,9 @@ struct LinuxTrayImpl { return false; } - if (app_window) { + if (hide_window) { + hide_window(); + } else if (app_window) { SDL_HideWindow(app_window); } return true; @@ -939,16 +953,20 @@ struct LinuxTrayImpl { } void ShowWindow() { - if (!app_window) { - return; + if (show_window) { + show_window(); + } else if (app_window) { + SDL_ShowWindow(app_window); + SDL_RestoreWindow(app_window); + SDL_RaiseWindow(app_window); } - - SDL_ShowWindow(app_window); - SDL_RestoreWindow(app_window); - SDL_RaiseWindow(app_window); } void RequestExit() { + if (exit_app) { + exit_app(); + return; + } SDL_Event event{}; event.type = exit_event_type == 0 || exit_event_type == static_cast(-1) @@ -958,6 +976,9 @@ struct LinuxTrayImpl { } ::SDL_Window* app_window = nullptr; + std::function show_window; + std::function hide_window; + std::function exit_app; std::string tooltip; int language_index = 0; uint32_t exit_event_type = 0; @@ -1005,6 +1026,14 @@ LinuxTray::LinuxTray(::SDL_Window* app_window, const std::string& tooltip, : impl_(std::make_unique(app_window, tooltip, language_index, exit_event_type)) {} +LinuxTray::LinuxTray(std::function show_window, + std::function hide_window, + std::function exit_app, + const std::string& tooltip, int language_index) + : impl_(std::make_unique( + std::move(show_window), std::move(hide_window), std::move(exit_app), + tooltip, language_index)) {} + LinuxTray::~LinuxTray() = default; bool LinuxTray::MinimizeToTray() { return impl_->MinimizeToTray(); } diff --git a/src/gui/platform/tray/linux_tray.h b/src/gui/platform/tray/linux_tray.h index fcf421c..d2d840b 100644 --- a/src/gui/platform/tray/linux_tray.h +++ b/src/gui/platform/tray/linux_tray.h @@ -10,6 +10,7 @@ #if defined(__linux__) && !defined(__APPLE__) #include +#include #include #include @@ -23,6 +24,9 @@ class LinuxTray { public: LinuxTray(::SDL_Window* app_window, const std::string& tooltip, int language_index, uint32_t exit_event_type); + LinuxTray(std::function show_window, + std::function hide_window, std::function exit_app, + const std::string& tooltip, int language_index); ~LinuxTray(); bool MinimizeToTray(); diff --git a/src/gui/platform/tray/mac_tray.h b/src/gui/platform/tray/mac_tray.h index 172102a..2d761a5 100644 --- a/src/gui/platform/tray/mac_tray.h +++ b/src/gui/platform/tray/mac_tray.h @@ -7,6 +7,7 @@ #ifndef _MAC_TRAY_H_ #define _MAC_TRAY_H_ +#include #include #include @@ -20,6 +21,11 @@ class MacTray { public: MacTray(::SDL_Window* app_window, const std::string& tooltip, int language_index); + MacTray(std::function show_window, + std::function hide_window, + std::function open_settings, + std::function exit_app, + const std::string& tooltip, int language_index); ~MacTray(); void MinimizeToTray(); diff --git a/src/gui/platform/tray/mac_tray.mm b/src/gui/platform/tray/mac_tray.mm index e6c11c7..3cb9f32 100644 --- a/src/gui/platform/tray/mac_tray.mm +++ b/src/gui/platform/tray/mac_tray.mm @@ -13,6 +13,7 @@ @interface CrossDeskMacTrayTarget : NSObject - (instancetype)initWithOwner:(crossdesk::MacTrayImpl *)owner; - (void)statusItemClicked:(id)sender; +- (void)openSettings:(id)sender; - (void)exitApplication:(id)sender; @end @@ -24,7 +25,24 @@ struct MacTrayImpl { : app_window(window), tooltip(std::move(tray_tooltip)), language_index(language_index_value), - target([[CrossDeskMacTrayTarget alloc] initWithOwner:this]) {} + target([[CrossDeskMacTrayTarget alloc] initWithOwner:this]) { + EnsureStatusItem(); + } + + explicit MacTrayImpl(std::function show_window_callback, + std::function hide_window_callback, + std::function open_settings_callback, + std::function exit_callback, + std::string tray_tooltip, int language_index_value) + : show_window(std::move(show_window_callback)), + hide_window(std::move(hide_window_callback)), + open_settings(std::move(open_settings_callback)), + exit_app(std::move(exit_callback)), + tooltip(std::move(tray_tooltip)), + language_index(language_index_value), + target([[CrossDeskMacTrayTarget alloc] initWithOwner:this]) { + EnsureStatusItem(); + } ~MacTrayImpl() { RemoveTrayIcon(); @@ -33,7 +51,9 @@ struct MacTrayImpl { void MinimizeToTray() { EnsureStatusItem(); - if (app_window) { + if (hide_window) { + hide_window(); + } else if (app_window) { SDL_HideWindow(app_window); } } @@ -48,12 +68,12 @@ struct MacTrayImpl { } void ShowWindow() { - if (!app_window) { - return; + if (show_window) { + show_window(); + } else if (app_window) { + SDL_ShowWindow(app_window); + SDL_RaiseWindow(app_window); } - - SDL_ShowWindow(app_window); - SDL_RaiseWindow(app_window); [NSApp activateIgnoringOtherApps:YES]; } @@ -64,6 +84,18 @@ struct MacTrayImpl { } NSMenu *menu = [[NSMenu alloc] initWithTitle:@"CrossDesk"]; + NSString *settings_title = + NSStringFromUtf8(localization::settings + [localization::detail::ClampLanguageIndex( + language_index)]); + NSMenuItem *settings_item = + [[NSMenuItem alloc] initWithTitle:settings_title + action:@selector(openSettings:) + keyEquivalent:@""]; + [settings_item setTarget:target]; + [menu addItem:settings_item]; + [menu addItem:[NSMenuItem separatorItem]]; + NSString *exit_title = NSStringFromUtf8(localization::exit_program [localization::detail::ClampLanguageIndex( @@ -86,11 +118,25 @@ struct MacTrayImpl { } void RequestExit() { + if (exit_app) { + exit_app(); + return; + } SDL_Event event; event.type = SDL_EVENT_QUIT; SDL_PushEvent(&event); } + void OpenSettings() { + if (open_settings) { + open_settings(); + } else { + ShowWindow(); + return; + } + [NSApp activateIgnoringOtherApps:YES]; + } + private: void EnsureStatusItem() { if (status_item) { @@ -191,6 +237,10 @@ struct MacTrayImpl { } ::SDL_Window *app_window = nullptr; + std::function show_window; + std::function hide_window; + std::function open_settings; + std::function exit_app; std::string tooltip; int language_index = 0; NSStatusItem *status_item = nil; @@ -202,6 +252,16 @@ MacTray::MacTray(::SDL_Window *app_window, const std::string &tooltip, : impl_( std::make_unique(app_window, tooltip, language_index)) {} +MacTray::MacTray(std::function show_window, + std::function hide_window, + std::function open_settings, + std::function exit_app, const std::string &tooltip, + int language_index) + : impl_(std::make_unique( + std::move(show_window), std::move(hide_window), + std::move(open_settings), std::move(exit_app), tooltip, + language_index)) {} + MacTray::~MacTray() = default; void MacTray::MinimizeToTray() { impl_->MinimizeToTray(); } @@ -244,6 +304,13 @@ void MacTray::RemoveTrayIcon() { impl_->RemoveTrayIcon(); } } } +- (void)openSettings:(id)sender { + (void)sender; + if (owner_) { + owner_->OpenSettings(); + } +} + @end #endif // __APPLE__ diff --git a/src/gui/platform/tray/win_tray.cpp b/src/gui/platform/tray/win_tray.cpp index 1f93170..e0b1249 100644 --- a/src/gui/platform/tray/win_tray.cpp +++ b/src/gui/platform/tray/win_tray.cpp @@ -4,6 +4,8 @@ #include "localization.h" +#include + namespace crossdesk { // callback for the message-only window that handles tray icon messages @@ -60,6 +62,16 @@ WinTray::WinTray(HWND app_hwnd, HICON icon, const std::wstring& tooltip, wcsncpy_s(nid_.szTip, tip_.c_str(), _TRUNCATE); } +WinTray::WinTray(std::function show_window, + std::function hide_window, + std::function exit_app, HICON icon, + const std::wstring& tooltip, int language_index) + : WinTray(nullptr, icon, tooltip, language_index) { + show_window_ = std::move(show_window); + hide_window_ = std::move(hide_window); + exit_app_ = std::move(exit_app); +} + WinTray::~WinTray() { RemoveTrayIcon(); if (hwnd_message_only_) DestroyWindow(hwnd_message_only_); @@ -67,20 +79,31 @@ WinTray::~WinTray() { void WinTray::MinimizeToTray() { Shell_NotifyIcon(NIM_ADD, &nid_); - // hide application window - ShowWindow(app_hwnd_, SW_HIDE); + if (hide_window_) { + hide_window_(); + } else if (app_hwnd_) { + ShowWindow(app_hwnd_, SW_HIDE); + } } void WinTray::RemoveTrayIcon() { Shell_NotifyIcon(NIM_DELETE, &nid_); } +void WinTray::ShowApplicationWindow() { + if (show_window_) { + show_window_(); + } else if (app_hwnd_) { + ShowWindow(app_hwnd_, SW_SHOW); + SetForegroundWindow(app_hwnd_); + } +} + bool WinTray::HandleTrayMessage(MSG* msg) { if (!msg || msg->message != WM_TRAY_CALLBACK) return false; switch (LOWORD(msg->lParam)) { case WM_LBUTTONDBLCLK: case WM_LBUTTONUP: { - ShowWindow(app_hwnd_, SW_SHOW); - SetForegroundWindow(app_hwnd_); + ShowApplicationWindow(); break; } @@ -99,13 +122,15 @@ bool WinTray::HandleTrayMessage(MSG* msg) { // handle menu command if (cmd == 1001) { - // exit application - SDL_Event event; - event.type = SDL_EVENT_QUIT; - SDL_PushEvent(&event); + if (exit_app_) { + exit_app_(); + } else { + SDL_Event event; + event.type = SDL_EVENT_QUIT; + SDL_PushEvent(&event); + } } else if (cmd == 1002) { - ShowWindow(app_hwnd_, SW_SHOW); // show main window - SetForegroundWindow(app_hwnd_); + ShowApplicationWindow(); } break; } diff --git a/src/gui/platform/tray/win_tray.h b/src/gui/platform/tray/win_tray.h index a1e069b..17deed8 100644 --- a/src/gui/platform/tray/win_tray.h +++ b/src/gui/platform/tray/win_tray.h @@ -10,6 +10,7 @@ #include #include +#include #include #define WM_TRAY_CALLBACK (WM_USER + 1) @@ -20,6 +21,9 @@ class WinTray { public: WinTray(HWND app_hwnd, HICON icon, const std::wstring& tooltip, int language_index); + WinTray(std::function show_window, + std::function hide_window, std::function exit_app, + HICON icon, const std::wstring& tooltip, int language_index); ~WinTray(); void MinimizeToTray(); @@ -27,12 +31,17 @@ class WinTray { bool HandleTrayMessage(MSG* msg); private: + void ShowApplicationWindow(); + HWND app_hwnd_; HWND hwnd_message_only_; HICON icon_; std::wstring tip_; int language_index_; NOTIFYICONDATA nid_; + std::function show_window_; + std::function hide_window_; + std::function exit_app_; }; } // namespace crossdesk -#endif \ No newline at end of file +#endif diff --git a/src/gui/platform/window_drag.h b/src/gui/platform/window_drag.h new file mode 100644 index 0000000..5b1f328 --- /dev/null +++ b/src/gui/platform/window_drag.h @@ -0,0 +1,27 @@ +#pragma once + +#include + +namespace crossdesk { + +// Starts the platform's native move loop for the window receiving the current +// left-mouse-down event. Returns false when no suitable native event exists. +bool StartNativeWindowDrag(); + +// Hides the disabled native zoom button on CrossDesk's fixed-size main +// window. Returns true once the matching AppKit window has been configured. +bool HideDisabledMainWindowZoomButton(); + +// Configures live resize and replaces the stream window's native Space +// fullscreen action with an immediate, single-window fullscreen transition. +bool ConfigureStreamWindowLiveResize(); + +// Enters or leaves the stream window's animation-free fullscreen mode. +bool SetStreamWindowFullscreen(bool fullscreen); +bool IsStreamWindowFullscreen(); + +// Opens an application-modal macOS file picker. The panel is made key before +// entering its modal loop so it receives keyboard input immediately. +std::string OpenNativeFileDialog(const std::string &title); + +} // namespace crossdesk diff --git a/src/gui/platform/window_drag_mac.mm b/src/gui/platform/window_drag_mac.mm new file mode 100644 index 0000000..1cbbfe8 --- /dev/null +++ b/src/gui/platform/window_drag_mac.mm @@ -0,0 +1,265 @@ +#include "platform/window_drag.h" + +#import + +@interface CrossDeskStreamFullscreenTarget : NSObject +- (void)toggleStreamFullscreen:(id)sender; +@end + +@implementation CrossDeskStreamFullscreenTarget +- (void)toggleStreamFullscreen:(id)sender { + (void)sender; + crossdesk::SetStreamWindowFullscreen( + !crossdesk::IsStreamWindowFullscreen()); +} +@end + +namespace crossdesk { +namespace { + +NSWindow *stream_window = nil; +CrossDeskStreamFullscreenTarget *stream_fullscreen_target = nil; +bool stream_fullscreen = false; +NSRect stream_restore_frame = NSZeroRect; +NSWindowStyleMask stream_restore_style_mask = NSWindowStyleMaskTitled; +NSWindowTitleVisibility stream_restore_title_visibility = + NSWindowTitleVisible; +BOOL stream_restore_titlebar_transparent = NO; +BOOL stream_restore_movable = YES; +BOOL stream_restore_close_hidden = NO; +BOOL stream_restore_miniaturize_hidden = NO; +BOOL stream_restore_zoom_hidden = NO; +NSApplicationPresentationOptions stream_restore_presentation_options = + NSApplicationPresentationDefault; + +void InstallStreamFullscreenButton(NSWindow *window) { + if (window == nil) { + return; + } + if (stream_fullscreen_target == nil) { + stream_fullscreen_target = + [[CrossDeskStreamFullscreenTarget alloc] init]; + } + NSButton *zoom_button = + [window standardWindowButton:NSWindowZoomButton]; + zoom_button.target = stream_fullscreen_target; + zoom_button.action = @selector(toggleStreamFullscreen:); +} + +void SetTitlebarButtonsHidden(NSWindow *window, BOOL hidden) { + [window standardWindowButton:NSWindowCloseButton].hidden = hidden; + [window standardWindowButton:NSWindowMiniaturizeButton].hidden = hidden; + [window standardWindowButton:NSWindowZoomButton].hidden = hidden; +} + +void CenterWindowOnScreen(NSWindow *window, NSScreen *screen) { + if (window == nil || screen == nil) { + return; + } + const NSRect available_frame = screen.visibleFrame; + const NSRect window_frame = window.frame; + const NSPoint centered_origin = NSMakePoint( + NSMidX(available_frame) - NSWidth(window_frame) * 0.5, + NSMidY(available_frame) - NSHeight(window_frame) * 0.5); + [window setFrameOrigin:centered_origin]; +} + +} // namespace + +bool StartNativeWindowDrag() { + @autoreleasepool { + NSEvent *event = [[NSApplication sharedApplication] currentEvent]; + if (event == nil || event.type != NSEventTypeLeftMouseDown || + event.window == nil) { + return false; + } + + [event.window performWindowDragWithEvent:event]; + return true; + } +} + +bool HideDisabledMainWindowZoomButton() { + @autoreleasepool { + for (NSWindow *window in [NSApp windows]) { + if (![window.title isEqualToString:@"CrossDesk"]) { + continue; + } + + NSButton *zoom_button = + [window standardWindowButton:NSWindowZoomButton]; + // The main window has a fixed size, so AppKit disables its zoom button. + // Stream windows remain resizable and must keep their native button. + if (zoom_button == nil || zoom_button.enabled) { + continue; + } + + zoom_button.hidden = YES; + return true; + } + return false; + } +} + +bool ConfigureStreamWindowLiveResize() { + @autoreleasepool { + for (NSWindow *window in [NSApp windows]) { + if (![window.title isEqualToString:@"CrossDesk"]) { + continue; + } + + NSButton *zoom_button = + [window standardWindowButton:NSWindowZoomButton]; + // The stream window is resizable and therefore keeps an enabled native + // zoom button. The fixed-size main window's zoom button is disabled. + if (zoom_button == nil || !zoom_button.enabled) { + continue; + } + + NSView *content_view = window.contentView; + if (content_view == nil) { + return false; + } + + // During an ordinary edge resize, scale one cached frame and redraw once + // the new window size has settled. + window.preservesContentDuringLiveResize = YES; + content_view.layerContentsRedrawPolicy = + NSViewLayerContentsRedrawBeforeViewResize; + content_view.layerContentsPlacement = + NSViewLayerContentsPlacementScaleProportionallyToFit; + stream_window = window; + // Native Space fullscreen cross-fades an AppKit source snapshot over a + // separately rendered destination window. Disable that path for the + // stream window and route the green button to the immediate transition. + window.collectionBehavior = + (window.collectionBehavior & + ~(NSWindowCollectionBehaviorFullScreenPrimary | + NSWindowCollectionBehaviorFullScreenAuxiliary | + NSWindowCollectionBehaviorFullScreenAllowsTiling)) | + NSWindowCollectionBehaviorFullScreenNone; + InstallStreamFullscreenButton(window); + content_view.needsDisplay = YES; + return true; + } + return false; + } +} + +bool SetStreamWindowFullscreen(bool fullscreen) { + @autoreleasepool { + if (stream_window == nil || stream_fullscreen == fullscreen) { + return stream_window != nil; + } + + if (fullscreen) { + NSScreen *screen = stream_window.screen ?: NSScreen.mainScreen; + if (screen == nil) { + return false; + } + + stream_restore_frame = stream_window.frame; + stream_restore_style_mask = stream_window.styleMask; + stream_restore_title_visibility = stream_window.titleVisibility; + stream_restore_titlebar_transparent = + stream_window.titlebarAppearsTransparent; + stream_restore_movable = stream_window.movable; + stream_restore_close_hidden = + [stream_window standardWindowButton:NSWindowCloseButton].hidden; + stream_restore_miniaturize_hidden = + [stream_window standardWindowButton:NSWindowMiniaturizeButton].hidden; + stream_restore_zoom_hidden = + [stream_window standardWindowButton:NSWindowZoomButton].hidden; + stream_restore_presentation_options = NSApp.presentationOptions; + + stream_fullscreen = true; + stream_window.styleMask = + stream_restore_style_mask | NSWindowStyleMaskFullSizeContentView; + stream_window.titleVisibility = NSWindowTitleHidden; + stream_window.titlebarAppearsTransparent = YES; + stream_window.movable = NO; + SetTitlebarButtonsHidden(stream_window, YES); + + NSApplicationPresentationOptions presentation_options = + stream_restore_presentation_options; + if ((presentation_options & (NSApplicationPresentationHideDock | + NSApplicationPresentationAutoHideDock)) == + 0) { + presentation_options |= NSApplicationPresentationAutoHideDock; + } + if ((presentation_options & (NSApplicationPresentationHideMenuBar | + NSApplicationPresentationAutoHideMenuBar)) == + 0) { + presentation_options |= NSApplicationPresentationAutoHideMenuBar; + } + NSApp.presentationOptions = presentation_options; + [stream_window setFrame:screen.frame display:YES animate:NO]; + [stream_window makeKeyAndOrderFront:nil]; + } else { + stream_fullscreen = false; + NSApp.presentationOptions = stream_restore_presentation_options; + stream_window.styleMask = stream_restore_style_mask; + stream_window.titleVisibility = stream_restore_title_visibility; + stream_window.titlebarAppearsTransparent = + stream_restore_titlebar_transparent; + stream_window.movable = stream_restore_movable; + [stream_window setFrame:stream_restore_frame display:YES animate:NO]; + [stream_window standardWindowButton:NSWindowCloseButton].hidden = + stream_restore_close_hidden; + [stream_window standardWindowButton:NSWindowMiniaturizeButton].hidden = + stream_restore_miniaturize_hidden; + [stream_window standardWindowButton:NSWindowZoomButton].hidden = + stream_restore_zoom_hidden; + InstallStreamFullscreenButton(stream_window); + [stream_window makeKeyAndOrderFront:nil]; + } + + stream_window.contentView.alphaValue = 1.0; + stream_window.contentView.needsDisplay = YES; + [stream_window displayIfNeeded]; + return true; + } +} + +bool IsStreamWindowFullscreen() { + return stream_fullscreen; +} + +std::string OpenNativeFileDialog(const std::string &title) { + @autoreleasepool { + NSOpenPanel *panel = [NSOpenPanel openPanel]; + panel.canChooseFiles = YES; + panel.canChooseDirectories = NO; + panel.allowsMultipleSelection = NO; + panel.resolvesAliases = YES; + + NSString *prompt = + [[NSString alloc] initWithBytes:title.data() + length:title.size() + encoding:NSUTF8StringEncoding]; + if (prompt != nil && prompt.length > 0) { + panel.title = prompt; + panel.message = prompt; + } + + // tinyfiledialogs uses an external osascript process on macOS, so its + // chooser can appear without becoming the active window. Keep the picker + // inside CrossDesk and explicitly activate both the app and panel. + [NSApp activateIgnoringOtherApps:YES]; + NSWindow *owner = stream_window ?: NSApp.keyWindow ?: NSApp.mainWindow; + if (owner != nil) { + [owner makeKeyAndOrderFront:nil]; + } + NSScreen *target_screen = owner.screen ?: NSScreen.mainScreen; + CenterWindowOnScreen(panel, target_screen); + [panel makeKeyAndOrderFront:nil]; + + if ([panel runModal] != NSModalResponseOK || panel.URL == nil) { + return {}; + } + const char *path = panel.URL.fileSystemRepresentation; + return path != nullptr ? std::string(path) : std::string{}; + } +} + +} // namespace crossdesk diff --git a/src/gui/runtime/connection_runtime.cpp b/src/gui/runtime/connection_runtime.cpp index 8bb6ea2..02e82a1 100644 --- a/src/gui/runtime/connection_runtime.cpp +++ b/src/gui/runtime/connection_runtime.cpp @@ -200,8 +200,6 @@ int GuiRuntime::RequestSingleDevicePresence(const std::string &remote_id, } void GuiRuntime::CloseRemoteSession(std::shared_ptr props) { - SDL_FlushEvent(STREAM_REFRESH_EVENT); - std::shared_ptr> frame_snapshot; int video_width = 0; int video_height = 0; @@ -290,11 +288,6 @@ void GuiRuntime::WaitForThumbnailSaveTasks() { void GuiRuntime::ResetRemoteSessionResources( std::shared_ptr props) { - if (props->stream_texture_) { - SDL_DestroyTexture(props->stream_texture_); - props->stream_texture_ = nullptr; - } - { std::lock_guard lock(props->video_frame_mutex_); props->front_frame_.reset(); diff --git a/src/gui/runtime/gui_runtime.cpp b/src/gui/runtime/gui_runtime.cpp index 41f6996..d250404 100644 --- a/src/gui/runtime/gui_runtime.cpp +++ b/src/gui/runtime/gui_runtime.cpp @@ -168,10 +168,10 @@ void GuiRuntime::UpdateLabels() { void GuiRuntime::HandleRecentConnections() { - if (reload_recent_connections_ && main_renderer_) { + if (reload_recent_connections_ && thumbnail_) { uint32_t now_time = SDL_GetTicks(); if (now_time - recent_connection_image_save_time_ >= 50) { - int ret = thumbnail_->LoadThumbnail(main_renderer_, recent_connections_, + int ret = thumbnail_->LoadThumbnail(recent_connections_, &recent_connection_image_width_, &recent_connection_image_height_); if (!ret) { diff --git a/src/gui/runtime/peer_data_callbacks.cpp b/src/gui/runtime/peer_data_callbacks.cpp index 65be3f4..0f162c9 100644 --- a/src/gui/runtime/peer_data_callbacks.cpp +++ b/src/gui/runtime/peer_data_callbacks.cpp @@ -14,6 +14,7 @@ #include "device_controller.h" #include "file_transfer.h" #include "localization.h" +#include "filesystem_utf8.h" #include "platform.h" #include "rd_log.h" #include "runtime/gui_runtime.h" @@ -90,7 +91,7 @@ void PeerEventHandler::OnReceiveDataBuffer( std::string configured_path = runtime->config_center_->GetFileTransferSavePath(); if (!configured_path.empty()) { - receiver.SetOutputDir(std::filesystem::u8path(configured_path)); + receiver.SetOutputDir(PathFromUtf8(configured_path)); } else if (receiver.OutputDir().empty()) { receiver = FileReceiver(); // re-init with default desktop path } diff --git a/src/gui/runtime/peer_event_handler.cpp b/src/gui/runtime/peer_event_handler.cpp index 9e8d22b..98a3d1f 100644 --- a/src/gui/runtime/peer_event_handler.cpp +++ b/src/gui/runtime/peer_event_handler.cpp @@ -230,12 +230,6 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status, runtime->need_to_create_stream_window_ = true; } props->connection_established_ = true; - props->stream_render_rect_ = { - 0, (int)runtime->title_bar_height_, (int)runtime->stream_window_width_, - (int)(runtime->stream_window_height_ - runtime->title_bar_height_)}; - props->stream_render_rect_f_ = { - 0.0f, runtime->title_bar_height_, runtime->stream_window_width_, - runtime->stream_window_height_ - runtime->title_bar_height_}; runtime->start_keyboard_capturer_ = true; break; } @@ -259,11 +253,6 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status, props->stream_cleanup_pending_ = true; } - SDL_Event event; - event.type = runtime->STREAM_REFRESH_EVENT; - event.user.data1 = props.get(); - SDL_PushEvent(&event); - runtime->focus_on_stream_window_ = false; break; diff --git a/src/gui/runtime/peer_media_callbacks.cpp b/src/gui/runtime/peer_media_callbacks.cpp index 71a7b49..b5a931c 100644 --- a/src/gui/runtime/peer_media_callbacks.cpp +++ b/src/gui/runtime/peer_media_callbacks.cpp @@ -70,12 +70,9 @@ void PeerEventHandler::OnReceiveVideoBuffer( props->video_size_ = video_frame->size; props->front_frame_.swap(props->back_frame_); + ++props->video_frame_sequence_; } - SDL_Event event; - event.type = runtime->STREAM_REFRESH_EVENT; - event.user.data1 = props; - SDL_PushEvent(&event); props->streaming_ = true; if (props->net_traffic_stats_button_pressed_) { diff --git a/src/gui/runtime/remote_session.h b/src/gui/runtime/remote_session.h index 296217d..c1c6faa 100644 --- a/src/gui/runtime/remote_session.h +++ b/src/gui/runtime/remote_session.h @@ -1,8 +1,6 @@ #ifndef CROSSDESK_GUI_REMOTE_SESSION_H_ #define CROSSDESK_GUI_REMOTE_SESSION_H_ -#include - #include #include #include @@ -16,7 +14,6 @@ #include #include "display_info.h" -#include "imgui.h" #include "minirtc.h" namespace crossdesk::gui_detail { @@ -111,7 +108,7 @@ struct RemoteSession { double control_bar_button_pressed_time_ = 0; double net_traffic_stats_button_pressed_time_ = 0; - // Written by the decode callback thread and consumed by the SDL thread. + // Written by the decode callback thread and consumed by the UI thread. std::mutex video_frame_mutex_; std::shared_ptr> front_frame_; std::shared_ptr> back_frame_; @@ -129,6 +126,7 @@ struct RemoteSession { int video_height_last_ = 0; int selected_display_ = 0; size_t video_size_ = 0; + uint64_t video_frame_sequence_ = 0; bool tab_selected_ = false; bool tab_opened_ = true; std::optional pos_x_before_docked_; @@ -146,15 +144,7 @@ struct RemoteSession { bool remote_service_available_ = false; std::string remote_interactive_stage_; std::vector display_info_list_; - SDL_Texture *stream_texture_ = nullptr; - uint8_t *argb_buffer_ = nullptr; - int argb_buffer_size_ = 0; - SDL_FRect stream_render_rect_f_ = {0.0f, 0.0f, 0.0f, 0.0f}; - SDL_Rect stream_render_rect_{}; - SDL_Rect stream_render_rect_last_{}; - ImVec2 control_window_pos_{}; - - // Shared by minirtc callbacks, SDL rendering and SDL audio callbacks. + // Shared by minirtc callbacks, Slint rendering and SDL audio callbacks. std::atomic connection_status_ = ConnectionStatus::Closed; TraversalMode traversal_mode_ = TraversalMode::UnknownMode; int fps_ = 0; diff --git a/src/gui/runtime/runtime_state.h b/src/gui/runtime/runtime_state.h index dc93103..c5f68e3 100644 --- a/src/gui/runtime/runtime_state.h +++ b/src/gui/runtime/runtime_state.h @@ -91,6 +91,7 @@ struct PlatformIntegrationState { bool show_portable_service_prompt_suppressed_window_ = false; bool portable_service_do_not_remind_ = false; bool portable_service_prompt_suppressed_ = false; + bool portable_service_installed_ = false; std::atomic portable_service_install_state_{ PortableServiceInstallState::idle}; std::thread portable_service_install_thread_; diff --git a/src/gui/ui/common.slint b/src/gui/ui/common.slint new file mode 100644 index 0000000..ded42ac --- /dev/null +++ b/src/gui/ui/common.slint @@ -0,0 +1,458 @@ +// The legacy client uses ImGui::StyleColorsLight(). Keep the structural line +// colors in one place so the Slint renderer produces the same visual weight. +export global ImGuiLineStyle { + out property border: #0000004d; + out property separator: #6363639e; + out property divider: #0000007a; + out property control-separator: #b2b2b2; +} + +// ImGui loads a 32px font atlas and applies per-window font scales. These +// logical sizes reproduce those visual tiers on Slint's HiDPI renderer. +export global ImGuiFontStyle { + out property base: 12px; + out property section-title: 18px; + out property panel-label: 17px; + out property field-value: 22px; + out property prominent: 16px; + out property body: 11px; + out property small: 10px; +} + +// Font Awesome 6 Free Solid glyphs used by the legacy ImGui client. The font +// data is registered for every Slint window by GuiApplication before show(). +export global FontAwesomeIcons { + out property angle-down: "\u{f107}"; + out property angle-left: "\u{f104}"; + out property angle-right: "\u{f105}"; + out property angle-up: "\u{f106}"; + out property arrow-right-long: "\u{f178}"; + out property bars: "\u{f0c9}"; + out property check: "\u{f00c}"; + out property circle-arrow-up: "\u{f0aa}"; + out property compress: "\u{f066}"; + out property computer-mouse: "\u{f8cc}"; + out property copy: "\u{f0c5}"; + out property display: "\u{e163}"; + out property expand: "\u{f065}"; + out property eye: "\u{f06e}"; + out property eye-slash: "\u{f070}"; + out property folder-open: "\u{f07c}"; + out property keyboard: "\u{f11c}"; + out property minus: "\u{f068}"; + out property pen: "\u{f304}"; + out property signal: "\u{f012}"; + out property shield-halved: "\u{f3ed}"; + out property square-full: "\u{f45c}"; + out property trash-can: "\u{f2ed}"; + out property volume-high: "\u{f028}"; + out property volume-xmark: "\u{f6a9}"; + out property xmark: "\u{f00d}"; +} + +export component IconButton inherits Rectangle { + in property icon; + in property tooltip; + in property danger: false; + in property icon-size: 16px; + in property icon-color: #24272d; + in property normal-background: transparent; + in property hover-background: #edf0f5; + in property pressed-background: #d9dde5; + in property slashed: false; + callback clicked; + + width: 30px; + height: 30px; + background: touch.pressed ? (danger ? #d92d20 : root.pressed-background) + : touch.has-hover ? (danger ? #f04438 : root.hover-background) + : root.normal-background; + border-radius: 4px; + + Text { + text: root.icon; + color: touch.has-hover && root.danger ? white : root.icon-color; + font-family: "Font Awesome 6 Free"; + font-weight: 900; + font-size: root.icon-size; + horizontal-alignment: center; + vertical-alignment: center; + } + + if root.slashed: Text { + text: "/"; + color: touch.has-hover && root.danger ? white : root.icon-color; + font-size: root.icon-size + 7px; + font-weight: 700; + horizontal-alignment: center; + vertical-alignment: center; + } + + touch := TouchArea { + clicked => { root.clicked(); } + } + + if touch.has-hover && root.tooltip != "": Rectangle { + x: min(root.width - self.width, 0px); + y: root.height + 3px; + width: tip.preferred-width + 12px; + height: 24px; + background: #22252be8; + border-radius: 4px; + z: 20; + tip := Text { + text: root.tooltip; + color: white; + font-size: ImGuiFontStyle.small; + horizontal-alignment: center; + vertical-alignment: center; + } + } +} + +// Retained for non-font artwork. UI action icons use IconButton so the Slint +// and ImGui clients share Font Awesome's glyph geometry. +export component ImageButton inherits Rectangle { + in property icon; + in property tooltip; + in property danger: false; + in property icon-width: 16px; + in property icon-height: 16px; + callback clicked; + + width: 30px; + height: 30px; + background: touch.pressed ? (danger ? #d92d20 : #d9dde5) + : touch.has-hover ? (danger ? #f04438 : #edf0f5) + : transparent; + border-radius: 4px; + + Image { + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + width: root.icon-width; + height: root.icon-height; + source: root.icon; + image-fit: contain; + } + + touch := TouchArea { + clicked => { root.clicked(); } + } + + if touch.has-hover && root.tooltip != "": Rectangle { + x: min(root.width - self.width, 0px); + y: root.height + 3px; + width: tip.preferred-width + 12px; + height: 24px; + background: #22252be8; + border-radius: 4px; + z: 20; + tip := Text { + text: root.tooltip; + color: white; + font-size: ImGuiFontStyle.small; + horizontal-alignment: center; + vertical-alignment: center; + } + } +} + +export component CompactComboBox inherits Rectangle { + in property <[string]> model; + in-out property current-index: 0; + in property enabled: true; + property popup-animation-enabled: false; + property popup-progress: 0.0; + callback selected(int); + + animate popup-progress { + duration: 160ms; + easing: ease-out; + enabled: root.popup-animation-enabled; + } + + width: 73px; + height: 24px; + background: root.enabled ? white : #f1f1f1; + border-width: 1px; + border-color: #bfc2c7; + border-radius: 3px; + + Text { + x: 5px; + width: parent.width - 24px; + text: root.model[root.current-index]; + color: root.enabled ? #202124 : #8b8f95; + font-size: ImGuiFontStyle.body; + overflow: elide; + vertical-alignment: center; + } + Rectangle { + x: parent.width - 21px; + width: 20px; + height: parent.height - 2px; + y: 1px; + background: root.enabled ? #9bc9f8 : #d7d9dc; + Text { + text: FontAwesomeIcons.angle-down; + color: #202124; + font-family: "Font Awesome 6 Free"; + font-weight: 900; + font-size: 10px; + horizontal-alignment: center; + vertical-alignment: center; + transform-rotation: combo-popup.is-open ? 180deg : 0deg; + animate transform-rotation { + duration: 140ms; + easing: ease-out; + enabled: combo-popup.is-open; + } + } + } + TouchArea { + enabled: root.enabled; + clicked => { + root.popup-animation-enabled = false; + root.popup-progress = 0.0; + combo-popup.show(); + popup-open-timer.running = true; + } + } + + // Start on the next frame so the native popup first renders at zero + // height, making the downward expansion visible on every backend. + popup-open-timer := Timer { + interval: 16ms; + running: false; + triggered => { + self.running = false; + if combo-popup.is-open { + root.popup-animation-enabled = true; + root.popup-progress = 1.0; + } + } + } + + combo-popup := PopupWindow { + x: 0px; + y: root.height; + width: root.width; + height: root.model.length * 23px; + close-policy: close-on-click-outside; + + Rectangle { + // Keep the reveal anchored to the popup's top edge while the + // content settles downward into its final position. + y: (1.0 - root.popup-progress) * -8px; + width: parent.width; + height: parent.height * root.popup-progress; + opacity: root.popup-progress; + background: white; + border-width: 1px; + border-color: #bfc2c7; + border-radius: 3px; + clip: true; + for choice[index] in root.model: Rectangle { + y: index * 23px; + width: parent.width; + height: 23px; + background: option-touch.has-hover ? #dbeafb : index == root.current-index ? #eef5fd : white; + Text { x: 5px; width: parent.width - 10px; text: choice; color: #202124; font-size: ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; } + option-touch := TouchArea { + clicked => { + popup-open-timer.running = false; + root.popup-animation-enabled = false; + root.popup-progress = 0.0; + root.current-index = index; + root.selected(index); + combo-popup.close(); + } + } + } + Rectangle { + width: parent.width; + height: parent.height; + background: transparent; + border-width: 1px; + border-color: #bfc2c7; + border-radius: 3px; + z: 2; + } + } + } +} + +export component ImGuiCheckBox inherits Rectangle { + in-out property checked; + in property enabled: true; + + width: 21px; + height: 21px; + background: root.enabled ? white : #f1f1f1; + border-width: 1px; + border-color: check-touch.has-hover && root.enabled ? #8cbef2 : #b9bdc2; + border-radius: 4px; + if root.checked: Text { + text: FontAwesomeIcons.check; + color: #2e86de; + font-family: "Font Awesome 6 Free"; + font-weight: 900; + font-size: 16px; + horizontal-alignment: center; + vertical-alignment: center; + } + check-touch := TouchArea { + enabled: root.enabled; + clicked => { root.checked = !root.checked; } + } +} + +export component MiniToggleSwitch inherits Rectangle { + in-out property checked; + in property enabled: true; + callback toggled(bool); + + width: 29px; + height: 16px; + background: !root.enabled ? (root.checked ? #6e9ddacc : #9a9a9acc) + : root.checked ? #2463c7 : #9a9a9a; + border-radius: 8px; + Rectangle { + x: root.checked ? parent.width - self.width - 2px : 2px; + y: 2px; + width: 12px; + height: 12px; + border-radius: 6px; + background: white; + } + TouchArea { + enabled: root.enabled; + clicked => { root.checked = !root.checked; root.toggled(root.checked); } + } +} + +export component CompactButton inherits Rectangle { + in property text; + in property primary: false; + in property enabled: true; + callback clicked; + + width: 34px; + height: 24px; + background: !root.enabled ? #eceef0 + : button-touch.pressed ? (root.primary ? #82b8f2 : #d8dadd) + : button-touch.has-hover ? (root.primary ? #b8d9fb : #f0f1f2) + : root.primary ? #a8d0fa : #e2e4e7; + border-width: 1px; + border-color: root.primary ? #9bc3ed : #c8cbd0; + border-radius: 3px; + Text { + text: root.text; + color: root.enabled ? #24272d : #8e9298; + font-size: ImGuiFontStyle.body; + overflow: elide; + horizontal-alignment: center; + vertical-alignment: center; + } + button-touch := TouchArea { enabled: root.enabled; clicked => { root.clicked(); } } +} + +export component PrimaryButton inherits Rectangle { + in property text; + in property enabled: true; + callback clicked; + + min-width: 72px; + height: 30px; + background: !root.enabled ? #b8c1d1 + : touch.pressed ? #1849a9 + : touch.has-hover ? #2970d6 + : #2463c7; + border-radius: 5px; + + Text { + text: root.text; + color: white; + font-size: ImGuiFontStyle.body; + horizontal-alignment: center; + vertical-alignment: center; + } + + touch := TouchArea { + enabled: root.enabled; + clicked => { root.clicked(); } + } +} + +export component SecondaryButton inherits Rectangle { + in property text; + in property enabled: true; + callback clicked; + + min-width: 72px; + height: 30px; + background: !root.enabled ? #f1f2f4 + : touch.pressed ? #dfe3e9 + : touch.has-hover ? #f3f4f6 : white; + border-width: 1px; + border-color: #c9ced7; + border-radius: 5px; + clip: true; + + Text { + text: root.text; + color: root.enabled ? #24272d : #949aa5; + font-size: ImGuiFontStyle.body; + horizontal-alignment: center; + vertical-alignment: center; + } + + touch := TouchArea { + enabled: root.enabled; + clicked => { root.clicked(); } + } +} + +export component ToggleSwitch inherits Rectangle { + in-out property checked; + in property enabled: true; + callback toggled(bool); + + width: 36px; + height: 20px; + background: !root.enabled ? #d0d5dd : root.checked ? #2463c7 : #98a2b3; + border-radius: self.height / 2; + + Rectangle { + x: root.checked ? parent.width - self.width - 3px : 3px; + y: 3px; + width: 14px; + height: 14px; + border-radius: 7px; + background: white; + animate x { duration: 100ms; easing: ease-in-out; } + } + + TouchArea { + enabled: root.enabled; + clicked => { + root.checked = !root.checked; + root.toggled(root.checked); + } + } +} + +export component SectionTitle inherits Text { + color: #00000080; + font-size: ImGuiFontStyle.section-title; + font-weight: 500; + vertical-alignment: center; +} + +export component DialogSurface inherits Rectangle { + background: white; + border-width: 1px; + border-color: ImGuiLineStyle.border; + border-radius: 8px; +} diff --git a/src/gui/ui/crossdesk_ui.slint b/src/gui/ui/crossdesk_ui.slint new file mode 100644 index 0000000..891db8f --- /dev/null +++ b/src/gui/ui/crossdesk_ui.slint @@ -0,0 +1,5 @@ +// Single AOT entry point. Generating one header for the entire UI avoids +// duplicate imported-component definitions when C++ uses all three windows. +export { MainWindow, RecentConnection, UiStrings } from "main_window.slint"; +export { StreamWindow, StreamTab, FileTransferEntry, NetworkStatsRow, StreamStrings } from "stream_window.slint"; +export { ServerWindow, ControllerEntry } from "server_window.slint"; diff --git a/src/gui/ui/icons/arrow-right-white.svg b/src/gui/ui/icons/arrow-right-white.svg new file mode 100644 index 0000000..e6d7f61 --- /dev/null +++ b/src/gui/ui/icons/arrow-right-white.svg @@ -0,0 +1 @@ + diff --git a/src/gui/ui/icons/arrow-right.svg b/src/gui/ui/icons/arrow-right.svg new file mode 100644 index 0000000..b7c481e --- /dev/null +++ b/src/gui/ui/icons/arrow-right.svg @@ -0,0 +1 @@ + diff --git a/src/gui/ui/icons/check.svg b/src/gui/ui/icons/check.svg new file mode 100644 index 0000000..4cb4185 --- /dev/null +++ b/src/gui/ui/icons/check.svg @@ -0,0 +1 @@ + diff --git a/src/gui/ui/icons/chevron-down.svg b/src/gui/ui/icons/chevron-down.svg new file mode 100644 index 0000000..95bbdb4 --- /dev/null +++ b/src/gui/ui/icons/chevron-down.svg @@ -0,0 +1 @@ + diff --git a/src/gui/ui/icons/chevron-left.svg b/src/gui/ui/icons/chevron-left.svg new file mode 100644 index 0000000..1ae7b51 --- /dev/null +++ b/src/gui/ui/icons/chevron-left.svg @@ -0,0 +1 @@ + diff --git a/src/gui/ui/icons/chevron-right.svg b/src/gui/ui/icons/chevron-right.svg new file mode 100644 index 0000000..f10daa4 --- /dev/null +++ b/src/gui/ui/icons/chevron-right.svg @@ -0,0 +1 @@ + diff --git a/src/gui/ui/icons/chevron-up.svg b/src/gui/ui/icons/chevron-up.svg new file mode 100644 index 0000000..0223af3 --- /dev/null +++ b/src/gui/ui/icons/chevron-up.svg @@ -0,0 +1 @@ + diff --git a/src/gui/ui/icons/close-white.svg b/src/gui/ui/icons/close-white.svg new file mode 100644 index 0000000..16379b8 --- /dev/null +++ b/src/gui/ui/icons/close-white.svg @@ -0,0 +1 @@ + diff --git a/src/gui/ui/icons/close.svg b/src/gui/ui/icons/close.svg new file mode 100644 index 0000000..7ce7fb9 --- /dev/null +++ b/src/gui/ui/icons/close.svg @@ -0,0 +1 @@ + diff --git a/src/gui/ui/icons/compress.svg b/src/gui/ui/icons/compress.svg new file mode 100644 index 0000000..ad274da --- /dev/null +++ b/src/gui/ui/icons/compress.svg @@ -0,0 +1 @@ + diff --git a/src/gui/ui/icons/copy.svg b/src/gui/ui/icons/copy.svg new file mode 100644 index 0000000..5c52da7 --- /dev/null +++ b/src/gui/ui/icons/copy.svg @@ -0,0 +1 @@ + diff --git a/src/gui/ui/icons/expand.svg b/src/gui/ui/icons/expand.svg new file mode 100644 index 0000000..ecc2feb --- /dev/null +++ b/src/gui/ui/icons/expand.svg @@ -0,0 +1 @@ + diff --git a/src/gui/ui/icons/eye-slash.svg b/src/gui/ui/icons/eye-slash.svg new file mode 100644 index 0000000..1c2aef3 --- /dev/null +++ b/src/gui/ui/icons/eye-slash.svg @@ -0,0 +1 @@ + diff --git a/src/gui/ui/icons/eye.svg b/src/gui/ui/icons/eye.svg new file mode 100644 index 0000000..6386302 --- /dev/null +++ b/src/gui/ui/icons/eye.svg @@ -0,0 +1 @@ + diff --git a/src/gui/ui/icons/file-up.svg b/src/gui/ui/icons/file-up.svg new file mode 100644 index 0000000..c491b4f --- /dev/null +++ b/src/gui/ui/icons/file-up.svg @@ -0,0 +1 @@ + diff --git a/src/gui/ui/icons/keyboard.svg b/src/gui/ui/icons/keyboard.svg new file mode 100644 index 0000000..714bba6 --- /dev/null +++ b/src/gui/ui/icons/keyboard.svg @@ -0,0 +1 @@ + diff --git a/src/gui/ui/icons/maximize.svg b/src/gui/ui/icons/maximize.svg new file mode 100644 index 0000000..7755f4f --- /dev/null +++ b/src/gui/ui/icons/maximize.svg @@ -0,0 +1 @@ + diff --git a/src/gui/ui/icons/menu.svg b/src/gui/ui/icons/menu.svg new file mode 100644 index 0000000..59bc0ce --- /dev/null +++ b/src/gui/ui/icons/menu.svg @@ -0,0 +1 @@ + diff --git a/src/gui/ui/icons/minimize.svg b/src/gui/ui/icons/minimize.svg new file mode 100644 index 0000000..a1e6571 --- /dev/null +++ b/src/gui/ui/icons/minimize.svg @@ -0,0 +1 @@ + diff --git a/src/gui/ui/icons/monitor.svg b/src/gui/ui/icons/monitor.svg new file mode 100644 index 0000000..13fdcbc --- /dev/null +++ b/src/gui/ui/icons/monitor.svg @@ -0,0 +1 @@ + diff --git a/src/gui/ui/icons/mouse-off.svg b/src/gui/ui/icons/mouse-off.svg new file mode 100644 index 0000000..2b89f22 --- /dev/null +++ b/src/gui/ui/icons/mouse-off.svg @@ -0,0 +1 @@ + diff --git a/src/gui/ui/icons/mouse.svg b/src/gui/ui/icons/mouse.svg new file mode 100644 index 0000000..df8bf87 --- /dev/null +++ b/src/gui/ui/icons/mouse.svg @@ -0,0 +1 @@ + diff --git a/src/gui/ui/icons/pen-white.svg b/src/gui/ui/icons/pen-white.svg new file mode 100644 index 0000000..95daea7 --- /dev/null +++ b/src/gui/ui/icons/pen-white.svg @@ -0,0 +1 @@ + diff --git a/src/gui/ui/icons/pen.svg b/src/gui/ui/icons/pen.svg new file mode 100644 index 0000000..c76095b --- /dev/null +++ b/src/gui/ui/icons/pen.svg @@ -0,0 +1 @@ + diff --git a/src/gui/ui/icons/stats.svg b/src/gui/ui/icons/stats.svg new file mode 100644 index 0000000..a3dc13a --- /dev/null +++ b/src/gui/ui/icons/stats.svg @@ -0,0 +1 @@ + diff --git a/src/gui/ui/icons/trash-white.svg b/src/gui/ui/icons/trash-white.svg new file mode 100644 index 0000000..851195a --- /dev/null +++ b/src/gui/ui/icons/trash-white.svg @@ -0,0 +1 @@ + diff --git a/src/gui/ui/icons/trash.svg b/src/gui/ui/icons/trash.svg new file mode 100644 index 0000000..85b51c7 --- /dev/null +++ b/src/gui/ui/icons/trash.svg @@ -0,0 +1 @@ + diff --git a/src/gui/ui/icons/volume-off.svg b/src/gui/ui/icons/volume-off.svg new file mode 100644 index 0000000..699f11a --- /dev/null +++ b/src/gui/ui/icons/volume-off.svg @@ -0,0 +1 @@ + diff --git a/src/gui/ui/icons/volume.svg b/src/gui/ui/icons/volume.svg new file mode 100644 index 0000000..2c7f93c --- /dev/null +++ b/src/gui/ui/icons/volume.svg @@ -0,0 +1 @@ + diff --git a/src/gui/ui/main_window.slint b/src/gui/ui/main_window.slint new file mode 100644 index 0000000..3a101e0 --- /dev/null +++ b/src/gui/ui/main_window.slint @@ -0,0 +1,1481 @@ +import { LineEdit, ScrollView } from "std-widgets.slint"; +import { FontAwesomeIcons, IconButton, CompactComboBox, ImGuiCheckBox, MiniToggleSwitch, CompactButton, PrimaryButton, SecondaryButton, ToggleSwitch, SectionTitle, DialogSurface, ImGuiLineStyle, ImGuiFontStyle } from "common.slint"; + +export struct RecentConnection { + remote-id: string, + display-name: string, + host-name: string, + online: bool, + thumbnail: image, +} + +export global UiStrings { + in-out property local-desktop: "Local Desktop"; + in-out property remote-desktop: "Remote Desktop"; + in-out property recent-connections: "Recent Connections"; + in-out property local-id: "Local ID"; + in-out property device-name: "Device Name"; + in-out property remote-id: "Remote ID"; + in-out property online: "Online"; + in-out property offline: "Offline"; + in-out property password: "Password"; + in-out property copied: "Local ID copied to clipboard"; + in-out property connect: "Connect"; + in-out property settings: "Settings"; + in-out property about: "About"; + in-out property ok: "OK"; + in-out property cancel: "Cancel"; + in-out property save: "Save"; + in-out property new-password: "New password (6 characters)"; + in-out property invalid-password: "Password must contain exactly 6 characters"; + in-out property edit-alias: "Connection alias"; + in-out property delete-connection: "Delete connection"; + in-out property confirm-delete: "Delete this recent connection?"; + in-out property language: "Language"; + in-out property video-quality: "Video quality"; + in-out property frame-rate: "Frame rate"; + in-out property codec: "Video codec"; + in-out property hardware-codec: "Hardware video codec"; + in-out property turn-relay: "TURN relay"; + in-out property srtp: "SRTP encryption"; + in-out property self-hosted: "Self-hosted server"; + in-out property autostart: "Start with system"; + in-out property daemon: "Run in background"; + in-out property minimize-to-tray: "Minimize to tray"; + in-out property file-save-path: "File save path"; + in-out property default-desktop: "Desktop"; + in-out property server-host: "Server host"; + in-out property server-port: "Signal port"; + in-out property coturn-port: "TURN port"; + in-out property version: "Version"; + in-out property copyright: "Copyright © CrossDesk contributors"; + in-out property license: "Licensed under GPL-3.0-only"; + in-out property signal-connected: "Signal server connected"; + in-out property signal-disconnected: "Signal server disconnected"; + in-out property tls-error: "Signal server TLS certificate error"; + in-out property update-available: "A new version is available"; + in-out property release-notes: "Release notes"; + in-out property later: "Later"; + in-out property download: "Download"; + in-out property input-password: "Input password"; + in-out property reinput-password: "Incorrect password, please try again"; + in-out property remember-password: "Remember password"; + in-out property validate-password: "Validating password"; + in-out property request-permissions: "Request permissions"; + in-out property permission-required: "The application requires the following permissions:"; + in-out property screen-recording-permission: "Screen recording permission"; + in-out property accessibility-permission: "Accessibility permission"; + in-out property service-setup-title: "Install CrossDesk Service"; + in-out property service-setup-message: "Administrator permission is required to control this device while it is locked."; + in-out property service-settings-label: "Lock Screen Service:"; + in-out property install-service: "Install"; + in-out property service-installed: "Installed"; + in-out property do-not-remind: "Do not remind again"; + in-out property notification: "Notification"; + in-out property service-suppressed-message: "You will not be reminded again. You can install the service later in Settings."; + in-out property quality-low: "Low"; + in-out property quality-medium: "Medium"; + in-out property quality-high: "High"; + in-out property codec-h264: "H.264"; + in-out property codec-av1: "AV1"; + in-out property self-hosted-settings: "Self-hosted server settings"; + in-out property access-website: "Website: "; + in-out property release-date-label: "Release Date: "; +} + +// Fluent LineEdit draws a blue 2px focus underline. The ImGui-style field +// already has its own border, so cover that underline and restore its bottom +// edge without changing the editor's focus, selection, or clear-button logic. +component LineEditFocusUnderlineMask inherits Rectangle { + height: 3px; + background: white; + z: 2; + + Rectangle { + y: parent.height - 1px; + height: 1px; + background: ImGuiLineStyle.border; + } +} + +// Prompt copy in modal dialogs sits slightly low in the legacy ImGui layout. +// Offset only the rendered text so input fields and action buttons keep their +// established geometry. +component DialogPromptText inherits Rectangle { + in property prompt; + in property text-color: #30343b; + in property text-size: ImGuiFontStyle.base; + in property text-weight: 400; + + // Mirror Text's intrinsic layout constraints exactly. This makes the + // wrapper invisible to VerticalLayout: siblings retain the same geometry + // they had when the Text item was a direct child. + min-height: prompt-label.min-height; + preferred-height: prompt-label.preferred-height; + max-height: prompt-label.max-height; + vertical-stretch: prompt-label.vertical-stretch; + + prompt-label := Text { + y: 10px; + width: parent.width; + text: root.prompt; + color: root.text-color; + font-size: root.text-size; + font-weight: root.text-weight; + wrap: word-wrap; + horizontal-alignment: center; + } +} + +// Shared online-state artwork. Both the recent-connection card and the +// server status bar use the same pure green center and proportional glow. +component OnlineStatusIndicator inherits Rectangle { + in property dot-diameter: 8px; + + width: root.dot-diameter * 2.2; + height: self.width; + border-radius: self.width / 2; + background: @radial-gradient( + circle, + #00ff0030 0%, + #00ff0018 48%, + transparent 100% + ); + + Rectangle { + x: (parent.width - root.dot-diameter) / 2; + y: (parent.height - root.dot-diameter) / 2; + width: root.dot-diameter; + height: root.dot-diameter; + border-radius: root.dot-diameter / 2; + background: #00ff00; + } +} + +// ImGui::Button under StyleColorsLight(), used by both actions in the legacy +// connection-password dialog. The old dialog does not distinguish primary and +// secondary actions visually. +component ConnectionDialogButton inherits Rectangle { + in property text; + callback clicked; + + width: max(40px, button-label.preferred-width + 8px); + height: 24px; + background: button-touch.pressed ? #0f87fa + : button-touch.has-hover ? #4296fa + : #4296fa66; + border-radius: 3px; + + button-label := Text { + text: root.text; + color: #000000de; + font-size: ImGuiFontStyle.base; + horizontal-alignment: center; + vertical-alignment: center; + } + button-touch := TouchArea { clicked => { root.clicked(); } } +} + +export component MainWindow inherits Window { + title: "CrossDesk"; + preferred-width: 640px; + preferred-height: 450px; + min-width: 640px; + max-width: 640px; + min-height: 450px; + max-height: 450px; + no-frame: false; + background: white; + default-font-size: ImGuiFontStyle.base; + + in property local-id: ""; + in property local-password: ""; + in-out property remote-id-input: ""; + in property password-visible: true; + in property signal-connected: false; + in property signal-tls-error: false; + in property <[RecentConnection]> recent-connections; + in property update-available: false; + in property current-version: ""; + in property latest-version: ""; + in property release-name: ""; + in property release-notes: ""; + in property release-date: ""; + in-out property offline-warning: ""; + in property connection-dialog-open: false; + in property connection-status-text: ""; + in property connection-pending: false; + in property connection-password-required: false; + in property connection-validating: false; + in-out property connection-password: ""; + in-out property connection-remember-password: false; + in property permission-dialog-open: false; + in property screen-recording-granted: true; + in property accessibility-granted: true; + in property portable-service-settings-visible: false; + in property portable-service-dialog-open: false; + in property portable-service-suppressed-notice-open: false; + in property portable-service-installed: false; + in property portable-service-installing: false; + in property portable-service-succeeded: false; + in property portable-service-status: ""; + in-out property portable-service-do-not-remind: false; + + in-out property language-index: 0; + in-out property video-quality-index: 2; + in-out property frame-rate-index: 1; + in-out property codec-index: 0; + in-out property hardware-codec-enabled: true; + in-out property turn-enabled: true; + in-out property srtp-enabled: false; + in-out property self-hosted-enabled: false; + in-out property autostart-enabled: false; + in-out property daemon-enabled: false; + in-out property minimize-to-tray-enabled: false; + in-out property file-save-path: ""; + in-out property server-host: ""; + in-out property server-port: ""; + in-out property coturn-port: ""; + in property settings-session-active: false; + in property hardware-codec-visible: true; + + callback copy-local-id; + callback toggle-password-visibility; + callback reset-password(string) -> bool; + callback format-remote-id(string) -> string; + callback connect-requested(string); + callback recent-connect(string); + callback recent-edit-alias(string, string); + callback recent-delete(string); + callback save-settings; + callback cancel-settings; + callback save-self-hosted-settings; + callback cancel-self-hosted-settings; + callback browse-save-path; + callback open-download; + callback connection-cancel; + callback connection-acknowledge; + callback connection-submit-password(string, bool); + callback reset-remote-id; + callback request-screen-recording-permission; + callback request-accessibility-permission; + callback install-portable-service; + callback cancel-portable-service; + callback acknowledge-portable-service; + callback acknowledge-portable-service-suppressed; + + private property menu-open: false; + in-out property settings-open: false; + in-out property self-host-settings-open: false; + in-out property about-open: false; + in-out property update-open: root.update-available; + in-out property reset-password-open: false; + in-out property reset-password-invalid: false; + in-out property new-password-input: ""; + in-out property alias-open: false; + private property alias-remote-id: ""; + in-out property alias-input: ""; + in-out property delete-open: false; + private property delete-remote-id: ""; + private property copy-toast: false; + private property compact-language: root.language-index == 0; + private property update-icon-blink-on: + root.update-available && mod(animation-tick(), 2s) < 1s; + private property recent-tooltip-visible: false; + private property recent-tooltip-name: ""; + private property recent-tooltip-host: ""; + private property recent-tooltip-id: ""; + private property recent-tooltip-online: false; + private property recent-tooltip-pointer-x: 0px; + private property recent-tooltip-pointer-y: 0px; + private property recent-tooltip-width: 260px; + private property recent-tooltip-height: + root.recent-tooltip-host != "" && root.recent-tooltip-host != root.recent-tooltip-name + ? 80px : 64px; + private property recent-tooltip-x: min( + root.width - root.recent-tooltip-width - 8px, + max(8px, + root.recent-tooltip-pointer-x > root.width * 0.7 + ? root.recent-tooltip-pointer-x - root.recent-tooltip-width - 12px + : root.recent-tooltip-pointer-x + 12px)); + private property recent-tooltip-y: min( + root.height - root.recent-tooltip-height - 8px, + max(8px, root.recent-tooltip-pointer-y - root.recent-tooltip-height - 8px)); + + reset-remote-id => { + root.remote-id-input = ""; + remote-id-editor.focus(); + } + + Rectangle { + width: root.width; + height: root.height; + background: white; + border-width: 0px; + border-radius: 0px; + + focus-sink := FocusScope { x: 0px; y: 0px; width: 0px; height: 0px; } + TouchArea { clicked => { focus-sink.focus(); } } + + // Slint owns the client area below the native macOS title bar. Draw + // the boundary here because AppKit's titlebar separator is not shown + // reliably when winit creates a window without an NSToolbar. + Rectangle { + x: 0px; + y: 0px; + width: parent.width; + height: 1px; + background: ImGuiLineStyle.control-separator; + z: 7; + } + + // App actions live in the content header; native window controls and + // dragging are provided by the system title bar. + menu-button := IconButton { + x: parent.width - 40px; + y: 4px; + width: 32px; + height: 32px; + icon: FontAwesomeIcons.bars; + icon-size: 14px; + hover-background: #e1e5eb; + pressed-background: #cbd1d9; + z: 8; + clicked => { root.menu-open = !root.menu-open; } + } + if root.update-available && !root.menu-open && root.update-icon-blink-on: Text { + x: menu-button.x + 16px; + y: menu-button.y + 1px; + width: 16px; + height: 16px; + text: FontAwesomeIcons.circle-arrow-up; + color: #24272d; + font-family: "Font Awesome 6 Free"; + font-weight: 900; + font-size: 13px; + horizontal-alignment: center; + vertical-alignment: center; + z: 9; + } + + // Local and remote desktop panels. + Rectangle { + y: 0px; + height: 234px; + background: white; + + SectionTitle { x: 29px; y: 9px; width: 256px; height: 31px; text: UiStrings.local-desktop; } + Rectangle { + x: 29px; + y: 42px; + width: 256px; + height: 183px; + background: #eff0f2; + border-radius: 9px; + border-width: 1px; + border-color: ImGuiLineStyle.border; + + Text { x: 8px; y: 10px; width: 160px; height: 26px; text: UiStrings.local-id; color: #111111; font-size: ImGuiFontStyle.panel-label; } + Rectangle { + x: 8px; + y: 42px; + width: 160px; + height: 38px; + background: white; + border-width: 1px; + border-color: ImGuiLineStyle.border; + border-radius: 4px; + Text { + x: 8px; + // Compensate for the font's ascender-heavy + // metrics so digits sit at the optical center. + y: 4px; + width: parent.width - 16px; + height: parent.height - 4px; + text: root.local-id; + color: #20242a; + font-size: ImGuiFontStyle.field-value; + letter-spacing: 1px; + horizontal-alignment: left; + vertical-alignment: center; + } + } + IconButton { + x: 174px; + y: 46px; + width: 29px; + height: 30px; + icon: FontAwesomeIcons.copy; + icon-size: 16px; + tooltip: UiStrings.copied; + clicked => { + root.copy-local-id(); + root.copy-toast = true; + copy-timer.restart(); + } + } + Rectangle { x: 8px; y: 88px; width: 240px; height: 1px; background: ImGuiLineStyle.separator; } + Text { x: 8px; y: 101px; width: 160px; height: 26px; text: UiStrings.password; color: #111111; font-size: ImGuiFontStyle.panel-label; } + Rectangle { + x: 8px; + y: 129px; + width: 160px; + height: 39px; + background: white; + border-width: 1px; + border-color: ImGuiLineStyle.border; + border-radius: 4px; + Text { + x: 8px; + y: 4px; + width: parent.width - 16px; + height: parent.height - 4px; + text: root.password-visible ? root.local-password : "••••••"; + color: #20242a; + font-size: ImGuiFontStyle.field-value; + letter-spacing: 2px; + horizontal-alignment: left; + vertical-alignment: center; + } + } + IconButton { + x: 174px; + y: 133px; + width: 29px; + height: 30px; + icon: root.password-visible ? FontAwesomeIcons.eye : FontAwesomeIcons.eye-slash; + icon-size: 17px; + clicked => { root.toggle-password-visibility(); } + } + IconButton { + x: 214px; + y: 133px; + width: 29px; + height: 30px; + icon: FontAwesomeIcons.pen; + icon-size: 15px; + clicked => { root.reset-password-open = true; } + } + } + + Rectangle { x: parent.width / 2; y: 18px; width: 1px; height: 206px; background: ImGuiLineStyle.divider; } + + SectionTitle { x: 356px; y: 9px; width: 256px; height: 31px; text: UiStrings.remote-desktop; } + Rectangle { + x: 356px; + y: 42px; + width: 256px; + height: 183px; + background: #eff0f2; + border-radius: 9px; + border-width: 1px; + border-color: ImGuiLineStyle.border; + + Text { x: 8px; y: 10px; width: 160px; height: 26px; text: UiStrings.remote-id; color: #111111; font-size: ImGuiFontStyle.panel-label; } + Rectangle { + x: 8px; + y: 42px; + width: 160px; + height: 38px; + background: white; + border-width: 1px; + border-color: ImGuiLineStyle.border; + border-radius: 4px; + remote-id-editor := TextInput { + x: 10px; + y: 1.5px; + width: parent.width - 20px; + height: parent.height - 3px; + text <=> root.remote-id-input; + font-size: ImGuiFontStyle.field-value; + input-type: InputType.number; + color: #202124; + vertical-alignment: center; + selection-foreground-color: white; + selection-background-color: #2463c7; + // Run formatting after the input event has + // finished so the caret state isn't reset by + // a synchronous two-way binding rewrite. + edited => { remote-id-format-timer.restart(); } + accepted => { root.connect-requested(root.remote-id-input); } + } + } + IconButton { + x: 175px; + y: 42px; + width: 64px; + height: 38px; + normal-background: #a7cefc; + hover-background: #b8d8fc; + pressed-background: #76aeea; + border-radius: 4px; + icon: FontAwesomeIcons.arrow-right-long; + icon-size: 25px; + clicked => { root.connect-requested(root.remote-id-input); } + } + } + } + + // Recent connections: horizontal cards with thumbnail, state and actions. + recent-panel := Rectangle { + y: 234px; + height: 192px; + background: white; + border-width: 0px; + border-color: ImGuiLineStyle.border; + + Rectangle { y: 0px; height: 1px; background: ImGuiLineStyle.border; } + + SectionTitle { x: 29px; y: 7px; height: 28px; text: UiStrings.recent-connections; } + + recent-list := Rectangle { + x: 29px; + y: 42px; + width: parent.width - 58px; + height: 139px; + background: #eff0f2; + border-width: 1px; + border-color: ImGuiLineStyle.border; + border-radius: 10px; + clip: true; + + recent-scroll := ScrollView { + width: parent.width; + height: parent.height; + horizontal-stretch: 1; + + HorizontalLayout { + padding-left: 8px; + padding-top: 8px; + padding-bottom: 14px; + spacing: 24px; + alignment: start; + for connection[index] in root.recent-connections: connection-card := Rectangle { + width: 178px; + height: 117px; + background: transparent; + private property content-width: self.width - 30px; + private property toolbar-width: 79px; + private property toolbar-right-padding: 3px; + + Image { + x: 7px; + y: 7px; + width: connection-card.content-width; + height: 84px; + source: connection.thumbnail; + image-fit: cover; + } + Rectangle { + x: 7px; + y: 91px; + width: connection-card.content-width; + height: 25px; + background: #00000018; + border-radius: 3px; + Text { + x: 4px; + y: 1px; + height: parent.height - self.y; + width: max(0px, parent.width - self.x - 4px + - (card-touch.has-hover + ? connection-card.toolbar-width + connection-card.toolbar-right-padding + : 0px)); + text: connection.display-name; + color: #30343b; + // The ImGui card footer uses its own 0.52 font + // scale and renders slightly larger than the + // compact body text used in dialogs. + font-size: ImGuiFontStyle.base; + overflow: elide; + vertical-alignment: center; + } + } + if connection.online: OnlineStatusIndicator { + x: 16px - self.width / 2; + y: 16px - self.height / 2; + dot-diameter: 9px; + } + if !connection.online: Rectangle { + x: 11.5px; + y: 11.5px; + width: 9px; + height: 9px; + border-radius: 4.5px; + background: #9ca3af; + } + card-touch := TouchArea { + x: 7px; + y: 7px; + width: connection-card.content-width; + height: 109px; + clicked => { root.recent-connect(connection.remote-id); } + image-touch := TouchArea { + width: parent.width; + height: 84px; + clicked => { root.recent-connect(connection.remote-id); } + changed has-hover => { + if self.has-hover { + root.recent-tooltip-name = connection.display-name; + root.recent-tooltip-host = connection.host-name; + root.recent-tooltip-id = connection.remote-id; + root.recent-tooltip-online = connection.online; + root.recent-tooltip-pointer-x = recent-panel.x + recent-list.x + + recent-scroll.x + recent-scroll.viewport-x + + connection-card.x + card-touch.x + self.mouse-x; + root.recent-tooltip-pointer-y = recent-panel.y + recent-list.y + + recent-scroll.y + recent-scroll.viewport-y + + connection-card.y + card-touch.y + self.mouse-y; + root.recent-tooltip-visible = true; + } else if root.recent-tooltip-id == connection.remote-id { + root.recent-tooltip-visible = false; + } + } + moved => { + if self.has-hover { + root.recent-tooltip-pointer-x = recent-panel.x + recent-list.x + + recent-scroll.x + recent-scroll.viewport-x + + connection-card.x + card-touch.x + self.mouse-x; + root.recent-tooltip-pointer-y = recent-panel.y + recent-list.y + + recent-scroll.y + recent-scroll.viewport-y + + connection-card.y + card-touch.y + self.mouse-y; + } + } + } + if card-touch.has-hover: Rectangle { + x: parent.width - self.width - connection-card.toolbar-right-padding; + y: 85px; + width: connection-card.toolbar-width; + height: 23px; + background: #4a4e55dc; + border-radius: 5px; + HorizontalLayout { + spacing: 0px; + IconButton { + width: 26px; height: 23px; icon: FontAwesomeIcons.pen; icon-size: 12px; icon-color: white; + hover-background: #ffffff2e; + pressed-background: #598cf273; + clicked => { + root.alias-remote-id = connection.remote-id; + root.alias-input = connection.display-name; + root.alias-open = true; + } + } + IconButton { + width: 26px; height: 23px; icon: FontAwesomeIcons.trash-can; icon-size: 12px; icon-color: white; + hover-background: #ffffff2e; + pressed-background: #598cf273; + clicked => { + root.delete-remote-id = connection.remote-id; + root.delete-open = true; + } + } + IconButton { + width: 27px; height: 23px; icon: FontAwesomeIcons.arrow-right-long; icon-size: 13px; icon-color: white; + hover-background: #ffffff2e; + pressed-background: #598cf273; + clicked => { root.recent-connect(connection.remote-id); } + } + } + } + } + if index < root.recent-connections.length - 1: Rectangle { + x: 183px; + y: 7px; + width: 1px; + height: 109px; + background: #0000007a; + } + } + } + } + } + } + + // Status bar. + Rectangle { + y: 426px; + height: 24px; + background: #fafbfc; + Rectangle { + y: 0px; + height: 1px; + background: ImGuiLineStyle.border; + } + if root.signal-connected && !root.signal-tls-error: OnlineStatusIndicator { + x: 15px - self.width / 2; + y: 12px - self.height / 2; + dot-diameter: 8px; + } + if !root.signal-connected || root.signal-tls-error: Rectangle { + x: 11px; + y: 8px; + width: 8px; + height: 8px; + border-radius: 4px; + background: root.signal-tls-error ? #f59e0b : #ef4444; + } + Text { + x: 28px; + y: 3px; + height: 18px; + text: root.signal-tls-error ? UiStrings.tls-error + : root.signal-connected ? UiStrings.signal-connected + : UiStrings.signal-disconnected; + color: #343941; + font-size: ImGuiFontStyle.base; + vertical-alignment: center; + } + } + } + + // Match the ImGui recent-connection tooltip while keeping it outside the + // clipped ScrollView. It is deliberately limited to the thumbnail area. + if root.recent-tooltip-visible: Rectangle { + x: root.recent-tooltip-x; + y: root.recent-tooltip-y; + width: root.recent-tooltip-width; + height: root.recent-tooltip-height; + background: #22252bf2; + border-width: 1px; + border-color: #ffffff38; + border-radius: 5px; + z: 50; + + VerticalLayout { + padding: 8px; + spacing: 2px; + Text { + text: UiStrings.device-name + ": " + root.recent-tooltip-name; + color: white; + font-size: ImGuiFontStyle.small; + overflow: elide; + } + if root.recent-tooltip-host != "" && root.recent-tooltip-host != root.recent-tooltip-name: Text { + text: root.recent-tooltip-host; + color: #e1e4e8; + font-size: ImGuiFontStyle.small; + overflow: elide; + } + Text { + text: UiStrings.remote-id + ": " + root.recent-tooltip-id; + color: #e1e4e8; + font-size: ImGuiFontStyle.small; + overflow: elide; + } + Text { + text: root.recent-tooltip-online ? UiStrings.online : UiStrings.offline; + color: root.recent-tooltip-online ? #62d98b : #d1d5db; + font-size: ImGuiFontStyle.small; + } + } + } + + // Dismiss the title menu when clicking anywhere outside of it. + if root.menu-open: TouchArea { + x: 0px; + y: 0px; + width: root.width; + height: root.height; + z: 9; + clicked => { + root.menu-open = false; + focus-sink.focus(); + } + } + + // Title menu. + if root.menu-open: Rectangle { + x: menu-button.x + menu-button.width - self.width; + y: menu-button.y + menu-button.height + 2px; + width: min(118px, max(menu-settings-label.preferred-width, + menu-about-label.preferred-width + (root.update-available ? 25px : 0px)) + 28px); + height: 64px; + background: white; + border-width: 1px; + border-color: ImGuiLineStyle.border; + border-radius: 5px; + z: 10; + TouchArea { } + VerticalLayout { + padding: 4px; + spacing: 2px; + menu-settings := Rectangle { + height: 27px; + background: settings-touch.pressed ? #d8dadd + : settings-touch.has-hover ? #f0f1f2 + : white; + border-width: 0px; + border-radius: 3px; + accessible-role: button; + accessible-label: UiStrings.settings; + accessible-action-default => { settings-touch.clicked(); } + menu-settings-label := Text { + x: 9px; + height: parent.height; + text: UiStrings.settings; + color: #2d3138; + vertical-alignment: center; + accessible-role: none; + } + settings-touch := TouchArea { + mouse-cursor: pointer; + clicked => { root.menu-open = false; root.settings-open = true; } + } + } + menu-about := Rectangle { + height: 27px; + background: about-touch.pressed ? #d8dadd + : about-touch.has-hover ? #f0f1f2 + : white; + border-width: 0px; + border-radius: 3px; + accessible-role: button; + accessible-label: UiStrings.about; + accessible-action-default => { about-touch.clicked(); } + menu-about-label := Text { + x: 9px; + height: parent.height; + text: UiStrings.about; + color: #2d3138; + vertical-alignment: center; + accessible-role: none; + } + if root.update-available && root.update-icon-blink-on: Text { + x: menu-about-label.x + menu-about-label.preferred-width + 7px; + width: 18px; + height: parent.height; + text: FontAwesomeIcons.circle-arrow-up; + color: #2d3138; + font-family: "Font Awesome 6 Free"; + font-weight: 900; + font-size: 16px; + horizontal-alignment: center; + vertical-alignment: center; + accessible-role: none; + } + about-touch := TouchArea { + mouse-cursor: pointer; + clicked => { root.menu-open = false; root.about-open = true; } + } + } + } + Rectangle { + x: 4px; + y: 31.5px; + width: parent.width - 8px; + height: 1px; + background: ImGuiLineStyle.separator; + z: 1; + } + } + + copy-timer := Timer { + interval: 1000ms; + triggered => { root.copy-toast = false; } + } + remote-id-format-timer := Timer { + interval: 1ms; + triggered => { + let formatted = root.format-remote-id(root.remote-id-input); + root.remote-id-input = formatted; + // IDs are ASCII-only, so character-count is also the UTF-8 offset + // expected by set-selection-offsets(). + remote-id-editor.set-selection-offsets( + formatted.character-count, formatted.character-count); + } + } + if root.copy-toast: Rectangle { + x: 180px; y: 170px; width: 280px; height: 68px; + background: #fffffff2; + border-radius: 8px; + border-width: 1px; + border-color: ImGuiLineStyle.border; + z: 30; + Text { text: UiStrings.copied; color: #333840; horizontal-alignment: center; vertical-alignment: center; } + } + + // Reset password modal. + if root.reset-password-open: Rectangle { + background: #00000042; + z: 40; + TouchArea { clicked => { focus-sink.focus(); } } + DialogSurface { + x: root.compact-language ? 205px : 160px; + y: 140px; + width: root.compact-language ? 230px : 320px; + height: 170px; + VerticalLayout { + padding: 20px; + spacing: 12px; + DialogPromptText { prompt: UiStrings.new-password; } + Rectangle { + height: 28px; + background: white; + border-width: 1px; + border-color: ImGuiLineStyle.border; + border-radius: 4px; + + LineEdit { + x: 1px; + y: 1.5px; + width: parent.width - 2px; + height: parent.height - 2px; + text <=> root.new-password-input; + input-type: InputType.password; + } + LineEditFocusUnderlineMask { + x: 5px; + y: parent.height - 3px; + width: parent.width - 10px; + } + } + Text { height: root.reset-password-invalid ? 18px : 0px; visible: root.reset-password-invalid; text: UiStrings.invalid-password; color: #d92d20; font-size: ImGuiFontStyle.small; horizontal-alignment: center; } + HorizontalLayout { + alignment: center; spacing: 8px; + PrimaryButton { + text: UiStrings.ok; + clicked => { + if (root.reset-password(root.new-password-input)) { + root.new-password-input = ""; + root.reset-password-invalid = false; + root.reset-password-open = false; + } else { root.reset-password-invalid = true; } + } + } + SecondaryButton { text: UiStrings.cancel; clicked => { root.new-password-input = ""; root.reset-password-invalid = false; root.reset-password-open = false; } } + } + } + } + } + + // Recent connection alias modal. + if root.alias-open: Rectangle { + background: #00000042; + z: 40; + TouchArea { clicked => { focus-sink.focus(); } } + DialogSurface { + x: root.compact-language ? 205px : 160px; + y: 145px; + width: root.compact-language ? 230px : 320px; + height: 160px; + VerticalLayout { + padding: 20px; spacing: 14px; + DialogPromptText { prompt: UiStrings.edit-alias; } + Rectangle { + height: 28px; + background: white; + border-width: 1px; + border-color: ImGuiLineStyle.border; + border-radius: 4px; + + LineEdit { + x: 1px; + y: 1.5px; + width: parent.width - 2px; + height: parent.height - 2px; + text <=> root.alias-input; + accepted => { + root.recent-edit-alias(root.alias-remote-id, root.alias-input); + root.alias-open = false; + } + } + LineEditFocusUnderlineMask { + x: 5px; + y: parent.height - 3px; + width: parent.width - 10px; + } + } + HorizontalLayout { + alignment: center; spacing: 8px; + PrimaryButton { text: UiStrings.ok; clicked => { root.recent-edit-alias(root.alias-remote-id, root.alias-input); root.alias-open = false; } } + SecondaryButton { text: UiStrings.cancel; clicked => { root.alias-open = false; } } + } + } + } + } + + // Delete confirmation modal. + if root.delete-open: Rectangle { + background: #00000042; + z: 40; + TouchArea { clicked => { focus-sink.focus(); } } + DialogSurface { + x: root.compact-language ? 205px : 160px; + y: 155px; + width: root.compact-language ? 230px : 320px; + height: 140px; + VerticalLayout { + padding: 20px; spacing: 18px; + DialogPromptText { prompt: UiStrings.confirm-delete; } + HorizontalLayout { + alignment: center; spacing: 8px; + PrimaryButton { text: UiStrings.ok; clicked => { root.recent-delete(root.delete-remote-id); root.delete-open = false; } } + SecondaryButton { text: UiStrings.cancel; clicked => { root.delete-open = false; } } + } + } + } + } + + // Offline/error warning modal. + if root.offline-warning != "": Rectangle { + background: #00000042; + z: 39; + TouchArea { clicked => { focus-sink.focus(); } } + DialogSurface { + x: 185px; y: 150px; width: 270px; height: 150px; + VerticalLayout { + padding: 20px; spacing: 18px; + DialogPromptText { prompt: root.offline-warning; } + PrimaryButton { text: UiStrings.ok; clicked => { root.offline-warning = ""; } } + } + } + } + + // Connection progress and password retry modal from the ImGui client. + if root.connection-dialog-open: Rectangle { + background: #00000042; + z: 48; + TouchArea { clicked => { focus-sink.focus(); } } + DialogSurface { + x: root.connection-password-required ? 211px : 190px; + y: root.connection-password-required ? 143px : 130px; + width: root.connection-password-required ? 211px : 260px; + height: root.connection-password-required ? 158px : 145px; + + if root.connection-password-required: Rectangle { + Text { + y: 38px; + height: 22px; + text: root.connection-status-text; + color: #30343b; + horizontal-alignment: center; + vertical-alignment: center; + } + + if !root.connection-validating: Rectangle { + x: 71px; y: 63px; width: 70px; height: 23px; + background: white; + border-width: 1px; + border-color: ImGuiLineStyle.border; + border-radius: 3px; + connection-password-editor := TextInput { + x: 5px; y: 1px; width: parent.width - 10px; height: parent.height - 2px; + text <=> root.connection-password; + // The legacy ImGui field uses CharsNoBlank without the + // Password flag, so entered text remains visible. + input-type: InputType.text; + color: #202124; + font-size: ImGuiFontStyle.base; + vertical-alignment: center; + selection-foreground-color: white; + selection-background-color: #2463c7; + accepted => { root.connection-submit-password(root.connection-password, root.connection-remember-password); } + } + Timer { + interval: 1ms; + running: true; + triggered => { connection-password-editor.focus(); } + } + } + + if !root.connection-validating: Rectangle { + x: 72px; y: 89px; width: 120px; height: 21px; + ImGuiCheckBox { + x: 0px; y: 0px; + checked <=> root.connection-remember-password; + } + Text { + x: 27px; y: 0px; + width: parent.width - 27px; height: parent.height; + text: UiStrings.remember-password; + color: #30343b; + font-size: ImGuiFontStyle.body; + vertical-alignment: center; + } + TouchArea { + x: 27px; y: 0px; + width: parent.width - 27px; height: parent.height; + clicked => { root.connection-remember-password = !root.connection-remember-password; } + } + } + + if !root.connection-validating: ConnectionDialogButton { + x: 69px; y: 118px; + text: UiStrings.ok; + clicked => { root.connection-submit-password(root.connection-password, root.connection-remember-password); } + } + if !root.connection-validating: ConnectionDialogButton { + x: 117px; + y: 118px; + text: UiStrings.cancel; + clicked => { root.connection-cancel(); } + } + } + + if !root.connection-password-required: VerticalLayout { + padding: 20px; spacing: 13px; + DialogPromptText { prompt: root.connection-status-text; } + HorizontalLayout { + alignment: center; spacing: 8px; + if root.connection-pending: SecondaryButton { + text: UiStrings.cancel; + clicked => { root.connection-cancel(); } + } + if !root.connection-pending: PrimaryButton { + text: UiStrings.ok; + clicked => { root.connection-acknowledge(); } + } + } + } + } + } + + // macOS permission gate. On other platforms this remains hidden. + if root.permission-dialog-open: Rectangle { + background: transparent; + z: 50; + TouchArea { clicked => { focus-sink.focus(); } } + DialogSurface { + x: root.compact-language ? 255px : 190px; + y: 162px; + width: root.compact-language ? 130px : 260px; + height: 125px; + border-radius: 7px; + Rectangle { + x: 0px; + y: 0px; + width: parent.width; + height: 21px; + background: #d3d3d3; + border-radius: 7px; + z: 5; + Rectangle { y: parent.height - 7px; height: 7px; background: #d3d3d3; } + Text { x: 6px; text: UiStrings.request-permissions; font-size: ImGuiFontStyle.base; font-weight: 600; color: #202124; vertical-alignment: center; } + } + Text { x: 10px; y: 32px; width: root.compact-language ? 110px : 240px; height: 24px; text: UiStrings.permission-required; color: #202124; font-size: 8px; wrap: word-wrap; } + Text { x: 10px; y: 61px; width: root.compact-language ? 78px : 198px; height: 18px; text: "1. " + UiStrings.accessibility-permission + ":"; color: #202124; font-size: 8px; overflow: elide; vertical-alignment: center; } + MiniToggleSwitch { + x: root.compact-language ? 90px : 210px; + y: 61px; + checked: root.accessibility-granted; + enabled: !root.accessibility-granted; + toggled(value) => { if (value) { root.request-accessibility-permission(); } } + } + Text { x: 10px; y: 85px; width: root.compact-language ? 78px : 198px; height: 18px; text: "2. " + UiStrings.screen-recording-permission + ":"; color: #202124; font-size: 8px; overflow: elide; vertical-alignment: center; } + MiniToggleSwitch { + x: root.compact-language ? 90px : 210px; + y: 85px; + checked: root.screen-recording-granted; + enabled: !root.screen-recording-granted; + toggled(value) => { if (value) { root.request-screen-recording-permission(); } } + } + } + } + + // Settings modal. + if root.settings-open: Rectangle { + background: transparent; + z: 45; + TouchArea { clicked => { focus-sink.focus(); } } + DialogSurface { + x: root.compact-language ? 219px : 190px; + y: 9px; + width: root.compact-language ? 202px : 269px; + height: 432px; + border-radius: 7px; + + Rectangle { + x: 0px; + y: 0px; + width: parent.width; + height: 21px; + background: #d3d3d3; + border-radius: 7px; + z: 5; + Rectangle { y: parent.height - 7px; height: 7px; background: #d3d3d3; } + Text { x: 6px; text: UiStrings.settings; font-size: ImGuiFontStyle.base; font-weight: 600; color: #202124; vertical-alignment: center; } + } + + Text { x: 8px; y: 31px; width: root.compact-language ? 108px : 176px; height: 24px; text: UiStrings.language; font-size: ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; } + CompactComboBox { x: root.compact-language ? 120px : 188px; y: 30px; model: ["中文", "English", "Русский"]; current-index <=> root.language-index; } + Rectangle { x: 7px; y: 57px; width: parent.width - 14px; height: 1px; background: ImGuiLineStyle.separator; } + + Text { x: 8px; y: 61px; width: root.compact-language ? 108px : 176px; height: 24px; text: UiStrings.video-quality; font-size: ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; } + CompactComboBox { x: root.compact-language ? 120px : 188px; y: 60px; enabled: !root.settings-session-active; model: [UiStrings.quality-low, UiStrings.quality-medium, UiStrings.quality-high]; current-index <=> root.video-quality-index; } + Rectangle { x: 7px; y: 87px; width: parent.width - 14px; height: 1px; background: ImGuiLineStyle.separator; } + + Text { x: 8px; y: 91px; width: root.compact-language ? 108px : 176px; height: 24px; text: UiStrings.frame-rate; font-size: ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; } + CompactComboBox { x: root.compact-language ? 120px : 188px; y: 90px; enabled: !root.settings-session-active; model: ["30 fps", "60 fps"]; current-index <=> root.frame-rate-index; } + Rectangle { x: 7px; y: 117px; width: parent.width - 14px; height: 1px; background: ImGuiLineStyle.separator; } + + Text { x: 8px; y: 121px; width: root.compact-language ? 108px : 176px; height: 24px; text: UiStrings.codec; font-size: ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; } + CompactComboBox { x: root.compact-language ? 120px : 188px; y: 120px; enabled: !root.settings-session-active; model: [UiStrings.codec-h264, UiStrings.codec-av1]; current-index <=> root.codec-index; } + Rectangle { x: 7px; y: 147px; width: parent.width - 14px; height: 1px; background: ImGuiLineStyle.separator; } + + if root.hardware-codec-visible: Text { x: 8px; y: 151px; width: root.compact-language ? 155px : 225px; height: 24px; text: UiStrings.hardware-codec; font-size: ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; } + if root.hardware-codec-visible: ImGuiCheckBox { x: root.compact-language ? 171px : 238px; y: 151px; checked <=> root.hardware-codec-enabled; enabled: !root.settings-session-active; } + Rectangle { x: 7px; y: 177px; width: parent.width - 14px; height: 1px; background: ImGuiLineStyle.separator; } + + Text { x: 8px; y: 181px; width: root.compact-language ? 155px : 225px; height: 24px; text: UiStrings.turn-relay; font-size: ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; } + ImGuiCheckBox { x: root.compact-language ? 171px : 238px; y: 181px; checked <=> root.turn-enabled; enabled: !root.settings-session-active; } + Rectangle { x: 7px; y: 207px; width: parent.width - 14px; height: 1px; background: ImGuiLineStyle.separator; } + + Text { x: 8px; y: 211px; width: root.compact-language ? 155px : 225px; height: 24px; text: UiStrings.srtp; font-size: ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; } + ImGuiCheckBox { x: root.compact-language ? 171px : 238px; y: 211px; checked <=> root.srtp-enabled; enabled: !root.settings-session-active; } + Rectangle { x: 7px; y: 237px; width: parent.width - 14px; height: 1px; background: ImGuiLineStyle.separator; } + + CompactButton { x: 8px; y: 241px; width: root.compact-language ? 70px : 220px; text: UiStrings.self-hosted; enabled: !root.settings-session-active; clicked => { root.self-host-settings-open = true; } } + ImGuiCheckBox { x: root.compact-language ? 171px : 238px; y: 241px; checked <=> root.self-hosted-enabled; enabled: !root.settings-session-active; } + Rectangle { x: 7px; y: 267px; width: parent.width - 14px; height: 1px; background: ImGuiLineStyle.separator; } + + Text { x: 8px; y: 271px; width: root.compact-language ? 155px : 225px; height: 24px; text: UiStrings.autostart; font-size: ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; } + ImGuiCheckBox { x: root.compact-language ? 171px : 238px; y: 271px; checked <=> root.autostart-enabled; enabled: !root.settings-session-active; } + Rectangle { x: 7px; y: 297px; width: parent.width - 14px; height: 1px; background: ImGuiLineStyle.separator; } + + Text { x: 8px; y: 301px; width: root.compact-language ? 155px : 225px; height: 24px; text: UiStrings.daemon; font-size: ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; } + ImGuiCheckBox { x: root.compact-language ? 171px : 238px; y: 301px; checked <=> root.daemon-enabled; enabled: !root.settings-session-active; } + Rectangle { x: 7px; y: 327px; width: parent.width - 14px; height: 1px; background: ImGuiLineStyle.separator; } + + Text { x: 8px; y: 331px; width: root.compact-language ? 155px : 225px; height: 24px; text: UiStrings.minimize-to-tray; font-size: ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; } + ImGuiCheckBox { x: root.compact-language ? 171px : 238px; y: 331px; checked <=> root.minimize-to-tray-enabled; enabled: !root.settings-session-active; } + Rectangle { x: 7px; y: 357px; width: parent.width - 14px; height: 1px; background: ImGuiLineStyle.separator; } + + Text { x: 8px; y: 361px; width: root.compact-language ? 108px : 168px; height: 24px; text: UiStrings.file-save-path; font-size: ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; } + CompactButton { x: root.compact-language ? 120px : 180px; y: 361px; width: root.compact-language ? 73px : 80px; text: root.file-save-path == "" ? UiStrings.default-desktop : root.file-save-path; enabled: !root.settings-session-active; clicked => { root.browse-save-path(); } } + + CompactButton { x: root.compact-language ? 63px : 89px; y: 396px; primary: true; text: UiStrings.ok; clicked => { root.save-settings(); root.settings-open = false; root.self-host-settings-open = false; } } + CompactButton { x: root.compact-language ? 104px : 130px; y: 396px; width: root.compact-language ? 34px : 56px; text: UiStrings.cancel; clicked => { root.cancel-settings(); root.settings-open = false; root.self-host-settings-open = false; } } + } + } + + if root.self-host-settings-open: Rectangle { + background: transparent; + z: 49; + TouchArea { clicked => { focus-sink.focus(); } } + DialogSurface { + x: root.compact-language ? 190px : 140px; + y: 105px; + width: root.compact-language ? 260px : 360px; + height: 168px; + border-radius: 7px; + + Rectangle { + x: 0px; + y: 0px; + width: parent.width; + height: 21px; + background: #d3d3d3; + border-radius: 7px; + z: 5; + Rectangle { y: parent.height - 7px; height: 7px; background: #d3d3d3; } + Text { x: 6px; text: UiStrings.self-hosted-settings; font-size: ImGuiFontStyle.base; font-weight: 600; color: #202124; vertical-alignment: center; } + } + Text { x: 8px; y: 31px; width: root.compact-language ? 88px : 165px; height: 24px; text: UiStrings.server-host; font-size: ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; } + Rectangle { + x: root.compact-language ? 100px : 173px; + y: 30px; + width: root.compact-language ? 152px : 179px; + height: 24px; + background: white; + border-width: 1px; + border-color: ImGuiLineStyle.border; + border-radius: 4px; + LineEdit { x: 1px; y: 1.5px; width: parent.width - 2px; height: parent.height - 2px; text <=> root.server-host; font-size: ImGuiFontStyle.body; } + LineEditFocusUnderlineMask { x: 5px; y: parent.height - 3px; width: parent.width - 10px; } + } + Rectangle { x: 7px; y: 57px; width: parent.width - 14px; height: 1px; background: ImGuiLineStyle.separator; } + Text { x: 8px; y: 61px; width: root.compact-language ? 88px : 165px; height: 24px; text: UiStrings.server-port; font-size: ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; } + Rectangle { + x: root.compact-language ? 100px : 173px; + y: 60px; + width: root.compact-language ? 152px : 179px; + height: 24px; + background: white; + border-width: 1px; + border-color: ImGuiLineStyle.border; + border-radius: 4px; + LineEdit { x: 1px; y: 1.5px; width: parent.width - 2px; height: parent.height - 2px; text <=> root.server-port; input-type: InputType.number; font-size: ImGuiFontStyle.body; } + LineEditFocusUnderlineMask { x: 5px; y: parent.height - 3px; width: parent.width - 10px; } + } + Rectangle { x: 7px; y: 87px; width: parent.width - 14px; height: 1px; background: ImGuiLineStyle.separator; } + Text { x: 8px; y: 91px; width: root.compact-language ? 88px : 165px; height: 24px; text: UiStrings.coturn-port; font-size: ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; } + Rectangle { + x: root.compact-language ? 100px : 173px; + y: 90px; + width: root.compact-language ? 152px : 179px; + height: 24px; + background: white; + border-width: 1px; + border-color: ImGuiLineStyle.border; + border-radius: 4px; + LineEdit { x: 1px; y: 1.5px; width: parent.width - 2px; height: parent.height - 2px; text <=> root.coturn-port; input-type: InputType.number; font-size: ImGuiFontStyle.body; } + LineEditFocusUnderlineMask { x: 5px; y: parent.height - 3px; width: parent.width - 10px; } + } + Rectangle { x: 7px; y: 117px; width: parent.width - 14px; height: 1px; background: ImGuiLineStyle.separator; } + CompactButton { x: root.compact-language ? 93px : 135px; y: 130px; primary: true; text: UiStrings.ok; clicked => { root.save-self-hosted-settings(); root.self-host-settings-open = false; } } + CompactButton { x: root.compact-language ? 134px : 176px; y: 130px; width: root.compact-language ? 34px : 56px; text: UiStrings.cancel; clicked => { root.cancel-self-hosted-settings(); root.self-host-settings-open = false; } } + } + } + + // About modal. + if root.about-open: Rectangle { + background: transparent; + z: 46; + TouchArea { clicked => { focus-sink.focus(); } } + DialogSurface { + x: 170px; + y: 127px; + width: 300px; + height: 196px; + border-radius: 7px; + + Rectangle { + x: 0px; + y: 0px; + width: parent.width; + height: 21px; + background: #d3d3d3; + border-radius: 7px; + z: 5; + Rectangle { y: parent.height - 7px; height: 7px; background: #d3d3d3; } + Text { x: 6px; text: UiStrings.about; font-size: ImGuiFontStyle.base; font-weight: 600; color: #202124; vertical-alignment: center; } + } + Text { x: 30px; y: 29px; width: 240px; height: 20px; text: UiStrings.version + ": CrossDesk " + root.current-version; color: #202124; font-size: ImGuiFontStyle.body; vertical-alignment: center; } + if root.update-available: Text { x: 30px; y: 51px; width: 240px; height: 20px; text: UiStrings.update-available + ":"; color: #202124; font-size: ImGuiFontStyle.body; vertical-alignment: center; } + if root.update-available: Rectangle { + x: 30px; + y: 70px; + width: 240px; + height: 20px; + version-link := Text { text: root.latest-version; color: version-touch.has-hover ? #174ea6 : #334fd1; font-size: ImGuiFontStyle.body; horizontal-alignment: center; vertical-alignment: center; } + version-touch := TouchArea { mouse-cursor: pointer; clicked => { root.open-download(); } } + } + Text { x: 30px; y: 96px; width: 240px; height: 20px; text: UiStrings.copyright; color: #202124; font-size: ImGuiFontStyle.body; vertical-alignment: center; } + Text { x: 30px; y: 118px; width: 240px; height: 20px; text: UiStrings.license; color: #202124; font-size: ImGuiFontStyle.body; vertical-alignment: center; } + CompactButton { x: 133px; y: 157px; primary: true; text: UiStrings.ok; clicked => { root.about-open = false; } } + } + } + + // Update notification modal. + if root.update-open && root.update-available: Rectangle { + background: transparent; + z: 47; + TouchArea { clicked => { focus-sink.focus(); } } + DialogSurface { + x: 120px; + y: 65px; + width: 400px; + height: 320px; + border-radius: 7px; + Text { x: 40px; y: 22px; width: 320px; height: 24px; text: UiStrings.update-available + ": " + root.latest-version; color: #202124; font-size: ImGuiFontStyle.prominent; vertical-alignment: center; } + Rectangle { + x: 40px; + y: 47px; + width: 320px; + height: 22px; + Text { text: UiStrings.access-website + "https://crossdesk.cn"; color: website-touch.has-hover ? #174ea6 : #334fd1; font-size: ImGuiFontStyle.body; vertical-alignment: center; } + website-touch := TouchArea { mouse-cursor: pointer; clicked => { root.open-download(); } } + } + Rectangle { + x: 20px; + y: 74px; + width: 360px; + height: 200px; + background: white; + border-width: 1px; + border-color: #b9bdc3; + ScrollView { + x: 1px; + y: 1px; + width: parent.width - 2px; + height: parent.height - 2px; + VerticalLayout { + width: 340px; + padding-left: 19px; + padding-right: 8px; + padding-top: 8px; + padding-bottom: 8px; + spacing: 7px; + if root.release-name != "": Text { width: 312px; text: root.release-name; color: #202124; font-size: ImGuiFontStyle.body; wrap: word-wrap; } + Text { width: 312px; text: root.release-notes; color: #202124; font-size: ImGuiFontStyle.body; wrap: word-wrap; } + if root.release-date != "": Text { width: 312px; text: UiStrings.release-date-label + root.release-date; color: #4f555e; font-size: ImGuiFontStyle.small; wrap: word-wrap; } + } + } + } + CompactButton { x: root.compact-language ? 163px : 130px; y: 283px; width: root.compact-language ? 34px : 70px; primary: true; text: UiStrings.download; clicked => { root.open-download(); root.update-open = false; } } + CompactButton { x: root.compact-language ? 204px : 207px; y: 283px; width: root.compact-language ? 34px : 56px; text: UiStrings.later; clicked => { root.update-open = false; } } + } + } + + // Portable Windows builds prompt for the optional lock-screen service. + if root.portable-service-dialog-open: Rectangle { + background: #00000052; + z: 50; + TouchArea { clicked => { focus-sink.focus(); } } + DialogSurface { + x: 80px; y: 75px; width: 480px; height: 300px; + VerticalLayout { + padding: 24px; spacing: 14px; + DialogPromptText { + prompt: UiStrings.service-setup-title; + text-color: #242832; + text-size: ImGuiFontStyle.prominent; + text-weight: 600; + } + Rectangle { + background: #f7f8fa; border-color: #d9dde4; border-width: 1px; border-radius: 6px; + Text { x: 14px; y: 22px; width: parent.width - 28px; height: parent.height - 24px; text: UiStrings.service-setup-message; color: #3d434d; wrap: word-wrap; vertical-alignment: center; } + } + if root.portable-service-status != "": Text { + text: root.portable-service-status; + color: root.portable-service-succeeded ? #25874b : root.portable-service-installing ? #68707d : #b23b3b; + horizontal-alignment: center; + wrap: word-wrap; + } + if !root.portable-service-succeeded: HorizontalLayout { + height: 26px; alignment: center; spacing: 8px; + Text { text: UiStrings.do-not-remind; vertical-alignment: center; color: #4a505a; } + ToggleSwitch { checked <=> root.portable-service-do-not-remind; enabled: !root.portable-service-installing; } + } + HorizontalLayout { + height: 34px; alignment: center; spacing: 8px; + if root.portable-service-succeeded: PrimaryButton { text: UiStrings.ok; clicked => { root.acknowledge-portable-service(); } } + if !root.portable-service-succeeded: PrimaryButton { + text: UiStrings.install-service; + enabled: !root.portable-service-installing; + clicked => { root.install-portable-service(); } + } + if !root.portable-service-succeeded: SecondaryButton { + text: UiStrings.cancel; + enabled: !root.portable-service-installing; + clicked => { root.cancel-portable-service(); } + } + } + } + } + } + + if root.portable-service-suppressed-notice-open: Rectangle { + background: #00000052; + z: 51; + TouchArea { clicked => { focus-sink.focus(); } } + DialogSurface { + x: 100px; y: 130px; width: 440px; height: 190px; + VerticalLayout { + padding: 24px; spacing: 16px; + DialogPromptText { + prompt: UiStrings.notification; + text-color: #242832; + text-size: ImGuiFontStyle.prominent; + text-weight: 600; + } + DialogPromptText { + prompt: UiStrings.service-suppressed-message; + text-color: #3d434d; + } + PrimaryButton { text: UiStrings.ok; clicked => { root.acknowledge-portable-service-suppressed(); } } + } + } + } +} diff --git a/src/gui/ui/server_window.slint b/src/gui/ui/server_window.slint new file mode 100644 index 0000000..24ec27e --- /dev/null +++ b/src/gui/ui/server_window.slint @@ -0,0 +1,149 @@ +import { CompactComboBox, FontAwesomeIcons, IconButton, SecondaryButton, ImGuiLineStyle, ImGuiFontStyle } from "common.slint"; + +export struct ControllerEntry { + remote-id: string, + display-name: string, +} + +export component ServerWindow inherits Window { + title: "CrossDesk"; + preferred-width: root.language-index == 0 ? 250px : root.language-index == 1 ? 330px : 430px; + preferred-height: 150px; + min-width: root.language-index == 0 ? 250px : root.language-index == 1 ? 330px : 430px; + max-width: root.language-index == 0 ? 250px : root.language-index == 1 ? 330px : 430px; + no-frame: true; + always-on-top: true; + background: transparent; + default-font-size: ImGuiFontStyle.base; + + in property <[ControllerEntry]> controllers; + in property <[string]> controller-names; + in property language-index: 0; + in-out property selected-controller: 0; + in property connection-label: "Connection status"; + in property connection-status: ""; + in property controller-label: "Controller"; + in property file-transfer-label: "File transfer"; + in property select-file-label: "Select file"; + in property sending-file: false; + in property file-transfer-visible: false; + in property file-progress: 0; + in property file-progress-text: ""; + in property current-file-name: ""; + in property file-size-text: ""; + + callback title-drag(int, length, length); + callback toggle-collapsed(bool); + callback controller-selected(int); + callback select-file; + callback disconnect-controller; + + private property collapsed: false; + private property transfer-hovered: false; + + Rectangle { + width: root.width; + height: root.height; + background: white; + border-width: 1px; + border-color: ImGuiLineStyle.border; + border-radius: 8px; + clip: true; + + Rectangle { + x: 0px; + y: 0px; + width: parent.width; + height: 30px; + background: #f8f9fb; + Rectangle { y: parent.height - 1px; height: 1px; background: ImGuiLineStyle.border; } + IconButton { + x: 0px; + y: 0px; + width: 30px; height: 30px; + icon: root.collapsed ? FontAwesomeIcons.angle-down : FontAwesomeIcons.angle-up; + icon-size: 12px; + clicked => { root.collapsed = !root.collapsed; root.toggle-collapsed(root.collapsed); } + } + drag := TouchArea { + x: 30px; width: parent.width - 30px; + property phase: self.pressed ? 1 : 0; + changed phase => { root.title-drag(phase, self.mouse-x, self.mouse-y); } + moved => { if (self.pressed) { root.title-drag(2, self.mouse-x, self.mouse-y); } } + } + } + + if !root.collapsed: Rectangle { + x: 12px; y: 38px; width: parent.width - 52px; height: 101px; + background: white; border-width: 1px; border-color: ImGuiLineStyle.border; border-radius: 5px; + VerticalLayout { + padding: 8px; spacing: 3px; alignment: start; + HorizontalLayout { + height: 24px; spacing: 5px; + Text { width: root.language-index == 0 ? 69px : 110px; text: root.controller-label; color: #434953; font-size: ImGuiFontStyle.body; vertical-alignment: center; overflow: elide; } + CompactComboBox { + width: root.language-index == 0 ? 108px : root.language-index == 1 ? 147px : 247px; + model: root.controller-names; + current-index <=> root.selected-controller; + selected(index) => { root.controller-selected(index); } + } + } + Rectangle { height: 1px; background: ImGuiLineStyle.separator; } + HorizontalLayout { + height: 24px; spacing: 5px; + Text { width: root.language-index == 0 ? 69px : 110px; text: root.connection-label; color: #434953; font-size: ImGuiFontStyle.body; vertical-alignment: center; overflow: elide; } + Text { text: root.connection-status; color: #2463c7; font-size: ImGuiFontStyle.body; horizontal-alignment: left; vertical-alignment: center; overflow: elide; } + } + Rectangle { height: 1px; background: ImGuiLineStyle.separator; } + Rectangle { + height: 24px; + HorizontalLayout { + y: 0px; + width: parent.width; + height: 24px; + spacing: 5px; + Text { width: root.language-index == 0 ? 69px : 110px; text: root.file-transfer-label; color: #434953; font-size: ImGuiFontStyle.body; vertical-alignment: center; overflow: elide; } + SecondaryButton { width: root.language-index == 0 ? 78px : root.language-index == 1 ? 140px : 205px; height: 24px; text: root.select-file-label; clicked => { root.select-file(); } } + Rectangle { + visible: root.file-transfer-visible; + width: 35px; + Text { + text: root.sending-file ? "↑ " + round(root.file-progress * 100) + "%" : "✓"; + color: root.sending-file ? #2463c7 : #25874b; + font-size: ImGuiFontStyle.small; + horizontal-alignment: center; + vertical-alignment: center; + } + transfer-touch := TouchArea { + changed has-hover => { root.transfer-hovered = self.has-hover; } + } + } + } + } + } + } + if !root.collapsed: IconButton { + x: parent.width - 34px; y: 38px; width: 24px; height: 101px; + icon: FontAwesomeIcons.xmark; + icon-size: 13px; + icon-color: white; + normal-background: #ef2b2d; + hover-background: #d92d20; + pressed-background: #b42318; + border-radius: 5px; + clicked => { root.disconnect-controller(); } + } + if root.file-transfer-visible && root.transfer-hovered: Rectangle { + x: 15px; y: 67px; width: root.width - 30px; height: 70px; + background: #22252bf2; + border-radius: 5px; + z: 20; + VerticalLayout { + padding: 8px; spacing: 3px; + Text { text: root.current-file-name; color: white; font-size: ImGuiFontStyle.small; overflow: elide; } + Text { text: root.file-size-text; color: #e1e4e8; font-size: ImGuiFontStyle.small; } + Text { text: round(root.file-progress * 100) + "% " + root.file-progress-text; color: #e1e4e8; font-size: ImGuiFontStyle.small; } + } + } + } +} diff --git a/src/gui/ui/stream_window.slint b/src/gui/ui/stream_window.slint new file mode 100644 index 0000000..f372afa --- /dev/null +++ b/src/gui/ui/stream_window.slint @@ -0,0 +1,649 @@ +import { ComboBox, ScrollView } from "std-widgets.slint"; +import { FontAwesomeIcons, IconButton, PrimaryButton, ImGuiLineStyle, ImGuiFontStyle } from "common.slint"; + +export struct StreamTab { + remote-id: string, + title: string, + connected: bool, +} + +export struct FileTransferEntry { + name: string, + status: string, + progress: float, + speed: string, + size: string, +} + +export struct NetworkStatsRow { + label: string, + inbound: string, + outbound: string, + loss-rate: string, +} + +export global StreamStrings { + in-out property select-display: "Select Display"; + in-out property send-shortcut: "Send Shortcut"; + in-out property control-mouse: "Control Mouse"; + in-out property release-mouse: "Release Mouse"; + in-out property audio: "Audio Capture"; + in-out property mute: "Mute"; + in-out property select-file: "Select File to Send"; + in-out property show-stats: "Show Net Traffic Stats"; + in-out property hide-stats: "Hide Net Traffic Stats"; + in-out property fullscreen: "Fullscreen"; + in-out property exit-fullscreen: "Exit fullscreen"; + in-out property disconnect: "Disconnect"; + in-out property file-transfer: "File Transfer Progress"; + in-out property expand-control: "Expand Control Bar"; + in-out property collapse-control: "Collapse Control Bar"; + in-out property stats-in: "In"; + in-out property stats-out: "Out"; + in-out property stats-loss-rate: "Loss Rate"; + in-out property stats-resolution: "Res"; + in-out property stats-connection-mode: "Mode"; +} + +// The legacy ImGui control bar uses 24px buttons with Font Awesome rendered +// at half of the 32px atlas size. Keep this local to the stream window: the +// shared IconButton is intentionally larger and is used by the other windows. +component ControlBarButton inherits Rectangle { + in property icon; + in property badge: ""; + in property tooltip: ""; + in property selected: false; + in property slashed: false; + in property icon-size: 16px; + callback clicked; + + width: 24px; + height: 24px; + background: touch.pressed ? #0f87fa + : touch.has-hover ? #4296fa + : root.selected ? #4296fa : #4296fa66; + border-radius: 3px; + + Text { + text: root.icon; + color: #24272d; + font-family: "Font Awesome 6 Free"; + font-weight: 900; + font-size: root.icon-size; + horizontal-alignment: center; + vertical-alignment: center; + } + + // Draw the selected display number over the center of the monitor glyph. + if root.badge != "": Text { + // Use the rendered badge size so one- and multi-digit IDs share the + // exact horizontal and vertical center of the button. + x: (parent.width - self.preferred-width) / 2 + 0.5px; + y: (parent.height - self.preferred-height) / 2 - 0.5px; + text: root.badge; + color: black; + font-size: 11px; + } + + // Match the two offset diagonal strokes used by the ImGui mouse/audio + // disabled state instead of covering the glyph with a full-width slash. + if root.slashed: Rectangle { + x: 11px; + y: 0px; + width: 2px; + height: 24px; + background: black; + transform-rotation: -45deg; + } + if root.slashed: Rectangle { + x: 9.5px; + y: 1.5px; + width: 2px; + height: 24px; + background: touch.has-hover ? #4296fa : #b3d5fd; + transform-rotation: -45deg; + } + + touch := TouchArea { + clicked => { root.clicked(); } + } +} + +export component StreamWindow inherits Window { + title: "CrossDesk"; + preferred-width: 1280px; + preferred-height: 720px; + min-width: 640px; + min-height: 360px; + no-frame: false; + background: black; + default-font-size: ImGuiFontStyle.base; + + in property <[StreamTab]> tabs; + in-out property selected-tab: 0; + in property frame; + in property has-frame: false; + in property status-text: ""; + in property receiving-text: ""; + in property <[string]> displays; + in-out property selected-display: 0; + in property mouse-control-enabled: true; + in property audio-enabled: true; + in property srtp-enabled: false; + in property fullscreen-enabled: false; + in property stats-visible: false; + in property <[NetworkStatsRow]> stats-rows; + in property stats-fps: "0"; + in property stats-resolution: ""; + in property stats-connection-mode: ""; + in property file-transfer-visible: false; + in property <[FileTransferEntry]> file-transfers; + + callback select-tab(int); + callback reorder-tab(int, length, length); + callback close-tab(string); + callback switch-display(int); + callback send-shortcut(string); + callback toggle-mouse-control; + callback toggle-audio; + callback select-file; + callback close-file-transfer; + pure callback can-drop-file(data-transfer) -> bool; + callback file-dropped(data-transfer); + callback toggle-stats; + callback toggle-fullscreen; + callback disconnect; + callback pointer-input(PointerEventButton, PointerEventKind, length, length); + callback scroll-input(length, length, length, length); + callback key-input(string, bool, bool, bool, bool, bool); + + private property control-expanded: true; + private property display-menu-open: false; + private property shortcut-menu-open: false; + private property control-docked-left: true; + private property control-dragging: false; + private property control-drag-x: 0px; + // Start flush against the title/tab strip. This remains mutable so a + // manually dragged control bar keeps its chosen vertical position. + private property control-pos-y: 0px; + private property control-press-x: 0px; + private property control-press-y: 0px; + private property dragging-tab: -1; + callback begin-control-drag(length, length); + callback move-control-drag(length, length); + callback end-control-drag; + + begin-control-drag(press-x, press-y) => { + // Capture the snapped position before switching x to control-drag-x. + // Otherwise a second press briefly restores the previous drag target + // and makes a plain click move the control bar. + let current-x = control.x; + let current-y = control.y; + root.control-drag-x = current-x; + // Store the pointer offset in the control bar, while all following + // pointer positions remain relative to the stationary video surface. + root.control-press-x = press-x - current-x; + root.control-press-y = press-y - current-y; + root.display-menu-open = false; + root.shortcut-menu-open = false; + root.control-dragging = true; + } + move-control-drag(pointer-x, pointer-y) => { + if root.control-dragging { + root.control-drag-x = min(max(0px, pointer-x - root.control-press-x), video.width - control.width); + root.control-pos-y = min(max(0px, pointer-y - root.control-press-y), video.height - control.height); + } + } + end-control-drag() => { + if root.control-dragging { + root.control-docked-left = root.control-drag-x + control.width / 2 < video.width / 2; + root.control-pos-y = min(max(0px, control.y), video.height - control.height); + root.control-dragging = false; + } + } + + Rectangle { + width: root.width; + height: root.height; + background: black; + + // Keep the ImGui tab strip behavior, including pointer-drag reordering. + Rectangle { + y: 0px; + height: root.tabs.length > 1 ? 30px : 0px; + visible: !root.fullscreen-enabled && root.tabs.length > 1; + background: #f1f2f4; + z: 10; + HorizontalLayout { + spacing: 1px; + for tab[index] in root.tabs: tab-item := Rectangle { + width: min(190px, max(110px, (parent.width - 12px) / root.tabs.length)); + background: index == root.selected-tab ? white : tab-touch.has-hover || root.dragging-tab == index ? #e5e7eb : #d8dbe0; + border-width: root.dragging-tab == index ? 1px : 0px; + border-color: #2463c7; + Rectangle { + width: parent.width - 27px; + height: parent.height; + background: transparent; + if root.srtp-enabled: Text { + x: 0px; + width: 12px; + height: parent.height; + text: FontAwesomeIcons.shield-halved; + color: #30343b; + font-family: "Font Awesome 6 Free"; + font-weight: 900; + font-size: ImGuiFontStyle.base; + horizontal-alignment: center; + vertical-alignment: center; + } + Text { + x: root.srtp-enabled ? 16px : 0px; + width: parent.width - self.x; + height: parent.height; + text: tab.title; + color: #30343b; + font-size: ImGuiFontStyle.base; + overflow: elide; + vertical-alignment: center; + } + } + Rectangle { + y: parent.height - 2px; + height: 2px; + background: index == root.selected-tab ? #3b82f6 : transparent; + } + tab-touch := TouchArea { + width: parent.width - 27px; + changed pressed => { + if (self.pressed) { + root.dragging-tab = index; + } else if (root.dragging-tab == index) { + root.reorder-tab(index, tab-item.x + self.mouse-x, tab-item.width); + root.dragging-tab = -1; + } + } + clicked => { root.selected-tab = index; root.select-tab(index); } + } + IconButton { + x: parent.width - 27px; + y: 1px; + width: 27px; + height: 27px; + z: 2; + icon: FontAwesomeIcons.xmark; + icon-size: 11px; + icon-color: #555c66; + danger: true; + clicked => { root.close-tab(tab.remote-id); } + } + } + } + } + + video := Rectangle { + y: root.fullscreen-enabled ? 0px : (root.tabs.length > 1 ? 30px : 0px); + height: parent.height - self.y; + background: black; + clip: true; + + frame-image := Image { + width: parent.width; + height: parent.height; + source: root.frame; + image-fit: contain; + visible: root.has-frame; + } + + Text { + // A connection state and the frame-waiting state may change in + // the same UI tick. Render them through one text item so two + // independent labels can never occupy the center together. + visible: !root.has-frame && (root.receiving-text != "" || root.status-text != ""); + text: root.receiving-text != "" ? root.receiving-text : root.status-text; + color: root.receiving-text != "" ? #ffffffea : white; + font-size: root.receiving-text != "" ? 14px : ImGuiFontStyle.prominent; + horizontal-alignment: center; + vertical-alignment: center; + } + + drop-zone := DropArea { + can-drop(event) => { + return root.can-drop-file(event.data) ? DragAction.copy : DragAction.none; + } + dropped(event) => { + root.file-dropped(event.data); + return DragAction.copy; + } + } + + input-focus := FocusScope { + focus-on-click: true; + key-pressed(event) => { + root.key-input(event.text, true, event.modifiers.control, event.modifiers.alt, event.modifiers.shift, event.modifiers.meta); + accept + } + key-released(event) => { + root.key-input(event.text, false, event.modifiers.control, event.modifiers.alt, event.modifiers.shift, event.modifiers.meta); + accept + } + pointer := TouchArea { + pointer-event(event) => { + let over-control = self.mouse-x >= control.x + && self.mouse-x <= control.x + control.width + && self.mouse-y >= control.y + && self.mouse-y <= control.y + control.height; + if event.kind == PointerEventKind.down + && event.button == PointerEventButton.left + && over-control { + root.begin-control-drag(self.mouse-x, self.mouse-y); + } else if root.control-dragging + && (event.kind == PointerEventKind.up + || event.kind == PointerEventKind.cancel) { + root.end-control-drag(); + } else if !root.control-dragging { + root.pointer-input(event.button, event.kind, self.mouse-x, self.mouse-y); + } + } + moved => { + if root.control-dragging { + root.move-control-drag(self.mouse-x, self.mouse-y); + } + } + scroll-event(event) => { + if self.mouse-x < control.x + || self.mouse-x > control.x + control.width + || self.mouse-y < control.y + || self.mouse-y > control.y + control.height { + root.scroll-input(event.delta-x, event.delta-y, self.mouse-x, self.mouse-y); + } + accept + } + } + } + + // Floating control bar, matching the original top-left overlay. + control := Rectangle { + x: root.control-dragging ? root.control-drag-x : root.control-docked-left ? 0px : parent.width - self.width; + y: min(max(0px, root.control-pos-y), parent.height - self.height); + width: root.control-expanded ? min(300px, parent.width) : 20px; + height: root.stats-visible && root.control-expanded ? min(180px, parent.height) : 38px; + background: white; + border-width: 1px; + border-color: ImGuiLineStyle.border; + border-radius: 6px; + z: 8; + clip: true; + // Keep width changes atomic. When docked right, animating both + // x and the clip width can leave newly revealed buttons outside + // Slint's damage region until they receive a hover repaint. + animate height { duration: 60ms; easing: ease-out; } + + if root.control-expanded && !root.control-docked-left: Rectangle { + x: 31px; y: 12px; width: 2px; height: 15px; + background: ImGuiLineStyle.control-separator; + } + + if root.control-expanded: display-button := ControlBarButton { + x: root.control-docked-left ? 9px : 42px; y: 7px; + icon: FontAwesomeIcons.display; + badge: root.selected-display + 1; + tooltip: StreamStrings.select-display; + clicked => { root.display-menu-open = !root.display-menu-open; root.shortcut-menu-open = false; } + } + if root.control-expanded: shortcut-button := ControlBarButton { + x: root.control-docked-left ? 41px : 74px; y: 7px; + icon: FontAwesomeIcons.keyboard; tooltip: StreamStrings.send-shortcut; + clicked => { root.shortcut-menu-open = !root.shortcut-menu-open; root.display-menu-open = false; } + } + if root.control-expanded: mouse-button := ControlBarButton { + x: root.control-docked-left ? 73px : 106px; y: 7px; + icon: FontAwesomeIcons.computer-mouse; slashed: !root.mouse-control-enabled; + tooltip: root.mouse-control-enabled ? StreamStrings.release-mouse : StreamStrings.control-mouse; + clicked => { root.toggle-mouse-control(); } + } + if root.control-expanded: audio-button := ControlBarButton { + x: root.control-docked-left ? 105px : 138px; y: 7px; + icon: FontAwesomeIcons.volume-high; slashed: !root.audio-enabled; + tooltip: root.audio-enabled ? StreamStrings.mute : StreamStrings.audio; + clicked => { root.toggle-audio(); } + } + if root.control-expanded: file-button := ControlBarButton { + x: root.control-docked-left ? 137px : 170px; y: 7px; + icon: FontAwesomeIcons.folder-open; tooltip: StreamStrings.select-file; + clicked => { root.select-file(); } + } + if root.control-expanded: stats-button := ControlBarButton { + x: root.control-docked-left ? 169px : 202px; y: 7px; + icon: FontAwesomeIcons.signal; selected: root.stats-visible; + tooltip: root.stats-visible ? StreamStrings.hide-stats : StreamStrings.show-stats; + clicked => { root.toggle-stats(); } + } + if root.control-expanded: fullscreen-button := ControlBarButton { + x: root.control-docked-left ? 201px : 234px; y: 7px; + icon: root.fullscreen-enabled ? FontAwesomeIcons.compress : FontAwesomeIcons.expand; + tooltip: root.fullscreen-enabled ? StreamStrings.exit-fullscreen : StreamStrings.fullscreen; + clicked => { root.toggle-fullscreen(); } + } + if root.control-expanded: close-button := ControlBarButton { + x: root.control-docked-left ? 233px : 266px; y: 7px; + icon: FontAwesomeIcons.xmark; tooltip: StreamStrings.disconnect; + clicked => { root.disconnect(); } + } + + if root.control-expanded && root.control-docked-left: Rectangle { + x: 267px; y: 12px; width: 2px; height: 15px; + background: ImGuiLineStyle.control-separator; + } + collapse-button := ControlBarButton { + x: root.control-expanded ? (root.control-docked-left ? 275px : 10px) : 2px; + y: 7px; + width: 15px; + icon: root.control-expanded + ? (root.control-docked-left ? FontAwesomeIcons.angle-left : FontAwesomeIcons.angle-right) + : (root.control-docked-left ? FontAwesomeIcons.angle-right : FontAwesomeIcons.angle-left); + tooltip: root.control-expanded ? StreamStrings.collapse-control : StreamStrings.expand-control; + clicked => { root.control-expanded = !root.control-expanded; } + } + + if root.stats-visible && root.control-expanded: stats-table := Rectangle { + x: 14px; + y: 38px; + width: parent.width - 28px; + height: 128px; + background: transparent; + private property row-height: 16px; + private property label-column-width: 60px; + private property loss-column-width: 66px; + private property value-column-width: + (self.width - self.label-column-width - self.loss-column-width) / 2; + + Rectangle { + y: 0px; + height: 1px; + background: ImGuiLineStyle.border; + } + + Rectangle { + y: 0px; + height: stats-table.row-height; + Text { + x: stats-table.label-column-width + 4px; + width: stats-table.value-column-width - 8px; + text: StreamStrings.stats-in; + color: #30343b; + font-size: ImGuiFontStyle.small; + vertical-alignment: center; + } + Text { + x: stats-table.label-column-width + stats-table.value-column-width + 4px; + width: stats-table.value-column-width - 8px; + text: StreamStrings.stats-out; + color: #30343b; + font-size: ImGuiFontStyle.small; + vertical-alignment: center; + } + Text { + x: stats-table.label-column-width + stats-table.value-column-width * 2 + 4px; + width: stats-table.loss-column-width - 8px; + text: StreamStrings.stats-loss-rate; + color: #30343b; + font-size: ImGuiFontStyle.small; + vertical-alignment: center; + overflow: elide; + } + Rectangle { + y: parent.height - 1px; + height: 1px; + background: ImGuiLineStyle.border; + } + } + + for row[index] in root.stats-rows: Rectangle { + y: (index + 1) * stats-table.row-height; + height: stats-table.row-height; + Text { + x: 4px; + width: stats-table.label-column-width - 8px; + text: row.label; + color: #30343b; + font-size: ImGuiFontStyle.small; + vertical-alignment: center; + } + Text { + x: stats-table.label-column-width + 4px; + width: stats-table.value-column-width - 8px; + text: row.inbound; + color: #30343b; + font-size: ImGuiFontStyle.small; + vertical-alignment: center; + overflow: elide; + } + Text { + x: stats-table.label-column-width + stats-table.value-column-width + 4px; + width: stats-table.value-column-width - 8px; + text: row.outbound; + color: #30343b; + font-size: ImGuiFontStyle.small; + vertical-alignment: center; + overflow: elide; + } + Text { + x: stats-table.label-column-width + stats-table.value-column-width * 2 + 4px; + width: stats-table.loss-column-width - 8px; + text: row.loss-rate; + color: #30343b; + font-size: ImGuiFontStyle.small; + vertical-alignment: center; + overflow: elide; + } + Rectangle { + y: parent.height - 1px; + height: 1px; + background: ImGuiLineStyle.border; + } + } + + for metric[index] in [ + { label: "FPS:", value: root.stats-fps }, + { label: StreamStrings.stats-resolution + ":", value: root.stats-resolution }, + { label: StreamStrings.stats-connection-mode + ":", value: root.stats-connection-mode }, + ]: Rectangle { + y: (index + 5) * stats-table.row-height; + height: stats-table.row-height; + Text { + x: 4px; + width: stats-table.label-column-width - 8px; + text: metric.label; + color: #30343b; + font-size: ImGuiFontStyle.small; + vertical-alignment: center; + } + Text { + x: stats-table.label-column-width + 4px; + width: stats-table.value-column-width - 8px; + text: metric.value; + color: #30343b; + font-size: ImGuiFontStyle.small; + vertical-alignment: center; + overflow: elide; + } + Rectangle { + y: parent.height - 1px; + height: 1px; + background: ImGuiLineStyle.border; + } + } + } + } + + if root.display-menu-open: Rectangle { + x: control.x + (root.control-docked-left ? 9px : 42px); y: control.y + 40px; width: 180px; height: min(180px, root.displays.length * 30px + 8px); + background: white; border-width: 1px; border-color: ImGuiLineStyle.border; border-radius: 5px; z: 11; + VerticalLayout { + padding: 4px; spacing: 1px; + for display[index] in root.displays: Rectangle { + height: 29px; + background: display-touch.has-hover || index == root.selected-display ? #e8eef9 : transparent; + Text { x: 8px; text: display; color: #30343b; font-size: ImGuiFontStyle.body; vertical-alignment: center; } + display-touch := TouchArea { clicked => { root.selected-display = index; root.switch-display(index); root.display-menu-open = false; } } + } + } + } + + if root.shortcut-menu-open: Rectangle { + x: control.x + (root.control-docked-left ? 41px : 74px); y: control.y + 40px; width: 150px; height: 68px; + background: white; border-width: 1px; border-color: ImGuiLineStyle.border; border-radius: 5px; z: 11; + VerticalLayout { + padding: 4px; spacing: 1px; + for shortcut in ["Ctrl+Alt+Del", "Win+L"]: Rectangle { + height: 29px; background: shortcut-touch.has-hover ? #e8eef9 : transparent; + Text { x: 8px; text: shortcut; color: #30343b; font-size: ImGuiFontStyle.body; vertical-alignment: center; } + shortcut-touch := TouchArea { clicked => { root.send-shortcut(shortcut); root.shortcut-menu-open = false; } } + } + } + } + + // Transfer progress overlay at bottom-left. + if root.file-transfer-visible: Rectangle { + x: 18px; y: parent.height - min(210px, parent.height * 0.35); width: min(430px, parent.width * 0.6); height: min(190px, parent.height * 0.32); + background: #fffffff0; border-width: 1px; border-color: ImGuiLineStyle.border; border-radius: 6px; z: 7; + Text { x: 12px; y: 8px; text: StreamStrings.file-transfer; color: #2d3138; font-weight: 600; } + IconButton { x: parent.width - 32px; y: 1px; icon: FontAwesomeIcons.xmark; icon-size: 13px; clicked => { root.close-file-transfer(); } } + ScrollView { + x: 10px; y: 32px; width: parent.width - 20px; height: parent.height - 42px; + VerticalLayout { + // Slint's ScrollView draws its vertical scrollbar over + // the viewport. Reserve a gutter so transfer content + // never extends underneath it. + padding-right: 16px; + spacing: 8px; + for entry in root.file-transfers: VerticalLayout { + spacing: 3px; + HorizontalLayout { height: 18px; Text { text: entry.name; color: #30343b; font-size: ImGuiFontStyle.body; overflow: elide; } Text { width: 80px; text: entry.status; color: #5c6470; font-size: ImGuiFontStyle.body; horizontal-alignment: right; } } + Rectangle { + height: 7px; + background: #dfe3e8; + border-radius: 3px; + + Rectangle { + x: 0px; + y: 0px; + width: parent.width * max(0, min(1, entry.progress)); + height: parent.height; + background: #2463c7; + border-radius: 3px; + } + } + HorizontalLayout { + height: 14px; + Text { text: round(entry.progress * 100) + "% " + entry.speed; color: #6a717c; font-size: ImGuiFontStyle.small; } + Text { text: entry.size; color: #6a717c; font-size: ImGuiFontStyle.small; horizontal-alignment: right; } + } + } + } + } + } + } + } +} diff --git a/src/gui/views/windows/about_window.cpp b/src/gui/views/windows/about_window.cpp index a153d06..8dc2b41 100644 --- a/src/gui/views/windows/about_window.cpp +++ b/src/gui/views/windows/about_window.cpp @@ -123,7 +123,7 @@ int GuiApplication::AboutWindow() { } std::string copyright_text = "© 2025 by JUNKUN DI. All rights reserved."; - std::string license_text = "Licensed under GNU LGPL v3."; + std::string license_text = "Licensed under GNU GPL v3."; ImGui::SetCursorPosX(about_window_width * 0.1f); ImGui::Text("%s", copyright_text.c_str()); ImGui::SetCursorPosX(about_window_width * 0.1f); @@ -145,4 +145,4 @@ int GuiApplication::AboutWindow() { } return 0; } -} // namespace crossdesk \ No newline at end of file +} // namespace crossdesk diff --git a/src/thumbnail/thumbnail.cpp b/src/thumbnail/thumbnail.cpp index b254e85..19e0ccc 100644 --- a/src/thumbnail/thumbnail.cpp +++ b/src/thumbnail/thumbnail.cpp @@ -18,71 +18,12 @@ #define STB_IMAGE_IMPLEMENTATION #include "stb_image.h" + #define STB_IMAGE_WRITE_IMPLEMENTATION #include "stb_image_write.h" namespace crossdesk { -bool LoadTextureFromMemory(const void* data, size_t data_size, - SDL_Renderer* renderer, SDL_Texture** out_texture, - int* out_width, int* out_height) { - int image_width = 0; - int image_height = 0; - int channels = 4; - unsigned char* image_data = - stbi_load_from_memory((const unsigned char*)data, (int)data_size, - &image_width, &image_height, NULL, 4); - if (image_data == nullptr) { - LOG_ERROR("Failed to load image: [{}]", stbi_failure_reason()); - return false; - } - - // ABGR - int pitch = image_width * channels; - SDL_Surface* surface = - SDL_CreateSurfaceFrom(image_width, image_height, SDL_PIXELFORMAT_RGBA32, - (void*)image_data, pitch); - if (surface == nullptr) { - LOG_ERROR("Failed to create SDL surface: [{}]", SDL_GetError()); - return false; - } - - SDL_Texture* texture = SDL_CreateTextureFromSurface(renderer, surface); - if (texture == nullptr) { - LOG_ERROR("Failed to create SDL texture: [{}]", SDL_GetError()); - } - - *out_texture = texture; - *out_width = image_width; - *out_height = image_height; - - SDL_DestroySurface(surface); - stbi_image_free(image_data); - - return true; -} - -bool LoadTextureFromFile(const char* file_name, SDL_Renderer* renderer, - SDL_Texture** out_texture, int* out_width, - int* out_height) { - std::filesystem::path file_path(file_name); - if (!std::filesystem::exists(file_path)) return false; - std::ifstream file(file_path, std::ios::binary); - if (!file) return false; - file.seekg(0, std::ios::end); - size_t file_size = file.tellg(); - file.seekg(0, std::ios::beg); - if (file_size == -1) return false; - char* file_data = new char[file_size]; - if (!file_data) return false; - file.read(file_data, file_size); - bool ret = LoadTextureFromMemory(file_data, file_size, renderer, out_texture, - out_width, out_height); - delete[] file_data; - - return ret; -} - void ScaleNv12ToABGR(char* src, int src_w, int src_h, int dst_w, int dst_h, char* dst_rgba) { uint8_t* y = reinterpret_cast(src); @@ -201,17 +142,12 @@ int Thumbnail::SaveToThumbnail(const char* yuv420p, int width, int height, } int Thumbnail::LoadThumbnail( - SDL_Renderer* renderer, std::vector>& recent_connections, int* width, int* height) { - for (auto& it : recent_connections) { - if (it.second.texture != nullptr) { - SDL_DestroyTexture(it.second.texture); - it.second.texture = nullptr; - } - } recent_connections.clear(); + if (width) *width = thumbnail_width_; + if (height) *height = thumbnail_height_; std::vector image_paths = FindThumbnailPath(save_path_); @@ -270,17 +206,40 @@ int Thumbnail::LoadThumbnail( recent_connection.remote_host_name = remote_host_name; recent_connection.password = password; recent_connection.remember_password = remember_password; + recent_connection.image_path = image_path; recent_connections.emplace_back( std::make_pair(original_image_name, recent_connection)); - LoadTextureFromFile(image_path.c_str(), renderer, - &(recent_connections.back().second.texture), width, - height); } return 0; } return 0; } +bool Thumbnail::DecodeImage(const std::filesystem::path& image_path, + std::vector* rgba, int* width, + int* height) const { + if (!rgba || !width || !height) { + return false; + } + + int channels = 0; + unsigned char* pixels = + stbi_load(image_path.string().c_str(), width, height, &channels, 4); + if (!pixels || *width <= 0 || *height <= 0) { + if (pixels) { + stbi_image_free(pixels); + } + rgba->clear(); + return false; + } + + const size_t byte_count = + static_cast(*width) * static_cast(*height) * 4; + rgba->assign(pixels, pixels + byte_count); + stbi_image_free(pixels); + return true; +} + int Thumbnail::DeleteThumbnail(const std::string& filename_keyword) { for (const auto& entry : std::filesystem::directory_iterator(save_path_)) { if (entry.is_regular_file()) { diff --git a/src/thumbnail/thumbnail.h b/src/thumbnail/thumbnail.h index 1ede1ab..ba6b324 100644 --- a/src/thumbnail/thumbnail.h +++ b/src/thumbnail/thumbnail.h @@ -7,8 +7,6 @@ #ifndef _THUMBNAIL_H_ #define _THUMBNAIL_H_ -#include - #include #include #include @@ -19,7 +17,7 @@ namespace crossdesk { class Thumbnail { public: struct RecentConnection { - SDL_Texture* texture = nullptr; + std::filesystem::path image_path; std::string remote_id; std::string remote_host_name; std::string password; @@ -41,11 +39,14 @@ class Thumbnail { const std::string& password); int LoadThumbnail( - SDL_Renderer* renderer, std::vector>& recent_connections, int* width, int* height); + bool DecodeImage(const std::filesystem::path& image_path, + std::vector* rgba, int* width, + int* height) const; + int DeleteThumbnail(const std::string& filename_keyword); int DeleteAllFilesInDirectory(); @@ -88,4 +89,4 @@ class Thumbnail { unsigned char decryptedtext_[64]; }; } // namespace crossdesk -#endif \ No newline at end of file +#endif diff --git a/submodules/minirtc b/submodules/minirtc index 6587685..da4b0f2 160000 --- a/submodules/minirtc +++ b/submodules/minirtc @@ -1 +1 @@ -Subproject commit 658768547faf8fe6c2783832bfad6ce8f5999a8e +Subproject commit da4b0f2e9a0b59b7dde4f0901b16763d63d1cfd1 diff --git a/tests/connection_status_protocol_test.cpp b/tests/connection_status_protocol_test.cpp index 90b001f..eaf14f8 100644 --- a/tests/connection_status_protocol_test.cpp +++ b/tests/connection_status_protocol_test.cpp @@ -63,8 +63,10 @@ int main() { ReadFile(repo_root / "submodules/minirtc/src/api/minirtc.h"); const std::string peer_connection = ReadFile(repo_root / "submodules/minirtc/src/pc/peer_connection.cpp"); - const std::string connection_status_window = ReadFile( - repo_root / "src/gui/views/windows/connection_status_window.cpp"); + const std::string main_window = + ReadFile(repo_root / "src/gui/ui/main_window.slint"); + const std::string gui_application = + ReadFile(repo_root / "src/gui/application/gui_application.cpp"); const std::string runtime_state_h = ReadFile(repo_root / "src/gui/runtime/runtime_state.h"); const std::string remote_session_h = @@ -83,8 +85,14 @@ int main() { "\"Device offline\""); ok &= ExpectNotContains("peer_connection.cpp", peer_connection, "ConnectionStatus::DeviceOffline"); - ok &= ExpectContains("connection_status_window.cpp", connection_status_window, - "localization::device_offline"); + ok &= ExpectContains("main_window.slint", main_window, + "in property connection-dialog-open: false;"); + ok &= ExpectContains("main_window.slint", main_window, + "text: root.connection-status-text;"); + ok &= ExpectContains("gui_application.cpp", gui_application, + "case ConnectionStatus::RemoteUnavailable:"); + ok &= ExpectContains("gui_application.cpp", gui_application, + "return localization::device_offline[language];"); ok &= ExpectContains("runtime_state.h", runtime_state_h, "pending_presence_probe_started_at_"); ok &= ExpectContains("remote_session.h", remote_session_h, diff --git a/tests/display_popup_hover_state_test.cpp b/tests/display_popup_hover_state_test.cpp index 57d828c..4cf9a71 100644 --- a/tests/display_popup_hover_state_test.cpp +++ b/tests/display_popup_hover_state_test.cpp @@ -10,8 +10,7 @@ std::filesystem::path FindRepoRoot() { std::filesystem::path current = std::filesystem::current_path(); while (!current.empty()) { if (std::filesystem::exists(current / "xmake.lua") && - std::filesystem::exists(current / - "src/gui/views/toolbars/control_bar.cpp")) { + std::filesystem::exists(current / "src/gui/ui/stream_window.slint")) { return current; } current = current.parent_path(); @@ -19,88 +18,51 @@ std::filesystem::path FindRepoRoot() { return {}; } -std::string ReadFile(const std::filesystem::path& path) { +std::string ReadFile(const std::filesystem::path &path) { std::ifstream file(path, std::ios::binary); if (!file) { return {}; } - std::ostringstream stream; stream << file.rdbuf(); return stream.str(); } -bool ExpectContains(const char* name, const std::string& value, - const std::string& expected) { +bool ExpectContains(const char *name, const std::string &value, + const std::string &expected) { if (value.find(expected) != std::string::npos) { return true; } - std::cerr << name << " missing expected text: " << expected << "\n"; return false; } -bool ExpectNotContains(const char* name, const std::string& value, - const std::string& unexpected) { +bool ExpectNotContains(const char *name, const std::string &value, + const std::string &unexpected) { if (value.find(unexpected) == std::string::npos) { return true; } - std::cerr << name << " contains unexpected text: " << unexpected << "\n"; return false; } -bool ExpectContainsAtLeast(const char* name, const std::string& value, - const std::string& expected, size_t min_count) { +bool ExpectContainsAtLeast(const char *name, const std::string &value, + const std::string &expected, size_t min_count) { size_t count = 0; size_t pos = 0; while ((pos = value.find(expected, pos)) != std::string::npos) { ++count; pos += expected.size(); } - if (count >= min_count) { return true; } - std::cerr << name << " expected at least " << min_count << " occurrences of: " << expected << ", found " << count << "\n"; return false; } -bool ExpectResetBeforeDisplayPopup(const std::string& value) { - const std::string reset = "props->display_selectable_hovered_ = false;"; - const std::string popup = "ImGui::BeginPopup(\"display\")"; - const size_t reset_pos = value.find(reset); - const size_t popup_pos = value.find(popup); - - if (reset_pos != std::string::npos && popup_pos != std::string::npos && - reset_pos < popup_pos) { - return true; - } - - std::cerr << "control_bar.cpp must clear display_selectable_hovered_ before " - "checking the display popup\n"; - return false; -} - -bool ExpectResetBeforeShortcutPopup(const std::string& value) { - const std::string reset = "props->shortcut_selectable_hovered_ = false;"; - const std::string popup = "ImGui::BeginPopup(\"shortcut\")"; - const size_t reset_pos = value.find(reset); - const size_t popup_pos = value.find(popup); - - if (reset_pos != std::string::npos && popup_pos != std::string::npos && - reset_pos < popup_pos) { - return true; - } - - std::cerr << "control_bar.cpp must clear shortcut_selectable_hovered_ before " - "checking the shortcut popup\n"; - return false; -} - -} // namespace +} // namespace int main() { const std::filesystem::path repo_root = FindRepoRoot(); @@ -109,75 +71,61 @@ int main() { return 1; } - const std::string control_bar = - ReadFile(repo_root / "src/gui/views/toolbars/control_bar.cpp"); + const std::string stream = + ReadFile(repo_root / "src/gui/ui/stream_window.slint"); + const std::string common = ReadFile(repo_root / "src/gui/ui/common.slint"); + const std::string application = + ReadFile(repo_root / "src/gui/application/gui_application.cpp"); bool ok = true; - ok &= ExpectContains("control_bar.cpp", control_bar, - "props->display_selectable_hovered_ = false;"); - ok &= ExpectContains("control_bar.cpp", control_bar, - "ImGui::IsWindowHovered(" - "ImGuiHoveredFlags_RootAndChildWindows)"); - ok &= ExpectResetBeforeDisplayPopup(control_bar); - ok &= ExpectContains("control_bar.cpp", control_bar, - "props->shortcut_selectable_hovered_ ="); - ok &= ExpectResetBeforeShortcutPopup(control_bar); - ok &= ExpectContains("control_bar.cpp", control_bar, - "void ShowControlBarTooltip(const std::string& text)"); - ok &= ExpectContainsAtLeast("control_bar.cpp", control_bar, - "ShowControlBarTooltip(", 10); - ok &= ExpectContains("control_bar.cpp", control_bar, - "localization::select_display" - "[localization_language_index_]"); - ok &= ExpectContains("control_bar.cpp", control_bar, - "localization::send_shortcut" - "[localization_language_index_]"); - ok &= ExpectNotContains("control_bar.cpp", control_bar, - "ShowControlBarTooltip(" - "props->mouse_control_button_label_)"); - ok &= ExpectNotContains("control_bar.cpp", control_bar, - "ShowControlBarTooltip(" - "props->audio_capture_button_label_)"); - ok &= ExpectContains("control_bar.cpp", control_bar, - "localization::select_file" - "[localization_language_index_]"); - ok &= ExpectNotContains("control_bar.cpp", control_bar, - "ShowControlBarTooltip(" - "props->net_traffic_stats_button_label_)"); - ok &= ExpectNotContains("control_bar.cpp", control_bar, - "ShowControlBarTooltip(" - "props->fullscreen_button_label_)"); - ok &= ExpectContains("control_bar.cpp", control_bar, - "localization::release_mouse" - "[localization_language_index_]"); - ok &= ExpectContains("control_bar.cpp", control_bar, - "localization::control_mouse" - "[localization_language_index_]"); - ok &= ExpectContains("control_bar.cpp", control_bar, - "localization::audio_capture" - "[localization_language_index_]"); - ok &= ExpectContains("control_bar.cpp", control_bar, - "localization::mute[localization_language_index_]"); - ok &= ExpectContains("control_bar.cpp", control_bar, - "localization::hide_net_traffic_stats" - "[localization_language_index_]"); - ok &= ExpectContains("control_bar.cpp", control_bar, - "localization::show_net_traffic_stats" - "[localization_language_index_]"); - ok &= ExpectContains("control_bar.cpp", control_bar, - "localization::exit_fullscreen" - "[localization_language_index_]"); - ok &= ExpectContains("control_bar.cpp", control_bar, - "localization::fullscreen" - "[localization_language_index_]"); - ok &= ExpectContains("control_bar.cpp", control_bar, - "localization::disconnect" - "[localization_language_index_]"); - ok &= ExpectContains("control_bar.cpp", control_bar, - "localization::expand_control_bar" - "[localization_language_index_]"); - ok &= ExpectContains("control_bar.cpp", control_bar, - "localization::collapse_control_bar" - "[localization_language_index_]"); + ok &= ExpectContains("stream_window.slint", stream, + "private property display-menu-open: false;"); + ok &= ExpectContains("stream_window.slint", stream, + "root.shortcut-menu-open = false;"); + ok &= ExpectContains("stream_window.slint", stream, + "if root.display-menu-open: Rectangle"); + ok &= ExpectContains("stream_window.slint", stream, + "display-touch.has-hover"); + ok &= ExpectContains("stream_window.slint", stream, + "root.switch-display(index)"); + ok &= ExpectContains("stream_window.slint", stream, + "if root.shortcut-menu-open: Rectangle"); + ok &= ExpectContains("stream_window.slint", stream, + "shortcut-touch.has-hover"); + + ok &= ExpectContains("common.slint", common, + "if touch.has-hover && root.tooltip != \"\": Rectangle"); + ok &= ExpectContainsAtLeast("stream_window.slint", stream, "tooltip:", 9); + ok &= ExpectContains("stream_window.slint", stream, + "tooltip: StreamStrings.select-display"); + ok &= ExpectContains("stream_window.slint", stream, + "tooltip: StreamStrings.send-shortcut"); + ok &= ExpectContains("stream_window.slint", stream, + "root.mouse-control-enabled ? StreamStrings.release-mouse : StreamStrings.control-mouse"); + ok &= ExpectContains("stream_window.slint", stream, + "root.audio-enabled ? StreamStrings.mute : StreamStrings.audio"); + ok &= ExpectContains("stream_window.slint", stream, + "tooltip: StreamStrings.select-file"); + ok &= ExpectContains("stream_window.slint", stream, + "root.stats-visible ? StreamStrings.hide-stats : StreamStrings.show-stats"); + ok &= ExpectContains("stream_window.slint", stream, + "root.fullscreen-enabled ? StreamStrings.exit-fullscreen : StreamStrings.fullscreen"); + ok &= ExpectContains("stream_window.slint", stream, + "tooltip: StreamStrings.disconnect"); + ok &= ExpectContains("stream_window.slint", stream, + "root.control-expanded ? StreamStrings.collapse-control : StreamStrings.expand-control"); + + ok &= ExpectContains("gui_application.cpp", application, + "set_select_display("); + ok &= ExpectContains("gui_application.cpp", application, + "set_show_stats("); + ok &= ExpectContains("gui_application.cpp", application, + "set_expand_control("); + ok &= ExpectContains("gui_application.cpp", application, + "set_collapse_control("); + ok &= ExpectContains("gui_application.cpp", application, + "(*ui_->stream)->global()"); + ok &= ExpectNotContains("gui_application.cpp", application, + "#include +#include +#include +#include + +int main() { + auto window = crossdesk::ui::MainWindow::create(); + auto stream = crossdesk::ui::StreamWindow::create(); + auto server = crossdesk::ui::ServerWindow::create(); + window->set_local_id("123 456 789"); + window->set_local_password("123456"); + window->set_connection_dialog_open(true); + window->set_connection_password_required(true); + window->set_settings_session_active(true); + window->set_portable_service_settings_visible(true); + assert(std::string(window->get_local_id()) == "123 456 789"); + assert(window->get_connection_dialog_open()); + assert(window->get_connection_password_required()); + assert(window->get_settings_session_active()); + assert(window->get_portable_service_settings_visible()); + window->set_settings_open(true); + window->set_about_open(true); + assert(window->get_settings_open()); + assert(window->get_about_open()); + window->set_remote_id_input("987654321"); + window->invoke_reset_remote_id(); + assert(std::string(window->get_remote_id_input()).empty()); + + std::vector connections; + crossdesk::ui::RecentConnection connection; + connection.remote_id = "987654321"; + connection.display_name = "Office PC"; + connection.host_name = "office-pc"; + connection.online = true; + connections.emplace_back(std::move(connection)); + window->set_recent_connections( + std::make_shared>( + std::move(connections))); + assert(window->get_recent_connections()->row_count() == 1); + + bool connect_requested = false; + window->on_connect_requested([&](slint::SharedString remote_id) { + connect_requested = std::string(remote_id) == "987654321"; + }); + window->invoke_connect_requested("987654321"); + assert(connect_requested); + + bool password_submitted = false; + window->on_connection_submit_password( + [&](slint::SharedString password, bool remember) { + password_submitted = std::string(password) == "654321" && remember; + }); + window->invoke_connection_submit_password("654321", true); + assert(password_submitted); + + crossdesk::ui::StreamTab tab; + tab.remote_id = "123456789"; + tab.title = "Remote host"; + tab.connected = true; + stream->set_tabs( + std::make_shared>( + std::vector{tab})); + assert(stream->get_tabs()->row_count() == 1); + + bool tab_reordered = false; + stream->on_reorder_tab([&](int index, float x, float width) { + tab_reordered = index == 0 && x == 120.0f && width == 110.0f; + }); + stream->invoke_reorder_tab(0, 120.0f, 110.0f); + assert(tab_reordered); + + crossdesk::ui::FileTransferEntry transfer; + transfer.name = "archive.zip"; + transfer.status = "Sending"; + transfer.progress = 0.5f; + transfer.speed = "1.0 mbps"; + transfer.size = "2.00 MB"; + stream->set_file_transfers( + std::make_shared< + slint::VectorModel>( + std::vector{transfer})); + stream->set_file_transfer_visible(true); + crossdesk::ui::NetworkStatsRow video_stats; + video_stats.label = "Video"; + video_stats.inbound = "427 kbps"; + video_stats.outbound = "5 kbps"; + video_stats.loss_rate = "0%"; + stream->set_stats_rows( + std::make_shared< + slint::VectorModel>( + std::vector{video_stats})); + stream->set_stats_fps("53"); + stream->set_stats_resolution("3024x1964"); + stream->set_stats_connection_mode("Direct"); + stream->set_stats_visible(true); + stream->set_fullscreen_enabled(true); + assert(stream->get_file_transfers()->row_count() == 1); + assert(stream->get_file_transfer_visible()); + assert(stream->get_stats_rows()->row_count() == 1); + assert(std::string(stream->get_stats_fps()) == "53"); + assert(std::string(stream->get_stats_resolution()) == "3024x1964"); + assert(std::string(stream->get_stats_connection_mode()) == "Direct"); + assert(stream->get_stats_visible()); + assert(stream->get_fullscreen_enabled()); + + bool display_switched = false; + stream->on_switch_display( + [&](int index) { display_switched = index == 1; }); + stream->invoke_switch_display(1); + assert(display_switched); + + auto &stream_strings = stream->global(); + stream_strings.set_select_display("Select Display"); + stream_strings.set_expand_control("Expand Control Bar"); + assert(std::string(stream_strings.get_select_display()) == "Select Display"); + assert(std::string(stream_strings.get_expand_control()) == + "Expand Control Bar"); + + crossdesk::ui::ControllerEntry controller; + controller.remote_id = "123456789"; + controller.display_name = "Remote host"; + server->set_controllers( + std::make_shared>( + std::vector{controller})); + server->set_file_transfer_visible(true); + server->set_sending_file(true); + server->set_file_progress(0.5f); + server->set_current_file_name("archive.zip"); + server->set_file_size_text("1.00 MB / 2.00 MB"); + assert(server->get_controllers()->row_count() == 1); + assert(server->get_file_transfer_visible()); + assert(server->get_sending_file()); + assert(server->get_file_progress() == 0.5f); + + bool controller_selected = false; + server->on_controller_selected( + [&](int index) { controller_selected = index == 0; }); + server->invoke_controller_selected(0); + assert(controller_selected); + + return 0; +} diff --git a/tests/windows_sas_guard_test.cpp b/tests/windows_sas_guard_test.cpp index e63456f..22006f5 100644 --- a/tests/windows_sas_guard_test.cpp +++ b/tests/windows_sas_guard_test.cpp @@ -70,8 +70,10 @@ int main() { return 1; } - const std::string control_bar = - ReadFile(repo_root / "src/gui/views/toolbars/control_bar.cpp"); + const std::string stream_window = + ReadFile(repo_root / "src/gui/ui/stream_window.slint"); + const std::string gui_application = + ReadFile(repo_root / "src/gui/application/gui_application.cpp"); const std::string windows_service_runtime = ReadFile(repo_root / "src/gui/runtime/windows_service_runtime.cpp"); const std::string runtime_state_h = @@ -87,13 +89,16 @@ int main() { ok &= ExpectTrue( "secure desktop input routing", crossdesk::IsSecureDesktopInteractionRequired("secure-desktop")); - ok &= ExpectNotContains("control_bar.cpp", control_bar, - "CanSendSecureAttentionSequence(" - "props->remote_interactive_stage_)"); - ok &= ExpectNotContains("control_bar.cpp", control_bar, - "ImGui::BeginDisabled();\n" - " }\n" - " if (ImGui::Selectable(sas_label.c_str()))"); + ok &= ExpectContains("stream_window.slint", stream_window, + "for shortcut in [\"Ctrl+Alt+Del\", \"Win+L\"]"); + ok &= ExpectContains("stream_window.slint", stream_window, + "root.send-shortcut(shortcut)"); + ok &= ExpectContains("gui_application.cpp", gui_application, + "std::string(shortcut) == \"Ctrl+Alt+Del\""); + ok &= ExpectContains("gui_application.cpp", gui_application, + "ServiceCommandFlag::send_sas"); + ok &= ExpectContains("gui_application.cpp", gui_application, + "ServiceCommandFlag::lock_workstation"); ok &= ExpectNotContains("windows_service_runtime.cpp", windows_service_runtime, "sas_requires_lock_screen"); ok &= ExpectContains("runtime_state.h", runtime_state_h, diff --git a/xmake.lua b/xmake.lua index 395a232..42f8e53 100644 --- a/xmake.lua +++ b/xmake.lua @@ -1,8 +1,11 @@ set_project("crossdesk") -set_license("LGPL-3.0") +set_license("GPL-3.0-only") + +add_repositories("crossdesk-packages " .. path.join(os.scriptdir(), "xmake", "repository")) includes("xmake/options.lua") includes("xmake/platform.lua") +includes("xmake/rules/slint.lua") includes("xmake/targets.lua") setup_options_and_dependencies() diff --git a/xmake/options.lua b/xmake/options.lua index af08dd0..ceb03b8 100644 --- a/xmake/options.lua +++ b/xmake/options.lua @@ -30,6 +30,8 @@ function setup_options_and_dependencies() option_end() add_rules("mode.release", "mode.debug") + -- Preserve the existing codebase/toolchain contract. Slint targets opt in to + -- C++20 locally because its generated C++ API requires it. set_languages("c++17") set_encodings("utf-8") @@ -41,7 +43,9 @@ function setup_options_and_dependencies() add_defines("USE_CUDA=" .. (is_config("USE_CUDA", true) and "1" or "0")) add_defines("USE_WAYLAND=" .. (is_config("USE_WAYLAND", true) and "1" or "0")) add_defines("USE_DRM=" .. (is_config("USE_DRM", true) and "1" or "0")) - add_defines("CROSSDESK_PORTABLE=" .. (is_config("CROSSDESK_PORTABLE", true) and "1" or "0")) + if is_config("CROSSDESK_PORTABLE", true) then + add_defines("CROSSDESK_PORTABLE=1") + end if is_mode("debug") then add_defines("CROSSDESK_DEBUG") @@ -49,7 +53,8 @@ function setup_options_and_dependencies() add_requireconfs("*.python", {version = "3.12", override = true, configs = {pgo = false}}) add_requires("spdlog 1.14.1", {system = false}) - add_requires("imgui v1.92.1-docking", {configs = {sdl3 = true, sdl3_renderer = true}}) + add_requires("slint 1.17.1", {configs = {shared = true}}) + add_requires("libsdl3 3.2.26", {configs = {shared = false}}) add_requires("openssl3 3.3.2", {system = false}) add_requires("nlohmann_json 3.11.3") add_requires("cpp-httplib v0.26.0", {configs = {ssl = true}}) diff --git a/xmake/platform.lua b/xmake/platform.lua index de72d87..f454963 100644 --- a/xmake/platform.lua +++ b/xmake/platform.lua @@ -27,7 +27,7 @@ function setup_platform_settings() if is_os("windows") then add_requires("libyuv", "miniaudio 0.11.21") add_links("Shell32", "dwmapi", "User32", "kernel32", - "SDL3-static", "gdi32", "winmm", "setupapi", "version", + "gdi32", "winmm", "setupapi", "version", "Imm32", "iphlpapi", "d3d11", "dxgi") add_cxflags("/WX") set_runtimes("MT") @@ -35,7 +35,7 @@ function setup_platform_settings() add_links("pulse-simple", "pulse") add_requires("libyuv") add_syslinks("pthread", "dl") - add_links("SDL3", "asound", "X11", "Xext", "Xrender", "Xft", "Xtst", + add_links("asound", "X11", "Xext", "Xrender", "Xft", "Xtst", "Xrandr", "Xfixes") add_existing_include_dirs({ "/usr/include/freetype2", @@ -77,7 +77,6 @@ function setup_platform_settings() add_cxflags("-Wno-unused-variable") elseif is_os("macosx") then - add_links("SDL3") add_ldflags("-Wl,-ld_classic") add_cxflags("-Wno-unused-variable") add_frameworks("Cocoa", "OpenGL", "IOSurface", "ScreenCaptureKit", diff --git a/xmake/repository/packages/s/slint/xmake.lua b/xmake/repository/packages/s/slint/xmake.lua new file mode 100644 index 0000000..29d6acc --- /dev/null +++ b/xmake/repository/packages/s/slint/xmake.lua @@ -0,0 +1,62 @@ +package("slint") + set_homepage("https://slint.dev") + set_description("Declarative GUI toolkit for C++") + set_license("GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0") + + add_urls("https://github.com/slint-ui/slint/archive/refs/tags/v$(version).tar.gz", + "https://github.com/slint-ui/slint.git") + add_versions("1.17.1", "68222567f8c70ff677cd4a98cd94fb4765ac0f797eb8f8608a646911c908dc2a") + + add_configs("shared", {description = "Build the Slint runtime as a shared library", default = true, type = "boolean", readonly = true}) + + add_deps("cmake") + + on_load(function(package) + package:add("includedirs", "include/slint") + package:add("links", "slint_cpp") + end) + + on_install("windows", "linux", "macosx", function(package) + local configs = { + "-DSLINT_BUILD_TESTING=OFF", + "-DSLINT_BUILD_EXAMPLES=OFF", + "-DSLINT_COMPILER=download", + "-DSLINT_FEATURE_INTERPRETER=OFF", + "-DSLINT_FEATURE_LIVE_PREVIEW=OFF", + "-DSLINT_FEATURE_TESTING=OFF", + "-DSLINT_FEATURE_SYSTEM_TESTING=OFF", + "-DSLINT_FEATURE_MCP=OFF", + "-DSLINT_FEATURE_BACKEND_QT=OFF", + "-DSLINT_FEATURE_RENDERER_SKIA=OFF", + "-DSLINT_FEATURE_RENDERER_SKIA_OPENGL=OFF", + "-DSLINT_FEATURE_RENDERER_SKIA_VULKAN=OFF", + "-DSLINT_FEATURE_RENDERER_FEMTOVG=ON", + "-DSLINT_FEATURE_RENDERER_SOFTWARE=ON", + "-DSLINT_STYLE=fluent", + "-DBUILD_SHARED_LIBS=ON" + } + import("package.tools.cmake").install(package, configs) + + -- SLINT_COMPILER=download only configures downstream CMake projects. + -- xmake's native Slint rule needs the matching host executable here. + import("net.http") + import("utils.archive") + local host_system = is_host("windows") and "windows" or (is_host("macosx") and "Darwin" or "Linux") + local host_arch = os.arch() + if host_arch == "x64" then + host_arch = "x86_64" + end + local suffix = host_system .. "-" .. host_arch + local archive_name = "slint-compiler-" .. suffix .. ".tar.gz" + local archive_file = path.join(package:builddir(), archive_name) + local download_url = "https://github.com/slint-ui/slint/releases/download/v" .. package:version_str() .. "/" .. archive_name + http.download(download_url, archive_file) + os.mkdir(path.join(package:installdir(), "bin")) + archive.extract(archive_file, package:installdir()) + end) + + on_test(function(package) + assert(package:has_cxxincludes("slint.h", {configs = {languages = "c++20"}}), "Slint C++ headers are unusable") + assert(os.isfile(path.join(package:installdir(), "bin", is_host("windows") and "slint-compiler.exe" or "slint-compiler")), "slint-compiler was not installed") + end) +package_end() diff --git a/xmake/rules/slint.lua b/xmake/rules/slint.lua new file mode 100644 index 0000000..a6e8480 --- /dev/null +++ b/xmake/rules/slint.lua @@ -0,0 +1,53 @@ +rule("slint") + set_extensions(".slint") + + on_config(function(target) + local rule_name = "slint" + if target:rule("c++.build") then + local cpp_rule = target:rule("c++.build"):clone() + cpp_rule:add("deps", rule_name, {order = true}) + target:rule_add(cpp_rule) + end + + local outputdir = path.join(target:autogendir(), "rules", "slint") + os.mkdir(outputdir) + target:add("includedirs", outputdir, {public = true}) + + local sourcebatch = target:sourcebatches()[rule_name] + if sourcebatch and sourcebatch.sourcefiles then + for _, sourcefile in ipairs(sourcebatch.sourcefiles) do + -- Make the generated include discoverable during C++ dependency + -- scanning; the real contents are produced before compilation. + os.touch(path.join(outputdir, path.basename(sourcefile) .. ".h")) + end + end + end) + + before_buildcmd_file(function(target, batchcmds, sourcefile, opt) + local package = assert(target:pkg("slint"), "the slint package is required by this target") + local compiler = path.join(package:installdir(), "bin", is_host("windows") and "slint-compiler.exe" or "slint-compiler") + local outputdir = path.join(target:autogendir(), "rules", "slint") + local outputfile = path.join(outputdir, path.basename(sourcefile) .. ".h") + local depfile = outputfile .. ".d" + + batchcmds:show_progress(opt.progress, "${color.build.object}generating.slint %s", sourcefile) + batchcmds:mkdir(outputdir) + batchcmds:vrunv(compiler, { + sourcefile, + "-f", "cpp", + "-o", outputfile, + "--depfile", depfile, + "--style", "fluent", + "--embed-resources=embed-files", + "--cpp-namespace", "crossdesk::ui" + }) + -- The compiler's depfile is useful to external build systems, while + -- xmake needs imported .slint files registered explicitly so editing a + -- component regenerates the umbrella header without a forced rebuild. + for _, dependency in ipairs(os.files(path.join(path.directory(sourcefile), "*.slint"))) do + batchcmds:add_depfiles(dependency) + end + batchcmds:set_depmtime(os.mtime(outputfile)) + batchcmds:set_depcache(target:dependfile(outputfile)) + end) +rule_end() diff --git a/xmake/targets.lua b/xmake/targets.lua index 37bf92e..0a0ffed 100644 --- a/xmake/targets.lua +++ b/xmake/targets.lua @@ -1,5 +1,5 @@ function setup_targets() - add_packages("spdlog", "imgui", "nlohmann_json") + add_packages("spdlog", "libsdl3", "nlohmann_json") includes("submodules", "thirdparty") @@ -18,6 +18,7 @@ function setup_targets() set_kind("object") add_deps("rd_log") add_files("src/common/*.cpp") + remove_files("src/common/rounded_corner_button.cpp") if is_os("macosx") then add_files("src/common/*.mm") end @@ -33,6 +34,7 @@ function setup_targets() target("path_manager_portable_test") set_kind("binary") set_default(false) + set_policy("build.ccache", false) add_defines("CROSSDESK_PORTABLE=1") add_includedirs("src/path_manager") add_files("tests/path_manager_portable_test.cpp", @@ -81,6 +83,15 @@ function setup_targets() set_default(false) add_files("tests/display_popup_hover_state_test.cpp") + target("slint_ui_smoke_test") + set_kind("binary") + set_languages("c++20") + set_default(false) + add_packages("slint") + add_rules("slint") + add_files("src/gui/ui/crossdesk_ui.slint") + add_files("tests/slint_ui_smoke_test.cpp") + target("version_checker_test") set_kind("binary") set_default(false) @@ -209,24 +220,31 @@ function setup_targets() target("gui") set_kind("object") + set_languages("c++20") + -- spdlog 1.14 bundles fmt 10, whose consteval parser is rejected by + -- current Apple Clang in C++20 mode. Keep the established dependency + -- version and use fmt's supported runtime-parser fallback here. + add_defines("FMT_CONSTEVAL=") + add_packages("slint", {public = true}) add_packages("libyuv", "tinyfiledialogs") + add_rules("slint") add_defines("CROSSDESK_VERSION=\"" .. (get_config("CROSSDESK_VERSION") or "Unknown") .. "\"") add_deps("rd_log", "common", "assets", "config_center", "minirtc", "path_manager", "screen_capturer", "speaker_capturer", "device_controller", "thumbnail", "version_checker", "tools") - add_files("src/gui/render.cpp", "src/gui/application/*.cpp", + add_files("src/gui/render.cpp", "src/gui/application/gui_application.cpp", + "src/gui/application/portable_service_integration.cpp", "src/gui/runtime/*.cpp", "src/gui/features/devices/*.cpp", "src/gui/features/input/*.cpp", "src/gui/features/clipboard/*.cpp", "src/gui/features/file_transfer/*.cpp", - "src/gui/features/settings/*.cpp", "src/gui/views/panels/*.cpp", - "src/gui/views/toolbars/*.cpp", "src/gui/views/windows/*.cpp") + "src/gui/features/settings/*.cpp", "src/gui/ui/crossdesk_ui.slint") add_includedirs("src/gui", {public = true}) if is_os("windows") then add_files("src/gui/platform/tray/win_tray.cpp") add_includedirs("src/service/windows", {public = true}) elseif is_os("macosx") then - add_files("src/gui/runtime/*.mm", "src/gui/views/windows/*.mm", - "src/gui/platform/tray/*.mm") + add_files("src/gui/runtime/*.mm", "src/gui/platform/tray/*.mm", + "src/gui/platform/window_drag_mac.mm") elseif is_os("linux") then add_files("src/gui/platform/tray/linux_tray.cpp") end