diff --git a/LICENSE b/LICENSE index ada1a81..ef423e5 100644 --- a/LICENSE +++ b/LICENSE @@ -1,661 +1,681 @@ - GNU AFFERO GENERAL PUBLIC LICENSE - Version 3, 19 November 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU Affero General Public License is a free, copyleft license for -software and other kinds of works, specifically designed to ensure -cooperation with the community in the case of network server software. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -our General Public Licenses are 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. - - 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. - - Developers that use our General Public Licenses protect your rights -with two steps: (1) assert copyright on the software, and (2) offer -you this License which gives you legal permission to copy, distribute -and/or modify the software. - - A secondary benefit of defending all users' freedom is that -improvements made in alternate versions of the program, if they -receive widespread use, become available for other developers to -incorporate. Many developers of free software are heartened and -encouraged by the resulting cooperation. However, in the case of -software used on network servers, this result may fail to come about. -The GNU General Public License permits making a modified version and -letting the public access it on a server without ever releasing its -source code to the public. - - The GNU Affero General Public License is designed specifically to -ensure that, in such cases, the modified source code becomes available -to the community. It requires the operator of a network server to -provide the source code of the modified version running there to the -users of that server. Therefore, public use of a modified version, on -a publicly accessible server, gives the public access to the source -code of the modified version. - - An older license, called the Affero General Public License and -published by Affero, was designed to accomplish similar goals. This is -a different license, not a version of the Affero GPL, but Affero has -released a new version of the Affero GPL which permits relicensing under -this license. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU Affero General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Remote Network Interaction; Use with the GNU General Public License. - - Notwithstanding any other provision of this License, if you modify the -Program, your modified version must prominently offer all users -interacting with it remotely through a computer network (if your version -supports such interaction) an opportunity to receive the Corresponding -Source of your version by providing access to the Corresponding Source -from a network server at no charge, through some standard or customary -means of facilitating copying of software. This Corresponding Source -shall include the Corresponding Source for any work covered by version 3 -of the GNU General Public License that is incorporated pursuant to the -following paragraph. - - 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 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 work with which it is combined will remain governed by version -3 of the GNU General Public License. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU Affero 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 Affero 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 Affero 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 Affero 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 Affero 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 Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If your software can interact with users remotely through a computer -network, you should also make sure that it provides a way for users to -get its source. For example, if your program is a web application, its -interface could display a "Source" link that leads users to an archive -of the code. There are many ways you could offer source, and different -solutions will be better for different programs; see section 13 for the -specific requirements. - - 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 AGPL, see -. +ykxmz —— 教室多媒体辅助工具 +Copyright (C) 2026 小麦子 + +本程序是自由软件:你可以再发布和/或修改它,依据自由软件基金会发布的 +GNU Affero 通用公共许可证(第 3 版或你选择的任何更新版本)的条款。 + +本程序的发布是希望它有用,但**没有任何保证**;甚至没有适销性或 +特定用途适用性的默示保证。详见 GNU Affero 通用公共许可证。 + +你应该已经随本程序收到了一份 GNU Affero 通用公共许可证的副本 +(见本文件下半部分)。如果没有,见 。 + +-------------------------------------------------------------------------- +⚠ 使用限制请看 docs/LICENSE.txt(《使用条款与免责声明》): + 本软件是一套远程控制工具,禁止用于未经同意的他人设备、 + 禁止用于偷窥监视、禁止伪装隐藏。那份文件约束的是**使用行为**, + 和本文件(约束**代码**的授权)互不冲突。 +-------------------------------------------------------------------------- + + + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are 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. + + 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. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + 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 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 work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero 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 Affero 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 Affero 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 Affero 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 Affero 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 Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + 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 AGPL, see +. diff --git a/_build/CHANGELOG.md b/_build/CHANGELOG.md index 2d4880e..ee9b06e 100644 --- a/_build/CHANGELOG.md +++ b/_build/CHANGELOG.md @@ -1,3 +1,53 @@ +## 2.1.0 + +- **网站挂了会自己救**:中转服务器上常驻一个监测单元,每 60 秒从**公网** + 查一次(不是查本机 —— 本机通不代表用户能打开)。连续 3 次不通就按阶梯 + 自动抢救: + + | 级 | 动作 | 代价 | + |---|---|---| + | 1 | 重启隧道 | 不动中转,在线设备不掉线 | + | 2 | 重启中转服务 | 设备要重连,但很快 | + | 3 | 重新施加网络调优 | MSS / MTU 跑偏时用 | + | 4 | 重启无线网卡 | 4G / WiFi 卡死时用 | + | 5 | 重启整机 | 最后的底牌,**默认不开** | + + 每做完一级立刻复验,好了就停。救完 10 分钟内不再动手(防抖 —— + 否则预案刚重启完隧道还没连上,下一次检查又救一遍, + 来回重启反而永远连不上)。 + +- **网站出问题会自动留下诊断报告**:万一五级都没救回来,服务端会把现场 + (内存、进程、隧道连接数、路由和 MTU、三份日志尾部、内核异常) + 整理成一份 txt 留在服务端,需要排查时直接取来看就行。 + 只保留最近 30 份,不会把设备塞满。 + + 每次自动抢救成功,也会把「第几级奏效」记进知识库, + 下次同类问题优先试历史上管用的那一级。 + +- **网络热门音效**:手表上新增「音效」按钮,点一下电脑就响。 + 内置 8 个(冰冰冰 / 啊哈哈哈 / 老牧师 / 叮 / 当当当 / 警报 / 鼓点 / 弹簧)。 + + > 说明一下**版权**:网上那些「冰冰冰」「啊哈哈哈」是有版权的真实录音, + > 不能打包进安装包。所以内置的是**程序合成**的版本(正弦/方波现场算), + > 名字对着感觉来,但不是那些录音本身。 + > 想要一模一样的一手素材,把 mp3/wav 丢进电脑的 `data\sfx\` 目录, + > 点「刷新」就出现在列表里,照样能远程播。目录里放了一份说明。 + +- **修「安卓设置页一片空白」**:设置页建好了但**忘了挂到窗口上** + (少了 `root.addView`),`showOnly()` 让它可见也没用 —— 它根本不在 + 视图树里。已修,并加了断言盯着「每个面板都必须挂到 root、 + 并且纳入 showOnly()」—— 这类漏挂编译不报错、只在运行时表现为空白。 + +- **全量测试改成后台静默**:会开窗口的 6 个阶段(含开**全屏**那个) + 现在跑在独立的 Windows 隐藏桌面上 —— 窗口是**真的**(全屏、可见、 + 几何、Win32 枚举都真实),但屏幕上什么都看不到,也不会锁你的屏。 + + 顺带修了一个一直误判的检测:判断「锁屏」时只看 `OpenInputDesktop` + 成不成功是不对的 —— **锁屏时它同样成功**(那时输入桌面是 Winlogon)。 + 所以截屏类测试在锁屏时被记成 FAIL 而不是 SKIP,把真问题淹掉了。 + 改成看输入桌面的**名字**。现在锁屏也能跑全量:该 SKIP 的 SKIP, + 该跑的照跑。 + ## 2.0.0 - **安装时就能设中转服务器**:安装向导那一页现在是两个输入框 —— diff --git a/_build/_cmsg_210.txt b/_build/_cmsg_210.txt new file mode 100644 index 0000000..502f019 --- /dev/null +++ b/_build/_cmsg_210.txt @@ -0,0 +1,111 @@ +2.1.0:网站自动抢救 + 后台抢救报告分区 + 网络热门音效 + 全量测试后台静默 + +按用户定的规矩,这一轮有新功能,升第二位:2.0.0 -> 2.1.0。 + +== 一、网站挂了会自己救 == + +用户原话:「网站还是容易挂,建议继续增加隧道稳定性,并在后台设立 +检测单元,实时检测网站状态,一旦检测到网站挂了,立即启动紧急预案」。 + +新增 relay/monitor.sh + lanremote-monitor.timer: + 每 60 秒从**公网**查一次 /v1/health(不是查 127.0.0.1) + + 为什么不查本机:本机 200 只说明中转进程活着,用户真正关心的是 + 经过 Cloudflare 边缘 + 隧道 + 本机三段之后能不能打开。实测过 + 隧道两条实例都掉了、本机 curl 照样 200,只有从公网测才发现 502。 + + 连续 3 次不通才动手(这条 4G 线路本来就有 2~4% 偶发失败), + 救完 10 分钟内不再动手(否则预案刚重启完隧道还没连上, + 下一次检查又救一遍,来回重启反而永远连不上)。 + +新增 relay/rescue.sh:五级阶梯,每级做完立刻复验,好了就停 + 1 重启隧道 不动中转,在线设备不掉线 + 2 重启中转服务 设备要重连,但很快 + 3 重新施加网络调优 MSS / MTU 跑偏时用 + 4 重启无线网卡 4G / WiFi 卡死时用 + 5 重启整机 最后的底牌,**默认不开**(要 RESCUE_REBOOT=1) + + 理由:如果问题出在供电或上游线路,重启只会让恢复更慢; + 而且半夜自动重启一台没人管的机器风险太大。 + +== 二、抢救报告 + 后台分区 == + +用户原话:「如果紧急预案未能解决,则将问题打包成 .txt,然后在 +中转站设置里面加一个分区专门放还有下载这个 txt。解决后将解决问题 +的方法加入紧急预案」。 + + * 五级都没救回来 -> 把现场(内存/进程/隧道连接数/路由和 MTU、 + 三份日志尾部、内核异常)整理成 rescue_reports/<时间戳>.txt + * 后台新增「抢救报告」分区,列出报告(带结论摘要),点一下下载 + * 下载走单独的 /rescue/<名字>,**不发缓存头**(内容会变, + 也不该让 Cloudflare 边缘留着)。和 /download/ 分开是有意的: + 那个是给用户的安装包,文件名带版本号、内容永不改变, + 所以敢发 604800 长缓存 + * **要登录才能下** —— 报告里有内网 IP、进程表、日志尾部, + 是运维信息。第一版把这条路由插在 _admin_gate() 之前, + 等于匿名可下,自己查出来补上了 + * 每次自动抢救成功,把「第几级奏效」写进 rescue_knowledge.md; + 监测单元读它,下次同类症状优先试历史上管用的那一级 + (这就是用户说的"解决后将解决问题的方法加入紧急预案") + +== 三、网络热门音效 == + +用户原话:「再加上近期网络热门音效,什么冰冰冰,啊哈哈哈哈, +各种老牧师音效,可以直接远程播放」。 + +**版权说明**:网上那些「冰冰冰」「啊哈哈哈」是**有版权的真实录音** +(影视片段 / 别人的作品),不能下载打包进安装包 —— 那既侵权, +也会让这个项目在 Gitee 那边直接踩线(这个仓库已经被判过一次 +"内容可能含有违规信息")。 + +所以: + * 内置 8 个**程序合成**的音效(正弦/方波现场算,零版权风险、 + 不占安装包体积):冰冰冰 / 啊哈哈哈 / 老牧师 / 叮 / 当当当 / + 警报 / 鼓点 / 弹簧。名字对着感觉来,但不是那些录音本身 + * 做「自己放进来」的目录:mp3/wav 丢进 data/sfx/ 就自动出现在 + 列表里,照样能远程播。目录里放了一份说明 + * 手表首页新增「音效」按钮,点开选一个,电脑就响 + (复用现成的 showChooser,不新建面板) + +协议加 sfx_list / sfx_play 两条命令。 + +== 四、修「安卓设置页一片空白」 == + +用户报的。根因:设置页**建好了但忘了 root.addView()** —— +showOnly() 让它 VISIBLE 也没用,它根本不在视图树里。 + +这类"漏挂"编译完全不报,只在运行时表现为空白。已加断言 +_tests/test_watch_panels_wired.py 盯着:每个面板都必须挂到 root、 +并且纳入 showOnly()。 + +== 五、全量测试改成后台静默 == + +用户原话:「把全量测试改为在后台静默运行不要打开任何窗口或锁屏」。 + +会开窗口的 6 个阶段(含开**全屏**那个)现在跑在独立的 Windows +隐藏桌面上(_tests/hidden_desktop.py)。窗口是**真的** —— 全屏、 +可见、几何、Win32 枚举都真实,测试有效性一点没损失 —— +只是不在用户眼前,全屏也不会锁屏。 + +顺带修了一个一直在骗人的检测:判断"锁屏"时只看 OpenInputDesktop +成不成功是不对的 —— **锁屏时它同样成功**(那时输入桌面是 Winlogon)。 +于是截屏类测试在锁屏时被记成 FAIL 而不是 SKIP,把真问题淹掉了 +(用户电脑锁了一夜,我就是被这个误导过)。改成看输入桌面的**名字**。 +现在锁屏也能跑全量:该 SKIP 的 SKIP,该跑的照跑。 + +== 六、顺手修的真 bug == + +* windows/lanremote/server.py 的 sfx_play 分支用了不存在的变量名 body + (应该是 msg)-> **一调就 NameError**。pyflakes 那关抓出来的, + 说明"未定义名"这条检查有用。 +* _build/package_remote.py 的文件白名单只有 .py/.sh/.ico, + **.service/.timer 收不进服务端 zip** -> 新机器上监测根本不启动, + 而且不报错、只是静默不跑。已加。 +* protocol.py 被我补丁插入了 LF 行到 CRLF 文件里 -> 行尾混用,编码审计红。 +* deploy.sh 的 watchdog 块被我改锚点时吃掉了 start/say 两行 + (bash -n 通过、只是少两条执行语句)—— 改完读回来核对才发现。 +* _build/CHANGELOG.md 的 2.1.0 说明里写了"管理后台", + 触发对外材料清洁度检查。改成不提管理页(用户定的规矩: + 对外只能提官网)。 + +全量回归 46 个阶段全部通过。 diff --git a/_build/add_monitor_to_deploy.py b/_build/add_monitor_to_deploy.py new file mode 100644 index 0000000..281c5ba --- /dev/null +++ b/_build/add_monitor_to_deploy.py @@ -0,0 +1,135 @@ +# -*- coding: utf-8 -*- +r'''让 deploy.sh 在装新机器时把「监测单元」也装上。 + +== 为什么必须做这一步 == + +监测单元是我新加的:monitor.sh 每 60 秒查一次公网, +挂了就叫 rescue.sh 抢救。但如果 deploy.sh 不认识它, +**新装的机器上监测根本不会启动** —— 而且不会报任何错, +只是静默地不跑。等网站真挂了才发现"预案没触发",那就晚了。 + +部署脚本里 watchdog 已经是个现成的样板(同样是 timer + oneshot), +照着加一份即可。 + +== 顺便 == + +deploy.sh 里本来就有"包里有才装"的写法(`if [ -f ... ]`), +这里也沿用 —— 老的服务端 zip(没有 monitor.sh)重跑部署时 +不会因为找不到文件而报错。 +''' +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +DEPLOY = ROOT / "relay" / "deploy.sh" + +ANCHOR = ''' systemctl enable lanremote-watchdog.timer >/dev/null 2>&1 || true''' + +NEW = ''' systemctl enable lanremote-watchdog.timer >/dev/null 2>&1 || true + fi + + # ---- 网站监测单元(如果包里有)---- + # + # 和看门狗的区别很重要: + # 看门狗 看**隧道连接数** —— 本地指标,判断"隧道自己好不好" + # 监测单元 看**公网 HTTP** —— 用户视角,判断"用户能不能打开" + # + # 两个都要。实测过:隧道两条实例都掉了,本机 curl 127.0.0.1 照样 200, + # 看门狗也可能觉得没事;只有从公网测才发现 502。 + if [ -f "$RELAY_DIR/monitor.sh" ]; then + mkdir -p "$RELAY_DIR/rescue_reports" + chmod +x "$RELAY_DIR/monitor.sh" "$RELAY_DIR/rescue.sh" 2>/dev/null || true + if [ ! -f "$RELAY_DIR/rescue_knowledge.md" ]; then + cat > "$RELAY_DIR/rescue_knowledge.md" <<'KEOF' +# 紧急预案知识库 + +每次自动抢救成功,这里会追加一条「第几级奏效」。 +以后遇到同类症状,监测单元会优先试历史上奏效的那一级。 + +分级(从代价最小到最大): + + 1 重启隧道 不动中转,在线设备不掉线 + 2 重启中转服务 设备要重连,但很快 + 3 重新施加网络调优 MSS / MTU 跑偏时用 + 4 重启无线网卡 4G / WiFi 卡死时用 + 5 重启整机 最后的底牌,默认不开(要 RESCUE_REBOOT=1) + +KEOF + fi + + # 包里直接带了单元文件就用包里的(内容随版本更新); + # 没有就现场生成一份等价的 —— 兼容老 zip。 + if [ -f "$RELAY_DIR/lanremote-monitor.service" ]; then + cp "$RELAY_DIR/lanremote-monitor.service" "$UNIT_DIR/" 2>/dev/null || true + else + write_unit "$UNIT_DIR/lanremote-monitor.service" </dev/null || true + else + write_unit "$UNIT_DIR/lanremote-monitor.timer" <<'EOF' +[Unit] +Description=每 60 秒检查一次网站是否可用 + +[Timer] +OnBootSec=90s +OnUnitActiveSec=60s +AccuracySec=5s +Unit=lanremote-monitor.service + +[Install] +WantedBy=timers.target +EOF + fi + systemctl daemon-reload >/dev/null 2>&1 || true + systemctl enable lanremote-monitor.timer >/dev/null 2>&1 || true + systemctl start lanremote-monitor.timer >/dev/null 2>&1 || true + say " 网站监测单元已启用(每 60 秒查一次公网)"''' + + +def main() -> int: + raw = DEPLOY.read_bytes() + bom = raw.startswith(b"\xef\xbb\xbf") + text = raw.decode("utf-8-sig") + + if "lanremote-monitor.timer" in text: + print(" · deploy.sh 已经加过了") + return 0 + if ANCHOR not in text: + print(" ✗ 找不到锚点") + return 1 + if text.count(ANCHOR) != 1: + print(" ! 锚点出现 %d 次" % text.count(ANCHOR)) + text = text.replace(ANCHOR, NEW, 1) + + # 原来 watchdog 那个 if 块的 fi 会被我们的 NEW 提前收掉, + # 所以要把后面多余的那个 fi 去掉一个。先看看形状。 + import re + DEPLOY.write_bytes((b"\xef\xbb\xbf" if bom else b"") + + text.encode("utf-8")) + print(" ✓ deploy.sh 加了监测单元的安装") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/add_rescue_section.py b/_build/add_rescue_section.py new file mode 100644 index 0000000..af0730f --- /dev/null +++ b/_build/add_rescue_section.py @@ -0,0 +1,261 @@ +# -*- coding: utf-8 -*- +r'''后台加一个「抢救报告」分区。 + +用户要求: + 「如果紧急预案未能解决,则将问题打包成 .txt,然后在后台加一个分区 + 专门放还有下载这个 txt」 + +rescue.sh 抢救失败时会往 relay/rescue_reports/ 写一份诊断报告 .txt。 +这里让后台能: + * 列出这些报告(时间、大小、一句话结论) + * 点一下下载看内容 + +== 为什么不复用 /download/ == + +/download/ 是**给用户**的(安装包、APK),带 cf 长缓存 +(max-age=604800, immutable)——因为文件名带版本号、内容永不改变。 + +抢救报告正相反:内容会变、是给管理员看的、不该被边缘缓存。 +所以单独开一条 /rescue/<名字>,发 no-store。 +''' +from __future__ import annotations + +import html +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +RELAY = ROOT / "relay" / "relay.py" +ADMIN = ROOT / "relay" / "admin.py" + + +def patch(path: Path, pairs, label: str): + raw = path.read_bytes() + had_bom = raw.startswith(b"\xef\xbb\xbf") + text = raw.decode("utf-8-sig") + results = [] + for old, new, what in pairs: + if old not in text: + results.append(("✗", what, "找不到锚点")) + continue + if text.count(old) != 1: + results.append(("!", what, "锚点 %d 处" % text.count(old))) + text = text.replace(old, new, 1) + results.append(("✓", what, "")) + print(" ── %s ──" % label) + for m, w, why in results: + print(" %s %s%s" % (m, w, (" —— " + why) if why else "")) + if any(m == "✗" for m, _w, _y in results): + return False + path.write_bytes((b"\xef\xbb\xbf" if had_bom else b"") + + text.encode("utf-8")) + return True + + +# =========================================================================== +# relay.py +# =========================================================================== + +RELAY_LIST = ''' +# 抢救报告目录。rescue.sh 抢救失败时往这里写 .txt 诊断报告。 +# 和 downloads/ 分开:那个是给用户的安装包(带长缓存), +# 这个是给管理员看的诊断(必须不缓存)。 +RESCUE_DIR = Path(__file__).resolve().parent / "rescue_reports" +RESCUE_KEEP = 30 # 只保留最近 30 份(460MB 的小机器,别塞满) + + +def _safe_rescue_name(name: str) -> str: + """校验抢救报告文件名。只允许 <时间戳>.txt 这种形状。""" + name = (name or "").strip() + if not name or len(name) > 80: + return "" + if "/" in name or "\\\\" in name or ".." in name: + return "" + if not name.endswith(".txt"): + return "" + for ch in name: + if ch.isalnum() or ch in "._-": + continue + return "" + return name + + +def _list_rescue_reports() -> list: + """列出抢救报告(按时间倒序)。 + + 顺带读每个文件的前几行,抽出结论 —— 后台列表上直接显示, + 不用一个个点进去看。 + """ + items = [] + try: + if not RESCUE_DIR.is_dir(): + return items + for entry in sorted(RESCUE_DIR.glob("*.txt"), reverse=True): + if not entry.is_file(): + continue + safe = _safe_rescue_name(entry.name) + if not safe: + continue + stat = entry.stat() + summary = "" + try: + with entry.open("r", encoding="utf-8", errors="replace") as fh: + head = fh.read(1200) + for line in head.splitlines(): + s = line.strip() + if s.startswith("判断:"): + summary = s.replace("判断:", "").strip() + break + if not summary: + for line in head.splitlines(): + s = line.strip() + if s.startswith("公网 ") and "->" in s: + summary = s + break + except OSError: + pass + items.append({ + "name": entry.name, + "size": stat.st_size, + "mtime": stat.st_mtime, + "summary": summary[:120], + }) + except OSError: + return items + items.sort(key=lambda i: i["mtime"], reverse=True) + return items[:RESCUE_KEEP] + +''' + +ADMIN_CALL = ''' admin_path=ADMIN_PATH,''' + +ADMIN_CALL_NEW = ''' admin_path=ADMIN_PATH, + rescue_fn=_list_rescue_reports,''' + +# 下载路由(GET):插在 /download/ 之前 +RESCUE_ROUTE_ANCHOR = ''' if path.startswith("/download/"): + wanted = urllib.parse.unquote(path[len("/download/"):]) + safe = _safe_download_name(wanted)''' + +RESCUE_ROUTE = ''' if path.startswith("/rescue/"): + # 抢救报告下载。单独一条路由,**不发缓存头** —— + # 内容会变,而且不该让 Cloudflare 边缘留着。 + wanted = urllib.parse.unquote(path[len("/rescue/"):]) + safe = _safe_rescue_name(wanted) + if not safe: + self._json(400, {"ok": False, "error": "文件名不合法"}) + return + target = RESCUE_DIR / safe + if not target.is_file(): + self._json(404, {"ok": False, "error": "没有这份报告"}) + return + try: + body = target.read_bytes() + except OSError as exc: + self._json(500, {"ok": False, "error": f"读不到: {exc}"}) + return + self.send_response(200) + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + quoted = urllib.parse.quote(safe) + self.send_header( + "Content-Disposition", + f"attachment; filename*=UTF-8''{quoted}") + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + return + +''' + +# =========================================================================== +# admin.py +# =========================================================================== + +ADMIN_SIG = '''def render_admin(snap: dict, public: str, admin_path: str, codes: int, + downloads_fn) -> str:''' + +ADMIN_SIG_NEW = '''def render_admin(snap: dict, public: str, admin_path: str, codes: int, + downloads_fn, rescue_fn=None) -> str:''' + +ADMIN_CARD_ANCHOR = '''
+

下载区

+
官网下载按钮指向这里的文件。
+
%(dl)s
+
+ ''' + +ADMIN_CARD = '''
+

下载区

+
官网下载按钮指向这里的文件。
+
%(dl)s
+
+
+

抢救报告

+
网站挂了、自动预案也没救回来时,机器会把现场 + 打包成一份 txt 放在这里。点开看看是哪一段断了。
+
%(rescue)s
+
+ ''' + + +def _rescue_rows(items: list) -> str: + """抢救报告列表的 HTML。照 dl_rows 的风格(页面里直接渲染)。""" + if not items: + return ('
还没有报告 —— 说明网站一直没出过' + '「自动救不回来」的问题。
') + import time as _t + import urllib.parse as _up + rows = [] + for it in items: + when = _t.strftime("%m-%d %H:%M", _t.localtime(it["mtime"])) + size = it["size"] / 1024 + size_s = ("%.1f KB" % size) if size >= 1 else ("%d B" % it["size"]) + summary = html.escape(it.get("summary") or "(没有结论行)") + href = "/rescue/" + _up.quote(it["name"]) + rows.append( + '
' + '%s' + '' + '%s' + '' + '%s
' + % (href, when, size_s, summary)) + return "".join(rows) + + +def main() -> int: + ok = True + + ok &= patch(RELAY, [ + ("DOWNLOADS_DIR = Path(__file__).resolve().parent / \"downloads\"", + "DOWNLOADS_DIR = Path(__file__).resolve().parent / \"downloads\"\n" + + RELAY_LIST, "加 rescue_reports 列表函数"), + (RESCUE_ROUTE_ANCHOR, RESCUE_ROUTE + RESCUE_ROUTE_ANCHOR, + "加 /rescue/ 下载路由(no-store)"), + (ADMIN_CALL, ADMIN_CALL_NEW, "把 rescue_fn 传给后台页"), + ], "relay.py") + + ok &= patch(ADMIN, [ + (ADMIN_SIG, ADMIN_SIG_NEW, "render_admin 加 rescue_fn 参数"), + (ADMIN_CARD_ANCHOR, ADMIN_CARD, "后台加「抢救报告」卡片"), + # 算报告列表(照 dl_rows 的写法) + (''' return """''', + ''' # 抢救报告列表(和下载区同样在页面里直接渲染,不用 JS 拉) + try: + rescue_items = rescue_fn() if rescue_fn else [] + except Exception: # noqa: BLE001 + rescue_items = [] + rescue_rows = _rescue_rows(rescue_items) + + return """''', + "算抢救报告列表"), + ('"dl": dl_rows,', '"dl": dl_rows,\n "rescue": rescue_rows,', + "把报告列表塞进模板"), + ], "admin.py") + + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/add_watch_sfx.py b/_build/add_watch_sfx.py new file mode 100644 index 0000000..ebb54d3 --- /dev/null +++ b/_build/add_watch_sfx.py @@ -0,0 +1,137 @@ +# -*- coding: utf-8 -*- +r'''手表端加「音效」—— 用现成的 showChooser(),不新建面板。 + +== 为什么改方案 == + +第一版我给音效页写了完整的新面板(FrameLayout + ScrollView + 动态列表), +后来发现 MainActivity 里**已经有 showChooser(title, labels, Chooser)** +这个现成的选择器 —— pickVoice() 就是这么选 TTS 音色的。 + +用选择器的话: + * 不用新建面板 -> 不用碰 root.addView / showOnly(那两个地方 + 刚因为"漏挂"出过一次全白的事故) + * 代码量少一半,出错面小得多 + * 而且和"选音色"的操作手感一致,用户不用学两套 + +所以改成:首页加一个「音效」按钮 -> 弹出音效列表 -> 点哪个放哪个。 + +== 服务端配合 == + +服务端返回的是 {"sfx":[{"name":...},...]} 这种嵌套结构, +而手表的 stringList(reply, key) 只认扁平的字符串数组。 +audio_list 早就这么做了(同时返回 files / names / paths), +这里跟着加一个 "names",手表直接用。 +''' +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +SRC = (ROOT / "android" / "src" / "com" / "lanremote" / "watch" + / "MainActivity.java") + +# ---------------------------------------------------------------- 方法 +METHOD_ANCHOR = " /** 选择电脑上的 TTS 音色。 */" + +METHOD_NEW = ''' /** 让电脑放一个音效(网络热门音效那类)。 + * + * 用 showChooser 而不是新建面板 —— 见 _build/add_watch_sfx.py 里的说明: + * pickVoice() 已经是这个套路,复用省事也更不容易出错。 + */ + private void pickSfx() { + appendLog("正在获取音效…"); + io.execute(new Runnable() { + @Override + public void run() { + try { + final Map reply = + client.call(Json.obj("cmd", "sfx_list"), 15000); + // 服务端同时给了扁平的 names(和 audio_list 一个约定) + final List names = stringList(reply, "names"); + final String dir = Json.str(reply, "dir", ""); + ui.post(new Runnable() { + @Override + public void run() { + if (names.isEmpty()) { + toast("电脑上还没有音效(或没连上)"); + appendLog("音效列表是空的。" + + (dir.isEmpty() ? "" : "素材目录:" + dir)); + return; + } + showChooser("音效(点一下电脑就响)", names, + new Chooser() { + @Override + public void onPick(int index) { + String one = names.get(index); + sendCommand(Json.obj("cmd", "sfx_play", + "name", one), "音效 " + one); + } + }); + } + }); + } catch (final IOException e) { + postError("获取音效", e); + } + } + }); + } + +''' + METHOD_ANCHOR + + +def main() -> int: + text = SRC.read_text(encoding="utf-8") + out: list = [] + + def patch(old: str, new: str, what: str) -> None: + nonlocal text + if old not in text: + out.append(("✗", what, "找不到锚点")) + return + if text.count(old) != 1: + out.append(("!", what, "锚点 %d 处" % text.count(old))) + text = text.replace(old, new, 1) + out.append(("✓", what, "")) + + # 1) 插方法 + patch(METHOD_ANCHOR, METHOD_NEW, "插入 pickSfx()") + + # 2) 首页加按钮(放在「音效」该在的位置:设置按钮之后) + patch(''' box.addView(sfxButton);''', ''' box.addView(sfxButton);''', + "(占位,见下)") if False else None + + patch(''' box.addView(relayButton);''', + ''' box.addView(relayButton); + + // 音效:点一下电脑就响。用户要求"可以直接远程播放"。 + Button sfxButton = bigButton("音效(让电脑放一声)", C_ACCENT, 14); + LinearLayout.LayoutParams sfxParams = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, dp(52)); + sfxParams.setMargins(0, 0, 0, dp(8)); + sfxButton.setLayoutParams(sfxParams); + sfxButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + pickSfx(); + } + }); + box.addView(sfxButton);''', + "首页加「音效」按钮") + + print(" ── 补丁结果 ──") + for mark, what, why in out: + print(" %s %s%s" % (mark, what, (" —— " + why) if why else "")) + if any(m == "✗" for m, _w, _y in out): + print() + print(" 有锚点没匹配,没有写回") + return 1 + + SRC.write_text(text, encoding="utf-8", newline="\n") + print() + print(" 已写回 MainActivity.java") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/auth_rescue_route.py b/_build/auth_rescue_route.py new file mode 100644 index 0000000..bc5e888 --- /dev/null +++ b/_build/auth_rescue_route.py @@ -0,0 +1,104 @@ +# -*- coding: utf-8 -*- +r'''给 /rescue/ 加上管理鉴权。 + +== 问题 == + +我把 /rescue/ 路由插在 do_GET 的第 1531 行,而管理页的登录校验 +(self._admin_gate())在第 1723 行 —— **路由在鉴权之前**。 + +也就是说:任何人只要知道 + https://relay.xxmxxs.top/rescue/20261008-173000.txt +就能直接下载诊断报告。那里面有什么: + + * 内网 IP(192.168.2.x)、主机名 + * 进程列表、内存占用 + * 中转和隧道的日志尾部 + * MTU / 路由 / iptables 规则 + +这些是**运维信息**,不该公开。对比一下:/download/ 是给用户的 +安装包,公开是对的;/rescue/ 是给自己看的,必须登录。 + +(同样的问题 /shot/ 也有 —— 那个是屏幕共享截图,本来就该拦。 + 这次先修 /rescue/,顺手看一眼 /shot/。) + +== 改法 == + +在 /rescue/ 分支开头加一句 self._admin_gate(), +和 /v1/admin/* 那些接口一样。do_GET 和 do_HEAD 都要加。 +''' +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +RELAY = ROOT / "relay" / "relay.py" + +# do_GET 里的(有 body 的那份) +GET_OLD = ''' if path.startswith("/rescue/"): + # 抢救报告下载。**单独一条路由,不发缓存头** —— + # 内容会变,而且不该让 Cloudflare 边缘留着。 + # (和 /download/ 分成两套是有意的:那个是给用户的安装包, + # 文件名带版本号、内容永不改变,所以敢发 604800 长缓存。) + wanted = urllib.parse.unquote(path[len("/rescue/"):])''' + +GET_NEW = ''' if path.startswith("/rescue/"): + # 抢救报告下载。**单独一条路由,不发缓存头** —— + # 内容会变,而且不该让 Cloudflare 边缘留着。 + # (和 /download/ 分成两套是有意的:那个是给用户的安装包, + # 文件名带版本号、内容永不改变,所以敢发 604800 长缓存。) + # + # ⚠ 必须登录才能下。报告里有内网 IP、进程表、日志尾部、 + # MTU/路由/iptables —— 是运维信息,不是给所有人看的。 + # 第一版把这条路由插在 _admin_gate() **之前**, + # 等于匿名可下,实测踩到。 + if not self._admin_gate(): + return + wanted = urllib.parse.unquote(path[len("/rescue/"):])''' + +# do_HEAD 里的(只有 headers) +HEAD_OLD = ''' if path.startswith("/rescue/"): + # 抢救报告。HEAD 只回长度和类型,**不写 body** —— + # 这条路由同时存在于 do_GET(真正下载)和 do_HEAD(探测)。 + wanted = urllib.parse.unquote(path[len("/rescue/"):])''' + +HEAD_NEW = ''' if path.startswith("/rescue/"): + # 抢救报告。HEAD 只回长度和类型,**不写 body** —— + # 这条路由同时存在于 do_GET(真正下载)和 do_HEAD(探测)。 + # 同样要登录(见 do_GET 那段注释)。 + if not self._admin_gate(): + return no() + wanted = urllib.parse.unquote(path[len("/rescue/"):])''' + + +def main() -> int: + raw = RELAY.read_bytes() + bom = raw.startswith(b"\xef\xbb\xbf") + text = raw.decode("utf-8-sig") + + changed = [] + for old, new, what in ((GET_OLD, GET_NEW, "do_GET"), + (HEAD_OLD, HEAD_NEW, "do_HEAD")): + if new.split("\n")[0] in text and "_admin_gate" in text.split(old)[0][-600:]: + changed.append(("·", what, "好像已经加过了")) + continue + if old not in text: + changed.append(("✗", what, "找不到锚点")) + continue + text = text.replace(old, new, 1) + changed.append(("✓", what, "已加鉴权")) + + print(" ── 结果 ──") + for m, w, why in changed: + print(" %s %-10s %s" % (m, w, why)) + + if any(m == "✗" for m, _w, _y in changed): + return 1 + + RELAY.write_bytes((b"\xef\xbb\xbf" if bom else b"") + text.encode("utf-8")) + print(" 已写回 relay.py") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/check_device.py b/_build/check_device.py new file mode 100644 index 0000000..321e39a --- /dev/null +++ b/_build/check_device.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- +r'''看一眼中转服务器的完整状态。 + +为什么写成文件:这一轮我用 `python -c @"..."@` 传长命令失败了好几次 —— +PowerShell 的双引号 heredoc 会做变量插值、还会把 `$(...)` 当子表达式 +在本机执行(systemctl / iptables 跑到 Windows 上了)。凡是超过两行 +的远端命令,一律写成文件跑。 + +用法: + python _build/check_device.py +''' +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "_build")) +from devssh import run # noqa: E402 + +REMOTE = r''' +RELAY=/root/lanremote-relay +echo "════ 服务 ════" +for s in lanremote-relay lanremote-tunnel lanremote-tunnel-h2 lanremote-nettune; do + printf ' %-24s %s\n' "$s" "$(systemctl is-active $s 2>&1)" +done +printf ' %-24s %s\n' "monitor.timer" "$(systemctl is-active lanremote-monitor.timer 2>&1)" +printf ' %-24s %s\n' "watchdog.timer" "$(systemctl is-active lanremote-watchdog.timer 2>&1)" + +echo +echo "════ 网络 ════" +printf ' %-24s %s\n' "wlan0 MTU" "$(cat /sys/class/net/wlan0/mtu 2>/dev/null)" +printf ' %-24s %s\n' "MSS 规则" "$(iptables -t mangle -L -n 2>/dev/null | grep -ci mss) 条" +printf ' %-24s %s\n' "tcp_mtu_probing" "$(cat /proc/sys/net/ipv4/tcp_mtu_probing 2>/dev/null)" +printf ' %-24s %s\n' "默认路由" "$(ip route show default 2>/dev/null | head -1)" + +echo +echo "════ 内存 / 运行时长 ════" +printf ' %-24s %s\n' "运行时长" "$(uptime -p 2>/dev/null)" +free -m 2>/dev/null | sed -n '2p' | awk '{printf " %-24s %s/%s MB\n", "内存 used/total", $3, $2}' + +echo +echo "════ 隧道连接数 ════" +for p in 20241 20242; do + n=$(curl -s --max-time 5 "http://127.0.0.1:$p/metrics" 2>/dev/null \ + | grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}' | head -1) + printf ' %-24s %s\n' "指标端口 $p" "${n:-拿不到}" +done + +echo +echo "════ 公网自测 ════" +printf ' %-24s %s\n' "公网 /v1/health" "$(curl -s -o /dev/null -w '%{http_code} (%{time_total}s)' --max-time 15 https://relay.xxmxxs.top/v1/health 2>/dev/null)" +printf ' %-24s %s\n' "本机 /v1/health" "$(curl -s -o /dev/null -w '%{http_code}' --max-time 6 http://127.0.0.1:8080/v1/health 2>/dev/null)" +printf ' %-24s %s\n' "公网首页" "$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 https://relay.xxmxxs.top/ 2>/dev/null)" +printf ' %-24s %s\n' "后台抢救报告路由" "$(curl -s -o /dev/null -w '%{http_code}' --max-time 6 http://127.0.0.1:8080/rescue/none.txt 2>/dev/null)" + +echo +echo "════ 监测单元状态 ════" +cat $RELAY/monitor_state.txt 2>/dev/null | sed 's/^/ /' || echo " (还没有状态文件)" +echo " ── monitor.log 尾部 ──" +tail -5 $RELAY/monitor.log 2>/dev/null | sed 's/^/ /' || echo " (还没有日志)" + +echo +echo "════ 抢救报告 ════" +n=$(ls $RELAY/rescue_reports 2>/dev/null | wc -l) +echo " 共 $n 份" +ls -la $RELAY/rescue_reports/ 2>/dev/null | tail -n +4 | awk '{printf " %s %s\n", $6" "$7" "$8, $9}' + +echo +echo "════ 知识库 ════" +if [ -f $RELAY/rescue_knowledge.md ]; then + echo " 行数:$(wc -l < $RELAY/rescue_knowledge.md)" + tail -6 $RELAY/rescue_knowledge.md | sed 's/^/ /' +else + echo " (没有)" +fi + +echo +echo "════ 定时器下次触发 ════" +systemctl list-timers lanremote-monitor.timer lanremote-watchdog.timer --no-pager 2>/dev/null | head -4 | sed 's/^/ /' + +echo +echo "════ 后台页有没有「抢救报告」分区 ════" +printf ' 匹配次数: %s\n' "$(curl -s --max-time 8 http://127.0.0.1:8080/zhongzhuanzhan 2>/dev/null | grep -c '抢救报告')" +''' + + +def main() -> int: + out = run(REMOTE, timeout=300) + text = out[1] if isinstance(out, tuple) else str(out) + print(text) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/debug_rescue_000.py b/_build/debug_rescue_000.py new file mode 100644 index 0000000..c23cb93 --- /dev/null +++ b/_build/debug_rescue_000.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- +r'''查 /rescue/ 为什么返回 HTTP 000。 + +000 是 curl 的"没拿到任何响应"。但同一个端口上 /v1/health 是好的、 +目录穿越请求也能拿到 404 —— 说明不是端口不通,是**特定路径**出问题。 +''' +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "_build")) +from devssh import run # noqa: E402 + +REMOTE = r''' +RELAY=/root/lanremote-relay +B=http://127.0.0.1:8080 + +echo "════ 造一份报告 ════" +printf '测试报告\n\n 判断:测试结论\n' > $RELAY/rescue_reports/19700101-000000-demo.txt +ls -la $RELAY/rescue_reports/ | sed 's/^/ /' + +echo +echo "════ 对照:几个路径的返回 ════" +for p in /v1/health /rescue/19700101-000000-demo.txt /rescue/nonexistent.txt /rescue/; do + printf ' %-42s -> %s\n' "$p" "$(curl -s -o /dev/null -w '%{http_code}' --max-time 6 "$B$p")" +done + +echo +echo "════ 详细看 /rescue/ 那次请求 ════" +curl -sv --max-time 8 "$B/rescue/19700101-000000-demo.txt" 2>&1 | head -25 | sed 's/^/ /' + +echo +echo "════ relay 日志尾部(有没有 Traceback)════" +journalctl -u lanremote-relay --no-pager -n 30 2>/dev/null | tail -30 | sed 's/^/ /' + +echo +echo "════ 后台密码文件在不在 ════" +ls -la $RELAY/data/admin_password.txt 2>&1 | sed 's/^/ /' +echo " (长度 $(wc -c < $RELAY/data/admin_password.txt 2>/dev/null || echo 0))" + +echo +echo "════ 清理 ════" +rm -f $RELAY/rescue_reports/19700101-000000-demo.txt +''' + + +def main() -> int: + out = run(REMOTE, timeout=180) + text = out[1] if isinstance(out, tuple) else str(out) + print(text) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/deploy_monitor.py b/_build/deploy_monitor.py new file mode 100644 index 0000000..3468f3e --- /dev/null +++ b/_build/deploy_monitor.py @@ -0,0 +1,151 @@ +# -*- coding: utf-8 -*- +r'''把「网站监测 + 紧急预案 + 抢救报告分区」部署到中转服务器。 + +部署的东西: + relay.py / admin.py 后台多一个「抢救报告」分区 + /rescue/ 下载 + monitor.sh / rescue.sh 监测单元 + 紧急预案 + lanremote-monitor.service/.timer 每 60 秒拉一次 monitor.sh + lanremote-nettune.service 顺手修掉设备上那份过期的(见下) + +== 顺手修的设备问题 == + +设备上那份 lanremote-nettune.service 是**很早以前的版本**,有两个毛病: + + 1. ExecStart 里写了 `iptables ... || iptables ...` + systemd 的 ExecStart **不支持 `||`** —— 它把 `||` 当成一个参数, + iptables 报 `Bad argument '||'`,服务直接 exit 2 失败。 + + 2. 里面有一句 `ip link set wlan0 mtu 1400`,而 net-tune.sh 自己 + 实测过"降到 1400 反而掉到 50%"并把网卡设回 1500。 + **两条 ExecStart 顺序是 net-tune.sh 先、1400 后**,所以最终是 1400 —— + 也就是说这份过期配置一直在主动损害稳定性。 + +本地 relay/deploy.sh 生成的是对的(单入口 -> deploy-tune.sh)。 +这里直接按那个正确形状重写设备上的 unit。 +''' +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "_build")) +from devssh import upload, run # noqa: E402 + +RELAY_DIR = "/root/lanremote-relay" +UNIT_DIR = "/etc/systemd/system" + +FILES = [ + "relay.py", "admin.py", "landing.py", "showcase.py", + "monitor.sh", "rescue.sh", "net-tune.sh", "watchdog.sh", + "deploy-tune.sh", +] + +NETTUNE_UNIT = '''[Unit] +Description=ykxmz 网络调优(MSS 钳制 + 路径 MTU 探测) +After=network-online.target +Wants=network-online.target +Before=lanremote-relay.service lanremote-tunnel.service lanremote-tunnel-h2.service + +[Service] +Type=oneshot +RemainAfterExit=yes +# ⚠ 只跑 net-tune.sh 这一个入口 —— MSS / 接口 MTU / 路径探测的唯一出处。 +# +# 设备上原来那份 unit 有两条毛病: +# * ExecStart 里写了 `iptables ... || iptables ...` +# systemd 的 ExecStart **不支持 `||`**,它把 `||` 当参数传给 +# iptables,报 `Bad argument '||'`,服务直接 exit 2 失败。 +# * 还有一句 `ip link set wlan0 mtu 1400`,而 net-tune.sh 自己 +# 实测过"降到 1400 反而掉到 50%"并把网卡设回 1500。 +# 两条 ExecStart 是 net-tune.sh 先、1400 后,所以最终停在 1400 —— +# 这份过期配置一直在主动损害稳定性。 +# +# (仓库里的 deploy.sh 生成的是 `ExecStart=/bin/bash deploy-tune.sh` +# 那种形状,但设备上并没有 deploy-tune.sh,所以这里直接用 +# net-tune.sh,和设备的实际情况对齐。 +# deploy-tune.sh 也一并传上去了,以后想切回单入口随时可以。) +ExecStart=/bin/bash /root/lanremote-relay/net-tune.sh + +[Install] +WantedBy=multi-user.target +''' + + +def main() -> int: + print("=" * 62) + print(" 部署 监测单元 + 紧急预案 + 抢救报告分区") + print("=" * 62) + + print() + print(" ── 1. 上传文件 ──") + for name in FILES: + local = ROOT / "relay" / name + if not local.is_file(): + print(" [跳过] %s 不存在" % name) + continue + upload(str(local), "%s/%s" % (RELAY_DIR, name)) + print(" ✓ %s" % name) + + for unit in ("lanremote-monitor.service", "lanremote-monitor.timer"): + local = ROOT / "relay" / unit + upload(str(local), "%s/%s" % (UNIT_DIR, unit)) + print(" ✓ %s -> %s" % (unit, UNIT_DIR)) + + print() + print(" ── 2. 修掉过期的 nettune 服务(MTU 打架 + ExecStart 里的 ||)──") + import tempfile + tmp = Path(tempfile.gettempdir()) / "lanremote-nettune.service" + tmp.write_text(NETTUNE_UNIT, encoding="utf-8", newline="\n") + upload(str(tmp), "%s/lanremote-nettune.service" % UNIT_DIR) + print(" ✓ 已替换") + + print() + print(" ── 3. 语法检查 ──") + out = run( + "cd %s && for f in relay.py admin.py landing.py showcase.py; do " + "python3 -c \"import ast,sys; ast.parse(open(sys.argv[1],encoding='utf-8').read())\" $f " + "|| exit 1; done && echo PY_OK; " + "bash -n monitor.sh && bash -n rescue.sh && echo SH_OK; " + "systemd-analyze verify %s/lanremote-monitor.service 2>&1 | head -3 || true" + % (RELAY_DIR, UNIT_DIR), timeout=120) + for ln in out[1].strip().splitlines(): + print(" " + ln) + + print() + print(" ── 4. 建目录 + 知识库 + 重载并启用 ──") + out = run( + "mkdir -p %s/rescue_reports && chmod +x %s/monitor.sh %s/rescue.sh && " + "[ -f %s/rescue_knowledge.md ] || printf '# 紧急预案知识库\\n\\n" + '每次自动抢救成功,这里会记一条"第几级奏效"。\\n' + '以后遇到同类症状,监测单元会优先试历史上奏效的那一级。\\n" > %s/rescue_knowledge.md; ' + "systemctl daemon-reload && " + "systemctl restart lanremote-nettune.service; sleep 3; " + "systemctl enable --now lanremote-monitor.timer 2>&1 | tail -1; " + "sleep 2; systemctl is-active lanremote-monitor.timer" + % (RELAY_DIR, RELAY_DIR, RELAY_DIR, RELAY_DIR, RELAY_DIR), timeout=180) + for ln in out[1].strip().splitlines(): + print(" " + ln) + + print() + print(" ── 5. 复核 ──") + out = run( + "echo 'nettune: '$(systemctl is-active lanremote-nettune.service); " + "echo 'monitor.timer: '$(systemctl is-active lanremote-monitor.timer); " + "echo 'MTU: '$(cat /sys/class/net/wlan0/mtu); " + "echo 'MSS 规则: '$(iptables -t mangle -L -n 2>/dev/null | grep -ci mss)' 条'; " + "echo '报告目录: '$(ls %s/rescue_reports 2>/dev/null | wc -l)' 份'; " + "echo '后台分区: '$(curl -s -o /dev/null -w '%%{http_code}' http://127.0.0.1:8080/rescue/nonexistent.txt)" + % RELAY_DIR, timeout=120) + for ln in out[1].strip().splitlines(): + print(" " + ln) + + print() + print(" 提示:监测单元默认每 60 秒查一次公网,连续 3 次不通才动手;") + print(" 抢救完 10 分钟内不再动手(防抖,避免来回重启)。") + print(" 日志在 %s/monitor.log 和 %s/rescue.log。" % (RELAY_DIR, RELAY_DIR)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/diag_admin_section.py b/_build/diag_admin_section.py new file mode 100644 index 0000000..c54bfdb --- /dev/null +++ b/_build/diag_admin_section.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- +r'''查后台「抢救报告」分区为什么不显示。 + +可能原因: + 1. 后台页要登录,curl 直接拿是登录页(那 0 次是正常的,得带 cookie) + 2. relay.py 没真正重新加载(旧进程还在) + 3. 模板参数没传进去 + +一条条排除。 +''' +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "_build")) +from devssh import run # noqa: E402 + +REMOTE = r''' +RELAY=/root/lanremote-relay + +echo "════ 1. 直接 curl 后台页,看返回的是什么 ════" +curl -s --max-time 8 -D- http://127.0.0.1:8080/zhongzhuanzhan -o /tmp/adm.html 2>&1 | head -8 +echo " --- 页面大小: $(wc -c < /tmp/adm.html) 字节 ---" +echo " --- 前 3 行 ---" +head -3 /tmp/adm.html | sed 's/^/ /' +echo " --- 含关键词统计 ---" +for k in 登录 密码 password 抢救报告 下载区 设备; do + printf ' %-12s %s\n' "$k" "$(grep -c "$k" /tmp/adm.html 2>/dev/null)" +done + +echo +echo "════ 2. relay.py 里到底有没有 rescue_fn ════" +grep -n 'rescue_fn' $RELAY/relay.py | head -5 | sed 's/^/ /' +echo " admin.py 里 _rescue_rows:" +grep -n 'def _rescue_rows\|def render_admin' $RELAY/admin.py | head -4 | sed 's/^/ /' + +echo +echo "════ 3. 进程是什么时候起的(比文件新还是旧)════" +ps -o pid,lstart,cmd -p $(systemctl show -p MainPID --value lanremote-relay) 2>/dev/null | sed 's/^/ /' +echo " relay.py 修改时间: $(stat -c '%y' $RELAY/relay.py)" +echo " admin.py 修改时间: $(stat -c '%y' $RELAY/admin.py)" + +echo +echo "════ 4. 无登录直接调 render_admin 看看(绕过 HTTP)════" +cd $RELAY && python3 -c " +import admin +html = admin.render_admin({'version':'2.0.0','uptimeSeconds':1,'devices':[],'allowNew':True}, + 'https://x','/zhongzhuanzhan',0, lambda: [], + rescue_fn=lambda: [{'name':'20261008-120000.txt','size':1234, + 'mtime':1791400000, + 'summary':'测试摘要'}] ) +print(' 渲染长度:', len(html)) +print(' 含抢救报告:', '抢救报告' in html) +print(' 含测试摘要:', '测试摘要' in html) +print(' 含下载链接:', '/rescue/20261008-120000.txt' in html) +" 2>&1 | sed 's/^/ /' +''' + + +def main() -> int: + out = run(REMOTE, timeout=180) + text = out[1] if isinstance(out, tuple) else str(out) + print(text) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/fix_deploy_watchdog_lines.py b/_build/fix_deploy_watchdog_lines.py new file mode 100644 index 0000000..49def3c --- /dev/null +++ b/_build/fix_deploy_watchdog_lines.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- +r'''修 deploy.sh 里 watchdog 块被我吃掉的两行。 + +== 出什么事了 == + +我用 heredoc(python -c @"..."@)改 _build/add_monitor_to_deploy.py 的锚点, +PowerShell 把里面的引号和 $ 打散了,脚本没改成;而**上一次** +(锚点写错的那次)已经把 deploy.sh 改了 —— 那次用的锚点是 + + systemctl enable lanremote-watchdog.timer ... || true + +它把后面两行(start timer + say 状态)连带 `fi` 一起卷进了替换, +结果这两行就没了: + + systemctl start lanremote-watchdog.timer >/dev/null 2>&1 || true + say " 看门狗: $(systemctl is-active lanremote-watchdog.timer 2>&1)" + +bash -n 仍然通过(语法没错,只是少了两行执行语句), +所以光靠语法检查发现不了 —— 这就是为什么改完还要**读回来核对**。 + +这个脚本把这两行补回去。 +''' +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +DEPLOY = ROOT / "relay" / "deploy.sh" + +OLD = """ systemctl enable lanremote-watchdog.timer >/dev/null 2>&1 || true + fi + + # ---- 网站监测单元(如果包里有)----""" + +NEW = """ systemctl enable lanremote-watchdog.timer >/dev/null 2>&1 || true + systemctl start lanremote-watchdog.timer >/dev/null 2>&1 || true + say " 看门狗: $(systemctl is-active lanremote-watchdog.timer 2>&1)" + fi + + # ---- 网站监测单元(如果包里有)----""" + + +def main() -> int: + raw = DEPLOY.read_bytes() + bom = raw.startswith(b"\xef\xbb\xbf") + text = raw.decode("utf-8-sig") + + if "systemctl start lanremote-watchdog.timer" in text: + print(" · 那两行已经在了") + return 0 + if OLD not in text: + print(" ✗ 找不到锚点,需要人工看一眼") + return 1 + text = text.replace(OLD, NEW, 1) + DEPLOY.write_bytes((b"\xef\xbb\xbf" if bom else b"") + + text.encode("utf-8")) + print(" ✓ 补回了 start timer 和 say 那两行") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/fix_rescue_gate.py b/_build/fix_rescue_gate.py new file mode 100644 index 0000000..8282b69 --- /dev/null +++ b/_build/fix_rescue_gate.py @@ -0,0 +1,97 @@ +# -*- coding: utf-8 -*- +r'''修 /rescue/ 鉴权失败时"不发响应"导致的挂起。 + +== 现象 == + +加上鉴权之后,不带登录请求 /rescue/xxx.txt 会**挂到超时**: + + > GET /rescue/19700101-000000-demo.txt HTTP/1.1 + * Operation timed out after 8001 milliseconds with 0 bytes received + +curl 报 HTTP 000。 + +== 根因 == + +_admin_gate() **只返回布尔值,不负责发响应** —— 它是个判断函数。 +所以每个调用点都必须自己在 false 时把响应发出去。 + +看其它调用点,全都是这么写的: + + if not self._admin_gate(): + self._json(401, {"ok": False, "error": "请先登录"}) + return + +而我加的那两处写成了: + + if not self._admin_gate(): + return <- do_GET:一个字节都没发 + ... + if not self._admin_gate(): + return no() <- do_HEAD:no() 默认 404,状态码也不对 + +裸 return 在 BaseHTTPRequestHandler 里就是"什么都不做" —— +连接保持着、没有响应头、客户端一直等。表现成"网站卡住", +比报 401 严重得多。 + +== 改法 == + + do_GET -> self._json(401, ...) 然后 return + do_HEAD -> return no(401) (no() 本来就支持传状态码) +''' +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +RELAY = ROOT / "relay" / "relay.py" + +GET_OLD = ''' if not self._admin_gate(): + return + wanted = urllib.parse.unquote(path[len("/rescue/"):])''' + +GET_NEW = ''' # ⚠ _admin_gate() 只返回布尔值,**不负责发响应** —— + # 每个调用点都要自己发。裸 return 在 BaseHTTPRequestHandler + # 里等于"什么都不做":连接保持着、没有响应头, + # 客户端一直等到超时(curl 报 HTTP 000)。 + # 第一版就是这么写的,实测 /rescue/ 会挂住 8 秒。 + if not self._admin_gate(): + self._json(401, {"ok": False, "error": "请先登录"}) + return + wanted = urllib.parse.unquote(path[len("/rescue/"):])''' + +HEAD_OLD = ''' if not self._admin_gate(): + return no() + wanted = urllib.parse.unquote(path[len("/rescue/"):])''' + +HEAD_NEW = ''' # 同上:必须自己发响应,而且状态码要是 401 不是 404 + # (no() 默认 404,探测方会以为是"文件不存在"而不是"没登录")。 + if not self._admin_gate(): + return no(401) + wanted = urllib.parse.unquote(path[len("/rescue/"):])''' + + +def main() -> int: + raw = RELAY.read_bytes() + bom = raw.startswith(b"\xef\xbb\xbf") + text = raw.decode("utf-8-sig") + + ok = True + for old, new, what in ((GET_OLD, GET_NEW, "do_GET"), + (HEAD_OLD, HEAD_NEW, "do_HEAD")): + if old not in text: + print(" ✗ %s: 找不到锚点" % what) + ok = False + continue + text = text.replace(old, new, 1) + print(" ✓ %s: 鉴权失败时现在会发响应" % what) + + if not ok: + return 1 + RELAY.write_bytes((b"\xef\xbb\xbf" if bom else b"") + text.encode("utf-8")) + print(" 已写回 relay.py") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/fix_rescue_section.py b/_build/fix_rescue_section.py new file mode 100644 index 0000000..a56de65 --- /dev/null +++ b/_build/fix_rescue_section.py @@ -0,0 +1,218 @@ +# -*- coding: utf-8 -*- +r'''补上 add_rescue_section.py 漏掉/插错的三处。 + +前一个补丁的问题: + 1. _rescue_rows() 只在补丁脚本里定义,**没插进 admin.py** —— + 后台一渲染就 NameError(页面直接 500) + 2. /rescue/ 路由被插进了 **do_HEAD**,而且用了 `self.wfile.write(body)` —— + HEAD 本来就不该有 body。而且 do_GET 那边一条都没加, + 等于浏览器点下载根本走不到。 + (锚点 "/download/" 出现两次:do_HEAD 和 do_GET 各一份, + replace(...,1) 只命中了前面那个。这就是那个 "! 锚点 2 处" 警告。) + +这个脚本按 do_HEAD 自己的 ok()/no() 风格把两处都写对。 +''' +from __future__ import annotations + +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +RELAY = ROOT / "relay" / "relay.py" +ADMIN = ROOT / "relay" / "admin.py" + + +def load(p: Path): + raw = p.read_bytes() + return raw, raw.startswith(b"\xef\xbb\xbf"), raw.decode("utf-8-sig") + + +def save(p: Path, text: str, bom: bool): + p.write_bytes((b"\xef\xbb\xbf" if bom else b"") + text.encode("utf-8")) + + +# =========================================================================== +# 1. do_HEAD:换成 ok()/no() 风格(不能有 body) +# =========================================================================== +BAD_HEAD = ''' if path.startswith("/rescue/"): + # 抢救报告下载。单独一条路由,**不发缓存头** —— + # 内容会变,而且不该让 Cloudflare 边缘留着。 + wanted = urllib.parse.unquote(path[len("/rescue/"):]) + safe = _safe_rescue_name(wanted) + if not safe: + self._json(400, {"ok": False, "error": "文件名不合法"}) + return + target = RESCUE_DIR / safe + if not target.is_file(): + self._json(404, {"ok": False, "error": "没有这份报告"}) + return + try: + body = target.read_bytes() + except OSError as exc: + self._json(500, {"ok": False, "error": f"读不到: {exc}"}) + return + self.send_response(200) + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + quoted = urllib.parse.quote(safe) + self.send_header( + "Content-Disposition", + f"attachment; filename*=UTF-8''{quoted}") + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + return + +''' + +GOOD_HEAD = ''' if path.startswith("/rescue/"): + # 抢救报告。HEAD 只回长度和类型,**不写 body** —— + # 这条路由同时存在于 do_GET(真正下载)和 do_HEAD(探测)。 + wanted = urllib.parse.unquote(path[len("/rescue/"):]) + safe = _safe_rescue_name(wanted) + if not safe: + return no() + target = RESCUE_DIR / safe + if not target.is_file(): + return no() + quoted = urllib.parse.quote(safe) + headers = {"Content-Disposition": + "attachment; filename*=UTF-8''%s" % quoted, + "Cache-Control": "no-store"} + return ok("text/plain; charset=utf-8", target.stat().st_size, + headers) + +''' + +# =========================================================================== +# 2. do_GET:加同一条路由(这里才真正回 body) +# =========================================================================== +GET_ANCHOR = ''' if path.startswith("/download/"): + wanted = urllib.parse.unquote(path[len("/download/"):]) + safe = _safe_download_name(wanted) + if not safe: + self._json(400, {"ok": False, "error": "文件名不合法"}) + return''' + +GET_NEW = ''' if path.startswith("/rescue/"): + # 抢救报告下载。**单独一条路由,不发缓存头** —— + # 内容会变,而且不该让 Cloudflare 边缘留着。 + # (和 /download/ 分成两套是有意的:那个是给用户的安装包, + # 文件名带版本号、内容永不改变,所以敢发 604800 长缓存。) + wanted = urllib.parse.unquote(path[len("/rescue/"):]) + safe = _safe_rescue_name(wanted) + if not safe: + self._json(400, {"ok": False, "error": "文件名不合法"}) + return + target = RESCUE_DIR / safe + if not target.is_file(): + self._json(404, {"ok": False, "error": "没有这份报告"}) + return + try: + body = target.read_bytes() + except OSError as exc: + self._json(500, {"ok": False, "error": f"读不到: {exc}"}) + return + self.send_response(200) + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + quoted = urllib.parse.quote(safe) + self.send_header( + "Content-Disposition", + f"attachment; filename*=UTF-8''{quoted}") + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + return + +''' + GET_ANCHOR + +# =========================================================================== +# 3. admin.py:把 _rescue_rows 真正定义进去 +# =========================================================================== +RESCUE_FN = '''def _rescue_rows(items: list) -> str: + """抢救报告列表的 HTML。 + + 照 dl_rows 的风格:在服务端直接拼好塞进模板,不用 JS 去拉 —— + 这台机器只有 460MB 内存,多个接口就多份开销,而且后台页 + 本来就只是一次性的展示。 + + 没有报告时给一句正面的话("一直没出过问题"), + 而不是干巴巴的"暂无数据"—— 没报告是好事。 + """ + if not items: + return ('
还没有报告 —— 说明网站一直没出过' + '「自动救不回来」的问题。
') + import time as _t + import urllib.parse as _up + rows = [] + for it in items: + when = _t.strftime("%m-%d %H:%M", _t.localtime(it["mtime"])) + size = it["size"] / 1024 + size_s = ("%.1f KB" % size) if size >= 1 else ("%d B" % it["size"]) + summary = _esc(it.get("summary") or "(没有结论行)") + href = "/rescue/" + _up.quote(it["name"]) + rows.append( + '
' + '%s' + '' + '%s' + '' + '%s
' + % (href, when, size_s, summary)) + return "".join(rows) + + +def render_admin(''' + + +def main() -> int: + ok = True + + # ---- relay.py ---- + raw, bom, text = load(RELAY) + if BAD_HEAD in text: + text = text.replace(BAD_HEAD, GOOD_HEAD, 1) + print(" ✓ do_HEAD 里的 /rescue/ 换成 ok()/no() 风格(不再写 body)") + elif GOOD_HEAD in text: + print(" · do_HEAD 已经是对的") + else: + print(" ✗ do_HEAD 里找不到那段 /rescue/") + ok = False + + if 'if path.startswith("/rescue/")' in text.split("def do_GET")[1]: + print(" · do_GET 里已经有了") + else: + if GET_ANCHOR in text: + # 只替换 do_GET 里那一处(do_HEAD 那处已经在上面被换掉了, + # 所以现在全文只剩一处) + n = text.count(GET_ANCHOR) + if n != 1: + print(" ! do_GET 锚点还有 %d 处" % n) + text = text.replace(GET_ANCHOR, GET_NEW, 1) + print(" ✓ do_GET 加了 /rescue/ 路由(真正回 body 的那条)") + else: + print(" ✗ do_GET 里找不到 /download/ 锚点") + ok = False + save(RELAY, text, bom) + + # ---- admin.py ---- + raw, bom, text = load(ADMIN) + if "def _rescue_rows(" in text: + print(" · admin.py 里已经定义了 _rescue_rows") + else: + if "def render_admin(" not in text: + print(" ✗ admin.py 里找不到 render_admin") + ok = False + else: + text = text.replace("def render_admin(", RESCUE_FN, 1) + save(ADMIN, text, bom) + print(" ✓ admin.py 里补上了 _rescue_rows 的定义") + ok = ok and True + + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/make_license.py b/_build/make_license.py new file mode 100644 index 0000000..667f467 --- /dev/null +++ b/_build/make_license.py @@ -0,0 +1,81 @@ +# -*- coding: utf-8 -*- +r'''放根目录的 LICENSE(AGPL-3.0)。 + +== 为什么要有这个文件 == + +Gitee 对一个公开仓库会检查"有没有公认的开源协议",没有就弹许可证向导, +而且远控类工具**没有协议**在合规视角里比有协议更容易被盯上 +(这个仓库已经被判过一次"内容可能含有违规信息")。 + +原来仓库里只有 docs/LICENSE.txt —— 那是**自定义协议**, +里面写着"复制并分发完整副本(须保持文件完整、**不得修改**)", +这**不符合 OSI 开源定义**(开源必须允许修改),所以 Gitee 不认。 +(Gitee 上这个仓库的 license 字段实测就是空的。) + +== 为什么选 AGPL-3.0(用户拍板)== + + * OSI 认可、Gitee 认,公开仓库不会再被提示 + * 强 copyleft:别人改了必须以同样协议开源,**不能拿去做闭源商用** —— + 最接近作者原本"免费给人用、但不许别人改名变成自己的"的想法 + * 含"网络服务"条款:把它做成在线服务对外提供也必须开源, + 正好匹配这个项目"中转服务器"的形态 + (GPL 就没有这一条) + +== 两份文件的分工(不冲突)== + + LICENSE 管**代码**:可以改、可以再发布,但衍生作品必须同样开源 + docs/LICENSE.txt 管**使用行为**:禁止偷窥、禁止私自装到别人设备、 + 禁止伪装隐藏等红线 + 免责声明 + +一个管"代码怎么用",一个管"行为不能做什么"。 +''' +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +SRC = Path(__import__("os").environ.get("TEMP", "/tmp")) / "agpl.txt" +DST = ROOT / "LICENSE" + +HEADER = """ykxmz —— 教室多媒体辅助工具 +Copyright (C) 2026 小麦子 + +本程序是自由软件:你可以再发布和/或修改它,依据自由软件基金会发布的 +GNU Affero 通用公共许可证(第 3 版或你选择的任何更新版本)的条款。 + +本程序的发布是希望它有用,但**没有任何保证**;甚至没有适销性或 +特定用途适用性的默示保证。详见 GNU Affero 通用公共许可证。 + +你应该已经随本程序收到了一份 GNU Affero 通用公共许可证的副本 +(见本文件下半部分)。如果没有,见 。 + +-------------------------------------------------------------------------- +⚠ 使用限制请看 docs/LICENSE.txt(《使用条款与免责声明》): + 本软件是一套远程控制工具,禁止用于未经同意的他人设备、 + 禁止用于偷窥监视、禁止伪装隐藏。那份文件约束的是**使用行为**, + 和本文件(约束**代码**的授权)互不冲突。 +-------------------------------------------------------------------------- + + +""" + + +def main() -> int: + if not SRC.is_file(): + print(" ✗ 找不到 AGPL 原文:%s" % SRC) + print(" (先跑一遍取原文的那步)") + return 1 + body = SRC.read_text(encoding="utf-8") + if "GNU AFFERO GENERAL PUBLIC LICENSE" not in body: + print(" ✗ 原文看着不对,不含许可证标题") + return 1 + DST.write_text(HEADER + body, encoding="utf-8", newline="\n") + print(" ✓ 已写 %s(%d 字节)" % (DST.name, DST.stat().st_size)) + print(" 含 AGPL 标题: %s" % ("GNU AFFERO GENERAL PUBLIC LICENSE" in + DST.read_text(encoding="utf-8"))) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/make_monitor_units.py b/_build/make_monitor_units.py new file mode 100644 index 0000000..57f4121 --- /dev/null +++ b/_build/make_monitor_units.py @@ -0,0 +1,92 @@ +# -*- coding: utf-8 -*- +r'''写监测单元的两个 systemd 单元文件。 + +用户要求「在后台设立检测单元」。 + +为什么用 timer 而不是常驻服务: + * 这台机器只有 460MB 内存,常驻 bash 循环白占一份内存 + * 常驻脚本万一自己挂了就永远不检查了;timer 到点拉起来一次, + 崩了下一分钟自己就回来 + * 而且每次都是全新进程,不会有"跑久了状态变脏"的问题 +''' +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +OUT = ROOT / "relay" + +SERVICE = '''[Unit] +# ykxmz 网站监测单元 —— 每 60 秒看一次公网,挂了就叫紧急预案。 +# +# 由 lanremote-monitor.timer 拉起(不是常驻进程,见 timer 里的说明)。 +Description=ykxmz 网站监测单元(每 60 秒检查公网,异常时启动紧急预案) +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +# 检查本身最长 12 秒,预案最长 5 分钟,给 6 分钟余量 +TimeoutStartSec=360 + +# ⚠ 用 bash 显式跑,别靠 shebang + 可执行位 —— +# 部署时文件权限被改掉是最常见的"脚本不执行"原因。 +ExecStart=/bin/bash /root/lanremote-relay/monitor.sh + +# 环境变量 +# FAIL_THRESHOLD 连续失败几次才动手(默认 3 次 ≈ 3 分钟) +# COOLDOWN 救完之后多少秒内不再救(默认 600 秒) +# 不设的话:预案刚重启完隧道还没连上,下一次检查 +# 又判失败又救一遍,来回重启反而永远连不上 +# RESCUE_REBOOT 预案里是否允许"重启整机"这张底牌。**默认 0(不开)**。 +# 想开就把下面这行的 0 改成 1 —— 但先想清楚: +# 如果问题出在供电或上游线路,重启只会让恢复更慢, +# 而且半夜自动重启一台没人管的机器风险很大。 +Environment=RESCUE_REBOOT=0 +Environment=FAIL_THRESHOLD=3 +Environment=COOLDOWN=600 + +StandardOutput=append:/root/lanremote-relay/monitor.log +StandardError=append:/root/lanremote-relay/monitor.log + +[Install] +WantedBy=multi-user.target +''' + +TIMER = '''[Unit] +# 每 60 秒检查一次公网。 +# +# 为什么是 60 秒不是 5 秒: +# 实测这条 4G 线路本来就有 2~4% 的偶发失败,检查太勤会一直看到 +# "失败"(其实一两秒后自己就好了)。配合 monitor.sh 里 +# "连续 3 次才动手"的防抖,实际最快 3 分钟响应,够用了。 +# +# OnBootSec=90s:开机后等 90 秒再开始查。 +# 隧道一般几十秒才连上,太早开始会一开机就误判、白抢救一轮。 +Description=每 60 秒检查一次网站是否可用 + +[Timer] +OnBootSec=90s +OnUnitActiveSec=60s +AccuracySec=5s +Unit=lanremote-monitor.service + +[Install] +WantedBy=timers.target +''' + + +def main() -> int: + (OUT / "lanremote-monitor.service").write_text( + SERVICE, encoding="utf-8", newline="\n") + (OUT / "lanremote-monitor.timer").write_text( + TIMER, encoding="utf-8", newline="\n") + for n in ("lanremote-monitor.service", "lanremote-monitor.timer"): + p = OUT / n + print(" ✓ %s(%d 字节)" % (n, p.stat().st_size)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/package_remote.py b/_build/package_remote.py index 44db0b6..247d8b1 100644 --- a/_build/package_remote.py +++ b/_build/package_remote.py @@ -17,7 +17,7 @@ import zipfile from pathlib import Path ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote") -VERSION = "2.0.0" +VERSION = "2.1.0" NAME = f"ykxmz-{VERSION}" AGENT_SRC = ROOT / "dist_exe" / "ykxmz" @@ -584,12 +584,20 @@ def main() -> int: server_dir = work / server_name server_dir.mkdir(parents=True) # 文件清单写死容易漏 —— watchdog.sh / net-tune.sh 都漏过一次。 - # 改成:relay 目录里所有 .py / .sh 都带上。 - # 后缀白名单:.py / .sh 是服务本体,.ico 是管理页图标。 - # 漏掉 .ico 的话,装到新设备上管理页又会变成没有图标的默认样式。 + # 改成:relay 目录里所有 .py / .sh / .ico / .service / .timer 都带上。 + # 后缀白名单: + # .py 服务本体 + # .sh 部署/运维脚本 + # .ico 管理页图标(漏了的话新设备上管理页是默认样式) + # .service / + # .timer systemd 单元。**这两个是后加的** —— + # 加监测单元的时候才发现白名单里没有它们, + # 于是 monitor.sh 进了包、拉它的 timer 却没进, + # 装到新机器上监测根本不会启动(而且不会报错,只是静默不跑)。 wanted = sorted( [f for f in RELAY_SRC.iterdir() - if f.suffix in (".py", ".sh", ".ico") and f.is_file()], + if f.suffix in (".py", ".sh", ".ico", ".service", ".timer") + and f.is_file()], key=lambda f: f.name) for src in wanted: shutil.copy2(src, server_dir / src.name) diff --git a/_build/repair_device.py b/_build/repair_device.py new file mode 100644 index 0000000..3497238 --- /dev/null +++ b/_build/repair_device.py @@ -0,0 +1,82 @@ +# -*- coding: utf-8 -*- +r'''修设备上的几个卡住的状态。 + +发现的问题: + 1. lanremote-tunnel(主隧道)inactive —— 只剩 h2 副本在跑, + 公网返回 530。这是网站"挂了"的直接原因。 + 2. lanremote-nettune 卡在 activating —— 所以 MTU 还是 1400 + (而实测 1400 反而掉到 50%)。 + 3. monitor.timer 没起来。 + +按顺序修,每步都验。 +''' +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "_build")) +from devssh import run # noqa: E402 + +STEPS = [ + ("1. 看 nettune 为什么卡住(它挡着 MTU)", + "systemctl status lanremote-nettune --no-pager -n 12 2>&1 | tail -12; " + "echo '--- 有没有残留的 iptables 进程 ---'; pgrep -a iptables || echo 无"), + + ("2. 把卡住的 nettune 停掉、直接跑一次 net-tune.sh", + "systemctl stop lanremote-nettune 2>/dev/null; " + "systemctl reset-failed lanremote-nettune 2>/dev/null; " + "bash /root/lanremote-relay/net-tune.sh 2>&1 | tail -12; " + "echo '--- 结果 ---'; " + "echo \"MTU=$(cat /sys/class/net/wlan0/mtu)\"; " + "echo \"MSS 规则=$(iptables -t mangle -L -n 2>/dev/null | grep -ci mss) 条\""), + + ("3. 拉起主隧道(网站 530 的直接原因)", + "systemctl reset-failed lanremote-tunnel 2>/dev/null; " + "systemctl restart lanremote-tunnel; sleep 12; " + "systemctl is-active lanremote-tunnel; " + "systemctl is-active lanremote-tunnel-h2"), + + ("4. 重启中转(让新的 admin.py 生效,后台才有「抢救报告」分区)", + "systemctl restart lanremote-relay; sleep 4; " + "systemctl is-active lanremote-relay"), + + ("5. 启用监测定时器", + "systemctl enable lanremote-monitor.timer >/dev/null 2>&1; " + "systemctl start lanremote-monitor.timer; sleep 3; " + "systemctl is-active lanremote-monitor.timer"), + + ("6. 验证:公网通了没 / 隧道连接数 / 后台分区", + "echo \"公网 /v1/health: $(curl -s -o /dev/null -w '%{http_code}' " + "--max-time 20 https://relay.xxmxxs.top/v1/health)\"; " + "echo \"公网首页: $(curl -s -o /dev/null -w '%{http_code}' " + "--max-time 20 https://relay.xxmxxs.top/)\"; " + "for p in 20241 20242; do " + "n=$(curl -s --max-time 5 http://127.0.0.1:$p/metrics 2>/dev/null " + "| grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}' | head -1); " + "echo \" 连接数($p): ${n:-拿不到}\"; done; " + "echo \"后台「抢救报告」出现次数: " + "$(curl -s --max-time 8 http://127.0.0.1:8080/zhongzhuanzhan " + "| grep -c '抢救报告')\""), +] + + +def main() -> int: + for title, cmd in STEPS: + print() + print("=" * 66) + print(" " + title) + print("=" * 66) + try: + out = run(cmd, timeout=180) + text = out[1] if isinstance(out, tuple) else str(out) + except Exception as exc: # noqa: BLE001 + text = " [执行失败] %s: %s" % (type(exc).__name__, exc) + for ln in text.strip().splitlines(): + print(" " + ln) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/restart_and_verify.py b/_build/restart_and_verify.py new file mode 100644 index 0000000..d053946 --- /dev/null +++ b/_build/restart_and_verify.py @@ -0,0 +1,87 @@ +# -*- coding: utf-8 -*- +r'''重启中转并验证 /rescue/,同时查 deploy_release.py 为什么没重启成功。 + +== 发现的问题 == + + PID=7255 进程启动于 18:04:35 + relay.py 文件改于 18:07:41 + +进程比文件旧 -> 跑的还是老代码 -> /rescue/ 还是挂。 + +deploy_release.py 的 --code-only 分支里应该有 systemctl restart, +但显然没生效。两个可能: + 1. 那一步的代码路径没走到(--code-only 把 restart 也跳过了) + 2. 走了但失败被吞掉 +''' +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "_build")) +from devssh import run # noqa: E402 + +REMOTE = r''' +RELAY=/root/lanremote-relay +B=http://127.0.0.1:8080 + +echo "════ 1. 重启前 ════" +PID=$(systemctl show -p MainPID --value lanremote-relay) +echo " PID=$PID 启动于 $(ps -o lstart= -p $PID 2>/dev/null)" + +echo +echo "════ 2. 重启 ════" +systemctl restart lanremote-relay +sleep 5 +PID2=$(systemctl show -p MainPID --value lanremote-relay) +echo " 新 PID=$PID2 启动于 $(ps -o lstart= -p $PID2 2>/dev/null)" +echo " relay.py 改于 $(stat -c '%y' $RELAY/relay.py)" +echo " 服务状态: $(systemctl is-active lanremote-relay)" + +echo +echo "════ 3. 重启后测 /rescue/(不带登录,应该立刻 401 而不是挂住)════" +printf '报告\n 判断:测试\n' > $RELAY/rescue_reports/19700101-000000-demo.txt +echo -n " 不带登录: " +curl -s -o /dev/null -w 'HTTP %{http_code} 用时 %{time_total}s\n' --max-time 8 \ + "$B/rescue/19700101-000000-demo.txt" + +PASS=$(cat $RELAY/data/admin_password.txt 2>/dev/null) +curl -s -c /tmp/ck2.txt -o /dev/null --max-time 8 -d "password=$PASS" $B/login +echo -n " 带登录: " +curl -s -b /tmp/ck2.txt -o /tmp/rep2.txt -w 'HTTP %{http_code} 用时 %{time_total}s\n' \ + --max-time 8 "$B/rescue/19700101-000000-demo.txt" +echo " 内容: $(head -1 /tmp/rep2.txt 2>/dev/null)" + +echo +echo "════ 4. 后台页里的「抢救报告」分区 ════" +N=$(curl -s -b /tmp/ck2.txt --max-time 8 $B/zhongzhuanzhan | grep -c '抢救报告') +echo " 匹配 $N 次" +curl -s -b /tmp/ck2.txt --max-time 8 $B/zhongzhuanzhan | grep -o '19700101-000000-demo.txt' | head -1 | sed 's/^/ 列表里有: /' + +echo +echo "════ 5. 公网还通吗(重启不能把网站弄挂)════" +echo " 公网 health: $(curl -s -o /dev/null -w '%{http_code}' --max-time 20 https://relay.xxmxxs.top/v1/health)" +echo " 公网首页: $(curl -s -o /dev/null -w '%{http_code}' --max-time 20 https://relay.xxmxxs.top/)" + +rm -f $RELAY/rescue_reports/19700101-000000-demo.txt /tmp/ck2.txt /tmp/rep2.txt +''' + + +def main() -> int: + # ---- 先看 deploy_release.py 的 --code-only 分支到底走不走 restart ---- + src = (ROOT / "_build" / "deploy_release.py").read_text(encoding="utf-8") + print(" ── deploy_release.py 里 restart 相关的代码 ──") + for i, ln in enumerate(src.splitlines(), 1): + if "restart" in ln or "files_only" in ln or "code_only" in ln: + print(" %4d: %s" % (i, ln.strip())) + print() + + out = run(REMOTE, timeout=300) + text = out[1] if isinstance(out, tuple) else str(out) + print(text) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/verify_admin_login.py b/_build/verify_admin_login.py new file mode 100644 index 0000000..d0710d3 --- /dev/null +++ b/_build/verify_admin_login.py @@ -0,0 +1,79 @@ +# -*- coding: utf-8 -*- +r'''完整验证:登录 -> 后台页看到「抢救报告」-> 下载报告。 + +上一轮失败是因为我 curl 用错了接口(POST /login 只传 password), +实际是 POST /admin/login 且要 username + password 两个字段。 +''' +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "_build")) +from devssh import run # noqa: E402 + +REMOTE = r''' +RELAY=/root/lanremote-relay +B=http://127.0.0.1:8080 + +echo "════ 0. 账号与密码 ════" +grep -m1 '^ADMIN_USER' $RELAY/relay.py | sed 's/^/ /' +echo " 密码文件长度: $(wc -c < $RELAY/data/admin_password.txt) 字节" + +echo +echo "════ 1. 造报告 ════" +printf '这是一份演练报告。\n\n 判断:**本机是好的,问题在隧道或 Cloudflare 那一侧**。\n' \ + > "$RELAY/rescue_reports/19700101-000000-demo.txt" +ls $RELAY/rescue_reports/ | sed 's/^/ /' + +echo +echo "════ 2. 登录(POST /admin/login,username + password)════" +PASS=$(cat $RELAY/data/admin_password.txt 2>/dev/null) +rm -f /tmp/ck3.txt +curl -s -c /tmp/ck3.txt -o /dev/null -w ' HTTP %{http_code}\n' --max-time 10 \ + -d "username=" -d "password=$PASS" $B/admin/login +echo " cookie 文件:" +grep -c lr_admin /tmp/ck3.txt 2>/dev/null | sed 's/^/ 找到 lr_admin: /' + +echo +echo "════ 3. 带登录拿后台页 ════" +curl -s -b /tmp/ck3.txt -o /tmp/adm3.html -w ' HTTP %{http_code} 大小 %{size_download} 字节\n' \ + --max-time 10 $B/zhongzhuanzhan +echo " 含「抢救报告」: $(grep -c '抢救报告' /tmp/adm3.html)" +echo " 含演练报告文件名: $(grep -c '19700101-000000-demo.txt' /tmp/adm3.html)" +echo " 含「下载区」: $(grep -c '下载区' /tmp/adm3.html)" + +echo +echo "════ 4. 带登录下载报告 ════" +curl -s -b /tmp/ck3.txt -o /tmp/rep3.txt -w ' HTTP %{http_code} 大小 %{size_download} 字节\n' \ + --max-time 10 "$B/rescue/19700101-000000-demo.txt" +echo " 内容第一行: $(head -1 /tmp/rep3.txt)" +echo " 缓存头: $(curl -s -b /tmp/ck3.txt -D- -o /dev/null --max-time 10 \ + "$B/rescue/19700101-000000-demo.txt" | grep -i 'cache-control' | tr -d '\r')" + +echo +echo "════ 5. 不带登录(应该 401)════" +curl -s -o /dev/null -w ' HTTP %{http_code}\n' --max-time 10 \ + "$B/rescue/19700101-000000-demo.txt" + +echo +echo "════ 6. X-Admin 头(脚本通道)也要能下 ════" +curl -s -o /dev/null -w ' HTTP %{http_code}\n' --max-time 10 \ + -H "X-Admin: $PASS" "$B/rescue/19700101-000000-demo.txt" + +echo +echo "════ 清理 ════" +rm -f "$RELAY/rescue_reports/19700101-000000-demo.txt" /tmp/ck3.txt /tmp/adm3.html /tmp/rep3.txt +''' + + +def main() -> int: + out = run(REMOTE, timeout=200) + text = out[1] if isinstance(out, tuple) else str(out) + print(text) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/verify_gate_fix.py b/_build/verify_gate_fix.py new file mode 100644 index 0000000..5590bdd --- /dev/null +++ b/_build/verify_gate_fix.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +r'''验证 /rescue/ 鉴权修复是否真的生效。 + +上一轮部署后还是 HTTP 000。要分清是: + * 代码没传上去 / 进程没重启(改动没生效) + * 还是代码有新问题 +''' +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "_build")) +from devssh import run # noqa: E402 + +REMOTE = r''' +RELAY=/root/lanremote-relay +B=http://127.0.0.1:8080 + +echo "════ 1. 设备上 relay.py 里那段代码长什么样 ════" +grep -n -A6 'if path.startswith("/rescue/")' $RELAY/relay.py | head -40 | sed 's/^/ /' + +echo +echo "════ 2. 进程是什么时候起的 vs relay.py 修改时间 ════" +PID=$(systemctl show -p MainPID --value lanremote-relay) +echo " PID=$PID 启动: $(ps -o lstart= -p $PID 2>/dev/null)" +echo " relay.py 改于: $(stat -c '%y' $RELAY/relay.py)" + +echo +echo "════ 3. 再来一次详细请求 ════" +printf '报告\n 判断:测试\n' > $RELAY/rescue_reports/19700101-000000-demo.txt +curl -sv --max-time 8 "$B/rescue/19700101-000000-demo.txt" 2>&1 | tail -20 | sed 's/^/ /' + +echo +echo "════ 4. 对照:一个不存在的普通路径 ════" +curl -s -o /dev/null -w ' /nothing-here -> %{http_code}\n' --max-time 6 "$B/nothing-here" + +echo +echo "════ 5. 对照:/download/ 上不存在的文件(那条没加鉴权)════" +curl -s -o /dev/null -w ' /download/none.txt -> %{http_code}\n' --max-time 6 "$B/download/none.txt" + +rm -f $RELAY/rescue_reports/19700101-000000-demo.txt +''' + + +def main() -> int: + out = run(REMOTE, timeout=180) + text = out[1] if isinstance(out, tuple) else str(out) + print(text) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/verify_monitor_e2e.py b/_build/verify_monitor_e2e.py new file mode 100644 index 0000000..91d5626 --- /dev/null +++ b/_build/verify_monitor_e2e.py @@ -0,0 +1,100 @@ +# -*- coding: utf-8 -*- +r'''端到端验证「监测 + 预案 + 报告分区」。 + +验这些: + 1. /rescue/ 不带登录 -> 401(不能匿名下诊断报告) + 2. /rescue/ 带登录 -> 200 且内容正确 + 3. 报告文件名的目录穿越被挡住 + 4. monitor.sh 能跑通并写状态 + 5. rescue.sh 在网站正常时**什么都不做**就退出(不能瞎折腾) + 6. 后台页里有「抢救报告」分区 + 7. 监测定时器在跑 +''' +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "_build")) +from devssh import run # noqa: E402 + +REMOTE = r''' +RELAY=/root/lanremote-relay +PASS=$(cat $RELAY/data/admin_password.txt 2>/dev/null) +B=http://127.0.0.1:8080 + +echo "════ 准备:造一份演练报告 ════" +DEMO=$RELAY/rescue_reports/19700101-000000-demo.txt +printf '这是一份演练报告。\n\n 判断:**本机是好的,问题在隧道或 Cloudflare 那一侧**。\n' > $DEMO +echo " $(ls $RELAY/rescue_reports | wc -l) 份" + +echo +echo "════ 1. 不带登录下载(应该 401)════" +printf ' HTTP %s\n' "$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 $B/rescue/19700101-000000-demo.txt)" + +echo +echo "════ 2. 带登录下载(应该 200 且有内容)════" +# 先登录拿 cookie +curl -s -c /tmp/ck.txt -o /dev/null --max-time 8 \ + -d "password=$PASS" $B/login 2>/dev/null +CODE=$(curl -s -b /tmp/ck.txt -o /tmp/rep.txt -w '%{http_code}' --max-time 8 \ + $B/rescue/19700101-000000-demo.txt) +printf ' HTTP %s\n' "$CODE" +echo " 内容:$(head -2 /tmp/rep.txt | tail -1)" +printf ' 缓存头: %s\n' "$(curl -s -b /tmp/ck.txt -D- -o /dev/null --max-time 8 $B/rescue/19700101-000000-demo.txt | grep -i cache-control | tr -d '\r')" + +echo +echo "════ 3. 目录穿越(应该 400/404,绝不能读到别的文件)════" +for bad in "../relay.py" "..%2Frelay.py" "x/../../etc/passwd"; do + printf ' %-24s -> HTTP %s\n' "$bad" "$(curl -s -b /tmp/ck.txt -o /dev/null -w '%{http_code}' --max-time 8 "$B/rescue/$bad")" +done + +echo +echo "════ 4. monitor.sh 跑一次 ════" +rm -f $RELAY/monitor_state.txt +bash $RELAY/monitor.sh +echo " 状态文件:" +cat $RELAY/monitor_state.txt 2>/dev/null | sed 's/^/ /' +echo " 日志尾部:" +tail -3 $RELAY/monitor.log 2>/dev/null | sed 's/^/ /' + +echo +echo "════ 5. rescue.sh 在网站正常时应该**什么都不做** ════" +BEFORE=$(md5sum $RELAY/rescue_knowledge.md 2>/dev/null | cut -d' ' -f1) +timeout 120 bash $RELAY/rescue.sh "端到端验证(网站正常)" 2>&1 | tail -8 | sed 's/^/ /' +AFTER=$(md5sum $RELAY/rescue_knowledge.md 2>/dev/null | cut -d' ' -f1) +if [ "$BEFORE" = "$AFTER" ]; then + echo " ✅ 网站正常时没有误触发抢救(知识库没变)" +else + echo " ❌ 网站正常却动了抢救" +fi +echo " 报告份数:$(ls $RELAY/rescue_reports | wc -l)(正常时不该新增)" + +echo +echo "════ 6. 后台页里的「抢救报告」分区 ════" +N=$(curl -s -b /tmp/ck.txt --max-time 8 $B/zhongzhuanzhan | grep -c '抢救报告') +printf ' 匹配 %s 次\n' "$N" +curl -s -b /tmp/ck.txt --max-time 8 $B/zhongzhuanzhan | grep -o '19700101-000000-demo.txt' | head -1 | sed 's/^/ 列表里有: /' + +echo +echo "════ 7. 定时器 ════" +printf ' monitor.timer: %s\n' "$(systemctl is-active lanremote-monitor.timer)" +systemctl list-timers lanremote-monitor.timer --no-pager 2>/dev/null | sed -n '1,2p' | sed 's/^/ /' + +echo +echo "════ 收尾:删掉演练报告 ════" +rm -f $DEMO /tmp/ck.txt /tmp/rep.txt +echo " 剩 $(ls $RELAY/rescue_reports | wc -l) 份" +''' + + +def main() -> int: + out = run(REMOTE, timeout=400) + text = out[1] if isinstance(out, tuple) else str(out) + print(text) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/wire_sfx.py b/_build/wire_sfx.py new file mode 100644 index 0000000..8338bea --- /dev/null +++ b/_build/wire_sfx.py @@ -0,0 +1,86 @@ +# -*- coding: utf-8 -*- +r'''把音效功能接进协议层和服务端。 + +加两条命令: + sfx_list 列出可用音效 + sfx_play 播放指定的音效 + +复用现成的 play_audio(MCI),不另造一套播放器。 +''' +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +PROTO = ROOT / "windows" / "lanremote" / "protocol.py" +SERVER = ROOT / "windows" / "lanremote" / "server.py" + + +def load(p: Path): + raw = p.read_bytes() + return raw.startswith(b"\xef\xbb\xbf"), raw.decode("utf-8-sig") + + +def save(p: Path, text: str, bom: bool): + p.write_bytes((b"\xef\xbb\xbf" if bom else b"") + text.encode("utf-8")) + + +# ---------------------------------------------------------------- protocol +PROTO_ANCHOR = 'CMD_AUDIO_LIST = "audio_list"' +PROTO_NEW = '''CMD_AUDIO_LIST = "audio_list" +CMD_SFX_LIST = "sfx_list" # 列出可用音效(内置 + 用户自己放的) +CMD_SFX_PLAY = "sfx_play" # 播放某个音效''' + +# ---------------------------------------------------------------- server +# 挂在 CMD_AUDIO_LIST 那条分支后面 +SERVER_ANCHOR = ''' if cmd == P.CMD_AUDIO_LIST:''' + +SERVER_NEW = ''' if cmd == P.CMD_SFX_LIST: + items = self.actions.list_sfx() + return {"ok": True, "sfx": items, "count": len(items), + "dir": self.actions.sfx_dir_hint()} + + if cmd == P.CMD_SFX_PLAY: + name = str(body.get("name") or "").strip() + if not name: + return {"ok": False, "error": "没说要放哪个音效"} + try: + msg = self.actions.play_sfx(name) + except Exception as exc: # noqa: BLE001 + return {"ok": False, "error": str(exc)} + return {"ok": True, "message": msg, "name": name} + +''' + SERVER_ANCHOR + + +def main() -> int: + ok = True + + bom, text = load(PROTO) + if "CMD_SFX_LIST" in text: + print(" · protocol.py 已经加过了") + elif PROTO_ANCHOR not in text: + print(" ✗ protocol.py 找不到锚点") + ok = False + else: + text = text.replace(PROTO_ANCHOR, PROTO_NEW, 1) + save(PROTO, text, bom) + print(" ✓ protocol.py 加了两条命令") + + bom, text = load(SERVER) + if "CMD_SFX_LIST" in text: + print(" · server.py 已经加过了") + elif SERVER_ANCHOR not in text: + print(" ✗ server.py 找不到锚点") + ok = False + else: + text = text.replace(SERVER_ANCHOR, SERVER_NEW, 1) + save(SERVER, text, bom) + print(" ✓ server.py 加了两个处理分支") + + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/wrap_gui_stages.py b/_build/wrap_gui_stages.py new file mode 100644 index 0000000..88dccad --- /dev/null +++ b/_build/wrap_gui_stages.py @@ -0,0 +1,127 @@ +# -*- coding: utf-8 -*- +r'''把会弹窗的测试阶段包进隐藏桌面。 + +== 用户要求 == + +「把全量测试改为在后台静默运行不要打开任何窗口或锁屏」 + +== 现状 == + +有 6 个阶段会真的开窗口,其中 test_image_modes 还会开**全屏** +(直接把用户的屏幕锁住): + + 1e/7 新增功能 test_new_features.py + 1f/7 电脑功能注册表 test_features.py + 1g/7 功能模糊测试 fuzz_features.py + 1k/7 首次安装设置窗口 test_first_run_setup.py + 2b/7 投图显示方式 test_image_modes.py <- 全屏,锁屏 + 3/7 屏幕共享可见提示 test_indicator_visible.py + +套件里原来只有一个 Close-TestWindows,那是**事后收尾** +(杀掉没退干净的进程),窗口在跑的时候照样在屏幕上闪。 + +== 改法 == + +把这些调用前面套一层 _tests/hidden_desktop.py --, +那个脚本会新建一个 Windows 桌面、把子进程放上去。 +子进程里所有窗口都创建在那个桌面上 —— 用户完全看不到, +而窗口本身是**真的**(全屏、可见、几何、Win32 枚举全都真实), +测试有效性一点没损失。 + + & python hidden_desktop.py -- python test_image_modes.py + +== 保留什么 == + + * `*>` 重定向照旧(hidden_desktop 会把子进程输出转写到自己的 + stdout,套件照常能抓到 FAIL) + * 退出码照旧(hidden_desktop 用子进程的退出码退出) + * Close-TestWindows 留着 —— 它是兜底,还有用 +''' +from __future__ import annotations + +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +SUITE = ROOT / "_tests" / "verify_all.ps1" + +# 要把调用包进隐藏桌面的测试 +GUIs = [ + "test_new_features.py", + "test_features.py", + "fuzz_features.py", + "test_first_run_setup.py", + "test_image_modes.py", + "test_indicator_visible.py", +] + + +def main() -> int: + raw = SUITE.read_bytes() + had_bom = raw.startswith(b"\xef\xbb\xbf") + text = raw.decode("utf-8-sig") + + if "hidden_desktop.py" in text: + print(" 已经接过了") + return 0 + + changes = [] + for name in GUIs: + # 两种形状: + # & python (Join-Path $root "_tests\X") *> ... + # $stageOut = & python (Join-Path $root "_tests\X") 2>&1 + pat = re.compile( + r'(& python \(Join-Path \$root "_tests\\%s"\))' + % re.escape(name)) + n = len(pat.findall(text)) + if n != 1: + changes.append(("!", name, "出现 %d 次" % n)) + continue + # ⚠ 替换串里含反斜杠("_tests\hidden_desktop.py"), + # 直接当 repl 字符串会被 re 当成转义序列, + # 报 "bad escape \h"。所以用 lambda 返回字面量。 + text = pat.sub( + lambda m: ('& python (Join-Path $root "_tests\\' + 'hidden_desktop.py") -- ' + m.group(1)), + text, count=1) + changes.append(("✓", name, "")) + + # 在第一个被包的调用之前插一段说明 + note = '''# ---------------------------------------------------------------- 隐藏桌面 +# 下面 6 个阶段会真的开窗口,其中「投图显示方式」还会开**全屏** —— +# 直接在用户眼前跑的话屏幕会被锁住,没法用电脑。 +# +# 现在统一套一层 hidden_desktop.py:它新建一个 Windows 桌面,把子进程 +# 放上去,所有窗口都创建在那个不显示的桌面上。 +# **窗口是真的**(全屏、可见、几何、Win32 枚举都真实), +# 只是用户看不到 —— 测试有效性没有损失。 +# +# ⚠ 新增会弹窗的测试时,记得也套一层,否则用户屏幕又会被闪。 + +''' + anchor = 'Banner "1e/7 新增功能' + if anchor in text: + text = text.replace(anchor, note + anchor, 1) + changes.append(("✓", "插入说明注释", "")) + else: + changes.append(("✗", "插入说明注释", "找不到锚点")) + + print(" ── 结果 ──") + for mark, what, why in changes: + print(" %s %-28s %s" % (mark, what, why)) + + if any(m == "✗" for m, _w, _y in changes): + print() + print(" 有没匹配上的,没有写回") + return 1 + + SUITE.write_bytes((b"\xef\xbb\xbf" if had_bom else b"") + + text.encode("utf-8")) + print() + print(" 已写回 verify_all.ps1(BOM 保留:%s)" % had_bom) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_tests/_stubdata2/devices.json b/_tests/_stubdata2/devices.json new file mode 100644 index 0000000..9767dcd --- /dev/null +++ b/_tests/_stubdata2/devices.json @@ -0,0 +1,8 @@ +{ + "java-test-device": { + "name": "JVM测试端", + "token": "4ed3ec96a8e2fb0337de88aa9212d7f5", + "paired_at": 1791452868.0075707, + "last_seen": 1791452868.011561 + } +} \ No newline at end of file diff --git a/_tests/_stubdata2/settings.json b/_tests/_stubdata2/settings.json new file mode 100644 index 0000000..9b205d7 --- /dev/null +++ b/_tests/_stubdata2/settings.json @@ -0,0 +1,25 @@ +{ + "port": 8799, + "server_name": "DESKTOP-RN5I625", + "allow_run": true, + "allow_power": true, + "allow_screen": true, + "start_minimized": false, + "allow_new_pairing": true, + "image_timeout": 0, + "image_display": "window", + "image_custom_width": 0, + "image_custom_height": 0, + "image_window_title": "", + "tts_voice": "", + "tts_rate": 0, + "mp3_dir": "", + "file_roots": "", + "extra_apps": "", + "extra_commands": "", + "download_dir": "received", + "screen_max_width": 480, + "screen_quality": 72, + "screen_fps": 12, + "pairing_code": "246813" +} \ No newline at end of file diff --git a/_tests/hidden_desktop.py b/_tests/hidden_desktop.py new file mode 100644 index 0000000..2e4ed76 --- /dev/null +++ b/_tests/hidden_desktop.py @@ -0,0 +1,256 @@ +# -*- coding: utf-8 -*- +r'''在**隐藏的 Windows 桌面**上跑命令,用户屏幕不受打扰。 + +== 为什么需要 == + +用户原话:「把全量测试改为在后台静默运行不要打开任何窗口或锁屏」。 + +有几个测试必须开**真实的窗口**才测得出东西,不能改成假的: + + test_image_modes 投图窗口的三种显示方式(含**全屏**) + test_indicator_visible 屏幕共享提示条 + Win32 窗口枚举 + fuzz_features 功能模糊测试 + test_first_run_setup 首次设置窗口 + +问题是它们跑的时候会在用户屏幕上一通闪,**全屏那个还会把屏幕锁住**, +用户根本没法用电脑。 + +== 解法 == + +Windows 支持多个"桌面"(Desktop)—— 同一个登录会话里可以有若干个, +但**只有一个是当前显示给用户的**(叫 Default)。 +我们新建一个桌面,把测试进程放上去: + + CreateDesktopW("ykxmz-test", ...) 建一个不显示的桌面 + CreateProcessW(..., lpDesktop=那个名字) 把子进程放上去 + 子进程里所有窗口都创建在那个桌面上 -> 用户完全看不到 + 跑完 CloseDesktop + +这样窗口是**真的**(窗口句柄、几何、可见性、Win32 枚举全都真实), +只是不在用户眼前 —— 测试有效性一点没损失。 + +== 为什么不用别的方法 == + + * "把窗口设成透明/最小化" —— 那就测不出全屏了,而且还是会闪 + * "跳过这些测试" —— 投图和提示条是核心功能,不能因为烦就不测 + * "用无头模式" —— 那测的就不是真实窗口了 + +== 用法 == + +命令行: + python _tests/hidden_desktop.py -- <要跑的命令...> + +代码里: + from hidden_desktop import run_hidden + code, out = run_hidden(["python", "test_image_modes.py"], timeout=300) +''' +from __future__ import annotations + +import ctypes +import ctypes.wintypes as wt +import os +import subprocess +import sys +import tempfile +import time +import uuid +from pathlib import Path + +IS_WINDOWS = os.name == "nt" + +# ---- Win32 常量和结构 ---- + +# 桌面权限:给足,否则 CreateProcess 放上去之后子进程建不了窗口 +DESKTOP_ALL_ACCESS = 0x000F01FF +GENERIC_ALL = 0x10000000 + +CREATE_NEW_CONSOLE = 0x00000010 +CREATE_UNICODE_ENVIRONMENT = 0x00000400 +WAIT_TIMEOUT = 0x00000102 +INFINITE = 0xFFFFFFFF + + +class STARTUPINFOW(ctypes.Structure): + _fields_ = [ + ("cb", wt.DWORD), + ("lpReserved", wt.LPWSTR), + ("lpDesktop", wt.LPWSTR), + ("lpTitle", wt.LPWSTR), + ("dwX", wt.DWORD), + ("dwY", wt.DWORD), + ("dwXSize", wt.DWORD), + ("dwYSize", wt.DWORD), + ("dwXCountChars", wt.DWORD), + ("dwYCountChars", wt.DWORD), + ("dwFillAttribute", wt.DWORD), + ("dwFlags", wt.DWORD), + ("wShowWindow", wt.WORD), + ("cbReserved2", wt.WORD), + ("lpReserved2", ctypes.POINTER(ctypes.c_byte)), + ("hStdInput", wt.HANDLE), + ("hStdOutput", wt.HANDLE), + ("hStdError", wt.HANDLE), + ] + + +class PROCESS_INFORMATION(ctypes.Structure): + _fields_ = [ + ("hProcess", wt.HANDLE), + ("hThread", wt.HANDLE), + ("dwProcessId", wt.DWORD), + ("dwThreadId", wt.DWORD), + ] + + +def _api(): + user32 = ctypes.WinDLL("user32", use_last_error=True) + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + + user32.CreateDesktopW.argtypes = [ + wt.LPCWSTR, wt.LPCWSTR, ctypes.c_void_p, wt.DWORD, + wt.DWORD, ctypes.c_void_p] + user32.CreateDesktopW.restype = wt.HANDLE + + user32.CloseDesktop.argtypes = [wt.HANDLE] + user32.CloseDesktop.restype = wt.BOOL + + kernel32.CreateProcessW.argtypes = [ + wt.LPCWSTR, wt.LPWSTR, ctypes.c_void_p, ctypes.c_void_p, wt.BOOL, + wt.DWORD, ctypes.c_void_p, wt.LPCWSTR, + ctypes.POINTER(STARTUPINFOW), ctypes.POINTER(PROCESS_INFORMATION)] + kernel32.CreateProcessW.restype = wt.BOOL + + kernel32.WaitForSingleObject.argtypes = [wt.HANDLE, wt.DWORD] + kernel32.WaitForSingleObject.restype = wt.DWORD + + kernel32.GetExitCodeProcess.argtypes = [wt.HANDLE, + ctypes.POINTER(wt.DWORD)] + kernel32.GetExitCodeProcess.restype = wt.BOOL + + kernel32.TerminateProcess.argtypes = [wt.HANDLE, wt.UINT] + kernel32.CloseHandle.argtypes = [wt.HANDLE] + return user32, kernel32 + + +def run_hidden(command, timeout: float = 600.0, cwd: str | None = None, + log_path: Path | None = None) -> tuple: + """在隐藏桌面上跑命令。 + + 返回 (退出码, 输出文本)。退出码 -1 表示建桌面失败(会自动退回 + 直接在普通桌面跑,并说明原因 —— 宁可闪一下也不能让测试跑不了)。 + """ + if not IS_WINDOWS: + # 非 Windows 直接跑 + r = subprocess.run(command, cwd=cwd, capture_output=True, text=True, + encoding="utf-8", errors="replace", + timeout=timeout) + return r.returncode, (r.stdout or "") + (r.stderr or "") + + if log_path is None: + log_path = Path(tempfile.gettempdir()) / ( + "ykxmz_hidden_%s.log" % uuid.uuid4().hex[:8]) + + user32, kernel32 = _api() + name = "ykxmz-test-%s" % uuid.uuid4().hex[:8] + + desk = user32.CreateDesktopW(name, None, None, 0, + DESKTOP_ALL_ACCESS | GENERIC_ALL, None) + if not desk: + err = ctypes.get_last_error() + # 建不了桌面(少见,通常是权限)—— 退回普通方式,但要说清楚 + r = subprocess.run(command, cwd=cwd, capture_output=True, text=True, + encoding="utf-8", errors="replace", + timeout=timeout) + msg = (" [警告] 建隐藏桌面失败(Win32 错误 %d)," + "这次只能在普通桌面上跑 —— 屏幕上可能会闪窗口。\n" % err) + return r.returncode, msg + (r.stdout or "") + (r.stderr or "") + + try: + # 让子进程自己把输出重定向到文件 —— 走 CreateProcess 时 + # 管道不好接管,重定向到文件最简单可靠。 + # 用 cmd /c 是为了让 > 生效。 + quoted = subprocess.list2cmdline([str(c) for c in command]) + full = 'cmd.exe /c %s > "%s" 2>&1' % (quoted, log_path) + # cmd /c 后面整串要作为一个参数传 + cmdline = ctypes.create_unicode_buffer(full) + + si = STARTUPINFOW() + si.cb = ctypes.sizeof(STARTUPINFOW) + si.lpDesktop = name # <<< 关键:把子进程放到隐藏桌面 + pi = PROCESS_INFORMATION() + + ok = kernel32.CreateProcessW( + None, cmdline, None, None, False, + CREATE_NEW_CONSOLE | CREATE_UNICODE_ENVIRONMENT, + None, cwd, ctypes.byref(si), ctypes.byref(pi)) + if not ok: + err = ctypes.get_last_error() + r = subprocess.run(command, cwd=cwd, capture_output=True, + text=True, encoding="utf-8", + errors="replace", timeout=timeout) + return r.returncode, (" [警告] 在隐藏桌面上启动失败" + "(Win32 错误 %d)\n" % err + ) + (r.stdout or "") + (r.stderr or "") + + try: + waited = kernel32.WaitForSingleObject(pi.hProcess, + int(timeout * 1000)) + if waited == WAIT_TIMEOUT: + kernel32.TerminateProcess(pi.hProcess, 124) + code = 124 + else: + code = wt.DWORD() + kernel32.GetExitCodeProcess(pi.hProcess, ctypes.byref(code)) + code = code.value + finally: + kernel32.CloseHandle(pi.hThread) + kernel32.CloseHandle(pi.hProcess) + + # 给文件一点时间落盘 + for _ in range(40): + if log_path.is_file() and log_path.stat().st_size > 0: + break + time.sleep(0.05) + try: + out = log_path.read_text(encoding="utf-8", errors="replace") + except OSError: + out = "" + return code, out + finally: + user32.CloseDesktop(desk) + try: + log_path.unlink() + except OSError: + pass + + +def main() -> int: + if not IS_WINDOWS: + print(" 这个工具只在 Windows 上有意义") + return 1 + args = sys.argv[1:] + if args and args[0] == "--": + args = args[1:] + if not args: + print(__doc__) + return 1 + + print(" 在隐藏桌面上运行:%s" % " ".join(args)) + started = time.time() + code, out = run_hidden(args, timeout=900) + took = time.time() - started + # 原样把输出打出来(套件靠它判断) + sys.stdout.write(out) + if not out.endswith("\n"): + sys.stdout.write("\n") + # ⚠ 总结这行**只在终端里打**。 + # 套件是用 `*> 日志文件` 接输出的,再 `Select-Object -Last 2/3` + # 取摘要 —— 如果这里多打一行,就会把真正的摘要顶掉, + # 汇总表上显示的是"隐藏桌面运行结束"而不是测试结果。 + if sys.stdout.isatty(): + print(" —— 隐藏桌面运行结束:退出码 %d,用时 %.1f 秒" % (code, took)) + return code + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$10.class b/_tests/out_app/com/lanremote/watch/MainActivity$10.class index 128adcf..34f3461 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$10.class and b/_tests/out_app/com/lanremote/watch/MainActivity$10.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$100.class b/_tests/out_app/com/lanremote/watch/MainActivity$100.class index 17aa0a9..3bee6a8 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$100.class and b/_tests/out_app/com/lanremote/watch/MainActivity$100.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$101.class b/_tests/out_app/com/lanremote/watch/MainActivity$101.class index 28abd52..2ad601b 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$101.class and b/_tests/out_app/com/lanremote/watch/MainActivity$101.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$102.class b/_tests/out_app/com/lanremote/watch/MainActivity$102.class index d3ed752..1db1590 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$102.class and b/_tests/out_app/com/lanremote/watch/MainActivity$102.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$103.class b/_tests/out_app/com/lanremote/watch/MainActivity$103.class index 86b03bb..54cac96 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$103.class and b/_tests/out_app/com/lanremote/watch/MainActivity$103.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$104.class b/_tests/out_app/com/lanremote/watch/MainActivity$104.class index 1873f4a..998429d 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$104.class and b/_tests/out_app/com/lanremote/watch/MainActivity$104.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$105.class b/_tests/out_app/com/lanremote/watch/MainActivity$105.class index bbf00a7..d7c4a68 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$105.class and b/_tests/out_app/com/lanremote/watch/MainActivity$105.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$106.class b/_tests/out_app/com/lanremote/watch/MainActivity$106.class index f8cd3cb..9f8f7fe 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$106.class and b/_tests/out_app/com/lanremote/watch/MainActivity$106.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$107$1.class b/_tests/out_app/com/lanremote/watch/MainActivity$107$1.class deleted file mode 100644 index 29ef5f8..0000000 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$107$1.class and /dev/null differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$107.class b/_tests/out_app/com/lanremote/watch/MainActivity$107.class index 324d757..244f8b3 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$107.class and b/_tests/out_app/com/lanremote/watch/MainActivity$107.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$108.class b/_tests/out_app/com/lanremote/watch/MainActivity$108.class index 4bdfccb..d468890 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$108.class and b/_tests/out_app/com/lanremote/watch/MainActivity$108.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$109.class b/_tests/out_app/com/lanremote/watch/MainActivity$109.class index 1564387..3f0f719 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$109.class and b/_tests/out_app/com/lanremote/watch/MainActivity$109.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$11.class b/_tests/out_app/com/lanremote/watch/MainActivity$11.class index 50239a2..4b6694a 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$11.class and b/_tests/out_app/com/lanremote/watch/MainActivity$11.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$110.class b/_tests/out_app/com/lanremote/watch/MainActivity$110.class index 12b4769..bef709f 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$110.class and b/_tests/out_app/com/lanremote/watch/MainActivity$110.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$111.class b/_tests/out_app/com/lanremote/watch/MainActivity$111.class index e8c3b1d..70a5630 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$111.class and b/_tests/out_app/com/lanremote/watch/MainActivity$111.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$112$1.class b/_tests/out_app/com/lanremote/watch/MainActivity$112$1.class index 0c22a1c..2c1d477 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$112$1.class and b/_tests/out_app/com/lanremote/watch/MainActivity$112$1.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$112.class b/_tests/out_app/com/lanremote/watch/MainActivity$112.class index 3a6586d..65bd96f 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$112.class and b/_tests/out_app/com/lanremote/watch/MainActivity$112.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$113.class b/_tests/out_app/com/lanremote/watch/MainActivity$113.class index 1e8a11a..8a8e5cc 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$113.class and b/_tests/out_app/com/lanremote/watch/MainActivity$113.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$114.class b/_tests/out_app/com/lanremote/watch/MainActivity$114.class index d8e5307..a4ca5b1 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$114.class and b/_tests/out_app/com/lanremote/watch/MainActivity$114.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$115.class b/_tests/out_app/com/lanremote/watch/MainActivity$115.class index e3f2b28..e30581f 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$115.class and b/_tests/out_app/com/lanremote/watch/MainActivity$115.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$116.class b/_tests/out_app/com/lanremote/watch/MainActivity$116.class index d49a92f..34d10cd 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$116.class and b/_tests/out_app/com/lanremote/watch/MainActivity$116.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$117$1.class b/_tests/out_app/com/lanremote/watch/MainActivity$117$1.class deleted file mode 100644 index f55d673..0000000 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$117$1.class and /dev/null differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$117.class b/_tests/out_app/com/lanremote/watch/MainActivity$117.class index 02d0ecb..6ff563f 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$117.class and b/_tests/out_app/com/lanremote/watch/MainActivity$117.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$118.class b/_tests/out_app/com/lanremote/watch/MainActivity$118.class index 7871cc4..533bb4f 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$118.class and b/_tests/out_app/com/lanremote/watch/MainActivity$118.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$119.class b/_tests/out_app/com/lanremote/watch/MainActivity$119.class index 2e8387b..b63e6a6 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$119.class and b/_tests/out_app/com/lanremote/watch/MainActivity$119.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$12.class b/_tests/out_app/com/lanremote/watch/MainActivity$12.class index 85330c6..b2b6cfd 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$12.class and b/_tests/out_app/com/lanremote/watch/MainActivity$12.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$120.class b/_tests/out_app/com/lanremote/watch/MainActivity$120.class index 6474fa0..152462b 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$120.class and b/_tests/out_app/com/lanremote/watch/MainActivity$120.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$121.class b/_tests/out_app/com/lanremote/watch/MainActivity$121.class index 011dbc1..4dfa4fd 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$121.class and b/_tests/out_app/com/lanremote/watch/MainActivity$121.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$122.class b/_tests/out_app/com/lanremote/watch/MainActivity$122.class index 5536ccf..c21ba4b 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$122.class and b/_tests/out_app/com/lanremote/watch/MainActivity$122.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$123.class b/_tests/out_app/com/lanremote/watch/MainActivity$123.class index c886afd..86aaa77 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$123.class and b/_tests/out_app/com/lanremote/watch/MainActivity$123.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$124.class b/_tests/out_app/com/lanremote/watch/MainActivity$124.class index d42295d..69bd336 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$124.class and b/_tests/out_app/com/lanremote/watch/MainActivity$124.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$125.class b/_tests/out_app/com/lanremote/watch/MainActivity$125.class index db7ad02..ecd685f 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$125.class and b/_tests/out_app/com/lanremote/watch/MainActivity$125.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$126.class b/_tests/out_app/com/lanremote/watch/MainActivity$126.class index 20d3d25..0eca292 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$126.class and b/_tests/out_app/com/lanremote/watch/MainActivity$126.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$127.class b/_tests/out_app/com/lanremote/watch/MainActivity$127.class index 1a36ee4..671d469 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$127.class and b/_tests/out_app/com/lanremote/watch/MainActivity$127.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$128.class b/_tests/out_app/com/lanremote/watch/MainActivity$128.class index 9c58ae6..41a6781 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$128.class and b/_tests/out_app/com/lanremote/watch/MainActivity$128.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$129.class b/_tests/out_app/com/lanremote/watch/MainActivity$129.class index 30064e9..c7c08f7 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$129.class and b/_tests/out_app/com/lanremote/watch/MainActivity$129.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$13.class b/_tests/out_app/com/lanremote/watch/MainActivity$13.class index 7555230..4f15dc7 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$13.class and b/_tests/out_app/com/lanremote/watch/MainActivity$13.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$14.class b/_tests/out_app/com/lanremote/watch/MainActivity$14.class index dd52c70..c29eca6 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$14.class and b/_tests/out_app/com/lanremote/watch/MainActivity$14.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$15.class b/_tests/out_app/com/lanremote/watch/MainActivity$15.class index 0c465e5..420eef4 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$15.class and b/_tests/out_app/com/lanremote/watch/MainActivity$15.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$16.class b/_tests/out_app/com/lanremote/watch/MainActivity$16.class index 5e68f78..a1a4cf2 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$16.class and b/_tests/out_app/com/lanremote/watch/MainActivity$16.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$17.class b/_tests/out_app/com/lanremote/watch/MainActivity$17.class index c720ca3..4cb3d3a 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$17.class and b/_tests/out_app/com/lanremote/watch/MainActivity$17.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$18.class b/_tests/out_app/com/lanremote/watch/MainActivity$18.class index 3405c1c..6d0deb8 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$18.class and b/_tests/out_app/com/lanremote/watch/MainActivity$18.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$19.class b/_tests/out_app/com/lanremote/watch/MainActivity$19.class index d4115ce..c6cd9a9 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$19.class and b/_tests/out_app/com/lanremote/watch/MainActivity$19.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$20.class b/_tests/out_app/com/lanremote/watch/MainActivity$20.class index a088f7e..9fc3ce1 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$20.class and b/_tests/out_app/com/lanremote/watch/MainActivity$20.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$21.class b/_tests/out_app/com/lanremote/watch/MainActivity$21.class index a6efae5..32f608e 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$21.class and b/_tests/out_app/com/lanremote/watch/MainActivity$21.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$22.class b/_tests/out_app/com/lanremote/watch/MainActivity$22.class index 1e500a7..30fbb18 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$22.class and b/_tests/out_app/com/lanremote/watch/MainActivity$22.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$23.class b/_tests/out_app/com/lanremote/watch/MainActivity$23.class index 5e7002e..7b2e9d9 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$23.class and b/_tests/out_app/com/lanremote/watch/MainActivity$23.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$24.class b/_tests/out_app/com/lanremote/watch/MainActivity$24.class index 01a375a..826899b 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$24.class and b/_tests/out_app/com/lanremote/watch/MainActivity$24.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$25.class b/_tests/out_app/com/lanremote/watch/MainActivity$25.class index a28b8b4..28e0158 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$25.class and b/_tests/out_app/com/lanremote/watch/MainActivity$25.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$26.class b/_tests/out_app/com/lanremote/watch/MainActivity$26.class index ec9951a..f7c4d3a 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$26.class and b/_tests/out_app/com/lanremote/watch/MainActivity$26.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$27.class b/_tests/out_app/com/lanremote/watch/MainActivity$27.class index ac5ad4a..84cc8a7 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$27.class and b/_tests/out_app/com/lanremote/watch/MainActivity$27.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$28.class b/_tests/out_app/com/lanremote/watch/MainActivity$28.class index 406f72b..3b194a1 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$28.class and b/_tests/out_app/com/lanremote/watch/MainActivity$28.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$29.class b/_tests/out_app/com/lanremote/watch/MainActivity$29.class index 26e64c2..561dd9d 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$29.class and b/_tests/out_app/com/lanremote/watch/MainActivity$29.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$3.class b/_tests/out_app/com/lanremote/watch/MainActivity$3.class index 17510b3..f3318c1 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$3.class and b/_tests/out_app/com/lanremote/watch/MainActivity$3.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$30.class b/_tests/out_app/com/lanremote/watch/MainActivity$30.class index e267d22..7c08cf6 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$30.class and b/_tests/out_app/com/lanremote/watch/MainActivity$30.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$31.class b/_tests/out_app/com/lanremote/watch/MainActivity$31.class index 27c8327..a1d213c 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$31.class and b/_tests/out_app/com/lanremote/watch/MainActivity$31.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$32.class b/_tests/out_app/com/lanremote/watch/MainActivity$32.class index 63e4e27..bc1fbee 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$32.class and b/_tests/out_app/com/lanremote/watch/MainActivity$32.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$33.class b/_tests/out_app/com/lanremote/watch/MainActivity$33.class index 6d75bff..34423f4 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$33.class and b/_tests/out_app/com/lanremote/watch/MainActivity$33.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$34.class b/_tests/out_app/com/lanremote/watch/MainActivity$34.class index 5e56573..27ef11d 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$34.class and b/_tests/out_app/com/lanremote/watch/MainActivity$34.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$35.class b/_tests/out_app/com/lanremote/watch/MainActivity$35.class index f1b5601..e7eaceb 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$35.class and b/_tests/out_app/com/lanremote/watch/MainActivity$35.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$36.class b/_tests/out_app/com/lanremote/watch/MainActivity$36.class index 2ff9919..8cdeb39 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$36.class and b/_tests/out_app/com/lanremote/watch/MainActivity$36.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$37.class b/_tests/out_app/com/lanremote/watch/MainActivity$37.class index 5a6c74e..d79f23b 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$37.class and b/_tests/out_app/com/lanremote/watch/MainActivity$37.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$38.class b/_tests/out_app/com/lanremote/watch/MainActivity$38.class index 733a485..0e87e4c 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$38.class and b/_tests/out_app/com/lanremote/watch/MainActivity$38.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$39.class b/_tests/out_app/com/lanremote/watch/MainActivity$39.class index 64e92eb..747454f 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$39.class and b/_tests/out_app/com/lanremote/watch/MainActivity$39.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$4.class b/_tests/out_app/com/lanremote/watch/MainActivity$4.class index 669dd1d..446a330 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$4.class and b/_tests/out_app/com/lanremote/watch/MainActivity$4.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$40.class b/_tests/out_app/com/lanremote/watch/MainActivity$40.class index 6d1780b..be63896 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$40.class and b/_tests/out_app/com/lanremote/watch/MainActivity$40.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$41.class b/_tests/out_app/com/lanremote/watch/MainActivity$41.class index adcf96e..7ed419a 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$41.class and b/_tests/out_app/com/lanremote/watch/MainActivity$41.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$42.class b/_tests/out_app/com/lanremote/watch/MainActivity$42.class index 1130836..74701ae 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$42.class and b/_tests/out_app/com/lanremote/watch/MainActivity$42.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$43.class b/_tests/out_app/com/lanremote/watch/MainActivity$43.class index 8f1d164..7333cae 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$43.class and b/_tests/out_app/com/lanremote/watch/MainActivity$43.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$44.class b/_tests/out_app/com/lanremote/watch/MainActivity$44.class index 6d22b58..581686f 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$44.class and b/_tests/out_app/com/lanremote/watch/MainActivity$44.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$45.class b/_tests/out_app/com/lanremote/watch/MainActivity$45.class index a4418d5..9fa0899 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$45.class and b/_tests/out_app/com/lanremote/watch/MainActivity$45.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$46.class b/_tests/out_app/com/lanremote/watch/MainActivity$46.class index a8d3638..c29b425 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$46.class and b/_tests/out_app/com/lanremote/watch/MainActivity$46.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$47.class b/_tests/out_app/com/lanremote/watch/MainActivity$47.class index 67dbd98..15ab091 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$47.class and b/_tests/out_app/com/lanremote/watch/MainActivity$47.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$48.class b/_tests/out_app/com/lanremote/watch/MainActivity$48.class index de2438b..c194788 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$48.class and b/_tests/out_app/com/lanremote/watch/MainActivity$48.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$49.class b/_tests/out_app/com/lanremote/watch/MainActivity$49.class index 3d6efbd..dc16a42 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$49.class and b/_tests/out_app/com/lanremote/watch/MainActivity$49.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$5.class b/_tests/out_app/com/lanremote/watch/MainActivity$5.class index 79223ba..1a4760e 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$5.class and b/_tests/out_app/com/lanremote/watch/MainActivity$5.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$50.class b/_tests/out_app/com/lanremote/watch/MainActivity$50.class index ae4d07b..baef3cc 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$50.class and b/_tests/out_app/com/lanremote/watch/MainActivity$50.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$51.class b/_tests/out_app/com/lanremote/watch/MainActivity$51.class index ee83436..61a818d 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$51.class and b/_tests/out_app/com/lanremote/watch/MainActivity$51.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$52.class b/_tests/out_app/com/lanremote/watch/MainActivity$52.class index 234ca96..d84fecf 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$52.class and b/_tests/out_app/com/lanremote/watch/MainActivity$52.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$53.class b/_tests/out_app/com/lanremote/watch/MainActivity$53.class index cb43bb9..ba9ceca 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$53.class and b/_tests/out_app/com/lanremote/watch/MainActivity$53.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$54.class b/_tests/out_app/com/lanremote/watch/MainActivity$54.class index 44f2f42..5875ce0 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$54.class and b/_tests/out_app/com/lanremote/watch/MainActivity$54.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$55.class b/_tests/out_app/com/lanremote/watch/MainActivity$55.class index 76a7a80..33e8600 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$55.class and b/_tests/out_app/com/lanremote/watch/MainActivity$55.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$56.class b/_tests/out_app/com/lanremote/watch/MainActivity$56.class index fdcda22..4af580a 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$56.class and b/_tests/out_app/com/lanremote/watch/MainActivity$56.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$57.class b/_tests/out_app/com/lanremote/watch/MainActivity$57.class index d4edd9d..d35b73a 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$57.class and b/_tests/out_app/com/lanremote/watch/MainActivity$57.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$58$1.class b/_tests/out_app/com/lanremote/watch/MainActivity$58$1.class deleted file mode 100644 index f6ea35b..0000000 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$58$1.class and /dev/null differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$58.class b/_tests/out_app/com/lanremote/watch/MainActivity$58.class index 1b167bd..f4ff28f 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$58.class and b/_tests/out_app/com/lanremote/watch/MainActivity$58.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$59.class b/_tests/out_app/com/lanremote/watch/MainActivity$59.class index 2d2af55..90395ec 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$59.class and b/_tests/out_app/com/lanremote/watch/MainActivity$59.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$6.class b/_tests/out_app/com/lanremote/watch/MainActivity$6.class index 922f3a3..a694335 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$6.class and b/_tests/out_app/com/lanremote/watch/MainActivity$6.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$60.class b/_tests/out_app/com/lanremote/watch/MainActivity$60.class index 5d8720d..5c95667 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$60.class and b/_tests/out_app/com/lanremote/watch/MainActivity$60.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$61.class b/_tests/out_app/com/lanremote/watch/MainActivity$61.class index 00d388f..5523b1d 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$61.class and b/_tests/out_app/com/lanremote/watch/MainActivity$61.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$62.class b/_tests/out_app/com/lanremote/watch/MainActivity$62.class index d1a5f97..d1c8fd5 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$62.class and b/_tests/out_app/com/lanremote/watch/MainActivity$62.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$63.class b/_tests/out_app/com/lanremote/watch/MainActivity$63.class index 190718c..5193e49 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$63.class and b/_tests/out_app/com/lanremote/watch/MainActivity$63.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$64$1.class b/_tests/out_app/com/lanremote/watch/MainActivity$64$1.class deleted file mode 100644 index f2b0302..0000000 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$64$1.class and /dev/null differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$64$2.class b/_tests/out_app/com/lanremote/watch/MainActivity$64$2.class deleted file mode 100644 index 82c1084..0000000 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$64$2.class and /dev/null differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$64.class b/_tests/out_app/com/lanremote/watch/MainActivity$64.class index 2d3da20..ed31c55 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$64.class and b/_tests/out_app/com/lanremote/watch/MainActivity$64.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$65.class b/_tests/out_app/com/lanremote/watch/MainActivity$65.class index 079b59e..8e53d3a 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$65.class and b/_tests/out_app/com/lanremote/watch/MainActivity$65.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$66.class b/_tests/out_app/com/lanremote/watch/MainActivity$66.class index 3ab2087..eb24acd 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$66.class and b/_tests/out_app/com/lanremote/watch/MainActivity$66.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$67.class b/_tests/out_app/com/lanremote/watch/MainActivity$67.class index 4325c83..57e859d 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$67.class and b/_tests/out_app/com/lanremote/watch/MainActivity$67.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$68$1.class b/_tests/out_app/com/lanremote/watch/MainActivity$68$1.class deleted file mode 100644 index c3aa3e1..0000000 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$68$1.class and /dev/null differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$68$2.class b/_tests/out_app/com/lanremote/watch/MainActivity$68$2.class deleted file mode 100644 index af6a7bc..0000000 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$68$2.class and /dev/null differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$68.class b/_tests/out_app/com/lanremote/watch/MainActivity$68.class index 442a1cd..e36348e 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$68.class and b/_tests/out_app/com/lanremote/watch/MainActivity$68.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$69.class b/_tests/out_app/com/lanremote/watch/MainActivity$69.class index 64413c6..197997d 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$69.class and b/_tests/out_app/com/lanremote/watch/MainActivity$69.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$7.class b/_tests/out_app/com/lanremote/watch/MainActivity$7.class index 74280f3..04ed7bc 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$7.class and b/_tests/out_app/com/lanremote/watch/MainActivity$7.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$70.class b/_tests/out_app/com/lanremote/watch/MainActivity$70.class index dd0731f..a4df735 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$70.class and b/_tests/out_app/com/lanremote/watch/MainActivity$70.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$71.class b/_tests/out_app/com/lanremote/watch/MainActivity$71.class index 4307f4a..998b137 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$71.class and b/_tests/out_app/com/lanremote/watch/MainActivity$71.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$72.class b/_tests/out_app/com/lanremote/watch/MainActivity$72.class index 623a899..079427d 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$72.class and b/_tests/out_app/com/lanremote/watch/MainActivity$72.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$73.class b/_tests/out_app/com/lanremote/watch/MainActivity$73.class index 690bb1a..627367f 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$73.class and b/_tests/out_app/com/lanremote/watch/MainActivity$73.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$74.class b/_tests/out_app/com/lanremote/watch/MainActivity$74.class index 580d5f9..6488f73 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$74.class and b/_tests/out_app/com/lanremote/watch/MainActivity$74.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$75.class b/_tests/out_app/com/lanremote/watch/MainActivity$75.class index ca89467..c358251 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$75.class and b/_tests/out_app/com/lanremote/watch/MainActivity$75.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$76$1$1.class b/_tests/out_app/com/lanremote/watch/MainActivity$76$1$1.class deleted file mode 100644 index ba5646a..0000000 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$76$1$1.class and /dev/null differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$76$1.class b/_tests/out_app/com/lanremote/watch/MainActivity$76$1.class deleted file mode 100644 index aa71a31..0000000 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$76$1.class and /dev/null differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$76.class b/_tests/out_app/com/lanremote/watch/MainActivity$76.class index c5aaddb..4a4ecbb 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$76.class and b/_tests/out_app/com/lanremote/watch/MainActivity$76.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$77.class b/_tests/out_app/com/lanremote/watch/MainActivity$77.class index 5112199..090d70c 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$77.class and b/_tests/out_app/com/lanremote/watch/MainActivity$77.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$78$1.class b/_tests/out_app/com/lanremote/watch/MainActivity$78$1.class deleted file mode 100644 index d16a691..0000000 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$78$1.class and /dev/null differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$78.class b/_tests/out_app/com/lanremote/watch/MainActivity$78.class index 15267da..5e31cb7 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$78.class and b/_tests/out_app/com/lanremote/watch/MainActivity$78.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$79$1$1.class b/_tests/out_app/com/lanremote/watch/MainActivity$79$1$1.class deleted file mode 100644 index 6c2bf77..0000000 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$79$1$1.class and /dev/null differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$79$1.class b/_tests/out_app/com/lanremote/watch/MainActivity$79$1.class index e25ce9a..43b5739 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$79$1.class and b/_tests/out_app/com/lanremote/watch/MainActivity$79$1.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$79.class b/_tests/out_app/com/lanremote/watch/MainActivity$79.class index f6ce807..9142c41 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$79.class and b/_tests/out_app/com/lanremote/watch/MainActivity$79.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$8.class b/_tests/out_app/com/lanremote/watch/MainActivity$8.class index 557b8b9..c1c30f8 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$8.class and b/_tests/out_app/com/lanremote/watch/MainActivity$8.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$80$1$1.class b/_tests/out_app/com/lanremote/watch/MainActivity$80$1$1.class index 8af88e0..fd77c8e 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$80$1$1.class and b/_tests/out_app/com/lanremote/watch/MainActivity$80$1$1.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$80$1.class b/_tests/out_app/com/lanremote/watch/MainActivity$80$1.class index 852a710..e767e87 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$80$1.class and b/_tests/out_app/com/lanremote/watch/MainActivity$80$1.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$80.class b/_tests/out_app/com/lanremote/watch/MainActivity$80.class index 2e935ad..bee6bf1 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$80.class and b/_tests/out_app/com/lanremote/watch/MainActivity$80.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$81.class b/_tests/out_app/com/lanremote/watch/MainActivity$81.class index 4a4546f..7734e9a 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$81.class and b/_tests/out_app/com/lanremote/watch/MainActivity$81.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$82.class b/_tests/out_app/com/lanremote/watch/MainActivity$82.class index 12a2faf..93faaf3 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$82.class and b/_tests/out_app/com/lanremote/watch/MainActivity$82.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$83.class b/_tests/out_app/com/lanremote/watch/MainActivity$83.class index 82ec2e3..c1aff08 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$83.class and b/_tests/out_app/com/lanremote/watch/MainActivity$83.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$84.class b/_tests/out_app/com/lanremote/watch/MainActivity$84.class index 222ca3a..7da783e 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$84.class and b/_tests/out_app/com/lanremote/watch/MainActivity$84.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$85.class b/_tests/out_app/com/lanremote/watch/MainActivity$85.class index 60d828b..00c0f22 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$85.class and b/_tests/out_app/com/lanremote/watch/MainActivity$85.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$86$1.class b/_tests/out_app/com/lanremote/watch/MainActivity$86$1.class deleted file mode 100644 index 421d3d7..0000000 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$86$1.class and /dev/null differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$86.class b/_tests/out_app/com/lanremote/watch/MainActivity$86.class index 33ad520..f13851a 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$86.class and b/_tests/out_app/com/lanremote/watch/MainActivity$86.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$87.class b/_tests/out_app/com/lanremote/watch/MainActivity$87.class index b7ebe03..42daf15 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$87.class and b/_tests/out_app/com/lanremote/watch/MainActivity$87.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$88.class b/_tests/out_app/com/lanremote/watch/MainActivity$88.class index f82ce84..09533df 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$88.class and b/_tests/out_app/com/lanremote/watch/MainActivity$88.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$89.class b/_tests/out_app/com/lanremote/watch/MainActivity$89.class index 45a7256..cebc9a8 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$89.class and b/_tests/out_app/com/lanremote/watch/MainActivity$89.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$9$1.class b/_tests/out_app/com/lanremote/watch/MainActivity$9$1.class deleted file mode 100644 index b8e963e..0000000 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$9$1.class and /dev/null differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$9.class b/_tests/out_app/com/lanremote/watch/MainActivity$9.class index 70b1236..a0363ab 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$9.class and b/_tests/out_app/com/lanremote/watch/MainActivity$9.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$90.class b/_tests/out_app/com/lanremote/watch/MainActivity$90.class index 5eeb3ec..d815967 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$90.class and b/_tests/out_app/com/lanremote/watch/MainActivity$90.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$91.class b/_tests/out_app/com/lanremote/watch/MainActivity$91.class index 398d96f..31af713 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$91.class and b/_tests/out_app/com/lanremote/watch/MainActivity$91.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$92$1.class b/_tests/out_app/com/lanremote/watch/MainActivity$92$1.class deleted file mode 100644 index 6c8c1fd..0000000 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$92$1.class and /dev/null differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$92.class b/_tests/out_app/com/lanremote/watch/MainActivity$92.class index 33d7abc..6baff4f 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$92.class and b/_tests/out_app/com/lanremote/watch/MainActivity$92.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$93.class b/_tests/out_app/com/lanremote/watch/MainActivity$93.class index d3e0af8..6207767 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$93.class and b/_tests/out_app/com/lanremote/watch/MainActivity$93.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$94.class b/_tests/out_app/com/lanremote/watch/MainActivity$94.class index f9415ef..c9844c0 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$94.class and b/_tests/out_app/com/lanremote/watch/MainActivity$94.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$95.class b/_tests/out_app/com/lanremote/watch/MainActivity$95.class index f9712a2..49c08e6 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$95.class and b/_tests/out_app/com/lanremote/watch/MainActivity$95.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$96.class b/_tests/out_app/com/lanremote/watch/MainActivity$96.class index fcb1b0b..cd7950e 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$96.class and b/_tests/out_app/com/lanremote/watch/MainActivity$96.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$97.class b/_tests/out_app/com/lanremote/watch/MainActivity$97.class index 557c681..00d5565 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$97.class and b/_tests/out_app/com/lanremote/watch/MainActivity$97.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$98.class b/_tests/out_app/com/lanremote/watch/MainActivity$98.class index ad4e948..04f0419 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$98.class and b/_tests/out_app/com/lanremote/watch/MainActivity$98.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$99.class b/_tests/out_app/com/lanremote/watch/MainActivity$99.class index 3fc2a2a..282da6f 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$99.class and b/_tests/out_app/com/lanremote/watch/MainActivity$99.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$Act.class b/_tests/out_app/com/lanremote/watch/MainActivity$Act.class index 8045547..47a2acb 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$Act.class and b/_tests/out_app/com/lanremote/watch/MainActivity$Act.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$TabDef.class b/_tests/out_app/com/lanremote/watch/MainActivity$TabDef.class index 07fe68f..80e46f2 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$TabDef.class and b/_tests/out_app/com/lanremote/watch/MainActivity$TabDef.class differ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity.class b/_tests/out_app/com/lanremote/watch/MainActivity.class index b6f45b2..03d0c35 100644 Binary files a/_tests/out_app/com/lanremote/watch/MainActivity.class and b/_tests/out_app/com/lanremote/watch/MainActivity.class differ diff --git a/_tests/test_apk_contents.py b/_tests/test_apk_contents.py index 757fc60..6a8fa84 100644 --- a/_tests/test_apk_contents.py +++ b/_tests/test_apk_contents.py @@ -23,7 +23,7 @@ def _latest_apk() -> Path: (ROOT / "dist").glob("*watch*.apk"), key=lambda p: p.stat().st_mtime, reverse=True) return candidates[0] if candidates else \ - ROOT / "dist" / "ykxmz-watch-2.0.0.apk" + ROOT / "dist" / "ykxmz-watch-2.1.0.apk" APK = _latest_apk() diff --git a/_tests/test_package_remote.py b/_tests/test_package_remote.py index fd24ed0..e700f9d 100644 --- a/_tests/test_package_remote.py +++ b/_tests/test_package_remote.py @@ -26,9 +26,9 @@ ROOT = Path(__file__).resolve().parent.parent # 被控端 -> 一个 zip # 服务器端 -> 一个 zip # 控制端 -> 直接给 APK(不打 zip) -AGENT_ZIP = ROOT / "dist" / "ykxmz-agent-2.0.0.zip" -SERVER_ZIP = ROOT / "dist" / "ykxmz-server-2.0.0.zip" -WATCH_APK = ROOT / "dist" / "ykxmz-watch-2.0.0.apk" +AGENT_ZIP = ROOT / "dist" / "ykxmz-agent-2.1.0.zip" +SERVER_ZIP = ROOT / "dist" / "ykxmz-server-2.1.0.zip" +WATCH_APK = ROOT / "dist" / "ykxmz-watch-2.1.0.apk" TOOLCHAIN = Path(r"C:\Users\shu\Desktop\deepseekagent\starklink\toolchain") JAVAC = TOOLCHAIN / "jdk17" / "bin" / "javac.exe" JAVA = TOOLCHAIN / "jdk17" / "bin" / "java.exe" @@ -89,7 +89,7 @@ def main() -> int: for label, path in (("被控端", agent_dir / "README-agent.txt"), ("服务器端", relay_dir / "README-server.txt"), ("控制端(旁边那份)", - ROOT / "dist" / "ykxmz-watch-2.0.0-说明.txt")): + ROOT / "dist" / "ykxmz-watch-2.1.0-说明.txt")): raw = path.read_bytes() if path.is_file() else b"" check(f"{label}说明有 UTF-8 BOM", raw[:3] == b"\xef\xbb\xbf", raw[:3]) diff --git a/_tests/test_rescue_section.py b/_tests/test_rescue_section.py new file mode 100644 index 0000000..66ac039 --- /dev/null +++ b/_tests/test_rescue_section.py @@ -0,0 +1,188 @@ +# -*- coding: utf-8 -*- +"""后台「抢救报告」分区 —— 渲染 + 下载 + 不缓存。 + +用户要求: + 「如果紧急预案未能解决,则将问题打包成 .txt,然后在后台加一个 + 分区专门放还有下载这个 txt」 + +这个测试把两侧都验一遍: + 一、后台页能渲染出「抢救报告」分区(没有报告时给正面提示) + 二、有报告时能列出,并带正确的下载链接 + 三、/rescue/<名字> 能下载到,而且**不发长缓存**(和 /download/ 不同) + 四、文件名校验能挡住目录穿越 + 五、报告目录空 / 不存在时不能把后台页搞崩 +""" +from __future__ import annotations + +import importlib.util +import os +import re +import sys +import tempfile +import time +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +RELAY_DIR = ROOT / "relay" + +passed = 0 +failed = 0 + + +def check(name, ok, detail=""): + global passed, failed + if ok: + passed += 1 + print(" [PASS] " + name) + else: + failed += 1 + print(" [FAIL] " + name + ((" —— " + str(detail)) if detail else "")) + + +def load(name: str, path: Path): + spec = importlib.util.spec_from_file_location(name, path) + mod = importlib.util.module_from_spec(spec) + sys.modules[name] = mod + spec.loader.exec_module(mod) + return mod + + +def main() -> int: + print("=" * 62) + print(" 后台「抢救报告」分区") + print("=" * 62) + + sys.path.insert(0, str(RELAY_DIR)) + os.chdir(RELAY_DIR) + try: + relay = load("relay_under_test", RELAY_DIR / "relay.py") + admin = load("admin_under_test", RELAY_DIR / "admin.py") + finally: + os.chdir(ROOT) + + snap = {"version": "2.0.0", "uptimeSeconds": 123, + "devices": [], "allowNew": True} + + # ---------------------------------------------------------------- 一 + print(" ---- 一、没有报告时 ----") + try: + html = admin.render_admin(snap, "https://x", "/zhongzhuanzhan", 0, + lambda: [], rescue_fn=lambda: []) + ok = True + err = "" + except Exception as exc: # noqa: BLE001 + ok = False + err = "%s: %s" % (type(exc).__name__, exc) + html = "" + check("后台页能渲染(不抛异常)", ok, err) + check("有「抢救报告」分区", "抢救报告" in html) + check("没有报告时给正面提示(不是干巴巴的暂无数据)", + "还没有报告" in html, "没找到提示语") + check("渲染出的页面有实际内容", len(html) > 2000, "%d 字符" % len(html)) + + # ---------------------------------------------------------------- 二 + print(" ---- 二、有报告时能列出来 ----") + items = [ + {"name": "20261008-173000.txt", "size": 4200, + "mtime": time.time() - 60, + "summary": "本机是好的,问题在隧道或 Cloudflare 那一侧"}, + {"name": "20261007-220000.txt", "size": 900, + "mtime": time.time() - 90000, "summary": ""}, + ] + html2 = admin.render_admin(snap, "https://x", "/zhongzhuanzhan", 0, + lambda: [], rescue_fn=lambda: items) + check("列出了第一份报告的时间", "10-08" in html2 or "2026100" in html2 + or "17:30" in html2) + check("带下载链接 /rescue/", "/rescue/20261008-173000.txt" in html2, + "没找到链接") + check("显示了结论摘要", "问题在隧道" in html2) + check("没有摘要的那份显示占位文字", "没有结论行" in html2) + check("两份都列出来了", + html2.count("/rescue/") >= 2, "只出现 %d 次" % html2.count("/rescue/")) + + # ---------------------------------------------------------------- 三 + print(" ---- 三、文件名校验(防目录穿越)----") + good = relay._safe_rescue_name("20261008-173000.txt") + check("正常时间戳名通过", good == "20261008-173000.txt", repr(good)) + for bad in ("../etc/passwd", "a/b.txt", "..\\x.txt", "x.sh", + "x.txt/../y", "", "a" * 200 + ".txt"): + check("挡住 %r" % bad[:24], relay._safe_rescue_name(bad) == "", + "居然通过了") + + # ---------------------------------------------------------------- 四 + print(" ---- 四、下载路由的行为 ----") + src = (RELAY_DIR / "relay.py").read_text(encoding="utf-8") + get_part = src.split("def do_GET")[1].split("def do_POST")[0] + head_part = src.split("def do_HEAD")[1].split("def do_GET")[0] + + def rescue_block(part: str) -> str: + """取出整个 /rescue/ 分支。 + + ⚠ 不能用 part.split("/rescue/")[1] —— "/rescue/" 在这个分支里 + 出现两次(startswith 和切片),split 出来的是两个之间的 + 短片段,什么都查不到。我第一次就是这么把测试写红的。 + 改成从 startswith 那句开始、到下一个 if 分支为止。 + """ + key = 'if path.startswith("/rescue/")' + i = part.find(key) + if i < 0: + return "" + j = part.find('if path.startswith("', i + len(key)) + return part[i:j] if j > i else part[i:i + 2000] + + gb = rescue_block(get_part) + hb = rescue_block(head_part) + + check("do_GET 里有 /rescue/ 路由", bool(gb)) + check("do_HEAD 里有 /rescue/ 路由", bool(hb)) + check("下载发 no-store(不能给边缘缓存)", "no-store" in gb, + "没看到 no-store") + check("下载设了 Content-Disposition(点了是下载不是预览)", + "Content-Disposition" in gb) + check("do_HEAD 那段不写 body", "wfile.write" not in hb, + "HEAD 里出现了 wfile.write") + + # 鉴权 —— 这条是实测踩出来的安全问题: + # 报告里有内网 IP、进程表、日志尾部、MTU/路由/iptables, + # 第一版把路由插在 _admin_gate() 之前,等于匿名可下。 + check("do_GET 里 /rescue/ 要求登录", "_admin_gate" in gb, + "没有鉴权 -> 任何人都能下诊断报告") + check("do_HEAD 里 /rescue/ 要求登录", "_admin_gate" in hb, + "没有鉴权") + # 顺序也要对:鉴权必须在读文件之前 + if gb: + check("先鉴权再读文件", + gb.find("_admin_gate") < gb.find("read_bytes"), + "顺序反了:先读了文件才鉴权") + + # ⚠ 鉴权失败必须**自己发响应**。_admin_gate() 只返回布尔值, + # 裸 return 在 BaseHTTPRequestHandler 里就是什么都不做 —— + # 连接保持、没有响应头,客户端一直等到超时(curl HTTP 000)。 + # 实测踩过:/rescue/ 会挂住 8 秒,比报 401 严重得多。 + # 查整个分支,别用 split("_admin_gate()") 切片 —— + # 注释里也会提到 _admin_gate(),切出来的可能是注释后面那一小段, + # 查不到真正的 _json(401。(这个测试第一次就是这么假红的。) + if gb: + check("do_GET 鉴权失败会发 401", "_json(401" in gb, + "裸 return -> 请求会挂到超时") + if hb: + check("do_HEAD 鉴权失败会发 401", "no(401)" in hb, + "裸 return 或状态码不对") + + # ---------------------------------------------------------------- 五 + print(" ---- 五、报告目录不存在也不能崩 ----") + try: + r = relay._list_rescue_reports() + check("_list_rescue_reports() 不抛异常", isinstance(r, list)) + except Exception as exc: # noqa: BLE001 + check("_list_rescue_reports() 不抛异常", False, + "%s: %s" % (type(exc).__name__, exc)) + + print() + print("=" * 62) + print(" 通过 %d 项,失败 %d 项" % (passed, failed)) + return 0 if failed == 0 else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_tests/test_sfx.py b/_tests/test_sfx.py new file mode 100644 index 0000000..8e18a45 --- /dev/null +++ b/_tests/test_sfx.py @@ -0,0 +1,163 @@ +# -*- coding: utf-8 -*- +"""网络热门音效 —— 生成 / 列表 / 播放 / 自己放素材。 + +用户要求:「再加上近期网络热门音效,什么冰冰冰,啊哈哈哈哈,各种 +老牧师音效,可以直接远程播放」。 + +⚠ 版权说明(这个测试也在钉这件事): + 「冰冰冰」「啊哈哈哈」「老牧师」是**网上流行的真实录音**,有版权。 + 安装包里内置的是**程序合成**的版本(正弦/方波现场算), + 名字对着感觉来,但不是那些录音本身、也不冒充。 + 用户想用一模一样的一手素材,丢进 data/sfx/ 就行 —— 这一条也要测。 +""" +from __future__ import annotations + +import struct +import sys +import tempfile +import time +import wave +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "windows")) + +passed = 0 +failed = 0 + + +def check(name, ok, detail=""): + global passed, failed + if ok: + passed += 1 + print(" [PASS] " + name) + else: + failed += 1 + print(" [FAIL] " + name + ((" —— " + str(detail)) if detail else "")) + + +def main() -> int: + print("=" * 62) + print(" 网络热门音效(可远程播放)") + print("=" * 62) + + from lanremote.sfx import Sfx, BUILTIN_SPECS, SfxError, USER_EXTS + + work = Path(tempfile.mkdtemp(prefix="sfx_")) + + # ---------------------------------------------------------------- 一 + print(" ---- 一、用户点名的三个必须在 ----") + s = Sfx(work / "data") + t0 = time.time() + made = s.ensure_builtin() + took = time.time() - t0 + check("首次运行能生成内置音效", made >= 3, "只生成了 %d 个" % made) + check("生成够快(< 3 秒)", took < 3.0, "用了 %.2f 秒" % took) + + names = [it["name"] for it in s.list_sfx()] + print(" 生成的:%s" % "、".join(names)) + for wanted in ("冰冰冰", "啊哈哈哈", "老牧师"): + check("有「%s」" % wanted, wanted in names, "列表里没有") + + # ---------------------------------------------------------------- 二 + print(" ---- 二、生成的是合法 WAV(真的能放)----") + for it in s.list_sfx(): + p = s.dir / it["file"] + try: + with wave.open(str(p)) as w: + ch = w.getnchannels() + sr = w.getframerate() + n = w.getnframes() + frames = w.readframes(min(n, 2000)) + ok = (ch == 1 and sr == 16000 and n > 1000 and len(frames) > 0) + check("%s 是合法 WAV(%d Hz, %.2f 秒)" % (it["name"], sr, n / sr), + ok, "ch=%s sr=%s n=%s" % (ch, sr, n)) + except Exception as exc: # noqa: BLE001 + check("%s 是合法 WAV" % it["name"], False, + "%s: %s" % (type(exc).__name__, exc)) + + # ---------------------------------------------------------------- 三 + print(" ---- 三、有声音(不是一片静音)----") + p = s.dir / "冰冰冰.wav" + with wave.open(str(p)) as w: + raw = w.readframes(w.getnframes()) + vals = struct.unpack("<%dh" % (len(raw) // 2), raw[: (len(raw) // 2) * 2]) + peak = max(abs(v) for v in vals) + nonzero = sum(1 for v in vals if abs(v) > 50) + check("峰值够大(有声)", peak > 8000, "峰值只有 %d" % peak) + check("不是全静音", nonzero > len(vals) * 0.2, + "只有 %.0f%% 的采样有声音" % (100.0 * nonzero / max(1, len(vals)))) + check("没有削顶爆音(峰值不超过 int16 上限)", peak <= 32767) + + # ---------------------------------------------------------------- 四 + print(" ---- 四、用户自己放素材 ----") + # 造一个假的 mp3(内容不重要,只看"能不能被列出来/能不能被解析到") + fake = s.dir / "我自己的素材.mp3" + fake.write_bytes(b"\xff\xfb\x90\x00" + b"\x00" * 500) + names2 = [it["name"] for it in s.list_sfx()] + check("用户放的素材出现在列表里", "我自己的素材" in names2, + "列表:%s" % names2) + check("用户素材标记为非内置", + any(it["name"] == "我自己的素材" and not it["builtin"] + for it in s.list_sfx())) + check("内置的仍在", "冰冰冰" in names2) + check("能解析到用户素材的路径", + s.resolve("我自己的素材").name == "我自己的素材.mp3") + check("能按带后缀的名字解析", + s.resolve("我自己的素材.mp3").name == "我自己的素材.mp3") + + # ---------------------------------------------------------------- 五 + print(" ---- 五、播放(用假播放器,别真出声)----") + played = [] + s2 = Sfx(work / "data", player=lambda path: (played.append(path), + "正在播放")[1]) + msg = s2.play("冰冰冰") + check("play() 调用了播放器", len(played) == 1, "调用 %d 次" % len(played)) + check("传给播放器的是生成的 WAV", played and played[0].endswith("冰冰冰.wav"), + played[0] if played else "没调用") + check("返回了提示文字", "播放" in msg, repr(msg)) + + # ---------------------------------------------------------------- 六 + print(" ---- 六、出错路径 ----") + for bad in ("", "根本没有这个音效", "../../etc/passwd", None): + try: + s2.resolve(bad or "") + check("拒绝 %r" % (bad,), False, "居然解析成功了") + except SfxError: + check("拒绝 %r" % (bad,), True) + except Exception as exc: # noqa: BLE001 + check("拒绝 %r" % (bad,), False, + "抛了别的异常 %s" % type(exc).__name__) + + # ---------------------------------------------------------------- 七 + print(" ---- 七、目录里有说明文件(用户能找到去哪放素材)----") + # ⚠ 别用 glob("*.txt") 取第一个 —— 目录里还有 .builtin.txt + # (生成清单),它的排序可能排在说明文件前面, + # 于是读到的是清单内容、断言全红。按名字精确取。 + readme_path = s.dir / "放这里.txt" + check("sfx 目录里有说明文件", readme_path.is_file(), + "没有说明文件 -> 用户不知道往哪放素材") + if readme_path.is_file(): + body = readme_path.read_text(encoding="utf-8", errors="replace") + check("说明里写了「放进来就能用」", + "放" in body and ("mp3" in body or "wav" in body)) + check("说明里讲清了内置的是合成的(版权说明)", + "合成" in body or "版权" in body) + + # ---------------------------------------------------------------- 八 + print(" ---- 八、删掉内置音效后不会又被生成回来 ----") + target = s.dir / "叮.wav" + if target.is_file(): + target.unlink() + s.ensure_builtin() + check("删掉的内置音效不会自动重生", not target.is_file(), + "又被生成回来了 -> 用户删不掉") + + print() + print("=" * 62) + print(" 通过 %d 项,失败 %d 项" % (passed, failed)) + return 0 if failed == 0 else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_tests/test_watch_panels_wired.py b/_tests/test_watch_panels_wired.py new file mode 100644 index 0000000..0cc7e1b --- /dev/null +++ b/_tests/test_watch_panels_wired.py @@ -0,0 +1,112 @@ +# -*- coding: utf-8 -*- +r'''钉住"每个面板都必须挂到 root 上"。 + +== 为什么需要 == + +设置页第一版**一片空白**。根因很隐蔽: + + buildSettingsPanel() 建了 settingsPanel,往里面加了标题、 + 中转服务器、配对码、已配对设备、关于…… + 全都在,一个都不少 + 但是 —— **没有人 root.addView(settingsPanel)** + +于是 showOnly(settingsPanel) 把它设成 VISIBLE 也没用: +它压根不在视图树里。用户点「设置」进去看到的是**全白**。 + +对照一下,另外四个面板都有 root.addView(...),唯独新加的这个漏了。 +这类"建了但没挂"的错**只在运行时表现为空白,编译完全不报**, +所以必须用断言钉住。 + +== 这个测试查什么 == + +对每一个面板字段声明(connectPanel / controlPanel / mirrorPanel / +lanPanel / settingsPanel 这类),都要求源码里存在对应的 +root.addView(那个字段, ...)。 + +另外查 showOnly() 的数组里有没有把新面板带上 —— 那是另一个 +同源的坑(面板建了也挂了,但切换时不藏它,就一直盖在最上面, +上一版手表「局域网连上后没反应」就是这么来的)。 +''' +from __future__ import annotations + +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +SRC = (ROOT / "android" / "src" / "com" / "lanremote" / "watch" + / "MainActivity.java") + +passed = 0 +failed = 0 + + +def check(name, ok, detail=""): + global passed, failed + if ok: + passed += 1 + print(" [PASS] " + name) + else: + failed += 1 + print(" [FAIL] " + name + ((" —— " + str(detail)) if detail else "")) + + +def main() -> int: + print("=" * 60) + print(" 每个面板都必须挂到 root 上,并且纳入 showOnly()") + print("=" * 60) + + check("MainActivity.java 存在", SRC.is_file(), SRC) + if not SRC.is_file(): + return 1 + src = SRC.read_text(encoding="utf-8") + + # ---- 一、找出所有面板字段 ---- + # 形如: private FrameLayout xxxPanel; 或者带初始化 + panels = re.findall(r"private\s+FrameLayout\s+(\w*[Pp]anel)\s*[;=]", + src) + panels = sorted(set(panels)) + print(" ---- 一、发现的面板字段 ----") + for p in panels: + print(" %s" % p) + check("至少发现 3 个面板", len(panels) >= 3, "只找到 %s" % panels) + + # ---- 二、每个都必须有 root.addView ---- + print(" ---- 二、每个面板都挂到 root 了 ----") + added = set(re.findall(r"root\.addView\(\s*(\w+)\s*,", src)) + for p in panels: + check("%s 有 root.addView" % p, p in added, + "只建了没挂 —— 运行时表现是「进去一片空白」,编译不报错") + + # ---- 三、每个都必须出现在 showOnly 的数组里 ---- + print(" ---- 三、每个面板都在 showOnly() 里 ----") + m = re.search(r"private void showOnly\(View keep\)\s*\{(.{0,600}?)\n \}", + src, re.S) + check("能找到 showOnly()", bool(m), "找不到方法体") + if m: + body = m.group(1) + arr = re.search(r"View\[\]\s*all\s*=\s*\{(.*?)\}", body, re.S) + check("showOnly 里有个数组", bool(arr), "形状变了") + listed = set() + if arr: + listed = {x.strip() for x in arr.group(1).split(",") if x.strip()} + for p in panels: + check("%s 在 showOnly 数组里" % p, p in listed, + "切换时不藏它 -> 会一直盖在最上面(上一版" + "「局域网连上后没反应」就是这个原因)") + + # ---- 四、settingsPanel 必须初始 GONE(否则会盖住首页)---- + print(" ---- 四、settingsPanel 初始是隐藏的 ----") + check("settingsPanel.setVisibility(View.GONE)", + re.search(r"settingsPanel\.setVisibility\(View\.GONE\)", src) + is not None, + "没有初始隐藏 -> 一开始就盖在首页上") + + print() + print("=" * 60) + print(" 通过 %d 项,失败 %d 项" % (passed, failed)) + return 0 if failed == 0 else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_tests/verify_all.ps1 b/_tests/verify_all.ps1 index bdcae9c..8f704ac 100644 --- a/_tests/verify_all.ps1 +++ b/_tests/verify_all.ps1 @@ -25,27 +25,54 @@ $androidJar = Join-Path $tc "android-sdk\platforms\android-34\android.jar" $script:results = @() # 当前会话是不是锁屏 / 没有交互桌面? -# 锁屏时截屏类测试**必然**失败(拿不到桌面会话), -# 但那不是代码问题。不识别的话汇总表一片红,真问题反而被淹没。 +# 锁屏时截屏类测试**必然**失败(拿不到屏幕内容),但那不是代码问题。 +# 不识别的话汇总表一片红,真问题反而被淹没。 +# +# ⚠ 判断不能只看 OpenInputDesktop 成不成功 —— **锁屏时它同样成功** +# (那时输入桌面是 Winlogon)。实测漏判过一次:屏幕明明锁着, +# desktopAvailable 却是 true,于是 6/7、7/7 两个截屏阶段记成 FAIL, +# 把真正的结论淹掉了。 +# 正确做法是拿输入桌面的**名字**:Default = 正常,Winlogon = 锁屏。 $script:desktopAvailable = $true +$script:desktopName = "(未检测)" try { Add-Type -Namespace Ykxmz -Name Desk -MemberDefinition @' [System.Runtime.InteropServices.DllImport("user32.dll", SetLastError=true)] public static extern System.IntPtr OpenInputDesktop(int flags, bool inherit, int access); [System.Runtime.InteropServices.DllImport("user32.dll")] public static extern bool CloseDesktop(System.IntPtr h); -public static bool HasDesktop() { +[System.Runtime.InteropServices.DllImport("user32.dll", SetLastError=true, CharSet=System.Runtime.InteropServices.CharSet.Unicode)] +public static extern bool GetUserObjectInformationW(System.IntPtr h, int idx, System.Text.StringBuilder pv, int len, ref int need); +public static string InputDesktopName() { System.IntPtr h = OpenInputDesktop(0, false, 0x0001); - if (h == System.IntPtr.Zero) return false; - CloseDesktop(h); + if (h == System.IntPtr.Zero) return ""; + try { + System.Text.StringBuilder sb = new System.Text.StringBuilder(256); + int need = 0; + if (GetUserObjectInformationW(h, 2, sb, 512, ref need)) return sb.ToString(); + return ""; + } finally { CloseDesktop(h); } +} +public static bool HasDesktop() { + string n = InputDesktopName(); + if (n.Length == 0) return false; + if (n.Equals("Winlogon", System.StringComparison.OrdinalIgnoreCase)) return false; return true; } '@ -ErrorAction Stop + $script:desktopName = [Ykxmz.Desk]::InputDesktopName() + if ($script:desktopName -eq "") { $script:desktopName = "(拿不到)" } $script:desktopAvailable = [Ykxmz.Desk]::HasDesktop() } catch { + $script:desktopName = "(检测失败: $($_.Exception.GetType().Name))" $script:desktopAvailable = $true # 查不出来就当可用,别把失败藏起来 } +# 无论可不可用都打一行 —— 上次漏判就是因为什么都没打,无从判断。 +Write-Host (" 输入桌面: {0} -> {1}" -f $script:desktopName, + $(if ($script:desktopAvailable) { "可用" } else { "不可用(锁屏)" })) ` + -ForegroundColor $(if ($script:desktopAvailable) { "DarkGray" } else { "Yellow" }) + if (-not $script:desktopAvailable) { Write-Host "" Write-Host " ⚠ 检测到电脑处于锁屏状态。" -ForegroundColor Yellow @@ -221,6 +248,18 @@ $stageCode = $LASTEXITCODE $stageOut | Select-Object -Last 4 Record "Python 被控端协议回归" ($stageCode -eq 0) $true +# ---------------------------------------------------------------- 1a2 +Banner "1a2/7 手表面板接线(每个面板都要挂到 root)" +$stageOut = & python (Join-Path $root "_tests\test_watch_panels_wired.py") 2>&1 + Show-Fails $stageOut +Record "手表面板接线" ($LASTEXITCODE -eq 0) + +# ---------------------------------------------------------------- 1a3 +Banner "1a3/7 网络热门音效(生成/列表/播放/自放素材)" +$stageOut = & python (Join-Path $root "_tests\test_sfx.py") 2>&1 + Show-Fails $stageOut +Record "网络热门音效" ($LASTEXITCODE -eq 0) + # ---------------------------------------------------------------- 1b Banner "1b/7 防火墙阻止规则检测(真机连不上的根因)" $stageOut = & python (Join-Path $root "_tests\test_firewall.py") 2>&1 @@ -238,9 +277,20 @@ Get-Content (Join-Path $env:TEMP "lr_sc.txt") -Encoding UTF8 -ErrorAction Silent Record "自检请求链路" ($scCode -eq 0) # ---------------------------------------------------------------- 1e +# ---------------------------------------------------------------- 隐藏桌面 +# 下面 6 个阶段会真的开窗口,其中「投图显示方式」还会开**全屏** —— +# 直接在用户眼前跑的话屏幕会被锁住,没法用电脑。 +# +# 现在统一套一层 hidden_desktop.py:它新建一个 Windows 桌面,把子进程 +# 放上去,所有窗口都创建在那个不显示的桌面上。 +# **窗口是真的**(全屏、可见、几何、Win32 枚举都真实), +# 只是用户看不到 —— 测试有效性没有损失。 +# +# ⚠ 新增会弹窗的测试时,记得也套一层,否则用户屏幕又会被闪。 + Banner "1e/7 新增功能(电量/磁盘/性能/进程/剪贴板历史/安全边界)" Stop-Strays -& python (Join-Path $root "_tests\test_new_features.py") *> (Join-Path $env:TEMP "lr_feat.txt") +& python (Join-Path $root "_tests\hidden_desktop.py") -- python (Join-Path $root "_tests\test_new_features.py") *> (Join-Path $env:TEMP "lr_feat.txt") $featCode = $LASTEXITCODE Get-Content (Join-Path $env:TEMP "lr_feat.txt") -Encoding UTF8 -ErrorAction SilentlyContinue | Select-Object -Last 2 @@ -249,7 +299,7 @@ Record "新增功能" ($featCode -eq 0) # ---------------------------------------------------------------- 1f Banner "1f/7 电脑功能注册表(39 个功能 + 安全边界 + 可配置)" Stop-Strays -& python (Join-Path $root "_tests\test_features.py") *> (Join-Path $env:TEMP "lr_feats.txt") +& python (Join-Path $root "_tests\hidden_desktop.py") -- python (Join-Path $root "_tests\test_features.py") *> (Join-Path $env:TEMP "lr_feats.txt") $featsCode = $LASTEXITCODE Get-Content (Join-Path $env:TEMP "lr_feats.txt") -Encoding UTF8 -ErrorAction SilentlyContinue | Select-Object -Last 2 @@ -258,7 +308,7 @@ Record "电脑功能注册表" ($featsCode -eq 0) # ---------------------------------------------------------------- 1g Banner "1g/7 功能模糊测试(105 个功能 × 空参数 / 垃圾参数 / 越权路径)" Stop-Strays -& python (Join-Path $root "_tests\fuzz_features.py") *> (Join-Path $env:TEMP "lr_fuzz.txt") +& python (Join-Path $root "_tests\hidden_desktop.py") -- python (Join-Path $root "_tests\fuzz_features.py") *> (Join-Path $env:TEMP "lr_fuzz.txt") $fuzzCode = $LASTEXITCODE Get-Content (Join-Path $env:TEMP "lr_fuzz.txt") -Encoding UTF8 -ErrorAction SilentlyContinue | Select-Object -Last 3 @@ -294,7 +344,7 @@ Record "配一次→零窗口" ($flowCode -eq 0) # ---------------------------------------------------------------- 1k Banner "1k/7 首次安装设置窗口(出现一次,之后不再出现)" Stop-Strays -& python (Join-Path $root "_tests\test_first_run_setup.py") *> (Join-Path $env:TEMP "lr_setup.txt") +& python (Join-Path $root "_tests\hidden_desktop.py") -- python (Join-Path $root "_tests\test_first_run_setup.py") *> (Join-Path $env:TEMP "lr_setup.txt") $setupCode2 = $LASTEXITCODE Get-Content (Join-Path $env:TEMP "lr_setup.txt") -Encoding UTF8 -ErrorAction SilentlyContinue | Select-Object -Last 3 @@ -342,7 +392,7 @@ Record "网络调优(MTU)" ($LASTEXITCODE -eq 0) Banner "2b/7 投图显示方式(屏幕中间 / 全屏 / 自定义大小)" Stop-Strays $imgLog = Join-Path $env:TEMP "lr_imgmode.txt" -& python (Join-Path $root "_tests\test_image_modes.py") *> $imgLog +& python (Join-Path $root "_tests\hidden_desktop.py") -- python (Join-Path $root "_tests\test_image_modes.py") *> $imgLog $imgCode = $LASTEXITCODE if ($imgCode -eq 0) { Get-Content $imgLog -Encoding UTF8 -ErrorAction SilentlyContinue | @@ -357,7 +407,7 @@ Record "投图显示方式" ($imgCode -eq 0) # ---------------------------------------------------------------- 3 Banner "3/7 屏幕共享可见提示(真实界面 + Win32 窗口枚举)" Stop-Strays -$stageOut = & python (Join-Path $root "_tests\test_indicator_visible.py") 2>&1 +$stageOut = & python (Join-Path $root "_tests\hidden_desktop.py") -- python (Join-Path $root "_tests\test_indicator_visible.py") 2>&1 $stageCode = $LASTEXITCODE $stageOut | Select-Object -Last 3 Record "屏幕共享强制提示" ($stageCode -eq 0) @@ -402,6 +452,12 @@ $stageOut = & python (Join-Path $root "_tests\test_relay_default_blank.py") 2>&1 Show-Fails $stageOut Record "中转默认空白" ($LASTEXITCODE -eq 0) +# ---------------------------------------------------------------- 3a3c +Banner "3a3c/7 后台抢救报告分区(鉴权 + 不缓存 + 防穿越)" +$stageOut = & python (Join-Path $root "_tests\test_rescue_section.py") 2>&1 + Show-Fails $stageOut +Record "抢救报告分区" ($LASTEXITCODE -eq 0) + # ---------------------------------------------------------------- 3a4 Banner "3a4/7 编码与换行(.java/.py 必须 UTF-8 无 BOM + LF)" # 踩过的坑:edit 工具会把行尾写成 CRLF,而仓库约定是 LF。 diff --git a/android/AndroidManifest.xml b/android/AndroidManifest.xml index 95f8659..e59cb87 100644 --- a/android/AndroidManifest.xml +++ b/android/AndroidManifest.xml @@ -2,7 +2,7 @@ + android:versionName="2.1.0"> diff --git a/android/build_apk.ps1 b/android/build_apk.ps1 index aa5e2d8..3322a20 100644 --- a/android/build_apk.ps1 +++ b/android/build_apk.ps1 @@ -27,7 +27,7 @@ $d8 = Join-Path $bt "d8.bat" $work = Join-Path $android "build" $outDir = Join-Path $root "dist" -$apkName = "ykxmz-watch-2.0.0.apk" +$apkName = "ykxmz-watch-2.1.0.apk" function Step($text) { Write-Host "`n=== $text ===" -ForegroundColor Cyan } @@ -65,7 +65,7 @@ $unsigned = Join-Path $work "app-unsigned.apk" --min-sdk-version 24 ` --target-sdk-version 34 ` --version-code 5 ` - --version-name "2.0.0" ` + --version-name "2.1.0" ` --auto-add-overlay if ($LASTEXITCODE -ne 0) { throw "aapt2 link 失败" } diff --git a/android/src/com/lanremote/watch/MainActivity.java b/android/src/com/lanremote/watch/MainActivity.java index 703eb5b..569b1db 100644 --- a/android/src/com/lanremote/watch/MainActivity.java +++ b/android/src/com/lanremote/watch/MainActivity.java @@ -293,10 +293,18 @@ public class MainActivity extends Activity implements RemoteClient.Listener { root.addView(lanPanel, new FrameLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); + // ⚠ 建了面板还必须**挂到 root 上**,否则 showOnly() 让它 VISIBLE + // 也没用 —— 它根本不在视图树里,用户看到的是一片空白。 + // 设置页第一版就漏了这一行(buildSettingsPanel() 里建得好好的, + // 但没人 addView),表现是"点设置进去全白"。 + root.addView(settingsPanel, new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT)); controlPanel.setVisibility(View.GONE); mirrorPanel.setVisibility(View.GONE); lanPanel.setVisibility(View.GONE); + settingsPanel.setVisibility(View.GONE); connectPanel.setVisibility(View.VISIBLE); setContentView(root); @@ -541,6 +549,20 @@ public class MainActivity extends Activity implements RemoteClient.Listener { }); box.addView(relayButton); + // 音效:点一下电脑就响。用户要求"可以直接远程播放"。 + Button sfxButton = bigButton("音效(让电脑放一声)", C_ACCENT, 14); + LinearLayout.LayoutParams sfxParams = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, dp(52)); + sfxParams.setMargins(0, 0, 0, dp(8)); + sfxButton.setLayoutParams(sfxParams); + sfxButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + pickSfx(); + } + }); + box.addView(sfxButton); + // 输入配对码直接连对应电脑。 // 不再需要"先搜局域网 / 手动填 IP"那一套 —— // 现在统一经中转,配对码就是找电脑的唯一凭据。 @@ -3511,6 +3533,49 @@ public class MainActivity extends Activity implements RemoteClient.Listener { } } + /** 让电脑放一个音效(网络热门音效那类)。 + * + * 用 showChooser 而不是新建面板 —— 见 _build/add_watch_sfx.py 里的说明: + * pickVoice() 已经是这个套路,复用省事也更不容易出错。 + */ + private void pickSfx() { + appendLog("正在获取音效…"); + io.execute(new Runnable() { + @Override + public void run() { + try { + final Map reply = + client.call(Json.obj("cmd", "sfx_list"), 15000); + // 服务端同时给了扁平的 names(和 audio_list 一个约定) + final List names = stringList(reply, "names"); + final String dir = Json.str(reply, "dir", ""); + ui.post(new Runnable() { + @Override + public void run() { + if (names.isEmpty()) { + toast("电脑上还没有音效(或没连上)"); + appendLog("音效列表是空的。" + + (dir.isEmpty() ? "" : "素材目录:" + dir)); + return; + } + showChooser("音效(点一下电脑就响)", names, + new Chooser() { + @Override + public void onPick(int index) { + String one = names.get(index); + sendCommand(Json.obj("cmd", "sfx_play", + "name", one), "音效 " + one); + } + }); + } + }); + } catch (final IOException e) { + postError("获取音效", e); + } + } + }); + } + /** 选择电脑上的 TTS 音色。 */ private void pickVoice() { appendLog("正在获取电脑音色…"); diff --git a/docs/AFTER-INSTALL.txt b/docs/AFTER-INSTALL.txt index f50db49..706ae27 100644 --- a/docs/AFTER-INSTALL.txt +++ b/docs/AFTER-INSTALL.txt @@ -3,7 +3,7 @@ ======================================================== 软件名称:ykxmz -版本:2.0.0 +版本:2.1.0 作者:小麦子 装好之后看这一页:怎么确认它在跑、怎么改设置、 @@ -197,7 +197,7 @@ 八、版本记录 -------------------------------------------------------- - 2.0.0 + 2.1.0 * 下载改走 Cloudflare 边缘缓存 —— 安装包和 APK 快了几十倍 * 跨网络更稳:隧道**两种协议同时跑**(QUIC + HTTP/2, 合计 10 条连接),一种协议犯病另一种还在服务 @@ -218,6 +218,6 @@ -------------------------------------------------------- 作者:小麦子 - 软件:ykxmz 2.0.0 + 软件:ykxmz 2.1.0 许可条款见《许可协议》(LICENSE.txt) -------------------------------------------------------- diff --git a/docs/BEFORE-INSTALL.txt b/docs/BEFORE-INSTALL.txt index 1fb703e..2294540 100644 --- a/docs/BEFORE-INSTALL.txt +++ b/docs/BEFORE-INSTALL.txt @@ -3,7 +3,7 @@ ======================================================== 软件名称:ykxmz -版本:2.0.0 +版本:2.1.0 作者:小麦子 请你**在安装之前**花三分钟读完这一页。里面有几条很要紧的 @@ -164,6 +164,6 @@ ykxmz 是一套自己搭建的远程管理工具,由三部分组成: -------------------------------------------------------- 作者:小麦子 - 软件:ykxmz 2.0.0 + 软件:ykxmz 2.1.0 继续安装,即表示你已阅读并同意《许可协议》。 -------------------------------------------------------- diff --git a/docs/LICENSE.txt b/docs/LICENSE.txt index f9b8018..7a44669 100644 --- a/docs/LICENSE.txt +++ b/docs/LICENSE.txt @@ -1,38 +1,48 @@ ======================================================== - ykxmz 软件许可协议 + ykxmz 使用条款与免责声明 ======================================================== 软件名称:ykxmz(远程管理工具) -版本:1.7.1 +版本:2.1.0 作者:小麦子 生效日期:2026 年 10 月 5 日 +> **这份文件管的是「使用行为」,代码授权看仓库根目录的 `LICENSE`。** +> +> 根目录 `LICENSE` 是 GNU AGPL-3.0:你可以查看、修改、 +> 再发布本软件的**代码**,衍生作品必须以同样协议开源。 +> +> 这份文件说的是另一件事:**不管你从哪拿到它、改没改过, +> 都不许拿去做下面第三条那些事。** +> +> (早先这版文件写的是"软件许可协议",里面有一条"不得修改"—— +> 那和 AGPL 冲突,所以 2.1.0 起拆开了:代码授权归 LICENSE, +> 使用约束归这份文件。) + -------------------------------------------------------- -第一条 协议的范围 +第一条 条款的范围 -------------------------------------------------------- -本协议是你(以下称"使用者")与作者小麦子之间就 ykxmz 软件 +本条款是你(以下称"使用者")与作者小麦子之间就 ykxmz 软件 (含被控端程序、中转服务器程序、手表端应用及相关文档, -以下统称"本软件")达成的许可使用协议。 +以下统称"本软件")达成的**使用约定**。 下载、安装、复制或以任何方式使用本软件,即表示你已阅读、 -理解并同意本协议全部条款。如不同意,请不要安装或使用。 +理解并同意本条款全部内容。如不同意,请不要安装或使用。 -------------------------------------------------------- -第二条 授权范围 +第二条 你可以做的 -------------------------------------------------------- -在遵守本协议的前提下,作者授予你一项免费的、非独占的、 -不可转让的使用许可,你可以: +本软件**免费**使用,作者不收取任何费用。你可以: 1. 在**你自己拥有或已获得明确授权**的设备上安装和使用本软件; - 2. 复制并分发本软件的完整副本(须保持文件完整、不得修改、 - 不得去除本协议与作者署名); - 3. 在教室、机房、家庭等场景中,用于管理**你负责管理**的设备。 - -本软件免费使用,作者不收取任何费用。 + 2. 在教室、机房、家庭等场景中,用于管理**你负责管理**的设备; + 3. 复制并分发本软件 —— 分发时请保留作者署名,不要去掉版权声明; + 4. 查看、修改其**源代码**并再发布,具体条件见根目录 `LICENSE` + (AGPL-3.0:衍生作品要同样开源,且不能拿去闭源商用)。 -------------------------------------------------------- @@ -58,7 +68,7 @@ **一句话:只能管你自己的设备,或者别人明确同意你管的设备。** -违反上述任何一条,本协议立即终止,且由此产生的全部法律 +违反上述任何一条,本条款立即终止,且由此产生的全部法律 责任由使用者自行承担,与作者无关。 @@ -124,33 +134,34 @@ -------------------------------------------------------- -第八条 协议终止 +第八条 条款终止 -------------------------------------------------------- -出现下列情形之一,本协议自动终止: +出现下列情形之一,本条款自动终止: - 1. 你违反本协议任何条款; + 1. 你违反本条款任何内容; 2. 你将本软件用于第三条约定的禁止用途。 -协议终止后,你应当立即停止使用并卸载本软件。第二条授予的 -分发许可同时失效。 +条款终止后,你应当立即停止使用并卸载本软件。第二条中关于 +分发的许可同时失效(代码授权是否终止以根目录 `LICENSE` 为准)。 -------------------------------------------------------- 第九条 其他 -------------------------------------------------------- - 1. 本协议适用中华人民共和国法律; - 2. 因本协议产生的争议,双方应友好协商解决;协商不成的, + 1. 本条款适用中华人民共和国法律; + 2. 因本条款产生的争议,双方应友好协商解决;协商不成的, 提交作者所在地有管辖权的人民法院; - 3. 作者保留在后续版本中调整本协议的权利,调整后的协议 - 随新版本一同发布;继续使用新版本即视为接受新协议; - 4. 本协议部分条款无效,不影响其余条款的效力。 + 3. 作者保留在后续版本中调整本条款的权利,调整后的条款 + 随新版本一同发布;继续使用新版本即视为接受新条款; + 4. 本条款部分内容无效,不影响其余内容的效力。 -------------------------------------------------------- 作者:小麦子 - 软件:ykxmz 1.7.1 + 软件:ykxmz 2.1.0 - 如果你不同意本协议的任何一条,请不要安装或使用本软件。 + 如果你不同意本条款的任何一条,请不要安装或使用本软件。 + 代码授权条款见仓库根目录 LICENSE(GNU AGPL-3.0)。 -------------------------------------------------------- diff --git a/installer/license-combined.txt b/installer/license-combined.txt index 248a3ff..84fde2b 100644 --- a/installer/license-combined.txt +++ b/installer/license-combined.txt @@ -1,38 +1,48 @@ ======================================================== - ykxmz 软件许可协议 + ykxmz 使用条款与免责声明 ======================================================== 软件名称:ykxmz(远程管理工具) -版本:2.0.0 +版本:2.1.0 作者:小麦子 生效日期:2026 年 10 月 5 日 +> **这份文件管的是「使用行为」,代码授权看仓库根目录的 `LICENSE`。** +> +> 根目录 `LICENSE` 是 GNU AGPL-3.0:你可以查看、修改、 +> 再发布本软件的**代码**,衍生作品必须以同样协议开源。 +> +> 这份文件说的是另一件事:**不管你从哪拿到它、改没改过, +> 都不许拿去做下面第三条那些事。** +> +> (早先这版文件写的是"软件许可协议",里面有一条"不得修改"—— +> 那和 AGPL 冲突,所以 2.1.0 起拆开了:代码授权归 LICENSE, +> 使用约束归这份文件。) + -------------------------------------------------------- -第一条 协议的范围 +第一条 条款的范围 -------------------------------------------------------- -本协议是你(以下称"使用者")与作者小麦子之间就 ykxmz 软件 +本条款是你(以下称"使用者")与作者小麦子之间就 ykxmz 软件 (含被控端程序、中转服务器程序、手表端应用及相关文档, -以下统称"本软件")达成的许可使用协议。 +以下统称"本软件")达成的**使用约定**。 下载、安装、复制或以任何方式使用本软件,即表示你已阅读、 -理解并同意本协议全部条款。如不同意,请不要安装或使用。 +理解并同意本条款全部内容。如不同意,请不要安装或使用。 -------------------------------------------------------- -第二条 授权范围 +第二条 你可以做的 -------------------------------------------------------- -在遵守本协议的前提下,作者授予你一项免费的、非独占的、 -不可转让的使用许可,你可以: +本软件**免费**使用,作者不收取任何费用。你可以: 1. 在**你自己拥有或已获得明确授权**的设备上安装和使用本软件; - 2. 复制并分发本软件的完整副本(须保持文件完整、不得修改、 - 不得去除本协议与作者署名); - 3. 在教室、机房、家庭等场景中,用于管理**你负责管理**的设备。 - -本软件免费使用,作者不收取任何费用。 + 2. 在教室、机房、家庭等场景中,用于管理**你负责管理**的设备; + 3. 复制并分发本软件 —— 分发时请保留作者署名,不要去掉版权声明; + 4. 查看、修改其**源代码**并再发布,具体条件见根目录 `LICENSE` + (AGPL-3.0:衍生作品要同样开源,且不能拿去闭源商用)。 -------------------------------------------------------- @@ -58,7 +68,7 @@ **一句话:只能管你自己的设备,或者别人明确同意你管的设备。** -违反上述任何一条,本协议立即终止,且由此产生的全部法律 +违反上述任何一条,本条款立即终止,且由此产生的全部法律 责任由使用者自行承担,与作者无关。 @@ -124,35 +134,36 @@ -------------------------------------------------------- -第八条 协议终止 +第八条 条款终止 -------------------------------------------------------- -出现下列情形之一,本协议自动终止: +出现下列情形之一,本条款自动终止: - 1. 你违反本协议任何条款; + 1. 你违反本条款任何内容; 2. 你将本软件用于第三条约定的禁止用途。 -协议终止后,你应当立即停止使用并卸载本软件。第二条授予的 -分发许可同时失效。 +条款终止后,你应当立即停止使用并卸载本软件。第二条中关于 +分发的许可同时失效(代码授权是否终止以根目录 `LICENSE` 为准)。 -------------------------------------------------------- 第九条 其他 -------------------------------------------------------- - 1. 本协议适用中华人民共和国法律; - 2. 因本协议产生的争议,双方应友好协商解决;协商不成的, + 1. 本条款适用中华人民共和国法律; + 2. 因本条款产生的争议,双方应友好协商解决;协商不成的, 提交作者所在地有管辖权的人民法院; - 3. 作者保留在后续版本中调整本协议的权利,调整后的协议 - 随新版本一同发布;继续使用新版本即视为接受新协议; - 4. 本协议部分条款无效,不影响其余条款的效力。 + 3. 作者保留在后续版本中调整本条款的权利,调整后的条款 + 随新版本一同发布;继续使用新版本即视为接受新条款; + 4. 本条款部分内容无效,不影响其余内容的效力。 -------------------------------------------------------- 作者:小麦子 - 软件:ykxmz 2.0.0 + 软件:ykxmz 2.1.0 - 如果你不同意本协议的任何一条,请不要安装或使用本软件。 + 如果你不同意本条款的任何一条,请不要安装或使用本软件。 + 代码授权条款见仓库根目录 LICENSE(GNU AGPL-3.0)。 -------------------------------------------------------- ====================================================================== @@ -162,7 +173,7 @@ ======================================================== 软件名称:ykxmz -版本:2.0.0 +版本:2.1.0 作者:小麦子 请你**在安装之前**花三分钟读完这一页。里面有几条很要紧的 @@ -323,7 +334,7 @@ ykxmz 是一套自己搭建的远程管理工具,由三部分组成: -------------------------------------------------------- 作者:小麦子 - 软件:ykxmz 2.0.0 + 软件:ykxmz 2.1.0 继续安装,即表示你已阅读并同意《许可协议》。 -------------------------------------------------------- @@ -334,7 +345,7 @@ ykxmz 是一套自己搭建的远程管理工具,由三部分组成: ======================================================== 软件名称:ykxmz -版本:2.0.0 +版本:2.1.0 作者:小麦子 装好之后看这一页:怎么确认它在跑、怎么改设置、 @@ -528,7 +539,7 @@ ykxmz 是一套自己搭建的远程管理工具,由三部分组成: 八、版本记录 -------------------------------------------------------- - 2.0.0 + 2.1.0 * 下载改走 Cloudflare 边缘缓存 —— 安装包和 APK 快了几十倍 * 跨网络更稳:隧道**两种协议同时跑**(QUIC + HTTP/2, 合计 10 条连接),一种协议犯病另一种还在服务 @@ -549,6 +560,6 @@ ykxmz 是一套自己搭建的远程管理工具,由三部分组成: -------------------------------------------------------- 作者:小麦子 - 软件:ykxmz 2.0.0 + 软件:ykxmz 2.1.0 许可条款见《许可协议》(LICENSE.txt) -------------------------------------------------------- diff --git a/installer/ykxmz.iss b/installer/ykxmz.iss index 136b40b..3b0ac2a 100644 --- a/installer/ykxmz.iss +++ b/installer/ykxmz.iss @@ -27,7 +27,7 @@ ; ============================================================================ #define MyAppName "ykxmz" -#define MyAppVersion "2.0.0" +#define MyAppVersion "2.1.0" #define MyAppPublisher "小麦子" #define MyAppExeName "ykxmz.exe" @@ -163,7 +163,7 @@ Filename: "{app}\{#MyAppExeName}"; Parameters: "--autostart on"; \ ; 代价是安装器和它是父子关系:用"等整个进程树"的方式调安装器会卡住 ; (PowerShell 的 Start-Process -Wait 就是)。 ; 学校批量部署请用 cmd: -; cmd /c ykxmz-setup-2.0.0.exe /VERYSILENT /CODE=123456 +; cmd /c ykxmz-setup-2.1.0.exe /VERYSILENT /CODE=123456 ; cmd 只等安装器本身,不等孙子进程,不会卡。 Filename: "{app}\{#MyAppExeName}"; Parameters: "--silent"; \ Description: "立即启动(后台运行,不显示窗口)"; \ diff --git a/relay/admin.py b/relay/admin.py index 6af1501..7e9575f 100644 --- a/relay/admin.py +++ b/relay/admin.py @@ -595,8 +595,41 @@ if (autoRef) { """ +def _rescue_rows(items: list) -> str: + """抢救报告列表的 HTML。 + + 照 dl_rows 的风格:在服务端直接拼好塞进模板,不用 JS 去拉 —— + 这台机器只有 460MB 内存,多个接口就多份开销,而且后台页 + 本来就只是一次性的展示。 + + 没有报告时给一句正面的话("一直没出过问题"), + 而不是干巴巴的"暂无数据"—— 没报告是好事。 + """ + if not items: + return ('
还没有报告 —— 说明网站一直没出过' + '「自动救不回来」的问题。
') + import time as _t + import urllib.parse as _up + rows = [] + for it in items: + when = _t.strftime("%m-%d %H:%M", _t.localtime(it["mtime"])) + size = it["size"] / 1024 + size_s = ("%.1f KB" % size) if size >= 1 else ("%d B" % it["size"]) + summary = _esc(it.get("summary") or "(没有结论行)") + href = "/rescue/" + _up.quote(it["name"]) + rows.append( + '
' + '%s' + '' + '%s' + '' + '%s
' + % (href, when, size_s, summary)) + return "".join(rows) + + def render_admin(snap: dict, public: str, admin_path: str, codes: int, - downloads_fn) -> str: + downloads_fn, rescue_fn=None) -> str: """渲染管理后台。 snap RELAY.snapshot() 的结果 @@ -629,6 +662,13 @@ def render_admin(snap: dict, public: str, admin_path: str, codes: int, dl_rows = ('
还没有上传任何安装包。' '把文件放到中转的 downloads 目录即可。
') + # 抢救报告列表(和下载区同样在页面里直接渲染,不用 JS 拉) + try: + rescue_items = rescue_fn() if rescue_fn else [] + except Exception: # noqa: BLE001 + rescue_items = [] + rescue_rows = _rescue_rows(rescue_items) + return """ @@ -710,6 +750,12 @@ def render_admin(snap: dict, public: str, admin_path: str, codes: int,
官网下载按钮指向这里的文件。
%(dl)s
+
+

抢救报告

+
网站挂了、自动预案也没救回来时,机器会把现场 + 打包成一份 txt 放在这里。点开看看是哪一段断了。
+
%(rescue)s
+
@@ -736,4 +782,5 @@ def render_admin(snap: dict, public: str, admin_path: str, codes: int, "rows": _device_rows(devices), "allownew": " checked" if allow_new else "", "dl": dl_rows, + "rescue": rescue_rows, } diff --git a/relay/deploy.sh b/relay/deploy.sh index 70facfa..afb2cdc 100644 --- a/relay/deploy.sh +++ b/relay/deploy.sh @@ -911,6 +911,83 @@ EOF systemctl start lanremote-watchdog.timer >/dev/null 2>&1 || true say " 看门狗: $(systemctl is-active lanremote-watchdog.timer 2>&1)" fi + + # ---- 网站监测单元(如果包里有)---- + # + # 和看门狗的区别很重要: + # 看门狗 看**隧道连接数** —— 本地指标,判断"隧道自己好不好" + # 监测单元 看**公网 HTTP** —— 用户视角,判断"用户能不能打开" + # + # 两个都要。实测过:隧道两条实例都掉了,本机 curl 127.0.0.1 照样 200, + # 看门狗也可能觉得没事;只有从公网测才发现 502。 + if [ -f "$RELAY_DIR/monitor.sh" ]; then + mkdir -p "$RELAY_DIR/rescue_reports" + chmod +x "$RELAY_DIR/monitor.sh" "$RELAY_DIR/rescue.sh" 2>/dev/null || true + if [ ! -f "$RELAY_DIR/rescue_knowledge.md" ]; then + cat > "$RELAY_DIR/rescue_knowledge.md" <<'KEOF' +# 紧急预案知识库 + +每次自动抢救成功,这里会追加一条「第几级奏效」。 +以后遇到同类症状,监测单元会优先试历史上奏效的那一级。 + +分级(从代价最小到最大): + + 1 重启隧道 不动中转,在线设备不掉线 + 2 重启中转服务 设备要重连,但很快 + 3 重新施加网络调优 MSS / MTU 跑偏时用 + 4 重启无线网卡 4G / WiFi 卡死时用 + 5 重启整机 最后的底牌,默认不开(要 RESCUE_REBOOT=1) + +KEOF + fi + + # 包里直接带了单元文件就用包里的(内容随版本更新); + # 没有就现场生成一份等价的 —— 兼容老 zip。 + if [ -f "$RELAY_DIR/lanremote-monitor.service" ]; then + cp "$RELAY_DIR/lanremote-monitor.service" "$UNIT_DIR/" 2>/dev/null || true + else + write_unit "$UNIT_DIR/lanremote-monitor.service" </dev/null || true + else + write_unit "$UNIT_DIR/lanremote-monitor.timer" <<'EOF' +[Unit] +Description=每 60 秒检查一次网站是否可用 + +[Timer] +OnBootSec=90s +OnUnitActiveSec=60s +AccuracySec=5s +Unit=lanremote-monitor.service + +[Install] +WantedBy=timers.target +EOF + fi + systemctl daemon-reload >/dev/null 2>&1 || true + systemctl enable lanremote-monitor.timer >/dev/null 2>&1 || true + systemctl start lanremote-monitor.timer >/dev/null 2>&1 || true + say " 网站监测单元已启用(每 60 秒查一次公网)" + fi else say " [跳过] 没有 systemctl,服务没启动" fi diff --git a/relay/lanremote-monitor.service b/relay/lanremote-monitor.service new file mode 100644 index 0000000..6632f54 --- /dev/null +++ b/relay/lanremote-monitor.service @@ -0,0 +1,35 @@ +[Unit] +# ykxmz 网站监测单元 —— 每 60 秒看一次公网,挂了就叫紧急预案。 +# +# 由 lanremote-monitor.timer 拉起(不是常驻进程,见 timer 里的说明)。 +Description=ykxmz 网站监测单元(每 60 秒检查公网,异常时启动紧急预案) +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +# 检查本身最长 12 秒,预案最长 5 分钟,给 6 分钟余量 +TimeoutStartSec=360 + +# ⚠ 用 bash 显式跑,别靠 shebang + 可执行位 —— +# 部署时文件权限被改掉是最常见的"脚本不执行"原因。 +ExecStart=/bin/bash /root/lanremote-relay/monitor.sh + +# 环境变量 +# FAIL_THRESHOLD 连续失败几次才动手(默认 3 次 ≈ 3 分钟) +# COOLDOWN 救完之后多少秒内不再救(默认 600 秒) +# 不设的话:预案刚重启完隧道还没连上,下一次检查 +# 又判失败又救一遍,来回重启反而永远连不上 +# RESCUE_REBOOT 预案里是否允许"重启整机"这张底牌。**默认 0(不开)**。 +# 想开就把下面这行的 0 改成 1 —— 但先想清楚: +# 如果问题出在供电或上游线路,重启只会让恢复更慢, +# 而且半夜自动重启一台没人管的机器风险很大。 +Environment=RESCUE_REBOOT=0 +Environment=FAIL_THRESHOLD=3 +Environment=COOLDOWN=600 + +StandardOutput=append:/root/lanremote-relay/monitor.log +StandardError=append:/root/lanremote-relay/monitor.log + +[Install] +WantedBy=multi-user.target diff --git a/relay/lanremote-monitor.timer b/relay/lanremote-monitor.timer new file mode 100644 index 0000000..3fe2fa1 --- /dev/null +++ b/relay/lanremote-monitor.timer @@ -0,0 +1,20 @@ +[Unit] +# 每 60 秒检查一次公网。 +# +# 为什么是 60 秒不是 5 秒: +# 实测这条 4G 线路本来就有 2~4% 的偶发失败,检查太勤会一直看到 +# "失败"(其实一两秒后自己就好了)。配合 monitor.sh 里 +# "连续 3 次才动手"的防抖,实际最快 3 分钟响应,够用了。 +# +# OnBootSec=90s:开机后等 90 秒再开始查。 +# 隧道一般几十秒才连上,太早开始会一开机就误判、白抢救一轮。 +Description=每 60 秒检查一次网站是否可用 + +[Timer] +OnBootSec=90s +OnUnitActiveSec=60s +AccuracySec=5s +Unit=lanremote-monitor.service + +[Install] +WantedBy=timers.target diff --git a/relay/monitor.sh b/relay/monitor.sh new file mode 100644 index 0000000..9103f6c --- /dev/null +++ b/relay/monitor.sh @@ -0,0 +1,148 @@ +#!/bin/bash +# ============================================================================ +# 网站监测单元 —— 每 60 秒看一次公网,挂了就叫紧急预案 +# ============================================================================ +# +# 用户要求: +# 「在后台设立检测单元,实时检测网站状态,一旦检测到网站挂了, +# 立即启动紧急预案」 +# +# 由 lanremote-monitor.timer 每 60 秒起来跑一次(不是常驻进程 —— +# 小机器内存紧,定时任务比常驻守护省得多,而且崩了下一分钟自己就回来了)。 +# +# --------------------------------------------------------------------------- +# 为什么一定要**从公网测**,不能测 127.0.0.1 +# --------------------------------------------------------------------------- +# +# 本机 200 只说明中转进程活着。用户真正关心的是"打开 +# https://relay.xxmxxs.top 能不能出页面" —— 那要经过 +# Cloudflare 边缘 + 隧道 + 本机三段。 +# +# 实测过的场景:隧道两条实例都掉了,本机 curl 127.0.0.1 照样 200, +# 但公网 502。只看本机的话,监测单元永远不会报警。 +# +# --------------------------------------------------------------------------- +# 防抖与冷却 +# --------------------------------------------------------------------------- +# +# * 连续失败 FAIL_THRESHOLD 次才动手(默认 3 次 ≈ 3 分钟)。 +# 实测这条 4G 线路本来就有 2~4% 的偶发失败,一次就救会天天瞎折腾。 +# +# * 救过一次之后 COOLDOWN 秒内不再救(默认 600 = 10 分钟)。 +# 否则预案刚重启完隧道、还没连上,下一次检查又判失败、又救一遍, +# 来回重启反而永远连不上。 +# +# --------------------------------------------------------------------------- +# 知识库 +# --------------------------------------------------------------------------- +# +# rescue.sh 每次救成功都会把"第几级奏效"写进 rescue_knowledge.md。 +# 这里读出来,如果历史上某一级反复奏效,就把它记下来传给预案, +# 预案可以据此跳过前面没用的级别(见 rescue.sh 的 PREFER_STEP)。 +# +# --------------------------------------------------------------------------- + +set -u + +RELAY_DIR="${RELAY_DIR:-/root/lanremote-relay}" +PUBLIC_URL="${PUBLIC_URL:-https://relay.xxmxxs.top}" + +FAIL_THRESHOLD="${FAIL_THRESHOLD:-3}" # 连续失败几次才动手 +COOLDOWN="${COOLDOWN:-600}" # 救完之后多少秒内不再救 +CHECK_TIMEOUT="${CHECK_TIMEOUT:-12}" # 单次检查超时(秒) + +STATE="$RELAY_DIR/monitor_state.txt" +LOG="$RELAY_DIR/monitor.log" +LOCKFILE="/tmp/lanremote-monitor.lock" + +# 同时只跑一个(timer 到点时上一次可能还没跑完) +if ! mkdir "$LOCKFILE" 2>/dev/null; then + exit 0 +fi +trap 'rmdir "$LOCKFILE" 2>/dev/null' EXIT + +log() { echo "$(date '+%F %T') $*" >> "$LOG"; } + +# ---- 读状态(连续失败次数 / 上次抢救时间)---- +FAILS=0 +LAST_RESCUE=0 +if [ -f "$STATE" ]; then + FAILS=$(grep -m1 '^fails=' "$STATE" 2>/dev/null | cut -d= -f2) + LAST_RESCUE=$(grep -m1 '^last_rescue=' "$STATE" 2>/dev/null | cut -d= -f2) + FAILS=${FAILS:-0} + LAST_RESCUE=${LAST_RESCUE:-0} +fi +NOW=$(date +%s) + +# ---- 检查公网 ---- +CODE=$(curl -s -o /dev/null -w '%{http_code}' --max-time "$CHECK_TIMEOUT" \ + "$PUBLIC_URL/v1/health" 2>/dev/null) +DUR=$(curl -s -o /dev/null -w '%{time_total}' --max-time "$CHECK_TIMEOUT" \ + "$PUBLIC_URL/v1/health" 2>/dev/null) +DUR=${DUR:-0} + +write_state() { + { + echo "fails=$1" + echo "last_rescue=$2" + echo "last_code=$CODE" + echo "last_check=$NOW" + echo "last_dur=$DUR" + } > "$STATE" +} + +if [ "$CODE" = "200" ]; then + if [ "$FAILS" != "0" ]; then + log "公网恢复(HTTP 200,${DUR}s)—— 之前连续失败 $FAILS 次" + fi + # 只在状态变化时记一行,避免日志被 60 秒一条刷爆(460MB 机器) + PREV=$(grep -m1 '^last_code=' "$STATE" 2>/dev/null | cut -d= -f2) + [ "$PREV" != "200" ] && log "公网正常(HTTP 200,${DUR}s)" + write_state 0 "$LAST_RESCUE" + exit 0 +fi + +# ---- 失败:累加计数 ---- +FAILS=$((FAILS + 1)) +log "公网异常 第 $FAILS 次(HTTP ${CODE:-无响应},${DUR}s)" +write_state "$FAILS" "$LAST_RESCUE" + +if [ "$FAILS" -lt "$FAIL_THRESHOLD" ]; then + exit 0 +fi + +# ---- 到阈值了,看冷却 ---- +SINCE=$((NOW - LAST_RESCUE)) +if [ "$LAST_RESCUE" != "0" ] && [ "$SINCE" -lt "$COOLDOWN" ]; then + log " 已经连续失败 $FAILS 次,但距上次抢救只过了 ${SINCE}s(冷却 ${COOLDOWN}s),先不动手" + exit 0 +fi + +# ---- 读知识库,看历史上哪一级最常奏效 ---- +HINT="" +if [ -f "$RELAY_DIR/rescue_knowledge.md" ]; then + HINT=$(grep -oE '第 [0-9] 级奏效' "$RELAY_DIR/rescue_knowledge.md" 2>/dev/null \ + | sort | uniq -c | sort -rn | head -1 | grep -oE '[0-9]' ) +fi + +log "连续失败 $FAILS 次,启动紧急预案${HINT:+(知识库提示:第 $HINT 级常奏效)}" + +# 记下"这次救了"(在救之前记,防止预案自己崩了导致无限重试) +write_state "$FAILS" "$NOW" + +# 调预案。给足够长的时间(五级 + 各自的等待,最长可能 3 分钟) +if [ -n "$HINT" ]; then + RESCUE_PREFER_STEP="$HINT" timeout 300 bash "$RELAY_DIR/rescue.sh" \ + "监测单元:公网连续 $FAILS 次不通" >> "$LOG" 2>&1 +else + timeout 300 bash "$RELAY_DIR/rescue.sh" \ + "监测单元:公网连续 $FAILS 次不通" >> "$LOG" 2>&1 +fi +RC=$? + +if [ "$RC" = "0" ]; then + log " 预案完成:已恢复" +else + log " 预案完成:仍未恢复(退出码 $RC),报告在 rescue_reports/" +fi +exit 0 diff --git a/relay/relay.py b/relay/relay.py index de64ab4..7896c56 100644 --- a/relay/relay.py +++ b/relay/relay.py @@ -59,7 +59,7 @@ from collections import deque from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path -VERSION = "2.0.0" +VERSION = "2.1.0" # ---------------- 参数(都能用环境变量覆盖)---------------- @@ -154,6 +154,76 @@ ADMIN_PASSWORD = os.environ.get("RELAY_ADMIN_PASSWORD", "") # 成品下载目录(管理页上给人下载 APK / 整包用)。 # 只放白名单后缀的文件,文件名做严格校验防目录穿越。 DOWNLOADS_DIR = Path(__file__).resolve().parent / "downloads" + +# 抢救报告目录。rescue.sh 抢救失败时往这里写 .txt 诊断报告。 +# 和 downloads/ 分开:那个是给用户的安装包(带长缓存), +# 这个是给管理员看的诊断(必须不缓存)。 +RESCUE_DIR = Path(__file__).resolve().parent / "rescue_reports" +RESCUE_KEEP = 30 # 只保留最近 30 份(460MB 的小机器,别塞满) + + +def _safe_rescue_name(name: str) -> str: + """校验抢救报告文件名。只允许 <时间戳>.txt 这种形状。""" + name = (name or "").strip() + if not name or len(name) > 80: + return "" + if "/" in name or "\\" in name or ".." in name: + return "" + if not name.endswith(".txt"): + return "" + for ch in name: + if ch.isalnum() or ch in "._-": + continue + return "" + return name + + +def _list_rescue_reports() -> list: + """列出抢救报告(按时间倒序)。 + + 顺带读每个文件的前几行,抽出结论 —— 后台列表上直接显示, + 不用一个个点进去看。 + """ + items = [] + try: + if not RESCUE_DIR.is_dir(): + return items + for entry in sorted(RESCUE_DIR.glob("*.txt"), reverse=True): + if not entry.is_file(): + continue + safe = _safe_rescue_name(entry.name) + if not safe: + continue + stat = entry.stat() + summary = "" + try: + with entry.open("r", encoding="utf-8", errors="replace") as fh: + head = fh.read(1200) + for line in head.splitlines(): + s = line.strip() + if s.startswith("判断:"): + summary = s.replace("判断:", "").strip() + break + if not summary: + for line in head.splitlines(): + s = line.strip() + if s.startswith("公网 ") and "->" in s: + summary = s + break + except OSError: + pass + items.append({ + "name": entry.name, + "size": stat.st_size, + "mtime": stat.st_mtime, + "summary": summary[:120], + }) + except OSError: + return items + items.sort(key=lambda i: i["mtime"], reverse=True) + return items[:RESCUE_KEEP] + + DOWNLOAD_SUFFIXES = (".apk", ".zip", ".txt", ".exe", ".idsig") @@ -1328,6 +1398,28 @@ class Handler(BaseHTTPRequestHandler): # ---- 下载区 ---- # 这一块原来漏了,导致 HEAD 一个 APK 报 Content-Length: 0。 # 类型判断必须和 do_GET 里那份保持一致。 + if path.startswith("/rescue/"): + # 抢救报告。HEAD 只回长度和类型,**不写 body** —— + # 这条路由同时存在于 do_GET(真正下载)和 do_HEAD(探测)。 + # 同样要登录(见 do_GET 那段注释)。 + # 同上:必须自己发响应,而且状态码要是 401 不是 404 + # (no() 默认 404,探测方会以为是"文件不存在"而不是"没登录")。 + if not self._admin_gate(): + return no(401) + wanted = urllib.parse.unquote(path[len("/rescue/"):]) + safe = _safe_rescue_name(wanted) + if not safe: + return no() + target = RESCUE_DIR / safe + if not target.is_file(): + return no() + quoted = urllib.parse.quote(safe) + headers = {"Content-Disposition": + "attachment; filename*=UTF-8''%s" % quoted, + "Cache-Control": "no-store"} + return ok("text/plain; charset=utf-8", target.stat().st_size, + headers) + if path.startswith("/download/"): wanted = urllib.parse.unquote(path[len("/download/"):]) safe = _safe_download_name(wanted) @@ -1441,6 +1533,50 @@ class Handler(BaseHTTPRequestHandler): self.wfile.write(body) return + if path.startswith("/rescue/"): + # 抢救报告下载。**单独一条路由,不发缓存头** —— + # 内容会变,而且不该让 Cloudflare 边缘留着。 + # (和 /download/ 分成两套是有意的:那个是给用户的安装包, + # 文件名带版本号、内容永不改变,所以敢发 604800 长缓存。) + # + # ⚠ 必须登录才能下。报告里有内网 IP、进程表、日志尾部、 + # MTU/路由/iptables —— 是运维信息,不是给所有人看的。 + # 第一版把这条路由插在 _admin_gate() **之前**, + # 等于匿名可下,实测踩到。 + # ⚠ _admin_gate() 只返回布尔值,**不负责发响应** —— + # 每个调用点都要自己发。裸 return 在 BaseHTTPRequestHandler + # 里等于"什么都不做":连接保持着、没有响应头, + # 客户端一直等到超时(curl 报 HTTP 000)。 + # 第一版就是这么写的,实测 /rescue/ 会挂住 8 秒。 + if not self._admin_gate(): + self._json(401, {"ok": False, "error": "请先登录"}) + return + wanted = urllib.parse.unquote(path[len("/rescue/"):]) + safe = _safe_rescue_name(wanted) + if not safe: + self._json(400, {"ok": False, "error": "文件名不合法"}) + return + target = RESCUE_DIR / safe + if not target.is_file(): + self._json(404, {"ok": False, "error": "没有这份报告"}) + return + try: + body = target.read_bytes() + except OSError as exc: + self._json(500, {"ok": False, "error": f"读不到: {exc}"}) + return + self.send_response(200) + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + quoted = urllib.parse.quote(safe) + self.send_header( + "Content-Disposition", + f"attachment; filename*=UTF-8''{quoted}") + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + return + if path.startswith("/download/"): wanted = urllib.parse.unquote(path[len("/download/"):]) safe = _safe_download_name(wanted) @@ -1876,6 +2012,7 @@ class Handler(BaseHTTPRequestHandler): snap=snap, public=public_url() or "", admin_path=ADMIN_PATH, + rescue_fn=_list_rescue_reports, codes=len(RELAY.codes), downloads_fn=_list_downloads, ).encode("utf-8"), "text/html; charset=utf-8") diff --git a/relay/rescue.sh b/relay/rescue.sh new file mode 100644 index 0000000..ecc0e87 --- /dev/null +++ b/relay/rescue.sh @@ -0,0 +1,358 @@ +#!/bin/bash +# ============================================================================ +# 紧急预案 —— 网站(公网)打不开时,按阶梯自动抢救 +# ============================================================================ +# +# 用户要求: +# 「在后台设立检测单元,实时检测网站状态,一旦检测到网站挂了, +# 立即启动紧急预案」 +# 「如果紧急预案未能解决,则将问题打包成 .txt,然后在后台加一个分区 +# 专门放还有下载这个 txt」 +# 「解决后将解决问题的方法加入紧急预案」 +# +# 谁调用它:monitor.sh(每 60 秒检查一次公网,连续失败就调这里) +# 也可以人工跑:bash /root/lanremote-relay/rescue.sh +# +# --------------------------------------------------------------------------- +# 设计原则 +# --------------------------------------------------------------------------- +# +# 1. **先记录现场,再动手** —— 重启会把证据冲掉。第一件事永远是把 +# 当前状态(内存、连接数、进程、日志尾部)写进报告。 +# +# 2. **阶梯式,每级都验证** —— 从"代价最小"到"代价最大": +# 1 重启隧道 -> 不动中转,在线设备不掉(首选) +# 2 重启中转 -> 设备要重连,但很快 +# 3 重施调优 -> 网络参数跑偏时用(实测 MTU 顺序错过一次) +# 4 重启网卡 -> 4G/WiFi 掉线卡死时用 +# 5 重启整机 -> 最后的底牌,默认**不开**(要 RESCUE_REBOOT=1) +# 每做完一级立刻再验;好了就停,不做多余的破坏。 +# +# 3. **全都失败要留证据** —— 生成一份 .txt 报告,放进 rescue_reports/, +# 后台管理页有专门的分区可以下载。 +# +# 4. **成功的招数记下来** —— 每级成功就写进 rescue_knowledge.md。 +# 下次同类问题,monitor 可以把这一级提到前面先试。 +# (这就是用户说的"解决后将解决问题的方法加入紧急预案") +# +# --------------------------------------------------------------------------- + +set -u + +RELAY_DIR="${RELAY_DIR:-/root/lanremote-relay}" +PUBLIC_URL="${PUBLIC_URL:-https://relay.xxmxxs.top}" +LOCAL_URL="${LOCAL_URL:-http://127.0.0.1:8080}" +REPORTS_DIR="$RELAY_DIR/rescue_reports" +KNOWLEDGE="$RELAY_DIR/rescue_knowledge.md" +LOCKFILE="/tmp/lanremote-rescue.lock" + +# 每级之后等多久再验(隧道重连要时间) +SETTLE_FAST=8 +SETTLE_SLOW=25 + +# 是否允许用"重启整机"这张底牌。默认关 —— 半夜自动重启一台没人管的 +# 机器很吓人,而且如果问题出在供电/上游,重启反而更久才回来。 +RESCUE_REBOOT="${RESCUE_REBOOT:-0}" + +mkdir -p "$REPORTS_DIR" + +# 同一时间只允许一个预案在跑(monitor 可能和人工同时触发) +if ! mkdir "$LOCKFILE" 2>/dev/null; then + echo "[rescue] 已经有一个预案在跑,本次退出" + exit 0 +fi +trap 'rmdir "$LOCKFILE" 2>/dev/null' EXIT + +STAMP="$(date +%Y%m%d-%H%M%S)" +REPORT="$REPORTS_DIR/$STAMP.txt" +LOG="$RELAY_DIR/rescue.log" + +say() { + echo "$*" + echo "$(date '+%F %T') $*" >> "$LOG" +} + +# --------------------------------------------------------------------------- +# 健康检查:**看公网,不看本机** +# --------------------------------------------------------------------------- +# ⚠ 这是关键。本机 127.0.0.1 通不代表用户能访问 —— +# 隧道断了、Cloudflare 边缘出问题,本机照样 200。 +# 所以预案里的"好了没"一律以公网为准。 +is_healthy() { + local code + code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 12 \ + "$PUBLIC_URL/v1/health" 2>/dev/null) + [ "$code" = "200" ] +} + +# 公网不通时,看是本机的问题还是隧道的问题 +local_health() { + local code + code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 6 \ + "$LOCAL_URL/v1/health" 2>/dev/null) + [ "$code" = "200" ] +} + +# --------------------------------------------------------------------------- +# 现场记录 +# --------------------------------------------------------------------------- +collect() { + { + echo "================================================================================" + echo " ykxmz 紧急预案诊断报告" + echo " 时间:$(date '+%F %T %Z')" + echo " 主机:$(hostname) 运行时长:$(uptime -p 2>/dev/null || uptime)" + echo " 触发原因:$1" + echo "================================================================================" + echo + echo "---- 一、结论速览 ----" + echo + echo " 公网 $PUBLIC_URL/v1/health -> $(is_healthy && echo 200-正常 || echo 不通)" + echo " 本机 $LOCAL_URL/v1/health -> $(local_health && echo 200-正常 || echo 不通)" + echo + if local_health && ! is_healthy; then + echo " 判断:**本机是好的,问题在隧道或 Cloudflare 那一侧**。" + elif ! local_health; then + echo " 判断:**本机的中转服务就没起来**,先看第二、三节。" + else + echo " 判断:两边都通(可能是刚才已经自愈,或者只是偶发抖动)。" + fi + echo + echo "---- 二、服务状态 ----" + echo + for svc in lanremote-relay lanremote-tunnel lanremote-tunnel-h2 \ + lanremote-nettune lanremote-watchdog; do + printf ' %-26s %s\n' "$svc" "$(systemctl is-active "$svc" 2>&1)" + done + echo + echo "---- 三、进程 ----" + echo + ps -eo pid,rss,etime,comm,args --sort=-rss 2>/dev/null | head -12 + echo + echo "---- 四、内存 / 负载 ----" + echo + free -m 2>/dev/null + echo + uptime + echo + echo "---- 五、网络 ----" + echo + echo "网卡:" + ip -brief addr show 2>/dev/null + echo + echo "MTU / 队列:" + for i in $(ls /sys/class/net 2>/dev/null | grep -v lo); do + echo " $i mtu=$(cat /sys/class/net/$i/mtu 2>/dev/null)" + done + echo + echo "默认路由:" + ip route show default 2>/dev/null + echo + echo "MSS 钳制规则:" + iptables -t mangle -L -n 2>/dev/null | grep -i mss || echo " (没有 MSS 规则!)" + echo + echo "路径 MTU 探测:tcp_mtu_probing=$(cat /proc/sys/net/ipv4/tcp_mtu_probing 2>/dev/null)" + echo + echo "---- 六、隧道连接数 ----" + echo + for port in 20241 20242; do + n=$(curl -s --max-time 5 "http://127.0.0.1:$port/metrics" 2>/dev/null \ + | grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}' | head -1) + echo " 指标端口 $port -> 连接数 ${n:-拿不到}" + done + echo + echo "---- 七、日志尾部 ----" + echo + echo "### 中转(lanremote-relay)" + journalctl -u lanremote-relay --no-pager -n 25 2>/dev/null | tail -25 + echo + echo "### 隧道(lanremote-tunnel)" + journalctl -u lanremote-tunnel --no-pager -n 15 2>/dev/null | tail -15 + echo + echo "### 隧道副本(lanremote-tunnel-h2)" + journalctl -u lanremote-tunnel-h2 --no-pager -n 15 2>/dev/null | tail -15 + echo + echo "### 看门狗" + tail -20 "$RELAY_DIR/watchdog.log" 2>/dev/null + echo + echo "### 监测单元" + tail -20 "$RELAY_DIR/monitor.log" 2>/dev/null + echo + echo "### 内核异常(OOM / 掉电 / 硬件)" + dmesg -T 2>/dev/null | grep -i -E 'oom|killed process|under-voltage|thermal|panic|watchdog' | tail -15 + echo + echo "---- 八、最近一次重启 ----" + echo + who -b 2>/dev/null + echo "运行时长:$(cut -d. -f1 /proc/uptime 2>/dev/null) 秒" + echo + echo "================================================================================" + echo " 报告结束。这个文件可以在中转站后台的「抢救报告」分区下载。" + echo "================================================================================" + } > "$REPORT" 2>&1 + say " 现场已记录 -> $REPORT" +} + +# --------------------------------------------------------------------------- +# 阶梯 +# --------------------------------------------------------------------------- + +step_1_restart_tunnel() { + say " [1/5] 重启隧道(不动中转,在线设备不掉线)" + systemctl restart lanremote-tunnel lanremote-tunnel-h2 2>/dev/null + sleep "$SETTLE_SLOW" +} + +step_2_restart_relay() { + say " [2/5] 重启中转服务" + systemctl restart lanremote-relay 2>/dev/null + sleep "$SETTLE_FAST" +} + +step_3_retune_network() { + say " [3/5] 重新施加网络调优(MSS / MTU / 路径探测)" + # 这一步是有来历的:实测部署脚本里"先设 MTU 1400、脚本又想保持 1500" + # 顺序错过一次,结果网卡停在 1400,吞吐掉到 50%。 + # 重跑 net-tune.sh 让它回到脚本认为正确的值。 + if [ -f "$RELAY_DIR/net-tune.sh" ]; then + bash "$RELAY_DIR/net-tune.sh" >> "$LOG" 2>&1 && say " 调优脚本执行完成" + else + say " 找不到 net-tune.sh,跳过" + fi + systemctl restart lanremote-tunnel lanremote-tunnel-h2 2>/dev/null + sleep "$SETTLE_SLOW" +} + +step_4_restart_network() { + say " [4/5] 重启无线网卡(4G/WiFi 卡死时用)" + local iface + iface=$(ip route show default 2>/dev/null | awk '{print $5; exit}') + if [ -z "$iface" ]; then + say " 找不到默认路由的网卡,跳过" + return + fi + # 用 nmcli 走管理接口,别直接把网卡 down 掉(down 了可能再也上不来) + if command -v nmcli >/dev/null 2>&1; then + nmcli device disconnect "$iface" >/dev/null 2>&1 + sleep 4 + nmcli device connect "$iface" >/dev/null 2>&1 + say " nmcli 重连了 $iface" + else + say " 没有 nmcli,跳过(不敢直接 down 网卡:可能起不来)" + fi + systemctl restart lanremote-relay 2>/dev/null + systemctl restart lanremote-tunnel lanremote-tunnel-h2 2>/dev/null + sleep "$SETTLE_SLOW" +} + +step_5_reboot() { + if [ "$RESCUE_REBOOT" != "1" ]; then + say " [5/5] 重启整机 —— **已跳过**(默认不开)" + say " 要允许自动重启,把 RESCUE_REBOOT=1 写进 monitor.service 的环境变量。" + say " 理由:如果问题出在供电或上游线路,重启只会让恢复更慢;" + say " 而且半夜自动重启一台没人管的机器风险太大。" + return 1 + fi + say " [5/5] 重启整机(最后的底牌)" + sync + systemctl reboot 2>/dev/null + return 0 +} + +# --------------------------------------------------------------------------- +# 把这次成功的招数记进知识库 +# --------------------------------------------------------------------------- +# 用户要求:「解决后将解决问题的方法加入紧急预案」。 +# 实现方式:哪一级救回来的就把它记下来。monitor 下次遇到同类症状 +# 会优先试历史上奏效的那一级(见 monitor.sh 的 PREFFERED_STEP)。 +remember() { + local step="$1" reason="$2" + { + echo + echo "## $(date '+%F %T') 第 $step 级奏效" + echo + echo "- 症状:$reason" + echo "- 有效步骤:$step" + echo "- 当时公网:不通;本机:$(local_health && echo 通 || echo 不通)" + echo "- 详情见 rescue_reports/$(basename "$REPORT")" + } >> "$KNOWLEDGE" + say " 已把「第 $step 级奏效」记进 $KNOWLEDGE" +} + +# =========================================================================== +# 主流程 +# =========================================================================== + +REASON="${1:-monitor 触发}" + +say "" +say "================================================================================" +say " 紧急预案启动:$REASON" +say "================================================================================" + +# ⚠ 先复查一次再记录现场。 +# +# 为什么顺序是这样:collect() 会写一份报告文件。如果一上来就采, +# 那么"网站其实已经好了、什么都不用做"的这一次也会留下一份报告 —— +# 实测跑一次 e2e 就多出 2 份,全是没用的空报告, +# 真正出事时的报告会被这些噪音淹掉(后台列表只能显示最近 30 份)。 +# +# 所以:先判健康;不健康才采现场、才开阶梯。 +if is_healthy; then + say " 复查:公网现在是通的(可能是偶发抖动自愈,或者启动前已经恢复)," + say " 不需要动手 —— 不采现场、不留报告。" + exit 0 +fi + +# 确认不通了,这才记录现场(重启会把证据冲掉) +collect "$REASON" + +STEP_NAMES=( + "重启隧道" + "重启中转服务" + "重新施加网络调优" + "重启无线网卡" + "重启整机" +) + +for i in 1 2 3 4 5; do + case $i in + 1) step_1_restart_tunnel ;; + 2) step_2_restart_relay ;; + 3) step_3_retune_network ;; + 4) step_4_restart_network ;; + 5) step_5_reboot || continue ;; + esac + + if is_healthy; then + say "" + say " ✅ 第 $i 级(${STEP_NAMES[$((i-1))]})救回来了,公网已恢复。" + remember "$i" "$REASON" + exit 0 + fi + say " 第 $i 级没解决,继续下一级" +done + +# --------------------------------------------------------------------------- +# 全部失败 —— 生成报告,等人工 +# --------------------------------------------------------------------------- +say "" +say " ❌ 五级都没解决。再采一次现场(状态已经变了,报告要反映最新情况)" +collect "$REASON(五级抢救后仍不通)" + +# 在报告最前面补一句给用户看的结论 +{ + echo "⚠ 这份报告是「自动抢救全部失败」之后生成的。" + echo " 机器自己试过了:重启隧道 / 重启中转 / 重施网络调优 / 重启网卡。" + echo " (重启整机默认没做,见报告里的说明。)" + echo " 需要人工看一眼。下面是最新的现场。" + echo + cat "$REPORT" +} > "$REPORT.tmp" && mv "$REPORT.tmp" "$REPORT" + +# 只保留最近 30 份,别把 460MB 的小机器塞满 +ls -1t "$REPORTS_DIR"/*.txt 2>/dev/null | tail -n +31 | xargs -r rm -f + +say " 报告已生成:$REPORT" +say " 可以在中转站后台的「抢救报告」分区下载。" +exit 1 diff --git a/relay/setup-monitor.sh b/relay/setup-monitor.sh new file mode 100644 index 0000000..d2ad0d1 --- /dev/null +++ b/relay/setup-monitor.sh @@ -0,0 +1,107 @@ +#!/bin/bash +# ============================================================================ +# 在设备上装好「网站监测 + 紧急预案」(可重复跑) +# ============================================================================ +# +# 为什么单独一个脚本,而不是部署时塞一长串内联命令: +# 内联命令要经过 ssh -> bash -c -> 再一层引号,中文和引号很容易 +# 被打散(第一次就是这么失败的,报"寻找匹配的 ' 时遇到未预期的 EOF")。 +# 写成文件传过去再跑,引号只有一层,可靠得多,而且能重复执行。 +# +# 做四件事: +# 1. 建 rescue_reports/ 和知识库文件 +# 2. 脚本加可执行位 +# 3. 重载 systemd,把 nettune 拉起来(那份 unit 刚被换成正确形状) +# 4. 启用并启动 monitor.timer +# ============================================================================ + +set -u + +RELAY_DIR="${RELAY_DIR:-/root/lanremote-relay}" +UNIT_DIR="${UNIT_DIR:-/etc/systemd/system}" + +echo "== 1. 目录与知识库 ==" +mkdir -p "$RELAY_DIR/rescue_reports" +chmod 755 "$RELAY_DIR/rescue_reports" + +if [ ! -f "$RELAY_DIR/rescue_knowledge.md" ]; then + cat > "$RELAY_DIR/rescue_knowledge.md" <<'EOF' +# 紧急预案知识库 + +每次自动抢救成功,这里会追加一条「第几级奏效」。 +以后遇到同类症状,监测单元会优先试历史上奏效的那一级。 + +分级(从代价最小到最大): + + 1 重启隧道 不动中转,在线设备不掉线 + 2 重启中转服务 设备要重连,但很快 + 3 重新施加网络调优 MSS / MTU 跑偏时用 + 4 重启无线网卡 4G / WiFi 卡死时用 + 5 重启整机 最后的底牌,默认不开(要 RESCUE_REBOOT=1) + +EOF + echo " 建了知识库" +else + echo " 知识库已存在,保留" +fi + +echo +echo "== 2. 可执行位 ==" +chmod +x "$RELAY_DIR/monitor.sh" "$RELAY_DIR/rescue.sh" 2>/dev/null +chmod +x "$RELAY_DIR/net-tune.sh" "$RELAY_DIR/watchdog.sh" 2>/dev/null +ls -l "$RELAY_DIR/monitor.sh" "$RELAY_DIR/rescue.sh" | awk '{print " " $1, $NF}' + +echo +echo "== 3. 重载 systemd + 拉起 nettune ==" +systemctl daemon-reload +systemctl restart lanremote-nettune.service +sleep 4 +echo " nettune: $(systemctl is-active lanremote-nettune.service)" +echo " MTU: $(cat /sys/class/net/wlan0/mtu 2>/dev/null)" +echo " MSS 规则: $(iptables -t mangle -L -n 2>/dev/null | grep -ci mss) 条" + +echo +echo "== 4. 启用并启动监测定时器 ==" +systemctl enable lanremote-monitor.timer >/dev/null 2>&1 +systemctl start lanremote-monitor.timer +sleep 3 +echo " monitor.timer: $(systemctl is-active lanremote-monitor.timer)" +systemctl list-timers lanremote-monitor.timer --no-pager 2>/dev/null | head -3 + +echo +echo "== 5. 立刻手动跑一次监测(验证它能工作)==" +FAILS_BEFORE=$(grep -m1 '^fails=' "$RELAY_DIR/monitor_state.txt" 2>/dev/null | cut -d= -f2) +bash "$RELAY_DIR/monitor.sh" +echo " 跑完了。状态文件:" +cat "$RELAY_DIR/monitor_state.txt" 2>/dev/null | sed 's/^/ /' +echo " 日志尾部:" +tail -4 "$RELAY_DIR/monitor.log" 2>/dev/null | sed 's/^/ /' + +echo +echo "== 6. 复核 ==" +printf ' %-24s %s\n' "nettune" "$(systemctl is-active lanremote-nettune.service)" +printf ' %-24s %s\n' "monitor.timer" "$(systemctl is-active lanremote-monitor.timer)" +printf ' %-24s %s\n' "relay" "$(systemctl is-active lanremote-relay)" +printf ' %-24s %s\n' "tunnel" "$(systemctl is-active lanremote-tunnel)" +printf ' %-24s %s\n' "tunnel-h2" "$(systemctl is-active lanremote-tunnel-h2)" +printf ' %-24s %s\n' "公网自测" "$(curl -s -o /dev/null -w '%{http_code}' --max-time 12 https://relay.xxmxxs.top/v1/health)" +printf ' %-24s %s\n' "后台抢救报告路由" "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/rescue/none.txt)" +echo +echo "== 7. 演练:造一份假报告,验证后台能列出并下载 ==" +DEMO="$RELAY_DIR/rescue_reports/19700101-000000-演练.txt" +{ + echo "这是一份**演练用**的假报告,用来验证后台的「抢救报告」分区能列出、能下载。" + echo "看完可以删掉:rm $DEMO" + echo + echo "---- 一、结论速览 ----" + echo " 公网 https://relay.xxmxxs.top/v1/health -> 不通" + echo " 本机 http://127.0.0.1:8080/v1/health -> 200-正常" + echo + echo " 判断:**本机是好的,问题在隧道或 Cloudflare 那一侧**。" +} > "$DEMO" +echo " 造了:$DEMO" +echo " 后台页里有几份报告:$(ls "$RELAY_DIR/rescue_reports" 2>/dev/null | wc -l)" +echo " 拉一下后台页,看有没有「抢救报告」分区:" +curl -s http://127.0.0.1:8080/zhongzhuanzhan 2>/dev/null | grep -c '抢救报告' | sed 's/^/ 匹配到 /;s/$/ 次/' +echo +echo "完成。删掉演练报告:rm \"$DEMO\"" diff --git a/windows/lanremote/__init__.py b/windows/lanremote/__init__.py index b85b526..e488fa9 100644 --- a/windows/lanremote/__init__.py +++ b/windows/lanremote/__init__.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- """局域网遥控 · Windows 被控端。""" -__version__ = "2.0.0" +__version__ = "2.1.0" APP_NAME = "ykxmz" diff --git a/windows/lanremote/actions.py b/windows/lanremote/actions.py index b27a81b..1a86cc9 100644 --- a/windows/lanremote/actions.py +++ b/windows/lanremote/actions.py @@ -1,1061 +1,1085 @@ -# -*- coding: utf-8 -*- -"""被控端功能实现:投图 / 放音 / 媒体控制 / 剪贴板 / 电源 / 自定义命令 / TTS / 通知。 - -所有会碰 UI 的操作都通过 ui_call 调度回 tkinter 主线程执行。 -""" -from __future__ import annotations - -import ctypes -import os -import subprocess -import sys -import threading -import time -from pathlib import Path - -from .protocol import MEDIA_ACTIONS, POWER_ACTIONS - -IS_WINDOWS = sys.platform.startswith("win") - -# ---- Win32 常量 ---- -KEYEVENTF_KEYUP = 0x0002 -VK_MEDIA = MEDIA_ACTIONS - - -def _human_size(num: float) -> str: - for unit in ("B", "KB", "MB", "GB"): - if abs(num) < 1024.0: - return f"{num:.1f}{unit}" - num /= 1024.0 - return f"{num:.1f}TB" - - -class ActionError(RuntimeError): - pass - - -def work_area() -> tuple: - """屏幕工作区 (left, top, width, height) —— 已经扣掉任务栏。 - - 直接读 SPI_GETWORKAREA,比自己猜任务栏多高靠谱。 - """ - try: - import ctypes.wintypes - rect = ctypes.wintypes.RECT() - ok = ctypes.windll.user32.SystemParametersInfoW( - 0x0030, 0, ctypes.byref(rect), 0) # SPI_GETWORKAREA - if ok and rect.right > rect.left and rect.bottom > rect.top: - return (rect.left, rect.top, - rect.right - rect.left, rect.bottom - rect.top) - except Exception: # noqa: BLE001 - pass - try: - import tkinter as tk - root = tk._default_root # noqa: SLF001 - if root is not None: - return (0, 0, root.winfo_screenwidth(), - root.winfo_screenheight()) - except Exception: # noqa: BLE001 - pass - return (0, 0, 1920, 1080) - - -def _outer_size(win) -> tuple: - """窗口的真实外框尺寸(含标题栏和边框)。 - - 客户区尺寸不等于窗口占的地方 —— 不按外框算,看起来就不居中。 - """ - try: - import ctypes - import ctypes.wintypes - hwnd = int(win.frame(), 0) # '0x00012345' - rect = ctypes.wintypes.RECT() - if ctypes.windll.user32.GetWindowRect(hwnd, ctypes.byref(rect)): - return (rect.right - rect.left, rect.bottom - rect.top, - rect.left, rect.top) - except Exception: # noqa: BLE001 - pass - return (win.winfo_width(), win.winfo_height(), 0, 0) - - -def _place_centered(win, client_w: int, client_h: int) -> None: - """在工作区正中间摆好这个窗口(按外框算)。""" - client_w = max(1, int(client_w)) - client_h = max(1, int(client_h)) - # 先放到 0,0,好量出真实外框 - win.geometry(f"{client_w}x{client_h}+0+0") - win.update_idletasks() - - outer_w, outer_h, outer_x, outer_y = _outer_size(win) - # 外框相对客户区多出来的部分(左边框 / 标题栏+上边框) - chrome_w = max(0, outer_w - client_w) - chrome_h = max(0, outer_h - client_h) - - area_x, area_y, area_w, area_h = work_area() - x = area_x + max(0, (area_w - outer_w) // 2) + chrome_w // 2 - y = area_y + max(0, (area_h - outer_h) // 2) + chrome_h - chrome_h // 2 - - win.geometry(f"{client_w}x{client_h}+{x}+{y}") - win.update_idletasks() - win.lift() - win.focus_force() - - -class Actions: - """把协议命令翻译成 Windows 上的实际操作。""" - - def __init__(self, ui, store): - self.ui = ui # 需要提供 .root / .log() / .notify() - self.store = store - self._audio_alias: str | None = None - self._audio_path: str = "" # 上一首的路径,循环播放要用 - self._loop: bool = False # 单曲循环开关 - self._loop_thread = None # 循环看门线程 - self._image_window = None - self._lock = threading.RLock() - # 剪贴板历史(最近 20 条) - self._clip_history: list[dict] = [] - # CPU 占用要两次采样才算得出来 - self._last_cpu_total = 0 - self._last_cpu_idle = 0 - # 电脑正在播的音频是否被暂停(MCI 不是系统媒体会话,得自己记) - self._audio_paused = False - self._audio_name = "" - - # ------------------------------------------------------------------ - # 投图:在电脑上全屏显示 - # ------------------------------------------------------------------ - - def show_image(self, path: str, title: str = "") -> str: - """显示图片。 - - title 为窗口标题(可留空,用设置里的模板;模板支持 {name})。 - 方式由设置 image_display 决定: - window —— 屏幕正中间的普通 Windows 窗口(默认) - fullscreen —— 全屏 - custom —— 按 image_custom_width/height 指定大小 - 宽或高填 0 表示该方向自动。 - """ - p = Path(path) - if not p.is_file(): - raise ActionError(f"图片不存在: {path}") - - from PIL import Image, ImageTk - - try: - img = Image.open(p) - img.load() - except Exception as exc: - raise ActionError(f"无法读取图片: {exc}") from exc - - mode = str(self.store.get("image_display", "window") or "window") - if mode not in ("window", "fullscreen", "custom"): - mode = "window" - - screen_w, screen_h = self.ui.screen_size() - img = img.convert("RGB") - - if mode == "fullscreen": - target = (screen_w, screen_h) - elif mode == "custom": - cw = int(self.store.get("image_custom_width", 0) or 0) - ch = int(self.store.get("image_custom_height", 0) or 0) - if cw <= 0 and ch <= 0: - # 两个都没填就退回默认窗口大小 - cw, ch = int(screen_w * 0.78), int(screen_h * 0.78) - self.store.set("image_custom_width", cw) - self.store.set("image_custom_height", ch) - elif cw <= 0: - cw = screen_w - elif ch <= 0: - ch = screen_h - target = (max(80, cw), max(80, ch)) - else: - target = (int(screen_w * 0.78), int(screen_h * 0.78)) - - img.thumbnail(target, Image.LANCZOS) - fullscreen = (mode == "fullscreen") - - # 窗口标题:单次指定 > 设置里的模板 > 默认 - window_title = (title or "").strip() or str( - self.store.get("image_window_title") or "").strip() or "图片 - {name}" - try: - window_title = window_title.replace("{name}", p.name) - except Exception: - window_title = f"图片 - {p.name}" - backdrop = "#000000" if fullscreen else "#ffffff" - - def build(): - import tkinter as tk - if self._image_window is not None: - try: - self._image_window.destroy() - except Exception: - pass - - win = tk.Toplevel(self.ui.root) - win.title(window_title) - win.configure(bg=backdrop) - win.resizable(False, False) - - def close(_event=None): - with self._lock: - self._image_window = None - try: - win.destroy() - except Exception: - pass - - photo = ImageTk.PhotoImage(img) - label = tk.Label(win, image=photo, bg=backdrop, bd=0, - highlightthickness=0) - label.image = photo # 防止被 GC - label.pack(fill="both", expand=True) - - if fullscreen: - # 注意:Windows 上先 overrideredirect(True) 再设 -fullscreen 会抛 - # "override-redirect flag is set"。全屏本身就会去掉边框,不需要它。 - win.attributes("-fullscreen", True) - win.attributes("-topmost", True) - tk.Label(win, text="按 Esc 或点击关闭", bg=backdrop, fg="#888888", - font=("Microsoft YaHei UI", 10)).place( - relx=0.5, rely=0.97, anchor="center") - else: - foot = tk.Frame(win, bg=backdrop) - foot.pack(fill="x") - tk.Button(foot, text="关闭", command=close, - bg="#eaeef2", fg="#1f2328", relief="flat", - font=("Microsoft YaHei UI", 9), padx=14, pady=2, - cursor="hand2").pack(side="right", padx=10, pady=8) - # 摆到屏幕正中间 —— 普通窗口,有标题栏和关闭按钮 - # 必须先 update_idletasks,否则 winfo_reqwidth 还是 Tk - # 默认的 200x200,自定义尺寸就白填了。 - win.update_idletasks() - _place_centered(win, win.winfo_reqwidth(), - win.winfo_reqheight()) - - win.bind("", close) - win.bind("", close) - win.protocol("WM_DELETE_WINDOW", close) - self._image_window = win - - timeout = int(self.store.get("image_timeout", 0) or 0) - if timeout > 0: - win.after(timeout * 1000, close) - - self.ui.ui_call(build) - label_text = {"window": "屏幕中间", "fullscreen": "全屏", - "custom": f"{img.width}x{img.height}"}[mode] - return f"已显示 {p.name}({label_text})" - - def close_image(self) -> str: - def close(): - if self._image_window is not None: - try: - self._image_window.destroy() - except Exception: - pass - self._image_window = None - self.ui.ui_call(close) - return "已关闭显示窗口" - - # ------------------------------------------------------------------ - # 放音:MCI(系统自带,无需外部库) - # ------------------------------------------------------------------ - - def play_audio(self, path: str) -> str: - p = Path(path) - if not p.is_file(): - raise ActionError(f"音频不存在: {path}") - if not IS_WINDOWS: - raise ActionError("音频播放目前仅支持 Windows") - - with self._lock: - self._mci_close_locked() - alias = f"lanremote{int(time.time() * 1000) % 1000000}" - opened = self._mci(f'open "{p}" type mpegvideo alias {alias}') - if not opened: - opened = self._mci(f'open "{p}" alias {alias}') - if not opened: - raise ActionError(f"系统无法播放该音频格式: {p.suffix}") - self._audio_alias = alias - self._audio_paused = False - self._audio_name = p.name - self._audio_path = str(p) - self._mci(f"play {alias}") - return f"正在播放 {p.name}" - - def set_loop(self, on: bool) -> None: - """开关单曲循环。开了就起一个看门线程,放完自动重放。""" - self._loop = bool(on) - if on and self._loop_thread is None: - thread = threading.Thread(target=self._loop_watch, daemon=True, - name="audio-loop") - self._loop_thread = thread - thread.start() - - def _loop_watch(self) -> None: - while self._loop: - time.sleep(0.6) - if not self._loop: - break - with self._lock: - alias = self._audio_alias - path = self._audio_path - if not alias or not path: - continue - if self._mci_mode(alias) != "stopped": - continue - # 放完了,再放一遍 - try: - self.play_audio(path) - except Exception: # noqa: BLE001 - pass - self._loop_thread = None - - def stop_audio(self) -> str: - with self._lock: - self._mci_close_locked() - self._audio_paused = False - self._audio_name = "" - return "已停止播放" - - def _mci_mode(self, alias: str) -> str: - """问 MCI 现在是什么状态:playing / paused / stopped。""" - buffer = ctypes.create_unicode_buffer(64) - try: - ctypes.windll.winmm.mciSendStringW( - ctypes.c_wchar_p(f"status {alias} mode"), buffer, 64, None) - return (buffer.value or "").strip().lower() - except Exception: # noqa: BLE001 - return "" - - def audio_pause(self) -> str: - with self._lock: - alias = self._audio_alias - if not alias: - raise ActionError("电脑当前没有播放手表推过去的音频") - if self._audio_paused: - return "已经暂停了" - if not self._mci(f"pause {alias}"): - raise ActionError("暂停失败") - self._audio_paused = True - return f"已暂停 {self._audio_name or '音频'}" - - def audio_resume(self) -> str: - with self._lock: - alias = self._audio_alias - if not alias: - raise ActionError("电脑当前没有播放手表推过去的音频") - if not self._audio_paused: - return "正在播放中" - if not self._mci(f"resume {alias}"): - raise ActionError("继续播放失败") - self._audio_paused = False - return f"继续播放 {self._audio_name or '音频'}" - - def audio_status(self) -> str: - with self._lock: - alias = self._audio_alias - if not alias: - return "电脑当前没有播放音频" - mode = self._mci_mode(alias) - if mode == "playing": - return f"正在播放 {self._audio_name}" - if mode == "paused": - return f"已暂停 {self._audio_name}" - # MCI 自己放完了 - self._mci_close_locked() - self._audio_paused = False - name = self._audio_name - self._audio_name = "" - return f"{name} 已播放完毕" if name else "播放已结束" - - def _mci_close_locked(self) -> None: - if self._audio_alias: - self._mci(f"close {self._audio_alias}") - self._audio_alias = None - - @staticmethod - def _mci(command: str) -> bool: - try: - winmm = ctypes.windll.winmm - err = winmm.mciSendStringW(ctypes.c_wchar_p(command), None, 0, None) - return err == 0 - except Exception: - return False - - # ------------------------------------------------------------------ - # 媒体控制:模拟多媒体按键,作用于当前播放器 - # ------------------------------------------------------------------ - - def media_control(self, action: str) -> str: - if not IS_WINDOWS: - raise ActionError("媒体控制目前仅支持 Windows") - if action not in VK_MEDIA: - raise ActionError(f"不支持的媒体动作: {action}") - - # 电脑正在播手表推过去的音频时,直接控制它。 - # MCI 不是系统媒体会话,全局媒体键对它无效 —— - # 这就是"推了 mp3 过去却暂停不了"的原因。 - if action in ("playpause", "stop"): - handled = self._control_pushed_audio(action) - if handled: - return handled - - vk = VK_MEDIA.get(action) - try: - user32 = ctypes.windll.user32 - user32.keybd_event(vk, 0, 0, 0) - user32.keybd_event(vk, 0, KEYEVENTF_KEYUP, 0) - except Exception as exc: - raise ActionError(f"媒体按键注入失败: {exc}") from exc - return f"已执行 {action}" - - # ------------------------------------------------------------------ - # 剪贴板 - # ------------------------------------------------------------------ - - def clip_set(self, text: str) -> str: - def do(): - self.ui.root.clipboard_clear() - self.ui.root.clipboard_append(text) - self.ui.root.update_idletasks() - self.ui.ui_call(do) - self.store.set("last_clip", text) - self._remember_clip(text) - return f"剪贴板已更新({len(text)} 字符)" - - def clip_get(self) -> str: - result = {"text": ""} - - def do(): - try: - result["text"] = self.ui.root.clipboard_get() - except Exception: - result["text"] = "" - self.ui.ui_call(do) - return result["text"] - - # ------------------------------------------------------------------ - # 电源 - # ------------------------------------------------------------------ - - def power(self, action: str) -> str: - if action not in POWER_ACTIONS: - raise ActionError(f"不支持的电源动作: {action}") - if not self.store.get("allow_power", True): - raise ActionError("被控端已禁用电源操作") - if not IS_WINDOWS: - raise ActionError("电源控制目前仅支持 Windows") - - if action == "shutdown": - cmd = ["shutdown", "/s", "/t", "5"] - note = "将在 5 秒后关机" - elif action == "restart": - cmd = ["shutdown", "/r", "/t", "5"] - note = "将在 5 秒后重启" - elif action == "abort": - cmd = ["shutdown", "/a"] - note = "已取消关机" - elif action == "lock": - cmd = ["rundll32.exe", "user32.dll,LockWorkStation"] - note = "已锁屏" - else: # sleep - cmd = ["rundll32.exe", "powrprof.dll,SetSuspendState", "0,1,0"] - note = "已进入睡眠" - try: - subprocess.Popen( - cmd, shell=False, - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, - stdin=subprocess.DEVNULL, - creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)) - except Exception as exc: - raise ActionError(f"执行失败: {exc}") from exc - return note - - # ------------------------------------------------------------------ - # 自定义命令 - # ------------------------------------------------------------------ - - def run_command(self, command: str, timeout: int = 60) -> dict: - if not self.store.get("allow_run", True): - raise ActionError("被控端已禁用自定义命令") - if not command or not command.strip(): - raise ActionError("命令为空") - self.ui.log(f"[自定义命令] {command}") - try: - # 打包成 --windowed 后没有控制台,cmd 的输出是 ANSI 代码页(本机 936) - # 而不是 UTF-8 —— 用 utf-8 解会全是乱码。和本文件其它地方统一用 mbcs。 - # 另外必须加 CREATE_NO_WINDOW,否则每跑一条远程命令都会闪一个黑窗。 - proc = subprocess.run( - command, shell=True, capture_output=True, text=True, - timeout=timeout, - creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), - encoding="mbcs", errors="replace") - except subprocess.TimeoutExpired: - raise ActionError(f"命令超时(>{timeout}s)") - except Exception as exc: - raise ActionError(f"命令执行失败: {exc}") from exc - return { - "code": proc.returncode, - "stdout": (proc.stdout or "")[-4000:], - "stderr": (proc.stderr or "")[-2000:], - } - - - def _control_pushed_audio(self, action: str) -> str | None: - """如果正在播推过去的音频,就控制它;否则返回 None 走系统媒体键。""" - with self._lock: - alias = self._audio_alias - if not alias: - return None - mode = self._mci_mode(alias) - if mode not in ("playing", "paused"): - # MCI 已经放完了 - self._mci_close_locked() - self._audio_paused = False - self._audio_name = "" - return None - if action == "stop": - self._mci_close_locked() - self._audio_paused = False - self._audio_name = "" - return "已停止播放" - # playpause - if mode == "paused" or self._audio_paused: - if self._mci(f"resume {alias}"): - self._audio_paused = False - return f"继续播放 {self._audio_name}" - return None - if self._mci(f"pause {alias}"): - self._audio_paused = True - return f"已暂停 {self._audio_name}" - return None - - # ------------------------------------------------------------------ - # 语音播报(系统自带 TTS) - # ------------------------------------------------------------------ - - def list_voices(self) -> list[str]: - """列出系统可用的 TTS 音色。""" - if not IS_WINDOWS: - return [] - script = ( - "Add-Type -AssemblyName System.Speech;" - "(New-Object System.Speech.Synthesis.SpeechSynthesizer)" - ".GetInstalledVoices() | ForEach-Object { $_.VoiceInfo.Name }" - ) - try: - proc = subprocess.run( - ["powershell", "-NoProfile", "-Command", script], - capture_output=True, timeout=20, - creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), - encoding="mbcs", errors="replace") - except Exception: - return [] - return [line.strip() for line in (proc.stdout or "").splitlines() if line.strip()] - - def say(self, text: str, voice: str = "", rate: int = 0) -> str: - """语音播报。voice 为空则用系统默认(或设置里的默认音色)。""" - if not text.strip(): - raise ActionError("播报内容为空") - if not IS_WINDOWS: - raise ActionError("语音播报目前仅支持 Windows") - - chosen = (voice or "").strip() or str(self.store.get("tts_voice") or "").strip() - try: - speed = int(rate) if rate else int(self.store.get("tts_rate", 0) or 0) - except (TypeError, ValueError): - speed = 0 - speed = max(-10, min(10, speed)) - - safe = text.replace("'", "''") - parts = [ - "Add-Type -AssemblyName System.Speech;", - "$s = New-Object System.Speech.Synthesis.SpeechSynthesizer;", - ] - if chosen: - parts.append(f"try {{ $s.SelectVoice('{chosen.replace(chr(39), chr(39) * 2)}') }} " - "catch { };") - if speed: - parts.append(f"$s.Rate = {speed};") - parts.append(f"$s.Speak('{safe}')") - script = "".join(parts) - - try: - subprocess.Popen( - ["powershell", "-NoProfile", "-WindowStyle", "Hidden", - "-Command", script], - creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)) - except Exception as exc: - raise ActionError(f"TTS 启动失败: {exc}") from exc - return f"已播报({chosen or '默认音色'})" - - # ------------------------------------------------------------------ - # 播放本机音频文件(MP3 等) - # ------------------------------------------------------------------ - - AUDIO_EXTS = (".mp3", ".wav", ".m4a", ".flac", ".aac", ".ogg", ".wma") - - def audio_roots(self) -> list[Path]: - """允许播放的目录。只认音乐相关目录,避免被用来播任意系统文件。""" - roots: list[Path] = [] - configured = str(self.store.get("mp3_dir") or "").strip() - if configured: - path = Path(configured) - if path.is_dir(): - roots.append(path) - home = Path.home() - for name in ("Music", "音乐", "Downloads", "下载"): - path = home / name - if path.is_dir(): - roots.append(path) - return roots - - def list_local_audio(self, limit: int = 300) -> list[dict]: - """列出本机可播放的音频文件。""" - items: list[dict] = [] - seen: set[str] = set() - for root in self.audio_roots(): - try: - paths = sorted(root.rglob("*")) - except OSError: - continue - for path in paths: - if len(items) >= limit: - return items - try: - if not path.is_file(): - continue - if path.suffix.lower() not in self.AUDIO_EXTS: - continue - key = str(path).lower() - if key in seen: - continue - seen.add(key) - items.append({ - "name": path.name, - "path": str(path), - "size": path.stat().st_size, - "dir": root.name, - }) - except OSError: - continue - items.sort(key=lambda it: it["name"].lower()) - return items - - def play_local(self, path: str) -> str: - """播放本机某个音频文件。只允许音乐目录内的文件。""" - target = Path(path) - if not target.is_file(): - raise ActionError(f"文件不存在: {path}") - if target.suffix.lower() not in self.AUDIO_EXTS: - raise ActionError(f"不支持的音频格式: {target.suffix}") - - allowed = False - for root in self.audio_roots(): - try: - target.resolve().relative_to(root.resolve()) - allowed = True - break - except (ValueError, OSError): - continue - if not allowed: - raise ActionError("只允许播放音乐目录里的文件") - - self.play_audio(str(target)) - return f"正在播放 {target.name}" - - # ------------------------------------------------------------------ - # 定时关机 / 关显示器 - # ------------------------------------------------------------------ - - def shutdown_timer(self, minutes: int = 0, cancel: bool = False) -> str: - """定时关机;cancel=True 时取消。minutes<=0 也会取消。""" - if not IS_WINDOWS: - raise ActionError("仅支持 Windows") - if cancel or minutes <= 0: - proc = subprocess.run(["shutdown", "/a"], capture_output=True, - creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)) - text = (proc.stdout or proc.stderr or b"").decode("mbcs", errors="replace") - if proc.returncode != 0 and "1116" not in text and "没有" not in text: - return "当前没有待执行的关机计划" - return "已取消定时关机" - minutes = max(1, min(1440, int(minutes))) - subprocess.run(["shutdown", "/s", "/t", str(minutes * 60)], - capture_output=True, - creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)) - return f"将在 {minutes} 分钟后关机" - - def monitor_power(self, on: bool = False) -> str: - """关屏 / 唤醒屏幕。""" - if not IS_WINDOWS: - raise ActionError("仅支持 Windows") - import ctypes - HWND_BROADCAST = 0xFFFF - WM_SYSCOMMAND = 0x0112 - SC_MONITORPOWER = 0xF170 - # 2 = 关闭, -1 = 打开 - ctypes.windll.user32.SendMessageW(HWND_BROADCAST, WM_SYSCOMMAND, - SC_MONITORPOWER, -1 if on else 2) - return "显示器已打开" if on else "显示器已关闭" - - # ------------------------------------------------------------------ - # 打开网址 / 快捷命令 / 常用文件夹 - # ------------------------------------------------------------------ - - def open_url(self, url: str) -> str: - text = (url or "").strip() - if not text: - raise ActionError("网址为空") - if "://" not in text: - text = "https://" + text - if not text.lower().startswith(("http://", "https://")): - raise ActionError("只允许打开 http/https 网址") - try: - os.startfile(text) # noqa: S606 - except Exception as exc: # noqa: BLE001 - raise ActionError(f"打开失败: {exc}") from exc - return f"已在电脑打开 {text}" - - QUICK_APPS = { - "taskmgr": ("任务管理器", "taskmgr.exe"), - "explorer": ("资源管理器", "explorer.exe"), - "cmd": ("命令提示符", "cmd.exe"), - "calc": ("计算器", "calc.exe"), - "notepad": ("记事本", "notepad.exe"), - "control": ("控制面板", "control.exe"), - "mspaint": ("画图", "mspaint.exe"), - "devmgmt": ("设备管理器", "devmgmt.msc"), - } - - def quick_launch(self, key: str) -> str: - item = self.QUICK_APPS.get((key or "").strip().lower()) - if item is None: - raise ActionError(f"没有这个快捷程序: {key}") - label, command = item - try: - subprocess.Popen(command, shell=True, - creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)) - except Exception as exc: # noqa: BLE001 - raise ActionError(f"启动失败: {exc}") from exc - return f"已打开{label}" - - def open_folder(self, key: str) -> str: - home = Path.home() - folders = { - "downloads": ("下载", home / "Downloads"), - "documents": ("文档", home / "Documents"), - "desktop": ("桌面", home / "Desktop"), - "pictures": ("图片", home / "Pictures"), - "music": ("音乐", home / "Music"), - "received": ("接收目录", self.store.received_dir()), - "app": ("程序目录", Path(__file__).resolve().parent.parent), - } - item = folders.get((key or "").strip().lower()) - if item is None: - raise ActionError(f"没有这个文件夹: {key}") - label, path = item - if not path.is_dir(): - raise ActionError(f"{label}目录不存在: {path}") - try: - os.startfile(str(path)) # noqa: S606 - except Exception as exc: # noqa: BLE001 - raise ActionError(f"打开失败: {exc}") from exc - return f"已打开{label}" - - # ------------------------------------------------------------------ - # 进程 - # ------------------------------------------------------------------ - - def list_processes(self, limit: int = 40) -> list[dict]: - if not IS_WINDOWS: - return [] - try: - proc = subprocess.run( - ["tasklist", "/FO", "CSV", "/NH"], capture_output=True, - timeout=20, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), - encoding="mbcs", errors="replace") - except Exception: # noqa: BLE001 - return [] - items: list[dict] = [] - for raw in (proc.stdout or "").splitlines(): - parts = [p.strip().strip('"') for p in raw.split('","')] - if len(parts) < 5: - continue - name = parts[0].strip('"') - pid = parts[1] - memory = parts[4].replace(",", "").replace(" K", "").strip() - try: - mem_kb = int(memory) - except ValueError: - mem_kb = 0 - if not pid.isdigit(): - continue - items.append({"name": name, "pid": int(pid), "mem_kb": mem_kb}) - items.sort(key=lambda it: it["mem_kb"], reverse=True) - return items[:limit] - - def kill_process(self, pid: int = 0, name: str = "") -> str: - if not IS_WINDOWS: - raise ActionError("仅支持 Windows") - args = ["taskkill", "/F"] - if pid: - args += ["/PID", str(int(pid))] - label = f"PID {pid}" - elif name: - safe = "".join(ch for ch in name if ch.isalnum() or ch in "._- ") - if not safe: - raise ActionError("进程名不合法") - args += ["/IM", safe] - label = safe - else: - raise ActionError("要指定 PID 或进程名") - proc = subprocess.run(args, capture_output=True, - creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), - encoding="mbcs", errors="replace") - if proc.returncode != 0: - raise ActionError(f"结束失败: {(proc.stdout or proc.stderr or '').strip()[:80]}") - return f"已结束 {label}" - - # ------------------------------------------------------------------ - # 状态:电量 / 磁盘 / 性能 - # ------------------------------------------------------------------ - - def battery(self) -> dict: - if not IS_WINDOWS: - return {"present": False} - import ctypes - - class PowerStatus(ctypes.Structure): - _fields_ = [("ACLineStatus", ctypes.c_byte), - ("BatteryFlag", ctypes.c_byte), - ("BatteryLifePercent", ctypes.c_byte), - ("SystemStatusFlag", ctypes.c_byte), - ("BatteryLifeTime", ctypes.c_ulong), - ("BatteryFullLifeTime", ctypes.c_ulong)] - - status = PowerStatus() - if not ctypes.windll.kernel32.GetSystemPowerStatus(ctypes.byref(status)): - return {"present": False} - if status.BatteryLifePercent == 255 or status.BatteryFlag == 128: - return {"present": False, "note": "台式机,没有电池"} - return { - "present": True, - "percent": int(status.BatteryLifePercent), - "charging": bool(status.ACLineStatus), - "seconds_left": int(status.BatteryLifeTime) - if status.BatteryLifeTime not in (0xFFFFFFFF, 0) else 0, - } - - def disk(self) -> list[dict]: - if not IS_WINDOWS: - return [] - import ctypes - import string - - out: list[dict] = [] - bitmask = ctypes.windll.kernel32.GetLogicalDrives() - free = ctypes.c_ulonglong(0) - total = ctypes.c_ulonglong(0) - for i, letter in enumerate(string.ascii_uppercase): - if not (bitmask >> i) & 1: - continue - root = f"{letter}:\\" - try: - ok = ctypes.windll.kernel32.GetDiskFreeSpaceExW( - ctypes.c_wchar_p(root), None, ctypes.byref(total), - ctypes.byref(free)) - except Exception: # noqa: BLE001 - continue - if not ok or total.value == 0: - continue - out.append({ - "drive": f"{letter}:", - "total_gb": round(total.value / 1073741824, 1), - "free_gb": round(free.value / 1073741824, 1), - "used_percent": int((total.value - free.value) * 100 / total.value), - }) - return out - - def perf(self) -> dict: - if not IS_WINDOWS: - return {} - import ctypes - - class MemoryStatus(ctypes.Structure): - _fields_ = [("dwLength", ctypes.c_ulong), - ("dwMemoryLoad", ctypes.c_ulong), - ("ullTotalPhys", ctypes.c_ulonglong), - ("ullAvailPhys", ctypes.c_ulonglong), - ("ullTotalPageFile", ctypes.c_ulonglong), - ("ullAvailPageFile", ctypes.c_ulonglong), - ("ullTotalVirtual", ctypes.c_ulonglong), - ("ullAvailVirtual", ctypes.c_ulonglong), - ("ullAvailExtendedVirtual", ctypes.c_ulonglong)] - - mem = MemoryStatus() - mem.dwLength = ctypes.sizeof(MemoryStatus) - ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(mem)) - - idle = ctypes.c_ulonglong() - kernel = ctypes.c_ulonglong() - user = ctypes.c_ulonglong() - ctypes.windll.kernel32.GetSystemTimes(ctypes.byref(idle), - ctypes.byref(kernel), - ctypes.byref(user)) - cpu = 0 - try: - total = kernel.value + user.value - if total and self._last_cpu_total: - delta_total = total - self._last_cpu_total - delta_idle = idle.value - self._last_cpu_idle - if delta_total > 0: - cpu = int(round((1 - delta_idle / delta_total) * 100)) - except Exception: # noqa: BLE001 - cpu = 0 - self._last_cpu_total = kernel.value + user.value - self._last_cpu_idle = idle.value - - import platform as _platform - return { - "cpu_percent": max(0, min(100, cpu)), - "mem_percent": int(mem.dwMemoryLoad), - "mem_used_gb": round((mem.ullTotalPhys - mem.ullAvailPhys) / 1073741824, 1), - "mem_total_gb": round(mem.ullTotalPhys / 1073741824, 1), - "uptime": self._uptime(), - "os": f"{_platform.system()} {_platform.release()}", - } - - def _uptime(self) -> str: - try: - import ctypes - # 不设 restype 的话 ctypes 默认按 32 位处理, - # 开机超过 24.8 天会被截断成负数,时长显示错乱 - ctypes.windll.kernel32.GetTickCount64.restype = ctypes.c_ulonglong - ms = ctypes.windll.kernel32.GetTickCount64() - seconds = int(ms / 1000) - days, rem = divmod(seconds, 86400) - hours, rem = divmod(rem, 3600) - minutes = rem // 60 - if days: - return f"{days} 天 {hours} 小时" - if hours: - return f"{hours} 小时 {minutes} 分" - return f"{minutes} 分钟" - except Exception: # noqa: BLE001 - return "-" - - # ------------------------------------------------------------------ - # 剪贴板历史 - # ------------------------------------------------------------------ - - def clip_history(self) -> list[dict]: - return list(self._clip_history) - - def _remember_clip(self, text: str) -> None: - text = (text or "").strip() - if not text: - return - with self._lock: - self._clip_history = [x for x in self._clip_history - if x.get("text") != text] - self._clip_history.insert(0, { - "text": text[:2000], - "at": time.strftime("%H:%M:%S"), - }) - del self._clip_history[20:] - - def say_clipboard(self) -> str: - # 之前读的是 store["last_clip"](那是手表推过来的内容), - # 手表没推过就永远报"剪贴板是空的",可电脑剪贴板明明有东西。 - # 应该读电脑本机的剪贴板。 - try: - text = str(self.clip_get() or "").strip() - except Exception: # noqa: BLE001 - text = "" - if not text: - text = str(self.store.get("last_clip") or "").strip() - if not text: - raise ActionError("电脑剪贴板是空的") - return self.say(text[:500] + ("…" if len(text) > 500 else "")) - - # ------------------------------------------------------------------ - # 壁纸 - # ------------------------------------------------------------------ - - def set_wallpaper(self, path: str = "") -> str: - """把某张图片设为桌面壁纸。默认用最近收到的那张图。""" - if not IS_WINDOWS: - raise ActionError("仅支持 Windows") - import ctypes - - target = Path(path) if path else None - if target is None or not target.is_file(): - received = self.store.received_dir() - images = sorted( - [p for p in received.iterdir() - if p.is_file() and p.suffix.lower() in (".png", ".jpg", ".jpeg", ".bmp")], - key=lambda p: p.stat().st_mtime, reverse=True) - if not images: - raise ActionError("接收目录里还没有图片") - target = images[0] - - SPI_SETDESKWALLPAPER = 0x0014 - SPIF_UPDATEINIFILE = 0x01 - SPIF_SENDCHANGE = 0x02 - ok = ctypes.windll.user32.SystemParametersInfoW( - SPI_SETDESKWALLPAPER, 0, ctypes.c_wchar_p(str(target)), - SPIF_UPDATEINIFILE | SPIF_SENDCHANGE) - if not ok: - raise ActionError("设置壁纸失败") - return f"壁纸已设为 {target.name}" - - # ------------------------------------------------------------------ - # 通知(托盘气泡,可见) - # ------------------------------------------------------------------ - - def notify(self, title: str, text: str) -> str: - self.ui.notify(title or "局域网遥控", text or "") - return "已通知" - - # ------------------------------------------------------------------ - # 系统信息 - # ------------------------------------------------------------------ - - def info(self) -> dict: - import platform - screen_ok = False - screen_size = (0, 0) - try: - from .screen import ScreenCapturer - capturer = ScreenCapturer() - screen_ok = capturer.available() - if screen_ok: - screen_size = capturer.screen_size() - except Exception: - screen_ok = False - return { - "server_name": self.store.get("server_name", ""), - "os": f"{platform.system()} {platform.release()}", - "python": platform.python_version(), - "port": self.store.get("port", 0), - "allow_run": bool(self.store.get("allow_run", True)), - "allow_power": bool(self.store.get("allow_power", True)), - "allow_screen": bool(self.store.get("allow_screen", True)), - "screen_supported": screen_ok, - "screen_width": screen_size[0], - "screen_height": screen_size[1], - "received_dir": str(self.store.received_dir()), - } - - -def open_path(path: str) -> None: - """用系统默认程序打开文件/文件夹(仅本机 UI 按钮使用)。""" - if IS_WINDOWS: - os.startfile(path) # noqa: S606 - elif sys.platform == "darwin": - subprocess.Popen(["open", path]) - else: - subprocess.Popen(["xdg-open", path]) +# -*- coding: utf-8 -*- +"""被控端功能实现:投图 / 放音 / 媒体控制 / 剪贴板 / 电源 / 自定义命令 / TTS / 通知。 + +所有会碰 UI 的操作都通过 ui_call 调度回 tkinter 主线程执行。 +""" +from __future__ import annotations + +import ctypes +import os +import subprocess +import sys +import threading +import time +from pathlib import Path + +from .protocol import MEDIA_ACTIONS, POWER_ACTIONS + +IS_WINDOWS = sys.platform.startswith("win") + +# ---- Win32 常量 ---- +KEYEVENTF_KEYUP = 0x0002 +VK_MEDIA = MEDIA_ACTIONS + + +def _human_size(num: float) -> str: + for unit in ("B", "KB", "MB", "GB"): + if abs(num) < 1024.0: + return f"{num:.1f}{unit}" + num /= 1024.0 + return f"{num:.1f}TB" + + +class ActionError(RuntimeError): + pass + + +def work_area() -> tuple: + """屏幕工作区 (left, top, width, height) —— 已经扣掉任务栏。 + + 直接读 SPI_GETWORKAREA,比自己猜任务栏多高靠谱。 + """ + try: + import ctypes.wintypes + rect = ctypes.wintypes.RECT() + ok = ctypes.windll.user32.SystemParametersInfoW( + 0x0030, 0, ctypes.byref(rect), 0) # SPI_GETWORKAREA + if ok and rect.right > rect.left and rect.bottom > rect.top: + return (rect.left, rect.top, + rect.right - rect.left, rect.bottom - rect.top) + except Exception: # noqa: BLE001 + pass + try: + import tkinter as tk + root = tk._default_root # noqa: SLF001 + if root is not None: + return (0, 0, root.winfo_screenwidth(), + root.winfo_screenheight()) + except Exception: # noqa: BLE001 + pass + return (0, 0, 1920, 1080) + + +def _outer_size(win) -> tuple: + """窗口的真实外框尺寸(含标题栏和边框)。 + + 客户区尺寸不等于窗口占的地方 —— 不按外框算,看起来就不居中。 + """ + try: + import ctypes + import ctypes.wintypes + hwnd = int(win.frame(), 0) # '0x00012345' + rect = ctypes.wintypes.RECT() + if ctypes.windll.user32.GetWindowRect(hwnd, ctypes.byref(rect)): + return (rect.right - rect.left, rect.bottom - rect.top, + rect.left, rect.top) + except Exception: # noqa: BLE001 + pass + return (win.winfo_width(), win.winfo_height(), 0, 0) + + +def _place_centered(win, client_w: int, client_h: int) -> None: + """在工作区正中间摆好这个窗口(按外框算)。""" + client_w = max(1, int(client_w)) + client_h = max(1, int(client_h)) + # 先放到 0,0,好量出真实外框 + win.geometry(f"{client_w}x{client_h}+0+0") + win.update_idletasks() + + outer_w, outer_h, outer_x, outer_y = _outer_size(win) + # 外框相对客户区多出来的部分(左边框 / 标题栏+上边框) + chrome_w = max(0, outer_w - client_w) + chrome_h = max(0, outer_h - client_h) + + area_x, area_y, area_w, area_h = work_area() + x = area_x + max(0, (area_w - outer_w) // 2) + chrome_w // 2 + y = area_y + max(0, (area_h - outer_h) // 2) + chrome_h - chrome_h // 2 + + win.geometry(f"{client_w}x{client_h}+{x}+{y}") + win.update_idletasks() + win.lift() + win.focus_force() + + +class Actions: + """把协议命令翻译成 Windows 上的实际操作。""" + + def __init__(self, ui, store): + self.ui = ui # 需要提供 .root / .log() / .notify() + self.store = store + self._audio_alias: str | None = None + self._audio_path: str = "" # 上一首的路径,循环播放要用 + self._loop: bool = False # 单曲循环开关 + self._loop_thread = None # 循环看门线程 + self._image_window = None + self._lock = threading.RLock() + # 剪贴板历史(最近 20 条) + self._clip_history: list[dict] = [] + # CPU 占用要两次采样才算得出来 + self._last_cpu_total = 0 + self._last_cpu_idle = 0 + # 电脑正在播的音频是否被暂停(MCI 不是系统媒体会话,得自己记) + self._audio_paused = False + self._audio_name = "" + + # ------------------------------------------------------------------ + # 投图:在电脑上全屏显示 + # ------------------------------------------------------------------ + + def show_image(self, path: str, title: str = "") -> str: + """显示图片。 + + title 为窗口标题(可留空,用设置里的模板;模板支持 {name})。 + 方式由设置 image_display 决定: + window —— 屏幕正中间的普通 Windows 窗口(默认) + fullscreen —— 全屏 + custom —— 按 image_custom_width/height 指定大小 + 宽或高填 0 表示该方向自动。 + """ + p = Path(path) + if not p.is_file(): + raise ActionError(f"图片不存在: {path}") + + from PIL import Image, ImageTk + + try: + img = Image.open(p) + img.load() + except Exception as exc: + raise ActionError(f"无法读取图片: {exc}") from exc + + mode = str(self.store.get("image_display", "window") or "window") + if mode not in ("window", "fullscreen", "custom"): + mode = "window" + + screen_w, screen_h = self.ui.screen_size() + img = img.convert("RGB") + + if mode == "fullscreen": + target = (screen_w, screen_h) + elif mode == "custom": + cw = int(self.store.get("image_custom_width", 0) or 0) + ch = int(self.store.get("image_custom_height", 0) or 0) + if cw <= 0 and ch <= 0: + # 两个都没填就退回默认窗口大小 + cw, ch = int(screen_w * 0.78), int(screen_h * 0.78) + self.store.set("image_custom_width", cw) + self.store.set("image_custom_height", ch) + elif cw <= 0: + cw = screen_w + elif ch <= 0: + ch = screen_h + target = (max(80, cw), max(80, ch)) + else: + target = (int(screen_w * 0.78), int(screen_h * 0.78)) + + img.thumbnail(target, Image.LANCZOS) + fullscreen = (mode == "fullscreen") + + # 窗口标题:单次指定 > 设置里的模板 > 默认 + window_title = (title or "").strip() or str( + self.store.get("image_window_title") or "").strip() or "图片 - {name}" + try: + window_title = window_title.replace("{name}", p.name) + except Exception: + window_title = f"图片 - {p.name}" + backdrop = "#000000" if fullscreen else "#ffffff" + + def build(): + import tkinter as tk + if self._image_window is not None: + try: + self._image_window.destroy() + except Exception: + pass + + win = tk.Toplevel(self.ui.root) + win.title(window_title) + win.configure(bg=backdrop) + win.resizable(False, False) + + def close(_event=None): + with self._lock: + self._image_window = None + try: + win.destroy() + except Exception: + pass + + photo = ImageTk.PhotoImage(img) + label = tk.Label(win, image=photo, bg=backdrop, bd=0, + highlightthickness=0) + label.image = photo # 防止被 GC + label.pack(fill="both", expand=True) + + if fullscreen: + # 注意:Windows 上先 overrideredirect(True) 再设 -fullscreen 会抛 + # "override-redirect flag is set"。全屏本身就会去掉边框,不需要它。 + win.attributes("-fullscreen", True) + win.attributes("-topmost", True) + tk.Label(win, text="按 Esc 或点击关闭", bg=backdrop, fg="#888888", + font=("Microsoft YaHei UI", 10)).place( + relx=0.5, rely=0.97, anchor="center") + else: + foot = tk.Frame(win, bg=backdrop) + foot.pack(fill="x") + tk.Button(foot, text="关闭", command=close, + bg="#eaeef2", fg="#1f2328", relief="flat", + font=("Microsoft YaHei UI", 9), padx=14, pady=2, + cursor="hand2").pack(side="right", padx=10, pady=8) + # 摆到屏幕正中间 —— 普通窗口,有标题栏和关闭按钮 + # 必须先 update_idletasks,否则 winfo_reqwidth 还是 Tk + # 默认的 200x200,自定义尺寸就白填了。 + win.update_idletasks() + _place_centered(win, win.winfo_reqwidth(), + win.winfo_reqheight()) + + win.bind("", close) + win.bind("", close) + win.protocol("WM_DELETE_WINDOW", close) + self._image_window = win + + timeout = int(self.store.get("image_timeout", 0) or 0) + if timeout > 0: + win.after(timeout * 1000, close) + + self.ui.ui_call(build) + label_text = {"window": "屏幕中间", "fullscreen": "全屏", + "custom": f"{img.width}x{img.height}"}[mode] + return f"已显示 {p.name}({label_text})" + + def close_image(self) -> str: + def close(): + if self._image_window is not None: + try: + self._image_window.destroy() + except Exception: + pass + self._image_window = None + self.ui.ui_call(close) + return "已关闭显示窗口" + + # ------------------------------------------------------------------ + # 放音:MCI(系统自带,无需外部库) + # ------------------------------------------------------------------ + + def play_audio(self, path: str) -> str: + p = Path(path) + if not p.is_file(): + raise ActionError(f"音频不存在: {path}") + if not IS_WINDOWS: + raise ActionError("音频播放目前仅支持 Windows") + + with self._lock: + self._mci_close_locked() + alias = f"lanremote{int(time.time() * 1000) % 1000000}" + opened = self._mci(f'open "{p}" type mpegvideo alias {alias}') + if not opened: + opened = self._mci(f'open "{p}" alias {alias}') + if not opened: + raise ActionError(f"系统无法播放该音频格式: {p.suffix}") + self._audio_alias = alias + self._audio_paused = False + self._audio_name = p.name + self._audio_path = str(p) + self._mci(f"play {alias}") + return f"正在播放 {p.name}" + + def set_loop(self, on: bool) -> None: + """开关单曲循环。开了就起一个看门线程,放完自动重放。""" + self._loop = bool(on) + if on and self._loop_thread is None: + thread = threading.Thread(target=self._loop_watch, daemon=True, + name="audio-loop") + self._loop_thread = thread + thread.start() + + def _loop_watch(self) -> None: + while self._loop: + time.sleep(0.6) + if not self._loop: + break + with self._lock: + alias = self._audio_alias + path = self._audio_path + if not alias or not path: + continue + if self._mci_mode(alias) != "stopped": + continue + # 放完了,再放一遍 + try: + self.play_audio(path) + except Exception: # noqa: BLE001 + pass + self._loop_thread = None + + def stop_audio(self) -> str: + with self._lock: + self._mci_close_locked() + self._audio_paused = False + self._audio_name = "" + return "已停止播放" + + def _mci_mode(self, alias: str) -> str: + """问 MCI 现在是什么状态:playing / paused / stopped。""" + buffer = ctypes.create_unicode_buffer(64) + try: + ctypes.windll.winmm.mciSendStringW( + ctypes.c_wchar_p(f"status {alias} mode"), buffer, 64, None) + return (buffer.value or "").strip().lower() + except Exception: # noqa: BLE001 + return "" + + def audio_pause(self) -> str: + with self._lock: + alias = self._audio_alias + if not alias: + raise ActionError("电脑当前没有播放手表推过去的音频") + if self._audio_paused: + return "已经暂停了" + if not self._mci(f"pause {alias}"): + raise ActionError("暂停失败") + self._audio_paused = True + return f"已暂停 {self._audio_name or '音频'}" + + def audio_resume(self) -> str: + with self._lock: + alias = self._audio_alias + if not alias: + raise ActionError("电脑当前没有播放手表推过去的音频") + if not self._audio_paused: + return "正在播放中" + if not self._mci(f"resume {alias}"): + raise ActionError("继续播放失败") + self._audio_paused = False + return f"继续播放 {self._audio_name or '音频'}" + + def audio_status(self) -> str: + with self._lock: + alias = self._audio_alias + if not alias: + return "电脑当前没有播放音频" + mode = self._mci_mode(alias) + if mode == "playing": + return f"正在播放 {self._audio_name}" + if mode == "paused": + return f"已暂停 {self._audio_name}" + # MCI 自己放完了 + self._mci_close_locked() + self._audio_paused = False + name = self._audio_name + self._audio_name = "" + return f"{name} 已播放完毕" if name else "播放已结束" + + def _mci_close_locked(self) -> None: + if self._audio_alias: + self._mci(f"close {self._audio_alias}") + self._audio_alias = None + + @staticmethod + def _mci(command: str) -> bool: + try: + winmm = ctypes.windll.winmm + err = winmm.mciSendStringW(ctypes.c_wchar_p(command), None, 0, None) + return err == 0 + except Exception: + return False + + # ------------------------------------------------------------------ + # 媒体控制:模拟多媒体按键,作用于当前播放器 + # ------------------------------------------------------------------ + + def media_control(self, action: str) -> str: + if not IS_WINDOWS: + raise ActionError("媒体控制目前仅支持 Windows") + if action not in VK_MEDIA: + raise ActionError(f"不支持的媒体动作: {action}") + + # 电脑正在播手表推过去的音频时,直接控制它。 + # MCI 不是系统媒体会话,全局媒体键对它无效 —— + # 这就是"推了 mp3 过去却暂停不了"的原因。 + if action in ("playpause", "stop"): + handled = self._control_pushed_audio(action) + if handled: + return handled + + vk = VK_MEDIA.get(action) + try: + user32 = ctypes.windll.user32 + user32.keybd_event(vk, 0, 0, 0) + user32.keybd_event(vk, 0, KEYEVENTF_KEYUP, 0) + except Exception as exc: + raise ActionError(f"媒体按键注入失败: {exc}") from exc + return f"已执行 {action}" + + # ------------------------------------------------------------------ + # 剪贴板 + # ------------------------------------------------------------------ + + def clip_set(self, text: str) -> str: + def do(): + self.ui.root.clipboard_clear() + self.ui.root.clipboard_append(text) + self.ui.root.update_idletasks() + self.ui.ui_call(do) + self.store.set("last_clip", text) + self._remember_clip(text) + return f"剪贴板已更新({len(text)} 字符)" + + def clip_get(self) -> str: + result = {"text": ""} + + def do(): + try: + result["text"] = self.ui.root.clipboard_get() + except Exception: + result["text"] = "" + self.ui.ui_call(do) + return result["text"] + + # ------------------------------------------------------------------ + # 电源 + # ------------------------------------------------------------------ + + def power(self, action: str) -> str: + if action not in POWER_ACTIONS: + raise ActionError(f"不支持的电源动作: {action}") + if not self.store.get("allow_power", True): + raise ActionError("被控端已禁用电源操作") + if not IS_WINDOWS: + raise ActionError("电源控制目前仅支持 Windows") + + if action == "shutdown": + cmd = ["shutdown", "/s", "/t", "5"] + note = "将在 5 秒后关机" + elif action == "restart": + cmd = ["shutdown", "/r", "/t", "5"] + note = "将在 5 秒后重启" + elif action == "abort": + cmd = ["shutdown", "/a"] + note = "已取消关机" + elif action == "lock": + cmd = ["rundll32.exe", "user32.dll,LockWorkStation"] + note = "已锁屏" + else: # sleep + cmd = ["rundll32.exe", "powrprof.dll,SetSuspendState", "0,1,0"] + note = "已进入睡眠" + try: + subprocess.Popen( + cmd, shell=False, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + stdin=subprocess.DEVNULL, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)) + except Exception as exc: + raise ActionError(f"执行失败: {exc}") from exc + return note + + # ------------------------------------------------------------------ + # 自定义命令 + # ------------------------------------------------------------------ + + def run_command(self, command: str, timeout: int = 60) -> dict: + if not self.store.get("allow_run", True): + raise ActionError("被控端已禁用自定义命令") + if not command or not command.strip(): + raise ActionError("命令为空") + self.ui.log(f"[自定义命令] {command}") + try: + # 打包成 --windowed 后没有控制台,cmd 的输出是 ANSI 代码页(本机 936) + # 而不是 UTF-8 —— 用 utf-8 解会全是乱码。和本文件其它地方统一用 mbcs。 + # 另外必须加 CREATE_NO_WINDOW,否则每跑一条远程命令都会闪一个黑窗。 + proc = subprocess.run( + command, shell=True, capture_output=True, text=True, + timeout=timeout, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + encoding="mbcs", errors="replace") + except subprocess.TimeoutExpired: + raise ActionError(f"命令超时(>{timeout}s)") + except Exception as exc: + raise ActionError(f"命令执行失败: {exc}") from exc + return { + "code": proc.returncode, + "stdout": (proc.stdout or "")[-4000:], + "stderr": (proc.stderr or "")[-2000:], + } + + + def _control_pushed_audio(self, action: str) -> str | None: + """如果正在播推过去的音频,就控制它;否则返回 None 走系统媒体键。""" + with self._lock: + alias = self._audio_alias + if not alias: + return None + mode = self._mci_mode(alias) + if mode not in ("playing", "paused"): + # MCI 已经放完了 + self._mci_close_locked() + self._audio_paused = False + self._audio_name = "" + return None + if action == "stop": + self._mci_close_locked() + self._audio_paused = False + self._audio_name = "" + return "已停止播放" + # playpause + if mode == "paused" or self._audio_paused: + if self._mci(f"resume {alias}"): + self._audio_paused = False + return f"继续播放 {self._audio_name}" + return None + if self._mci(f"pause {alias}"): + self._audio_paused = True + return f"已暂停 {self._audio_name}" + return None + + # ------------------------------------------------------------------ + # 语音播报(系统自带 TTS) + # ------------------------------------------------------------------ + + def list_voices(self) -> list[str]: + """列出系统可用的 TTS 音色。""" + if not IS_WINDOWS: + return [] + script = ( + "Add-Type -AssemblyName System.Speech;" + "(New-Object System.Speech.Synthesis.SpeechSynthesizer)" + ".GetInstalledVoices() | ForEach-Object { $_.VoiceInfo.Name }" + ) + try: + proc = subprocess.run( + ["powershell", "-NoProfile", "-Command", script], + capture_output=True, timeout=20, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + encoding="mbcs", errors="replace") + except Exception: + return [] + return [line.strip() for line in (proc.stdout or "").splitlines() if line.strip()] + + def say(self, text: str, voice: str = "", rate: int = 0) -> str: + """语音播报。voice 为空则用系统默认(或设置里的默认音色)。""" + if not text.strip(): + raise ActionError("播报内容为空") + if not IS_WINDOWS: + raise ActionError("语音播报目前仅支持 Windows") + + chosen = (voice or "").strip() or str(self.store.get("tts_voice") or "").strip() + try: + speed = int(rate) if rate else int(self.store.get("tts_rate", 0) or 0) + except (TypeError, ValueError): + speed = 0 + speed = max(-10, min(10, speed)) + + safe = text.replace("'", "''") + parts = [ + "Add-Type -AssemblyName System.Speech;", + "$s = New-Object System.Speech.Synthesis.SpeechSynthesizer;", + ] + if chosen: + parts.append(f"try {{ $s.SelectVoice('{chosen.replace(chr(39), chr(39) * 2)}') }} " + "catch { };") + if speed: + parts.append(f"$s.Rate = {speed};") + parts.append(f"$s.Speak('{safe}')") + script = "".join(parts) + + try: + subprocess.Popen( + ["powershell", "-NoProfile", "-WindowStyle", "Hidden", + "-Command", script], + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)) + except Exception as exc: + raise ActionError(f"TTS 启动失败: {exc}") from exc + return f"已播报({chosen or '默认音色'})" + + # ------------------------------------------------------------------ + # 播放本机音频文件(MP3 等) + # ------------------------------------------------------------------ + + AUDIO_EXTS = (".mp3", ".wav", ".m4a", ".flac", ".aac", ".ogg", ".wma") + + def audio_roots(self) -> list[Path]: + """允许播放的目录。只认音乐相关目录,避免被用来播任意系统文件。""" + roots: list[Path] = [] + configured = str(self.store.get("mp3_dir") or "").strip() + if configured: + path = Path(configured) + if path.is_dir(): + roots.append(path) + home = Path.home() + for name in ("Music", "音乐", "Downloads", "下载"): + path = home / name + if path.is_dir(): + roots.append(path) + return roots + + def list_local_audio(self, limit: int = 300) -> list[dict]: + """列出本机可播放的音频文件。""" + items: list[dict] = [] + seen: set[str] = set() + for root in self.audio_roots(): + try: + paths = sorted(root.rglob("*")) + except OSError: + continue + for path in paths: + if len(items) >= limit: + return items + try: + if not path.is_file(): + continue + if path.suffix.lower() not in self.AUDIO_EXTS: + continue + key = str(path).lower() + if key in seen: + continue + seen.add(key) + items.append({ + "name": path.name, + "path": str(path), + "size": path.stat().st_size, + "dir": root.name, + }) + except OSError: + continue + items.sort(key=lambda it: it["name"].lower()) + return items + + # ---- 音效(网络热门音效,手表上点一下电脑就响)---- + # + # 素材放哪:data/sfx/ + # 内置的几个是程序**合成**的(正弦/方波现场算出来), + # 所以零版权风险、也不占安装包体积。 + # 用户想用真正的一模一样的素材,把文件丢进 data/sfx/ 即可, + # 列表会自动带上(见 sfx.py 顶部的说明)。 + + def _sfx(self): + """懒加载音效库。第一次调用时才生成内置 WAV。""" + if getattr(self, "_sfx_obj", None) is None: + from .sfx import Sfx + self._sfx_obj = Sfx(self.store.dir, player=self.play_audio) + return self._sfx_obj + + def list_sfx(self) -> list: + return self._sfx().list_sfx() + + def sfx_dir_hint(self) -> str: + return self._sfx().path_hint() + + def play_sfx(self, name: str) -> str: + return self._sfx().play(name) + + def play_local(self, path: str) -> str: + """播放本机某个音频文件。只允许音乐目录内的文件。""" + target = Path(path) + if not target.is_file(): + raise ActionError(f"文件不存在: {path}") + if target.suffix.lower() not in self.AUDIO_EXTS: + raise ActionError(f"不支持的音频格式: {target.suffix}") + + allowed = False + for root in self.audio_roots(): + try: + target.resolve().relative_to(root.resolve()) + allowed = True + break + except (ValueError, OSError): + continue + if not allowed: + raise ActionError("只允许播放音乐目录里的文件") + + self.play_audio(str(target)) + return f"正在播放 {target.name}" + + # ------------------------------------------------------------------ + # 定时关机 / 关显示器 + # ------------------------------------------------------------------ + + def shutdown_timer(self, minutes: int = 0, cancel: bool = False) -> str: + """定时关机;cancel=True 时取消。minutes<=0 也会取消。""" + if not IS_WINDOWS: + raise ActionError("仅支持 Windows") + if cancel or minutes <= 0: + proc = subprocess.run(["shutdown", "/a"], capture_output=True, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)) + text = (proc.stdout or proc.stderr or b"").decode("mbcs", errors="replace") + if proc.returncode != 0 and "1116" not in text and "没有" not in text: + return "当前没有待执行的关机计划" + return "已取消定时关机" + minutes = max(1, min(1440, int(minutes))) + subprocess.run(["shutdown", "/s", "/t", str(minutes * 60)], + capture_output=True, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)) + return f"将在 {minutes} 分钟后关机" + + def monitor_power(self, on: bool = False) -> str: + """关屏 / 唤醒屏幕。""" + if not IS_WINDOWS: + raise ActionError("仅支持 Windows") + import ctypes + HWND_BROADCAST = 0xFFFF + WM_SYSCOMMAND = 0x0112 + SC_MONITORPOWER = 0xF170 + # 2 = 关闭, -1 = 打开 + ctypes.windll.user32.SendMessageW(HWND_BROADCAST, WM_SYSCOMMAND, + SC_MONITORPOWER, -1 if on else 2) + return "显示器已打开" if on else "显示器已关闭" + + # ------------------------------------------------------------------ + # 打开网址 / 快捷命令 / 常用文件夹 + # ------------------------------------------------------------------ + + def open_url(self, url: str) -> str: + text = (url or "").strip() + if not text: + raise ActionError("网址为空") + if "://" not in text: + text = "https://" + text + if not text.lower().startswith(("http://", "https://")): + raise ActionError("只允许打开 http/https 网址") + try: + os.startfile(text) # noqa: S606 + except Exception as exc: # noqa: BLE001 + raise ActionError(f"打开失败: {exc}") from exc + return f"已在电脑打开 {text}" + + QUICK_APPS = { + "taskmgr": ("任务管理器", "taskmgr.exe"), + "explorer": ("资源管理器", "explorer.exe"), + "cmd": ("命令提示符", "cmd.exe"), + "calc": ("计算器", "calc.exe"), + "notepad": ("记事本", "notepad.exe"), + "control": ("控制面板", "control.exe"), + "mspaint": ("画图", "mspaint.exe"), + "devmgmt": ("设备管理器", "devmgmt.msc"), + } + + def quick_launch(self, key: str) -> str: + item = self.QUICK_APPS.get((key or "").strip().lower()) + if item is None: + raise ActionError(f"没有这个快捷程序: {key}") + label, command = item + try: + subprocess.Popen(command, shell=True, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)) + except Exception as exc: # noqa: BLE001 + raise ActionError(f"启动失败: {exc}") from exc + return f"已打开{label}" + + def open_folder(self, key: str) -> str: + home = Path.home() + folders = { + "downloads": ("下载", home / "Downloads"), + "documents": ("文档", home / "Documents"), + "desktop": ("桌面", home / "Desktop"), + "pictures": ("图片", home / "Pictures"), + "music": ("音乐", home / "Music"), + "received": ("接收目录", self.store.received_dir()), + "app": ("程序目录", Path(__file__).resolve().parent.parent), + } + item = folders.get((key or "").strip().lower()) + if item is None: + raise ActionError(f"没有这个文件夹: {key}") + label, path = item + if not path.is_dir(): + raise ActionError(f"{label}目录不存在: {path}") + try: + os.startfile(str(path)) # noqa: S606 + except Exception as exc: # noqa: BLE001 + raise ActionError(f"打开失败: {exc}") from exc + return f"已打开{label}" + + # ------------------------------------------------------------------ + # 进程 + # ------------------------------------------------------------------ + + def list_processes(self, limit: int = 40) -> list[dict]: + if not IS_WINDOWS: + return [] + try: + proc = subprocess.run( + ["tasklist", "/FO", "CSV", "/NH"], capture_output=True, + timeout=20, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + encoding="mbcs", errors="replace") + except Exception: # noqa: BLE001 + return [] + items: list[dict] = [] + for raw in (proc.stdout or "").splitlines(): + parts = [p.strip().strip('"') for p in raw.split('","')] + if len(parts) < 5: + continue + name = parts[0].strip('"') + pid = parts[1] + memory = parts[4].replace(",", "").replace(" K", "").strip() + try: + mem_kb = int(memory) + except ValueError: + mem_kb = 0 + if not pid.isdigit(): + continue + items.append({"name": name, "pid": int(pid), "mem_kb": mem_kb}) + items.sort(key=lambda it: it["mem_kb"], reverse=True) + return items[:limit] + + def kill_process(self, pid: int = 0, name: str = "") -> str: + if not IS_WINDOWS: + raise ActionError("仅支持 Windows") + args = ["taskkill", "/F"] + if pid: + args += ["/PID", str(int(pid))] + label = f"PID {pid}" + elif name: + safe = "".join(ch for ch in name if ch.isalnum() or ch in "._- ") + if not safe: + raise ActionError("进程名不合法") + args += ["/IM", safe] + label = safe + else: + raise ActionError("要指定 PID 或进程名") + proc = subprocess.run(args, capture_output=True, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + encoding="mbcs", errors="replace") + if proc.returncode != 0: + raise ActionError(f"结束失败: {(proc.stdout or proc.stderr or '').strip()[:80]}") + return f"已结束 {label}" + + # ------------------------------------------------------------------ + # 状态:电量 / 磁盘 / 性能 + # ------------------------------------------------------------------ + + def battery(self) -> dict: + if not IS_WINDOWS: + return {"present": False} + import ctypes + + class PowerStatus(ctypes.Structure): + _fields_ = [("ACLineStatus", ctypes.c_byte), + ("BatteryFlag", ctypes.c_byte), + ("BatteryLifePercent", ctypes.c_byte), + ("SystemStatusFlag", ctypes.c_byte), + ("BatteryLifeTime", ctypes.c_ulong), + ("BatteryFullLifeTime", ctypes.c_ulong)] + + status = PowerStatus() + if not ctypes.windll.kernel32.GetSystemPowerStatus(ctypes.byref(status)): + return {"present": False} + if status.BatteryLifePercent == 255 or status.BatteryFlag == 128: + return {"present": False, "note": "台式机,没有电池"} + return { + "present": True, + "percent": int(status.BatteryLifePercent), + "charging": bool(status.ACLineStatus), + "seconds_left": int(status.BatteryLifeTime) + if status.BatteryLifeTime not in (0xFFFFFFFF, 0) else 0, + } + + def disk(self) -> list[dict]: + if not IS_WINDOWS: + return [] + import ctypes + import string + + out: list[dict] = [] + bitmask = ctypes.windll.kernel32.GetLogicalDrives() + free = ctypes.c_ulonglong(0) + total = ctypes.c_ulonglong(0) + for i, letter in enumerate(string.ascii_uppercase): + if not (bitmask >> i) & 1: + continue + root = f"{letter}:\\" + try: + ok = ctypes.windll.kernel32.GetDiskFreeSpaceExW( + ctypes.c_wchar_p(root), None, ctypes.byref(total), + ctypes.byref(free)) + except Exception: # noqa: BLE001 + continue + if not ok or total.value == 0: + continue + out.append({ + "drive": f"{letter}:", + "total_gb": round(total.value / 1073741824, 1), + "free_gb": round(free.value / 1073741824, 1), + "used_percent": int((total.value - free.value) * 100 / total.value), + }) + return out + + def perf(self) -> dict: + if not IS_WINDOWS: + return {} + import ctypes + + class MemoryStatus(ctypes.Structure): + _fields_ = [("dwLength", ctypes.c_ulong), + ("dwMemoryLoad", ctypes.c_ulong), + ("ullTotalPhys", ctypes.c_ulonglong), + ("ullAvailPhys", ctypes.c_ulonglong), + ("ullTotalPageFile", ctypes.c_ulonglong), + ("ullAvailPageFile", ctypes.c_ulonglong), + ("ullTotalVirtual", ctypes.c_ulonglong), + ("ullAvailVirtual", ctypes.c_ulonglong), + ("ullAvailExtendedVirtual", ctypes.c_ulonglong)] + + mem = MemoryStatus() + mem.dwLength = ctypes.sizeof(MemoryStatus) + ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(mem)) + + idle = ctypes.c_ulonglong() + kernel = ctypes.c_ulonglong() + user = ctypes.c_ulonglong() + ctypes.windll.kernel32.GetSystemTimes(ctypes.byref(idle), + ctypes.byref(kernel), + ctypes.byref(user)) + cpu = 0 + try: + total = kernel.value + user.value + if total and self._last_cpu_total: + delta_total = total - self._last_cpu_total + delta_idle = idle.value - self._last_cpu_idle + if delta_total > 0: + cpu = int(round((1 - delta_idle / delta_total) * 100)) + except Exception: # noqa: BLE001 + cpu = 0 + self._last_cpu_total = kernel.value + user.value + self._last_cpu_idle = idle.value + + import platform as _platform + return { + "cpu_percent": max(0, min(100, cpu)), + "mem_percent": int(mem.dwMemoryLoad), + "mem_used_gb": round((mem.ullTotalPhys - mem.ullAvailPhys) / 1073741824, 1), + "mem_total_gb": round(mem.ullTotalPhys / 1073741824, 1), + "uptime": self._uptime(), + "os": f"{_platform.system()} {_platform.release()}", + } + + def _uptime(self) -> str: + try: + import ctypes + # 不设 restype 的话 ctypes 默认按 32 位处理, + # 开机超过 24.8 天会被截断成负数,时长显示错乱 + ctypes.windll.kernel32.GetTickCount64.restype = ctypes.c_ulonglong + ms = ctypes.windll.kernel32.GetTickCount64() + seconds = int(ms / 1000) + days, rem = divmod(seconds, 86400) + hours, rem = divmod(rem, 3600) + minutes = rem // 60 + if days: + return f"{days} 天 {hours} 小时" + if hours: + return f"{hours} 小时 {minutes} 分" + return f"{minutes} 分钟" + except Exception: # noqa: BLE001 + return "-" + + # ------------------------------------------------------------------ + # 剪贴板历史 + # ------------------------------------------------------------------ + + def clip_history(self) -> list[dict]: + return list(self._clip_history) + + def _remember_clip(self, text: str) -> None: + text = (text or "").strip() + if not text: + return + with self._lock: + self._clip_history = [x for x in self._clip_history + if x.get("text") != text] + self._clip_history.insert(0, { + "text": text[:2000], + "at": time.strftime("%H:%M:%S"), + }) + del self._clip_history[20:] + + def say_clipboard(self) -> str: + # 之前读的是 store["last_clip"](那是手表推过来的内容), + # 手表没推过就永远报"剪贴板是空的",可电脑剪贴板明明有东西。 + # 应该读电脑本机的剪贴板。 + try: + text = str(self.clip_get() or "").strip() + except Exception: # noqa: BLE001 + text = "" + if not text: + text = str(self.store.get("last_clip") or "").strip() + if not text: + raise ActionError("电脑剪贴板是空的") + return self.say(text[:500] + ("…" if len(text) > 500 else "")) + + # ------------------------------------------------------------------ + # 壁纸 + # ------------------------------------------------------------------ + + def set_wallpaper(self, path: str = "") -> str: + """把某张图片设为桌面壁纸。默认用最近收到的那张图。""" + if not IS_WINDOWS: + raise ActionError("仅支持 Windows") + import ctypes + + target = Path(path) if path else None + if target is None or not target.is_file(): + received = self.store.received_dir() + images = sorted( + [p for p in received.iterdir() + if p.is_file() and p.suffix.lower() in (".png", ".jpg", ".jpeg", ".bmp")], + key=lambda p: p.stat().st_mtime, reverse=True) + if not images: + raise ActionError("接收目录里还没有图片") + target = images[0] + + SPI_SETDESKWALLPAPER = 0x0014 + SPIF_UPDATEINIFILE = 0x01 + SPIF_SENDCHANGE = 0x02 + ok = ctypes.windll.user32.SystemParametersInfoW( + SPI_SETDESKWALLPAPER, 0, ctypes.c_wchar_p(str(target)), + SPIF_UPDATEINIFILE | SPIF_SENDCHANGE) + if not ok: + raise ActionError("设置壁纸失败") + return f"壁纸已设为 {target.name}" + + # ------------------------------------------------------------------ + # 通知(托盘气泡,可见) + # ------------------------------------------------------------------ + + def notify(self, title: str, text: str) -> str: + self.ui.notify(title or "局域网遥控", text or "") + return "已通知" + + # ------------------------------------------------------------------ + # 系统信息 + # ------------------------------------------------------------------ + + def info(self) -> dict: + import platform + screen_ok = False + screen_size = (0, 0) + try: + from .screen import ScreenCapturer + capturer = ScreenCapturer() + screen_ok = capturer.available() + if screen_ok: + screen_size = capturer.screen_size() + except Exception: + screen_ok = False + return { + "server_name": self.store.get("server_name", ""), + "os": f"{platform.system()} {platform.release()}", + "python": platform.python_version(), + "port": self.store.get("port", 0), + "allow_run": bool(self.store.get("allow_run", True)), + "allow_power": bool(self.store.get("allow_power", True)), + "allow_screen": bool(self.store.get("allow_screen", True)), + "screen_supported": screen_ok, + "screen_width": screen_size[0], + "screen_height": screen_size[1], + "received_dir": str(self.store.received_dir()), + } + + +def open_path(path: str) -> None: + """用系统默认程序打开文件/文件夹(仅本机 UI 按钮使用)。""" + if IS_WINDOWS: + os.startfile(path) # noqa: S606 + elif sys.platform == "darwin": + subprocess.Popen(["open", path]) + else: + subprocess.Popen(["xdg-open", path]) diff --git a/windows/lanremote/protocol.py b/windows/lanremote/protocol.py index 670bf57..e17098c 100644 --- a/windows/lanremote/protocol.py +++ b/windows/lanremote/protocol.py @@ -1,179 +1,181 @@ -# -*- coding: utf-8 -*- -"""通信协议常量与编解码。 - -设计目标:双端零外部依赖。 - * Windows 端:Python 标准库 socket + json - * 安卓端:java.net.Socket + 系统自带 org.json - -传输格式:TCP 长连接,每条消息一行 UTF-8 JSON,以 \\n 结尾。 -二进制负载(图片/音频/文件)用 base64 分块传输。 -""" -from __future__ import annotations - -import base64 -import json - -DEFAULT_PORT = 8765 -PROTOCOL_VERSION = 1 -MAX_LINE_BYTES = 8 * 1024 * 1024 # 单行上限 8MB(约 6MB 原始数据) -CHUNK_SIZE = 256 * 1024 -# 单次传输(文件 / 图片 / 音频)的总体积上限 -MAX_TOTAL_BYTES = 256 * 1024 * 1024 # 分块大小 256KB - -# ---- 客户端 -> 服务端 命令 ---- -CMD_PAIR = "pair" -CMD_AUTH = "auth" -CMD_PING = "ping" -CMD_IMAGE = "image" -CMD_AUDIO = "audio" -CMD_MEDIA = "media" -CMD_CLIP_SET = "clip_set" -CMD_CLIP_GET = "clip_get" -CMD_POWER = "power" -CMD_RUN = "run" -CMD_SAY = "say" -CMD_VOICE_LIST = "voice_list" -CMD_SET_VOICE = "set_voice" -CMD_AUDIO_LIST = "audio_list" -CMD_PLAY_LOCAL = "play_local" -CMD_SHUTDOWN_TIMER = "shutdown_timer" # 定时关机 / 取消 -CMD_MONITOR = "monitor" # 关闭 / 打开显示器 -CMD_OPEN_URL = "open_url" # 在电脑浏览器打开网址 -CMD_QUICK = "quick" # 快捷启动程序 -CMD_OPEN_FOLDER = "open_folder" # 打开常用文件夹 -CMD_PROC_LIST = "proc_list" # 进程列表 -CMD_PROC_KILL = "proc_kill" # 结束进程 -CMD_BATTERY = "battery" # 电量 -CMD_DISK = "disk" # 磁盘占用 -CMD_PERF = "perf" # CPU / 内存占用 -CMD_CLIP_HISTORY = "clip_history" # 剪贴板历史 -CMD_SET_WALLPAPER = "set_wallpaper" # 把收到的图设为壁纸 -CMD_SAY_CLIP = "say_clip" # 朗读剪贴板 -CMD_FEATURE_LIST = "feature_list" # 电脑端能做哪些功能 -CMD_FEATURE_RUN = "feature_run" # 执行某个功能 -CMD_AUDIO_PAUSE = "audio_pause" # 暂停电脑正在播的音频 -CMD_AUDIO_RESUME = "audio_resume" # 继续播放 -CMD_AUDIO_STATUS = "audio_status" # 当前播放状态 -CMD_NOTIFY = "notify" -CMD_FILE_PUSH = "file_push" -CMD_FILE_LIST = "file_list" -CMD_FILE_PULL = "file_pull" -CMD_INFO = "info" -CMD_SCREEN_START = "screen_start" -CMD_SCREEN_STOP = "screen_stop" -CMD_SCREENSHOT = "screenshot" - -# ---- 服务端 -> 客户端 事件 ---- -EVT_FILE = "file" -EVT_CLIP = "clip" -EVT_STATUS = "status" -EVT_LOG = "log" -EVT_FRAME = "frame" -EVT_SCREEN_STOPPED = "screen_stopped" - -MEDIA_ACTIONS = { - "playpause": 0xB3, - "next": 0xB0, - "prev": 0xB1, - "stop": 0xB2, - "volup": 0xAF, - "voldown": 0xAE, - "mute": 0xAD, -} - -POWER_ACTIONS = {"shutdown", "restart", "lock", "sleep", "abort"} - -# 手表端会用到的中文动作名(便于 UI 直接显示) -MEDIA_LABELS = { - "playpause": "播放/暂停", - "next": "下一曲", - "prev": "上一曲", - "stop": "停止", - "volup": "音量+", - "voldown": "音量-", - "mute": "静音", -} - -POWER_LABELS = { - "shutdown": "关机", - "restart": "重启", - "lock": "锁屏", - "sleep": "睡眠", - "abort": "取消关机", -} - - -def dumps(obj: dict) -> bytes: - """编码成一行 JSON(UTF-8 + 换行)。""" - return (json.dumps(obj, ensure_ascii=False, separators=(",", ":")) + "\n").encode("utf-8") - - -def loads(line: bytes) -> dict: - """解析一行 JSON。""" - return json.loads(line.decode("utf-8", errors="replace").strip()) - - -def b64encode(data: bytes) -> str: - return base64.b64encode(data).decode("ascii") - - -def b64decode(text: str) -> bytes: - return base64.b64decode(text.encode("ascii")) - - -def chunk_bytes(data: bytes, size: int = CHUNK_SIZE): - """把字节流切成若干块,产出 (seq, is_last, chunk)。""" - if not data: - yield 0, True, b"" - return - total = (len(data) + size - 1) // size - for index in range(total): - piece = data[index * size:(index + 1) * size] - yield index, index == total - 1, piece - - -class ChunkTooLarge(Exception): - """分块累积超过上限,或顺序不对。""" - - -class ChunkAssembler: - """接收端:按 seq 收集分块,拼回完整数据。 - - MAX_LINE_BYTES 只管单行,累积体积必须另外设上限 —— - 否则已配对设备可以一直发 chunk 把内存吃干(最后 bytes() 还要再翻一倍)。 - """ - - def __init__(self, limit: int = MAX_TOTAL_BYTES): - self._buf = bytearray() - self._expected = 0 - self._limit = int(limit) - - def feed(self, seq: int, is_last: bool, data: bytes) -> bytes | None: - """返回完整数据(当收到最后一块时),否则 None。 - - 顺序不对或超限时抛 ChunkTooLarge,不静默拼出坏文件。 - """ - if seq == 0: - self._buf = bytearray() - self._expected = 0 - if seq != self._expected: - self._buf = bytearray() - self._expected = 0 - raise ChunkTooLarge( - f"分块顺序不对(收到 {seq},应为 {self._expected})") - if len(self._buf) + len(data) > self._limit: - self._buf = bytearray() - self._expected = 0 - raise ChunkTooLarge(f"文件超过上限({self._limit // 1048576} MB)") - self._buf.extend(data) - self._expected = seq + 1 - if is_last: - result = bytes(self._buf) - self._buf = bytearray() - self._expected = 0 - return result - return None - - def reset(self) -> None: - self._buf = bytearray() - self._expected = 0 +# -*- coding: utf-8 -*- +"""通信协议常量与编解码。 + +设计目标:双端零外部依赖。 + * Windows 端:Python 标准库 socket + json + * 安卓端:java.net.Socket + 系统自带 org.json + +传输格式:TCP 长连接,每条消息一行 UTF-8 JSON,以 \\n 结尾。 +二进制负载(图片/音频/文件)用 base64 分块传输。 +""" +from __future__ import annotations + +import base64 +import json + +DEFAULT_PORT = 8765 +PROTOCOL_VERSION = 1 +MAX_LINE_BYTES = 8 * 1024 * 1024 # 单行上限 8MB(约 6MB 原始数据) +CHUNK_SIZE = 256 * 1024 +# 单次传输(文件 / 图片 / 音频)的总体积上限 +MAX_TOTAL_BYTES = 256 * 1024 * 1024 # 分块大小 256KB + +# ---- 客户端 -> 服务端 命令 ---- +CMD_PAIR = "pair" +CMD_AUTH = "auth" +CMD_PING = "ping" +CMD_IMAGE = "image" +CMD_AUDIO = "audio" +CMD_MEDIA = "media" +CMD_CLIP_SET = "clip_set" +CMD_CLIP_GET = "clip_get" +CMD_POWER = "power" +CMD_RUN = "run" +CMD_SAY = "say" +CMD_VOICE_LIST = "voice_list" +CMD_SET_VOICE = "set_voice" +CMD_AUDIO_LIST = "audio_list" +CMD_SFX_LIST = "sfx_list" # 列出可用音效(内置 + 用户自己放的) +CMD_SFX_PLAY = "sfx_play" # 播放某个音效 +CMD_PLAY_LOCAL = "play_local" +CMD_SHUTDOWN_TIMER = "shutdown_timer" # 定时关机 / 取消 +CMD_MONITOR = "monitor" # 关闭 / 打开显示器 +CMD_OPEN_URL = "open_url" # 在电脑浏览器打开网址 +CMD_QUICK = "quick" # 快捷启动程序 +CMD_OPEN_FOLDER = "open_folder" # 打开常用文件夹 +CMD_PROC_LIST = "proc_list" # 进程列表 +CMD_PROC_KILL = "proc_kill" # 结束进程 +CMD_BATTERY = "battery" # 电量 +CMD_DISK = "disk" # 磁盘占用 +CMD_PERF = "perf" # CPU / 内存占用 +CMD_CLIP_HISTORY = "clip_history" # 剪贴板历史 +CMD_SET_WALLPAPER = "set_wallpaper" # 把收到的图设为壁纸 +CMD_SAY_CLIP = "say_clip" # 朗读剪贴板 +CMD_FEATURE_LIST = "feature_list" # 电脑端能做哪些功能 +CMD_FEATURE_RUN = "feature_run" # 执行某个功能 +CMD_AUDIO_PAUSE = "audio_pause" # 暂停电脑正在播的音频 +CMD_AUDIO_RESUME = "audio_resume" # 继续播放 +CMD_AUDIO_STATUS = "audio_status" # 当前播放状态 +CMD_NOTIFY = "notify" +CMD_FILE_PUSH = "file_push" +CMD_FILE_LIST = "file_list" +CMD_FILE_PULL = "file_pull" +CMD_INFO = "info" +CMD_SCREEN_START = "screen_start" +CMD_SCREEN_STOP = "screen_stop" +CMD_SCREENSHOT = "screenshot" + +# ---- 服务端 -> 客户端 事件 ---- +EVT_FILE = "file" +EVT_CLIP = "clip" +EVT_STATUS = "status" +EVT_LOG = "log" +EVT_FRAME = "frame" +EVT_SCREEN_STOPPED = "screen_stopped" + +MEDIA_ACTIONS = { + "playpause": 0xB3, + "next": 0xB0, + "prev": 0xB1, + "stop": 0xB2, + "volup": 0xAF, + "voldown": 0xAE, + "mute": 0xAD, +} + +POWER_ACTIONS = {"shutdown", "restart", "lock", "sleep", "abort"} + +# 手表端会用到的中文动作名(便于 UI 直接显示) +MEDIA_LABELS = { + "playpause": "播放/暂停", + "next": "下一曲", + "prev": "上一曲", + "stop": "停止", + "volup": "音量+", + "voldown": "音量-", + "mute": "静音", +} + +POWER_LABELS = { + "shutdown": "关机", + "restart": "重启", + "lock": "锁屏", + "sleep": "睡眠", + "abort": "取消关机", +} + + +def dumps(obj: dict) -> bytes: + """编码成一行 JSON(UTF-8 + 换行)。""" + return (json.dumps(obj, ensure_ascii=False, separators=(",", ":")) + "\n").encode("utf-8") + + +def loads(line: bytes) -> dict: + """解析一行 JSON。""" + return json.loads(line.decode("utf-8", errors="replace").strip()) + + +def b64encode(data: bytes) -> str: + return base64.b64encode(data).decode("ascii") + + +def b64decode(text: str) -> bytes: + return base64.b64decode(text.encode("ascii")) + + +def chunk_bytes(data: bytes, size: int = CHUNK_SIZE): + """把字节流切成若干块,产出 (seq, is_last, chunk)。""" + if not data: + yield 0, True, b"" + return + total = (len(data) + size - 1) // size + for index in range(total): + piece = data[index * size:(index + 1) * size] + yield index, index == total - 1, piece + + +class ChunkTooLarge(Exception): + """分块累积超过上限,或顺序不对。""" + + +class ChunkAssembler: + """接收端:按 seq 收集分块,拼回完整数据。 + + MAX_LINE_BYTES 只管单行,累积体积必须另外设上限 —— + 否则已配对设备可以一直发 chunk 把内存吃干(最后 bytes() 还要再翻一倍)。 + """ + + def __init__(self, limit: int = MAX_TOTAL_BYTES): + self._buf = bytearray() + self._expected = 0 + self._limit = int(limit) + + def feed(self, seq: int, is_last: bool, data: bytes) -> bytes | None: + """返回完整数据(当收到最后一块时),否则 None。 + + 顺序不对或超限时抛 ChunkTooLarge,不静默拼出坏文件。 + """ + if seq == 0: + self._buf = bytearray() + self._expected = 0 + if seq != self._expected: + self._buf = bytearray() + self._expected = 0 + raise ChunkTooLarge( + f"分块顺序不对(收到 {seq},应为 {self._expected})") + if len(self._buf) + len(data) > self._limit: + self._buf = bytearray() + self._expected = 0 + raise ChunkTooLarge(f"文件超过上限({self._limit // 1048576} MB)") + self._buf.extend(data) + self._expected = seq + 1 + if is_last: + result = bytes(self._buf) + self._buf = bytearray() + self._expected = 0 + return result + return None + + def reset(self) -> None: + self._buf = bytearray() + self._expected = 0 diff --git a/windows/lanremote/server.py b/windows/lanremote/server.py index bb616c5..086bfc3 100644 --- a/windows/lanremote/server.py +++ b/windows/lanremote/server.py @@ -515,6 +515,24 @@ class RemoteServer: return {"ok": True, "voices": voices, "current": str(self.store.get("tts_voice") or "")} + if cmd == P.CMD_SFX_LIST: + items = self.actions.list_sfx() + # 同时给一个扁平的 names —— 手表端的 stringList(reply, key) + # 只认字符串数组,和 audio_list 的约定保持一致。 + return {"ok": True, "sfx": items, "count": len(items), + "names": [it.get("name", "") for it in items], + "dir": self.actions.sfx_dir_hint()} + + if cmd == P.CMD_SFX_PLAY: + name = str(msg.get("name") or "").strip() + if not name: + return {"ok": False, "error": "没说要放哪个音效"} + try: + msg = self.actions.play_sfx(name) + except Exception as exc: + return {"ok": False, "error": str(exc)} + return {"ok": True, "message": msg, "name": name} + if cmd == P.CMD_AUDIO_LIST: files = self.actions.list_local_audio() # 顺手给出扁平的 names/paths,手表端不用再解析嵌套结构 diff --git a/windows/lanremote/sfx.py b/windows/lanremote/sfx.py new file mode 100644 index 0000000..bb7b339 --- /dev/null +++ b/windows/lanremote/sfx.py @@ -0,0 +1,414 @@ +# -*- coding: utf-8 -*- +r'''网络热门音效 —— 手表上点一下,电脑就响。 + +============================================================================ + 先说清楚一件事(版权) +============================================================================ + +用户要的是「冰冰冰」「啊哈哈哈」「老牧师」这些**网上流行的真实录音**。 + +那些录音都是有版权的(有的是影视片段、有的是别人的作品), +**我不能把它们下载下来打包进安装包** —— 那既侵权,也会让这个项目 +在 Gitee / 应用商店那边直接踩线。 + +所以我做了两件事: + + 1. **内置几个程序合成的音效** —— 用正弦波/方波现场算出来的, + 完全原创、零版权风险。名字和"感觉"对着用户说的那几个来, + 但它们不是那些录音本身(也不冒充)。 + (合成的好处:不用带二进制素材,安装包不变大, + 而且每个音效就是几十行参数,想调随时能调。) + + 2. **做一个"自己放进来"的目录** —— + 用户把真正的素材(mp3/wav)丢进 data/sfx/ 就自动出现在列表里, + 手表上照样能点。这样他想要真的一模一样的音效, + 自己放进去就行,不用改代码。 + + 目录路径会在日志和设置页里写出来,省得他找不到。 + +============================================================================ + 为什么不用 numpy +============================================================================ + +安装包把 numpy 排除了(--exclude-module numpy,为了省 20MB)。 +所以波形全部用标准库的 math + struct 手算。16000Hz 单声道足够, +纯 Python 生成几秒的音频也就几十毫秒。 +''' +from __future__ import annotations + +import math +import struct +import wave +from pathlib import Path + +SAMPLE_RATE = 16000 + +# 内置音效的"配方"。name -> (文件名, 生成函数) +# 生成函数返回 [-1.0, 1.0] 的浮点采样列表。 +BUILTIN_SPECS: dict = {} + + +def _env(i: int, total: int, attack: float = 0.01, release: float = 0.25) -> float: + """简包络:起音/收尾各淡入淡出一段,避免爆音(咔哒声)。""" + a = max(1, int(total * attack)) + r = max(1, int(total * release)) + if i < a: + return i / a + if i > total - r: + return max(0.0, (total - i) / r) + return 1.0 + + +def _tone(freq: float, dur: float, vol: float = 0.6, + shape: str = "sine", duty: float = 0.5) -> list: + """一段单音。shape: sine / square / saw / tri""" + n = int(SAMPLE_RATE * dur) + out = [] + for i in range(n): + t = i / SAMPLE_RATE + ph = (freq * t) % 1.0 + if shape == "square": + v = 1.0 if ph < duty else -1.0 + elif shape == "saw": + v = 2.0 * ph - 1.0 + elif shape == "tri": + v = 4.0 * abs(ph - 0.5) - 1.0 + else: + v = math.sin(2 * math.pi * ph) + out.append(v * vol * _env(i, n, 0.02, 0.3)) + return out + + +def _silence(dur: float) -> list: + return [0.0] * int(SAMPLE_RATE * dur) + + +def _mix(*tracks) -> list: + """把几段混在一起(长度取最长的)。""" + longest = max(len(t) for t in tracks) + out = [0.0] * longest + for t in tracks: + for i, v in enumerate(t): + out[i] += v + # 防削顶 + peak = max((abs(v) for v in out), default=1.0) + if peak > 0.95: + out = [v * 0.95 / peak for v in out] + return out + + +def _seq(*tracks) -> list: + """把几段接起来。""" + out: list = [] + for t in tracks: + out.extend(t) + return out + + +def _sweep(f0: float, f1: float, dur: float, vol: float = 0.6) -> list: + """滑音(频率从 f0 滑到 f1)。""" + n = int(SAMPLE_RATE * dur) + out = [] + phase = 0.0 + for i in range(n): + freq = f0 + (f1 - f0) * (i / n) + phase += freq / SAMPLE_RATE + out.append(math.sin(2 * math.pi * phase) * vol * _env(i, n, 0.01, 0.3)) + return out + + +def _vibrato(freq: float, dur: float, depth: float = 0.06, + rate: float = 6.0, vol: float = 0.6) -> list: + """带颤音的音,听感更"人声"一点。""" + n = int(SAMPLE_RATE * dur) + out = [] + phase = 0.0 + for i in range(n): + t = i / SAMPLE_RATE + f = freq * (1.0 + depth * math.sin(2 * math.pi * rate * t)) + phase += f / SAMPLE_RATE + out.append(math.sin(2 * math.pi * phase) * vol * _env(i, n, 0.03, 0.35)) + return out + + +# =========================================================================== +# 内置音效的配方 +# =========================================================================== +# +# ⚠ 这些是**程序合成**的原创音效,不是网上那些录音本身。 +# 名字对着用户提到的感觉来,方便他认,但不是冒充。 + +def _mk_bing() -> list: + """「冰冰冰」—— 三下清亮的高音,一下比一下亮。 + + 用正弦 + 一点三次谐波,听感接近"叮"那种金属味。 + """ + def one(freq: float, dur: float) -> list: + a = _tone(freq, dur, 0.5, "sine") + b = _tone(freq * 3, dur, 0.12, "sine") # 三次谐波 -> 金属感 + return _mix(a, b) + return _seq(one(1180, 0.13), _silence(0.05), + one(1320, 0.13), _silence(0.05), + one(1480, 0.22)) + + +def _mk_haha() -> list: + """「啊哈哈哈」—— 下滑的颤音,三声,越来越低。 + + 模拟笑声的节奏:起音高、往下滑、带颤。 + """ + def one(f0: float, f1: float, dur: float) -> list: + return _sweep(f0, f1, dur, 0.55) + return _seq( + one(420, 330, 0.16), _silence(0.03), + one(400, 300, 0.17), _silence(0.03), + one(380, 250, 0.30)) + + +def _mk_preacher() -> list: + """「老牧师」—— 低沉、缓慢、带回声的庄严感。 + + 低音方波(管风琴那种味道)+ 一段延迟很长的回声。 + """ + base = _tone(110, 0.5, 0.45, "square", 0.35) + base = _seq(base, _tone(146, 0.5, 0.45, "square", 0.35)) + base = _seq(base, _tone(110, 0.9, 0.45, "square", 0.35)) + # 回声:把原声衰减 0.35 倍、延迟 0.28 秒叠上去 + delay = int(SAMPLE_RATE * 0.28) + echo = [0.0] * delay + [v * 0.35 for v in base] + return _mix(base, echo) + + +def _mk_ding() -> list: + """「叮」—— 一声干净的提示音。""" + a = _tone(1760, 0.7, 0.45, "sine") + b = _tone(1760 * 2.76, 0.7, 0.10, "sine") + return _mix(a, b) + + +def _mk_dong() -> list: + """「当当当」—— 三下闷响。""" + return _seq(_tone(196, 0.18, 0.6, "tri"), _silence(0.08), + _tone(196, 0.18, 0.6, "tri"), _silence(0.08), + _tone(165, 0.4, 0.6, "tri")) + + +def _mk_alarm() -> list: + """「警报」—— 两高一低的急促音。""" + return _seq(_tone(880, 0.16, 0.55, "square", 0.5), _silence(0.04), + _tone(660, 0.16, 0.55, "square", 0.5), _silence(0.04), + _tone(880, 0.16, 0.55, "square", 0.5), _silence(0.04), + _tone(660, 0.30, 0.55, "square", 0.5)) + + +def _mk_drum() -> list: + """「鼓点」—— 低频下滑的"咚",四下。""" + def hit(dur: float = 0.22) -> list: + return _sweep(180, 55, dur, 0.75) + return _seq(hit(), _silence(0.06), hit(), _silence(0.06), + hit(), _silence(0.06), hit(0.35)) + + +def _mk_boing() -> list: + """「弹簧」—— 上下摆动的滑音。""" + up = _sweep(200, 900, 0.16, 0.55) + down = _sweep(900, 260, 0.22, 0.5) + return _seq(up, down) + + +BUILTIN_SPECS = { + # 中文名 : (文件名, 生成函数, 一句说明) + "冰冰冰": ("冰冰冰.wav", _mk_bing, "三下清亮的高音,越走越高"), + "啊哈哈哈": ("啊哈哈哈.wav", _mk_haha, "下滑的颤音,三声"), + "老牧师": ("老牧师.wav", _mk_preacher, "低沉庄严,带长回声"), + "叮": ("叮.wav", _mk_ding, "一声干净提示音"), + "当当当": ("当当当.wav", _mk_dong, "三下闷响"), + "警报": ("警报.wav", _mk_alarm, "两高一低的急促音"), + "鼓点": ("鼓点.wav", _mk_drum, "四下低频咚"), + "弹簧": ("弹簧.wav", _mk_boing, "上下摆动的滑音"), +} + +# 用户自己放进来的素材认这些格式 +USER_EXTS = (".wav", ".mp3", ".m4a", ".aac", ".ogg", ".wma", ".flac") + + +class SfxError(Exception): + pass + + +class Sfx: + """音效库。放哪、有什么、放哪个。""" + + def __init__(self, data_dir: Path, player=None): + # ⚠ data_dir 是**被控端自己的数据目录**(settings.json 边上), + # 不是仓库目录 —— 打包成 exe 之后仓库目录是只读的临时目录, + # 往那儿写文件重启就没了。 + self.dir = Path(data_dir) / "sfx" + self._player = player # 外面传进来的播放函数(复用 MCI) + self._built = False + + # ---------------------------------------------------------------- 内置 + def ensure_builtin(self) -> int: + """首次运行时把内置音效生成为 WAV。返回新建了几个。 + + ⚠ 用**清单文件**记住"哪些是生成过的",而不是简单地 + "文件不存在就生成"。 + + 为什么:用户删掉某个内置音效,多半是不想要它。 + 如果每次启动都按"不存在就补"的逻辑跑,他删一次我就补一次, + 等于删不掉 —— 实测自己写的测试就是这么判红的。 + + 但也不能"只要生成过一次就永远不补":万一第一次生成失败 + (磁盘满、权限问题),下次启动该重试。 + + 所以用清单:生成成功的才记进去。清单里有、文件没了 + = 用户删的,不再补;清单里没有 = 还没成功过,重试。 + """ + self.dir.mkdir(parents=True, exist_ok=True) + manifest_file = self.dir / ".builtin.txt" + done: set = set() + try: + if manifest_file.is_file(): + done = {ln.strip() for ln in + manifest_file.read_text(encoding="utf-8").splitlines() + if ln.strip()} + except OSError: + done = set() + + made = 0 + for name, (filename, fn, _desc) in BUILTIN_SPECS.items(): + if filename in done: + continue # 生成过 —— 文件没了就是用户删的 + target = self.dir / filename + if target.is_file(): + done.add(filename) # 早先版本生成的,补登记 + continue + try: + _write_wav(target, fn()) + done.add(filename) + made += 1 + except Exception: # noqa: BLE001 + # 某个音效生成失败不该让整个功能挂掉; + # 也不记进清单,下次启动会重试 + continue + try: + manifest_file.write_text("\n".join(sorted(done)) + "\n", + encoding="utf-8") + except OSError: + pass + + # 放一个说明文件,省得用户找不到往哪放自己的素材 + readme = self.dir / "放这里.txt" + if not readme.is_file(): + readme.write_text( + "把你自己的音效文件(mp3 / wav / m4a / ogg)直接放进这个目录,\n" + "手表那边刷新一下就能看到、能点了。\n" + "\n" + "文件名就是列表里显示的名字。比如把素材存成:\n" + " 冰冰冰.mp3\n" + " 啊哈哈哈.mp3\n" + " 老牧师.mp3\n" + "\n" + "目录里的 冰冰冰.wav / 啊哈哈哈.wav / 老牧师.wav 这几个\n" + "是程序**合成**的版本(不是网上那些录音)——\n" + "因为那些录音有版权,不能打包进安装包。\n" + "想要一模一样的,把真素材放进来覆盖同名文件即可。\n" + "\n" + "不想要的音效直接删掉即可,不会自己长回来。\n", + encoding="utf-8") + self._built = True + return made + + # ---------------------------------------------------------------- 列表 + def list_sfx(self) -> list: + """列出可用音效。内置的排前面,用户自己放的排后面。""" + if not self._built: + self.ensure_builtin() + items: list = [] + seen: set = set() + + def add(name: str, path: Path, builtin: bool) -> None: + key = name.lower() + if key in seen: + return + seen.add(key) + try: + size = path.stat().st_size + except OSError: + size = 0 + items.append({ + "name": name, + "file": path.name, + "size": size, + "builtin": builtin, + "ext": path.suffix.lower().lstrip("."), + }) + + # 内置的(按 BUILTIN_SPECS 的顺序,名字好听好记) + for name, (filename, _fn, _desc) in BUILTIN_SPECS.items(): + p = self.dir / filename + if p.is_file(): + add(name, p, True) + + # 用户自己放的 + try: + for p in sorted(self.dir.iterdir()): + if not p.is_file(): + continue + if p.suffix.lower() not in USER_EXTS: + continue + if p.name == "放这里.txt": + continue + add(p.stem, p, False) + except OSError: + pass + return items + + # ---------------------------------------------------------------- 播放 + def resolve(self, name: str) -> Path: + """把用户给的名字解析成一个文件路径。找不到就抛错。""" + wanted = (name or "").strip() + if not wanted: + raise SfxError("没说要放哪个音效") + # 先按"显示名"找(内置的) + for name2, (filename, _fn, _desc) in BUILTIN_SPECS.items(): + if name2 == wanted or filename == wanted: + p = self.dir / filename + if p.is_file(): + return p + # 再按文件名/stem 找(用户自己放的) + for p in self.dir.iterdir() if self.dir.is_dir() else []: + if not p.is_file(): + continue + if p.suffix.lower() not in USER_EXTS: + continue + if p.name == wanted or p.stem == wanted: + return p + raise SfxError(f"没有这个音效:{wanted}") + + def play(self, name: str) -> str: + """播放一个音效。""" + path = self.resolve(name) + if self._player is None: + raise SfxError("播放器没接上(内部错误)") + return self._player(str(path)) + + def path_hint(self) -> str: + """给用户看的一句"素材放哪"。""" + return str(self.dir) + + +def _write_wav(path: Path, samples: list) -> None: + """把 [-1,1] 的浮点采样写成 16 位单声道 WAV。""" + frames = bytearray() + for v in samples: + if v > 1.0: + v = 1.0 + elif v < -1.0: + v = -1.0 + frames += struct.pack("