From 7268188e406b30bc263bb140b53b4434911745c4 Mon Sep 17 00:00:00 2001 From: Wxw-Gu <84499436+Wxw-Gu@users.noreply.github.com> Date: Thu, 24 Sep 2026 02:43:35 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20mac=E5=8F=91=E9=80=81=E8=83=BD=E5=8A=9B?= =?UTF-8?q?=E9=87=8D=E6=9E=84=E4=B8=BAlibtmwechat=20-=20=E5=8F=91=E9=80=81?= =?UTF-8?q?=E6=94=B9=E8=B5=B0=E6=9C=AC=E5=9C=B0host,=20=E5=9C=A8=E5=BE=AE?= =?UTF-8?q?=E4=BF=A1=E7=99=BB=E5=BD=95=E6=97=B6=E8=A7=A6=E5=8F=91=20-=20?= =?UTF-8?q?=E5=88=A0=E9=99=A4=E6=97=A7OneBot=E5=8F=8A=E5=AF=B9=E5=BA=94UI?= =?UTF-8?q?=E6=8E=A7=E5=88=B6=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +- docs/README.md | 1 - docs/third-party/wechat-chatter/LICENSE | 674 -------- docs/third-party/wechat-chatter/NOTICE.md | 28 - electron-builder.yml | 1 - package.json | 4 +- scripts/prepare-wechat-chatter-runtime.cjs | 436 ----- scripts/prepare-wechat-native-runtime.cjs | 201 +++ src/main/index.ts | 129 +- .../services/mac-wechat-runtime-artifact.ts | 101 ++ .../services/mac-wechat-runtime-manager.ts | 710 +++++++++ .../personal-wechat-capability-service.ts | 28 +- .../personal-wechat-runtime-manager.ts | 603 ------- .../services/personal-wechat-send-service.ts | 1409 +++-------------- ...rsonal-wechat-voice-environment-service.ts | 46 +- src/main/services/settings-store.ts | 18 +- src/main/services/wechat-action-gateway.ts | 44 +- src/main/voice-pipeline/voice-quality.ts | 6 +- src/preload/index.d.ts | 23 +- src/preload/index.ts | 29 - .../chat/PersonalWechatSendDialog.tsx | 87 +- .../chat/PersonalWechatSetupGuide.tsx | 161 +- ...PersonalWechatSupportedVersionsContent.tsx | 46 - .../chat/PersonalWechatWindowsSendDialog.tsx | 58 +- .../chat/ReportImagePostfixInput.tsx | 32 + .../chat/useReportImagePostfixSetting.ts | 36 + .../VoiceEncodingEnvironmentSection.tsx | 6 +- .../settings/pages/PersonalWechatSendPage.tsx | 420 +---- src/renderer/src/styles/chat.scss | 93 +- src/shared/personal-wechat-mac-runtime.ts | 95 ++ src/shared/personal-wechat-runtime.ts | 29 - src/shared/personal-wechat-voice-runtime.ts | 5 +- src/shared/personal-wechat.ts | 7 +- src/shared/wechat-action.ts | 4 + .../personal-wechat-send-dialog.test.tsx | 92 +- .../personal-wechat-send-page.test.tsx | 99 +- .../personal-wechat-unavailable.test.tsx | 96 ++ tests/component/report-features.test.tsx | 6 +- .../text-to-speech-page-windows.test.tsx | 10 +- tests/component/text-to-speech-page.test.tsx | 9 +- tests/e2e/app.spec.ts | 19 +- tests/e2e/support/electron-main.cjs | 22 +- tests/unit/mac-wechat-native-runtime.test.ts | 127 ++ tests/unit/mac-wechat-runtime-manager.test.ts | 138 ++ ...personal-wechat-capability-service.test.ts | 29 +- .../unit/personal-wechat-send-service.test.ts | 223 +-- ...l-wechat-voice-environment-service.test.ts | 36 +- tests/unit/security-and-errors.test.ts | 35 +- .../settings-store-recall-migration.test.ts | 20 +- tests/unit/wechat-action-gateway.test.ts | 49 + 50 files changed, 2442 insertions(+), 4141 deletions(-) delete mode 100644 docs/third-party/wechat-chatter/LICENSE delete mode 100644 docs/third-party/wechat-chatter/NOTICE.md delete mode 100644 scripts/prepare-wechat-chatter-runtime.cjs create mode 100644 scripts/prepare-wechat-native-runtime.cjs create mode 100644 src/main/services/mac-wechat-runtime-artifact.ts create mode 100644 src/main/services/mac-wechat-runtime-manager.ts delete mode 100644 src/main/services/personal-wechat-runtime-manager.ts delete mode 100644 src/renderer/src/components/chat/PersonalWechatSupportedVersionsContent.tsx create mode 100644 src/renderer/src/components/chat/ReportImagePostfixInput.tsx create mode 100644 src/renderer/src/components/chat/useReportImagePostfixSetting.ts create mode 100644 src/shared/personal-wechat-mac-runtime.ts delete mode 100644 src/shared/personal-wechat-runtime.ts create mode 100644 tests/component/personal-wechat-unavailable.test.tsx create mode 100644 tests/unit/mac-wechat-native-runtime.test.ts create mode 100644 tests/unit/mac-wechat-runtime-manager.test.ts diff --git a/.gitignore b/.gitignore index 868a818..ea465ae 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,8 @@ out coverage/ playwright-report/ test-results/ -resources/connectors/wechat-personal/ +resources/runtime/darwin-arm64 +.native-runtime-source .omc .codex/ skills-lock.json diff --git a/docs/README.md b/docs/README.md index 347e4f8..86d94f6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -61,7 +61,6 @@ Agent Hub 让微信机器人调用本机 TraceMemo;Reader Skill / Local HTTP A - [实验性:自托管微信分享卡片](./deployment/experimental-wechat-share-card.md) - [微信分享卡片自动部署 Skill](./skill/setup-wechat-share-card/SKILL.md) - [TraceMemo Reader Skill 文件](./skill/tracememo-reader/SKILL.md) -- [第三方组件说明](./third-party/wechat-chatter/NOTICE.md) ## 版本说明 diff --git a/docs/third-party/wechat-chatter/LICENSE b/docs/third-party/wechat-chatter/LICENSE deleted file mode 100644 index 480c736..0000000 --- a/docs/third-party/wechat-chatter/LICENSE +++ /dev/null @@ -1,674 +0,0 @@ - GNU GENERAL PUBLIC LICENSE - Version 3, 29 June 2007 - - Copyright (C) 2026 yincongcyincong - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU General Public License is a free, copyleft license for -software and other kinds of works. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -the GNU General Public License is intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. We, the Free Software Foundation, use the -GNU General Public License for most of our software; it applies also to -any other work released this way by its authors. You can apply it to -your programs, too. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - To protect your rights, we need to prevent others from denying you -these rights or asking you to surrender the rights. Therefore, you have -certain responsibilities if you distribute copies of the software, or if -you modify it: responsibilities to respect the freedom of others. - - For example, if you distribute copies of such a program, whether -gratis or for a fee, you must pass on to the recipients the same -freedoms that you received. You must make sure that they, too, receive -or can get the source code. And you must show them these terms so they -know their rights. - - Developers that use the GNU GPL protect your rights with two steps: -(1) assert copyright on the software, and (2) offer you this License -giving you legal permission to copy, distribute and/or modify it. - - For the developers' and authors' protection, the GPL clearly explains -that there is no warranty for this free software. For both users' and -authors' sake, the GPL requires that modified versions be marked as -changed, so that their problems will not be attributed erroneously to -authors of previous versions. - - Some devices are designed to deny users access to install or run -modified versions of the software inside them, although the manufacturer -can do so. This is fundamentally incompatible with the aim of -protecting users' freedom to change the software. The systematic -pattern of such abuse occurs in the area of products for individuals to -use, which is precisely where it is most unacceptable. Therefore, we -have designed this version of the GPL to prohibit the practice for those -products. If such problems arise substantially in other domains, we -stand ready to extend this provision to those domains in future versions -of the GPL, as needed to protect the freedom of users. - - Finally, every program is threatened constantly by software patents. -States should not allow patents to restrict development and use of -software on general-purpose computers, but in those that do, we wish to -avoid the special danger that patents applied to a free program could -make it effectively proprietary. To prevent this, the GPL assures that -patents cannot be used to render the program non-free. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Use with the GNU Affero General Public License. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU Affero General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the special requirements of the GNU Affero General Public License, -section 13, concerning interaction through a network will apply to the -combination as such. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU General Public License from time to time. Such new versions will -be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If the program does terminal interaction, make it output a short -notice like this when it starts in an interactive mode: - - Copyright (C) - This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. - This is free software, and you are welcome to redistribute it - under certain conditions; type `show c' for details. - -The hypothetical commands `show w' and `show c' should show the appropriate -parts of the General Public License. Of course, your program's commands -might be different; for a GUI interface, you would use an "about box". - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU GPL, see -. - - The GNU General Public License does not permit incorporating your program -into proprietary programs. If your program is a subroutine library, you -may consider it more useful to permit linking proprietary applications with -the library. If this is what you want to do, use the GNU Lesser General -Public License instead of this License. But first, please read -. diff --git a/docs/third-party/wechat-chatter/NOTICE.md b/docs/third-party/wechat-chatter/NOTICE.md deleted file mode 100644 index c4ce5fa..0000000 --- a/docs/third-party/wechat-chatter/NOTICE.md +++ /dev/null @@ -1,28 +0,0 @@ -# wechat_chatter / OneBot 第三方组件说明 - -TraceMemo 的 macOS 个人微信发送功能会按需使用以下第三方组件: - -- 项目:`yincongcyincong/wechat_chatter` -- 上游仓库:https://github.com/yincongcyincong/wechat_chatter -- 当前运行时版本:`v0.0.18` -- 运行时文件:`onebot_mac_arm64.tar.gz` -- 许可证:GNU General Public License version 3(GPL-3.0) -- 上游版权:Copyright (C) 2026 yincongcyincong -- TraceMemo 修改日期:2026-08-17 - -## 集成方式 - -OneBot 运行时不会随 TraceMemo 安装包一起分发。用户启用该实验性功能时,TraceMemo 会从上述上游项目的 GitHub Release 按需下载运行时,并将其安装到应用的用户数据目录。 - -运行时作为独立进程启动,TraceMemo 通过本机 HTTP 接口与其通信。 - -## 本地修改 - -为适配连续发送、图片上传 Hook 状态检测以及微信核心模块基址定位,TraceMemo 会在用户设备上对上游 `onebot/script.js` 应用兼容性补丁。补丁逻辑位于: - -- `scripts/prepare-wechat-chatter-runtime.cjs` -- `src/main/services/personal-wechat-runtime-manager.ts` - -补丁中源自或修改自上游 `script.js` 的部分,以及补丁应用后产生的修改版 `script.js`,继续按照 GPL-3.0 提供。本说明只针对该第三方组件及相关修改,不用于声明 TraceMemo 仓库其他部分的许可证。 - -GPL-3.0 的完整文本见本目录下的 `LICENSE`。 diff --git a/electron-builder.yml b/electron-builder.yml index 2243ba5..1e83eb5 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -34,7 +34,6 @@ extraResources: to: resources filter: - '**/*' - - '!connectors/wechat-personal/**' - from: docs/skill/tracememo-reader to: skill/tracememo-reader filter: diff --git a/package.json b/package.json index 5a50389..d374a97 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "prepare:ffmpeg:mac:arm64": "node scripts/prepare-electron-runtime.cjs --platform darwin --arch arm64", "prepare:ffmpeg:mac:x64": "node scripts/prepare-electron-runtime.cjs --platform darwin --arch x64", "prepare:win-runtime": "node scripts/prepare-win-runtime.cjs && npm run prepare:ffmpeg:win", - "prepare:wechat-personal": "node scripts/prepare-wechat-chatter-runtime.cjs", + "prepare:wechat-native": "node scripts/prepare-wechat-native-runtime.cjs", "start": "electron-vite preview", "predev": "node scripts/ensure-electron-binary.cjs", "dev": "node scripts/ensure-env.cjs && electron-vite dev", @@ -169,4 +169,4 @@ "ffmpeg-static" ] } -} \ No newline at end of file +} diff --git a/scripts/prepare-wechat-chatter-runtime.cjs b/scripts/prepare-wechat-chatter-runtime.cjs deleted file mode 100644 index 31ec50a..0000000 --- a/scripts/prepare-wechat-chatter-runtime.cjs +++ /dev/null @@ -1,436 +0,0 @@ -/* - * wechat_chatter runtime integration - * - * Upstream: https://github.com/yincongcyincong/wechat_chatter - * Runtime version: v0.0.18 - * Upstream license: GNU General Public License version 3 (GPL-3.0) - * - * This file applies local compatibility patches to the upstream - * onebot/script.js. See docs/third-party/wechat-chatter/NOTICE.md. - */ - -/* eslint-disable @typescript-eslint/explicit-function-return-type, @typescript-eslint/no-require-imports */ -const { execFileSync } = require('node:child_process') -const fs = require('node:fs') -const os = require('node:os') -const path = require('node:path') - -const release = 'v0.0.18' -const asset = 'onebot_mac_arm64.tar.gz' -const url = `https://github.com/yincongcyincong/wechat_chatter/releases/download/${release}/${asset}` -const projectRoot = path.resolve(__dirname, '..') -const outputDir = path.join( - projectRoot, - 'resources', - 'connectors', - 'wechat-personal', - 'darwin-arm64' -) -const archive = path.join(os.tmpdir(), `wechat-chatter-${release}-${asset}`) -const appleSilicon = - process.platform === 'darwin' && - execFileSync('/usr/sbin/sysctl', ['-n', 'hw.optional.arm64'], { encoding: 'utf8' }).trim() === '1' - -if (!appleSilicon) { - throw new Error('个人微信发送运行时当前仅支持 macOS arm64') -} - -fs.mkdirSync(outputDir, { recursive: true }) -let archiveReady = false -if (fs.existsSync(archive)) { - try { - execFileSync('/usr/bin/tar', ['-tzf', archive], { stdio: 'ignore' }) - archiveReady = true - console.log(`[wechat-personal] 复用已下载归档:${archive}`) - } catch { - // The archive is partial or invalid; curl will resume it below. - } -} -if (!archiveReady) { - console.log(`[wechat-personal] 下载 ${release},支持断点续传:${archive}`) - execFileSync( - '/usr/bin/curl', - ['--http1.1', '-L', '--fail', '--retry', '3', '--continue-at', '-', '--output', archive, url], - { stdio: 'inherit' } - ) -} - -const extractionDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wechat-chatter-extract-')) -console.log(`[wechat-personal] 解压并原子安装到 ${outputDir}`) -try { - execFileSync('/usr/bin/tar', ['-xzf', archive, '-C', extractionDir], { stdio: 'inherit' }) - fs.mkdirSync(path.join(outputDir, 'onebot'), { recursive: true }) - fs.mkdirSync(path.join(outputDir, 'wechat_version'), { recursive: true }) - fs.copyFileSync( - path.join(extractionDir, 'onebot', 'script.js'), - path.join(outputDir, 'onebot', 'script.js') - ) - fs.cpSync(path.join(extractionDir, 'wechat_version'), path.join(outputDir, 'wechat_version'), { - recursive: true, - force: true - }) - const stagedExecutable = path.join(outputDir, 'onebot', `.onebot-${process.pid}.tmp`) - fs.copyFileSync(path.join(extractionDir, 'onebot', 'onebot'), stagedExecutable) - fs.chmodSync(stagedExecutable, 0o755) - fs.renameSync(stagedExecutable, path.join(outputDir, 'onebot', 'onebot')) -} finally { - fs.rmSync(extractionDir, { recursive: true, force: true }) -} - -const executable = path.join(outputDir, 'onebot', 'onebot') -const script = path.join(outputDir, 'onebot', 'script.js') -const config = path.join(outputDir, 'wechat_version', '4_1_11_53_mac.json') -for (const required of [executable, script, config]) { - if (!fs.existsSync(required)) throw new Error(`运行时文件缺失:${required}`) -} - -function patchPerSendPayload(scriptPath) { - let source = fs.readFileSync(scriptPath, 'utf8') - if (!source.includes('var activeTriggerX1Payload = ptr(0);')) return - - const activeSend = ` const payloadData = hexToByteArray(payloadHex); - activeTriggerX1Payload = Memory.alloc(payloadData.length); - activeTriggerX1Payload.writeByteArray(payloadData); - activeTriggerX1Payload.add(0x18).writePointer(info.cgiAddr); - activeTriggerX1Payload.add(0xb8).writePointer(activeTriggerX1Payload.add(0xc0)); - activeTriggerX1Payload.add(0x190).writePointer(activeTriggerX1Payload.add(0x198));` - const upstreamSend = ` const payloadData = hexToByteArray(payloadHex); - triggerX1Payload.writeByteArray(payloadData); - triggerX1Payload.add(0x18).writePointer(info.cgiAddr); - triggerX1Payload.add(0xb8).writePointer(triggerX1Payload.add(0xc0)); - triggerX1Payload.add(0x190).writePointer(triggerX1Payload.add(0x198));` - if (!source.includes(activeSend)) throw new Error('无法定位 wechat_chatter 连续发送补丁位置') - source = source - .replace( - 'var triggerX1Payload;\nvar activeTriggerX1Payload = ptr(0);\nvar triggerX0;', - 'var triggerX1Payload;\nvar triggerX0;' - ) - .replace(activeSend, upstreamSend) - .replace( - ' MMStartTask(triggerX0, activeTriggerX1Payload);', - ' MMStartTask(triggerX0, triggerX1Payload);' - ) - .replace( - ' activeTriggerX1Payload = ptr(0);\n console.error("[!] Error trigger " + msgType + " MMStartTask: " + e);', - ' console.error("[!] Error trigger " + msgType + " MMStartTask: " + e);' - ) - .replace( - '\t\t\t\tpendingSendMsgType = "";\n\t\t\t\tactiveTriggerX1Payload = ptr(0);\n\t\t\t\treturn', - '\t\t\t\tpendingSendMsgType = "";\n\t\t\t\treturn' - ) - fs.writeFileSync(scriptPath, source) - console.log('[wechat-personal] 已恢复原生发送 payload 布局') -} - -function patchSendContextCapture(scriptPath, strict = true) { - let source = fs.readFileSync(scriptPath, 'utf8') - if (source.includes('function isLikelySendContext(')) return - const original = `function AttachSendFunc() { - Interceptor.attach(sendFuncAddr.add(0x10), { - onEnter: function (args) { - - if (triggerX1Payload) { - return - } - - triggerX0 = this.context.x0; - triggerX1Payload = this.context.x1; - console.log(\`[+] 捕获到 StartTask 调用,X0:\${triggerX0}, Payload: \${triggerX1Payload}\`); - } - }) -}` - const patched = `function isLikelySendContext(candidateX0, candidateX1) { - try { - if (!isReadablePointer(candidateX0) || !isReadablePointer(candidateX1)) return false; - var manager = readPointerIfReadable(candidateX0.add(0x18)); - var cgi = readUtf8StringIfReadable(readPointerIfReadable(candidateX1.add(0x18))); - console.log("[debug] StartTask candidate x0=" + candidateX0 + " x1=" + candidateX1 + " x0+0x18=" + manager + " cgi=" + cgi); - return !manager.equals(ptr(0)); - } catch (e) { - console.error("[debug] StartTask candidate inspect failed: " + e); - return false; - } -} - -function AttachSendFunc() { - Interceptor.attach(sendFuncAddr.add(0x10), { - onEnter: function (args) { - if (triggerX1Payload) return; - var candidateX0 = this.context.x0; - var candidateX1 = this.context.x1; - if (!isLikelySendContext(candidateX0, candidateX1)) return; - triggerX0 = candidateX0; - triggerX1Payload = candidateX1; - console.log(\`[+] 捕获到有效 StartTask 上下文,X0:\${triggerX0}, Payload: \${triggerX1Payload}\`); - } - }) -}` - if (!source.includes(original)) { - if (strict) throw new Error('无法定位 StartTask 上下文 Hook') - return - } - source = source.replace(original, patched) - fs.writeFileSync(scriptPath, source) -} - -function patchVoiceAudioBuffer(scriptPath) { - let source = fs.readFileSync(scriptPath, 'utf8') - if (source.includes('voiceAudioDataAddr = Memory.alloc(audioLen + 1);')) return - - const staticAllocation = 'voiceAudioDataAddr = Memory.alloc(5 * 1024 * 1024); // 预分配5MB' - if (!source.includes(staticAllocation)) { - throw new Error('无法定位 wechat_chatter 语音缓冲区') - } - source = source.replace( - staticAllocation, - 'voiceAudioDataAddr = Memory.alloc(1); // 上传前按语音长度重新分配' - ) - const audioLengthMarker = ' const audioLen = audioBytes.length;\n' - if (!source.includes(audioLengthMarker)) { - throw new Error('无法定位 wechat_chatter 语音上传逻辑') - } - source = source.replace( - audioLengthMarker, - `${audioLengthMarker} voiceAudioDataAddr = Memory.alloc(audioLen + 1);\n` - ) - fs.writeFileSync(scriptPath, source) - console.log('[wechat-personal] 已应用按语音长度分配上传缓冲区补丁') -} - -function patchImageHookReadiness(scriptPath) { - let source = fs.readFileSync(scriptPath, 'utf8') - if ( - source.includes('捕获到图片上传上下文,uploadGlobalX0') && - source.includes('图片上传 Hook Setup Complete') - ) - return - const original = `\t\t\tuploadGlobalX0 = this.context.x0;` - const patched = `\t\t\tconst capturedUploadX0 = this.context.x0; -\t\t\tif (uploadGlobalX0.equals(ptr(0)) && !capturedUploadX0.equals(ptr(0))) { -\t\t\t\tconsole.log("[+] 捕获到图片上传上下文,uploadGlobalX0:" + capturedUploadX0); -\t\t\t} -\t\t\tuploadGlobalX0 = capturedUploadX0;` - if (!source.includes(original)) throw new Error('无法定位 wechat_chatter 图片 Hook 状态补丁位置') - source = source.replace(original, patched) - source = source.replace( - ' })\n}\n\n\n\nfunction patchCdnOnComplete()', - ' })\n console.log("[+] 图片上传 Hook Setup Complete.");\n}\n\n\n\nfunction patchCdnOnComplete()' - ) - fs.writeFileSync(scriptPath, source) - console.log('[wechat-personal] 已应用图片 Hook 状态补丁') -} - -// Backport of wechat_chatter PR #36 by @Leslielu: -// https://github.com/yincongcyincong/wechat_chatter/pull/36 -// TraceMemo adds the verified macOS WeChat 4.1.11.53 addresses. -// macOS WeChat 4.1.11.53, located and verified by TraceMemo -function patchCdnColdStart(scriptPath) { - let source = fs.readFileSync(scriptPath, 'utf8') - if (source.includes('function resolveCdnManager()')) return - - const initAddresses = ` uploadImageAddr = baseAddr.add({{.uploadImageAddr}}); - cndOnCompleteAddr = baseAddr.add({{.cndOnCompleteAddr}});` - const patchedInitAddresses = ` uploadImageAddr = baseAddr.add({{.uploadImageAddr}}); - cndOnCompleteAddr = baseAddr.add({{.cndOnCompleteAddr}}); - // 冷启动 CdnManager 解析(旧版本缺少可选键时保持 hook 捕获行为) - {{if .cdnGetServiceAddr}}cdnGetServiceAddr = baseAddr.add({{.cdnGetServiceAddr}});{{end}} - {{if .cdnManagerGetterAddr}}cdnManagerGetterAddr = baseAddr.add({{.cdnManagerGetterAddr}});{{end}}` - if (!source.includes(initAddresses)) throw new Error('下载的微信版本配置与当前应用不兼容') - source = source.replace(initAddresses, patchedInitAddresses) - - const downloadChunkEnd = `} - -function fillUploadX1AndStart` - const resolver = `} - -// 上传和下载共用同一个 mars::cdn::CdnManager。冷启动时通过服务定位器 -// 取得 [ctx + 0x40],避免必须先手动发送图片才能让 Hook 捕获上下文。 -function resolveCdnManager() { - if (cdnGetServiceAddr.equals(ptr(0)) || cdnManagerGetterAddr.equals(ptr(0))) { - return ptr(0); - } - try { - // libc++ SSO 短字符串:数据在 +0,长度写在 +0x17。 - var strDefault = Memory.alloc(24); - strDefault.writeUtf8String("default"); - strDefault.add(0x17).writeU8(7); - - var getService = new NativeFunction(cdnGetServiceAddr, 'pointer', ['pointer']); - var svc = getService(strDefault); - if (!isReadablePointer(svc)) { - console.error("[!] GetService(\\"default\\") 返回不可读: " + svc); - return ptr(0); - } - var getCtx = new NativeFunction(cdnManagerGetterAddr, 'pointer', ['pointer']); - var ctx = getCtx(svc); - if (!isReadablePointer(ctx)) { - console.error("[!] CdnManager getter 返回不可读: " + ctx); - return ptr(0); - } - var mgr = readPointerIfReadable(ctx.add(0x40)); - if (!isReadablePointer(mgr)) { - console.error("[!] ctx+0x40 管理器指针不可读: ctx=" + ctx); - return ptr(0); - } - return mgr; - } catch (e) { - console.error("[!] resolveCdnManager 异常: " + e); - return ptr(0); - } -} - -function ensureCdnManagerX0() { - if (uploadGlobalX0.equals(ptr(0)) && downloadGlobalX0 && !downloadGlobalX0.equals(ptr(0))) { - uploadGlobalX0 = downloadGlobalX0; - console.log("[+] downloadGlobalX0 回填 uploadGlobalX0: " + uploadGlobalX0); - } - if ((!downloadGlobalX0 || downloadGlobalX0.equals(ptr(0))) && !uploadGlobalX0.equals(ptr(0))) { - downloadGlobalX0 = uploadGlobalX0; - console.log("[+] uploadGlobalX0 回填 downloadGlobalX0: " + downloadGlobalX0); - } - if (uploadGlobalX0.equals(ptr(0))) { - var mgr = resolveCdnManager(); - if (!mgr.equals(ptr(0))) { - uploadGlobalX0 = mgr; - if (!downloadGlobalX0 || downloadGlobalX0.equals(ptr(0))) { - downloadGlobalX0 = mgr; - } - console.log("[+] 冷启动服务定位器解析 CdnManager: " + mgr); - } - } - return !uploadGlobalX0.equals(ptr(0)); -} - -function fillUploadX1AndStart` - if (!source.includes(downloadChunkEnd)) throw new Error('无法定位 wechat_chatter 媒体上传逻辑') - source = source.replace(downloadChunkEnd, resolver) - - const declarations = 'var uploadImageAddr;\n' - const patchedDeclarations = - 'var uploadImageAddr;\nvar cdnGetServiceAddr = ptr(0);\nvar cdnManagerGetterAddr = ptr(0);\n' - if (!source.includes(declarations)) throw new Error('无法定位 wechat_chatter 媒体地址声明') - source = source.replace(declarations, patchedDeclarations) - - const uploadGuard = `function fillUploadX1AndStart(idAddr, pathAddr, x1Buffer, receiver, md5, filePath, payloadHex) { - if (uploadGlobalX0.equals(ptr(0))) {` - const patchedUploadGuard = `function fillUploadX1AndStart(idAddr, pathAddr, x1Buffer, receiver, md5, filePath, payloadHex) { - if (uploadGlobalX0.equals(ptr(0))) { - ensureCdnManagerX0(); - } - if (uploadGlobalX0.equals(ptr(0))) {` - if (!source.includes(uploadGuard)) throw new Error('无法定位 wechat_chatter 媒体上传入口') - source = source.replace(uploadGuard, patchedUploadGuard) - - const voiceGuard = `function triggerUploadVoice(receiver, voicePath, payloadHex, audioDataHex, durationMs) { - if (uploadGlobalX0.equals(ptr(0))) {` - const patchedVoiceGuard = `function triggerUploadVoice(receiver, voicePath, payloadHex, audioDataHex, durationMs) { - if (uploadGlobalX0.equals(ptr(0))) { - ensureCdnManagerX0(); - } - if (uploadGlobalX0.equals(ptr(0))) {` - if (!source.includes(voiceGuard)) throw new Error('无法定位 wechat_chatter 语音上传入口') - source = source.replace(voiceGuard, patchedVoiceGuard) - - const uploadHook = `\t\t\tuploadGlobalX0 = capturedUploadX0;` - const patchedUploadHook = `\t\t\tuploadGlobalX0 = capturedUploadX0; - if ((!downloadGlobalX0 || downloadGlobalX0.equals(ptr(0))) && !capturedUploadX0.equals(ptr(0))) { - downloadGlobalX0 = capturedUploadX0; - console.log("[+] 上传hook回填 downloadGlobalX0: " + downloadGlobalX0); - }` - if (!source.includes(uploadHook)) throw new Error('无法定位 wechat_chatter 图片 Hook') - source = source.replace(uploadHook, patchedUploadHook) - - const downloadHook = ` downloadGlobalX0 = this.context.x0;` - const patchedDownloadHook = ` downloadGlobalX0 = this.context.x0; - if (uploadGlobalX0.equals(ptr(0)) && !downloadGlobalX0.equals(ptr(0))) { - uploadGlobalX0 = downloadGlobalX0; - console.log("[+] 下载hook回填 uploadGlobalX0: " + uploadGlobalX0); - }` - if (!source.includes(downloadHook)) throw new Error('无法定位 wechat_chatter 下载 Hook') - source = source.replace(downloadHook, patchedDownloadHook) - - const downloadGuard = `function triggerDownload(receiver, cdnUrl, aesKey, filePath, fileType) { - if (!downloadGlobalX0) {` - const patchedDownloadGuard = `function triggerDownload(receiver, cdnUrl, aesKey, filePath, fileType) { - if (!downloadGlobalX0 || downloadGlobalX0.equals(ptr(0))) { - ensureCdnManagerX0(); - } - if (!downloadGlobalX0) {` - if (!source.includes(downloadGuard)) throw new Error('无法定位 wechat_chatter 媒体下载入口') - source = source.replace(downloadGuard, patchedDownloadGuard) - - fs.writeFileSync(scriptPath, source) - console.log('[wechat-personal] 已应用 CdnManager 冷启动解析补丁') -} - -function patchCdnColdStartConfig(configPath) { - const source = fs.readFileSync(configPath, 'utf8') - let config - try { - config = JSON.parse(source) - } catch { - throw new Error('4.1.11.53 版本配置不是有效 JSON') - } - config.cdnGetServiceAddr = '0x50a15d0' - config.cdnManagerGetterAddr = '0x5259290' - fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`) - console.log('[wechat-personal] 已写入 4.1.11.53 CdnManager 地址') -} - -function patchWechatCoreModuleBase(scriptPath) { - let source = fs.readFileSync(scriptPath, 'utf8') - if (source.includes('WeChat core module base:')) return - const initMarker = 'function initAddresses() {' - const initIndex = source.indexOf(initMarker) - if (initIndex < 0 || !source.startsWith('var targetPath = ')) { - throw new Error('无法定位 wechat_chatter 基址扫描逻辑') - } - const patchedHeader = `var targetPath = "/Applications/WeChat.app/Contents/Resources/wechat.dylib"; -var module = Process.enumerateModules().find(function(m) { - return m.path === targetPath || m.path.endsWith("/Contents/Resources/wechat.dylib"); -}); -if (!module) { - throw new Error("[-] Cannot find WeChat core module: " + targetPath); -} -var moduleBase = module.base; -var baseAddr = moduleBase; -console.log("[+] WeChat core module base: " + baseAddr + " path=" + module.path); -setImmediate(initAddresses); - -` - source = patchedHeader + source.slice(initIndex) - fs.writeFileSync(scriptPath, source) - console.log('[wechat-personal] 已应用微信核心模块基址补丁') -} - -function addModifiedWorkNotice(scriptPath) { - let source = fs.readFileSync(scriptPath, 'utf8') - if (source.includes('TraceMemo wechat_chatter compatibility modifications')) return - - const notice = `/* - * TraceMemo wechat_chatter compatibility modifications - * Modified: 2026-08-17 - * Upstream: https://github.com/yincongcyincong/wechat_chatter - * Runtime version: v0.0.18 - * License: GNU General Public License version 3 (GPL-3.0) - * Changes: WeChat module discovery, per-send payload isolation, dynamic voice upload buffers, - * CdnManager cold-start resolution, media hook backfill, and image Hook readiness logging. - * These modifications are not provided by the upstream author. - */ - -` - source = notice + source - fs.writeFileSync(scriptPath, source) - console.log('[wechat-personal] 已写入 GPL 修改声明') -} - -patchWechatCoreModuleBase(script) -patchPerSendPayload(script) -patchSendContextCapture(script) -patchVoiceAudioBuffer(script) -patchImageHookReadiness(script) -patchCdnColdStartConfig(config) -patchCdnColdStart(script) -addModifiedWorkNotice(script) -fs.chmodSync(executable, 0o755) -console.log('[wechat-personal] 运行时准备完成') diff --git a/scripts/prepare-wechat-native-runtime.cjs b/scripts/prepare-wechat-native-runtime.cjs new file mode 100644 index 0000000..e042843 --- /dev/null +++ b/scripts/prepare-wechat-native-runtime.cjs @@ -0,0 +1,201 @@ +#!/usr/bin/env node + +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +/* eslint-disable @typescript-eslint/no-require-imports */ +const crypto = require('node:crypto') +const fs = require('node:fs') +const path = require('node:path') + +const REQUIRED_FILES = ['tm-wechat-host', 'libtmwechat.dylib', 'runtime-manifest.json'] + +function parseArgs(argv) { + const result = {} + for (let index = 0; index < argv.length; index += 1) { + const value = argv[index] + if (value === '--source' || value === '--target') { + if (!argv[index + 1]) throw new Error(`${value} requires a path`) + result[value.slice(2)] = argv[index + 1] + index += 1 + } else { + throw new Error(`Unknown option: ${value}`) + } + } + return result +} + +function sha256(filePath) { + return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex') +} + +function listFiles(root) { + const files = [] + function visit(directory) { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const absolute = path.join(directory, entry.name) + if (entry.isDirectory()) visit(absolute) + else if (entry.isFile()) files.push(path.relative(root, absolute)) + } + } + visit(root) + return files.sort() +} + +function assertMachOArm64(filePath, label) { + const buffer = fs.readFileSync(filePath) + if (buffer.length < 8 || buffer.readUInt32LE(0) !== 0xfeedfacf) { + throw new Error(`${label} is not a 64-bit Mach-O binary: ${filePath}`) + } + if (buffer.readUInt32LE(4) !== 0x0100000c) { + throw new Error(`${label} is not arm64: ${filePath}`) + } +} + +function readAndValidateArtifact(sourceDir) { + if (!fs.existsSync(sourceDir)) { + throw new Error('native runtime artifact not found: build the macOS runtime artifact first') + } + + const actualFiles = listFiles(sourceDir) + const expectedFiles = [...REQUIRED_FILES].sort() + if (JSON.stringify(actualFiles) !== JSON.stringify(expectedFiles)) { + throw new Error(`native runtime artifact has an unexpected tree: ${actualFiles.join(', ')}`) + } + + const manifestPath = path.join(sourceDir, 'runtime-manifest.json') + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) + if (manifest.runtime !== 'tm-wechat-native') throw new Error('Unexpected runtime name') + if (manifest.platform !== 'darwin-arm64') throw new Error('Runtime platform must be darwin-arm64') + if (manifest.architecture !== 'arm64') throw new Error('Runtime architecture must be arm64') + if (manifest.protocolVersion !== 1) throw new Error('Unsupported runtime protocolVersion') + if (!/^[a-f0-9]{64}$/.test(manifest.tmSendSourceSha256 || '')) { + throw new Error('Runtime manifest has no valid tmSendSourceSha256') + } + if (!/^[a-f0-9]{64}$/.test(manifest.addressProfileSha256 || '')) { + throw new Error('Runtime manifest has no valid addressProfileSha256') + } + const capabilities = new Set(manifest.capabilities || []) + for (const capability of ['text', 'image', 'voice']) { + if (!capabilities.has(capability)) throw new Error(`Runtime capability missing: ${capability}`) + } + if ( + !Array.isArray(manifest.supportedWechatVersions) || + manifest.supportedWechatVersions.length === 0 + ) { + throw new Error('Runtime manifest has no supportedWechatVersions') + } + + for (const relative of REQUIRED_FILES) { + const absolute = path.join(sourceDir, relative) + if (!fs.statSync(absolute).isFile()) throw new Error(`Runtime file missing: ${relative}`) + } + const hostPath = path.join(sourceDir, 'tm-wechat-host') + const dylibPath = path.join(sourceDir, 'libtmwechat.dylib') + assertMachOArm64(hostPath, 'tm-wechat-host') + assertMachOArm64(dylibPath, 'libtmwechat.dylib') + if (!fs.readFileSync(dylibPath).includes(Buffer.from(manifest.tmSendSourceSha256, 'utf8'))) { + throw new Error('Embedded agent hash is not present in libtmwechat.dylib') + } + if (!fs.readFileSync(dylibPath).includes(Buffer.from(manifest.addressProfileSha256, 'utf8'))) { + throw new Error('Embedded address profile hash is not present in libtmwechat.dylib') + } + + return { + manifest, + hashes: Object.fromEntries( + REQUIRED_FILES.map((file) => [file, sha256(path.join(sourceDir, file))]) + ) + } +} + +function syncArtifact(sourceDir, targetDir) { + const source = readAndValidateArtifact(sourceDir) + const parent = path.dirname(targetDir) + const name = path.basename(targetDir) + const staging = path.join(parent, `.${name}.prepare-${process.pid}`) + const backup = path.join(parent, `.${name}.backup-${process.pid}`) + + fs.mkdirSync(parent, { recursive: true }) + fs.rmSync(staging, { recursive: true, force: true }) + fs.rmSync(backup, { recursive: true, force: true }) + fs.mkdirSync(staging, { recursive: true }) + + try { + for (const relative of REQUIRED_FILES) { + const destination = path.join(staging, relative) + fs.mkdirSync(path.dirname(destination), { recursive: true }) + fs.copyFileSync(path.join(sourceDir, relative), destination) + } + fs.chmodSync(path.join(staging, 'tm-wechat-host'), 0o755) + + const staged = readAndValidateArtifact(staging) + for (const relative of REQUIRED_FILES) { + if (source.hashes[relative] !== staged.hashes[relative]) { + throw new Error(`Packaged runtime hash mismatch: ${relative}`) + } + } + + if (fs.existsSync(targetDir)) fs.renameSync(targetDir, backup) + try { + fs.renameSync(staging, targetDir) + } catch (error) { + if (fs.existsSync(backup)) fs.renameSync(backup, targetDir) + throw error + } + fs.rmSync(backup, { recursive: true, force: true }) + return staged + } finally { + fs.rmSync(staging, { recursive: true, force: true }) + } +} + +function resolveSource(explicit) { + if (explicit) return path.resolve(explicit) + const fromEnv = String(process.env.TM_NATIVE_RUNTIME_DIR || '').trim() + if (fromEnv) return path.resolve(fromEnv) + // 本机路径写在这里即可,该文件不进仓库。 + const localConfig = path.join(__dirname, '..', '.native-runtime-source') + if (fs.existsSync(localConfig)) { + const configured = fs.readFileSync(localConfig, 'utf8').trim() + if (configured) return path.resolve(configured) + } + throw new Error( + 'runtime artifact source is required: pass --source , set TM_NATIVE_RUNTIME_DIR, ' + + 'or write the path into .native-runtime-source' + ) +} + +function main() { + const projectRoot = path.resolve(__dirname, '..') + const args = parseArgs(process.argv.slice(2)) + const sourceDir = resolveSource(args.source) + const targetDir = path.resolve( + args.target || path.join(projectRoot, 'resources', 'runtime', 'darwin-arm64') + ) + const result = syncArtifact(sourceDir, targetDir) + console.log(`[prepare-wechat-native] source: ${sourceDir}`) + console.log(`[prepare-wechat-native] target: ${targetDir}`) + console.log( + `[prepare-wechat-native] runtime: ${result.manifest.runtime}/${result.manifest.version}` + ) + console.log(`[prepare-wechat-native] agent: ${result.manifest.tmSendSourceSha256}`) + console.log(`[prepare-wechat-native] address profile: ${result.manifest.addressProfileSha256}`) + console.log('[prepare-wechat-native] files: 3') +} + +module.exports = { + REQUIRED_FILES, + assertMachOArm64, + readAndValidateArtifact, + syncArtifact +} + +if (require.main === module) { + try { + main() + } catch (error) { + console.error( + `[prepare-wechat-native] ${error instanceof Error ? error.message : String(error)}` + ) + process.exitCode = 1 + } +} diff --git a/src/main/index.ts b/src/main/index.ts index 7530508..4cd895e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -120,14 +120,15 @@ import { GroupStatsService } from './services/group-stats-service' import { wechatActionLogService } from './services/wechat-action-log-service' import { toPersonalWechatSendResult, wechatActionGateway } from './services/wechat-action-gateway' import { personalWechatSendService } from './services/personal-wechat-send-service' +import { macWechatRuntimeManager } from './services/mac-wechat-runtime-manager' import { getPersonalWechatSendCapability } from './services/personal-wechat-capability-service' import { scheduledReportService } from './services/scheduled-report-service' -import { PersonalWechatRuntimeManager } from './services/personal-wechat-runtime-manager' import { personalWechatVoiceEnvironmentService } from './services/personal-wechat-voice-environment-service' import type { PersonalWechatGeneratedTtsVoiceRequest, PersonalWechatSendRequest, - PersonalWechatSendResult + PersonalWechatSendResult, + PersonalWechatSenderStatus } from '../shared/personal-wechat' import type { ScheduledReportCreateInput, @@ -201,6 +202,12 @@ import type { WechatShareServiceConfig } from '../shared/wechat-share-card' +async function currentPersonalWechatSenderStatus(): Promise { + return process.platform === 'darwin' + ? macWechatRuntimeManager.buildSenderStatus() + : personalWechatSendService.getStatus() +} + // electron-vite can close the child's stdout/stderr after spawning Electron. // Plain console.error then throws EPIPE on a closed pipe and crashes the IPC // handler. Wrap console.* before any other module logs anything. @@ -228,7 +235,6 @@ const databaseKeyStore = new DatabaseKeyStore() const imageKeyConfigService = new ImageKeyConfigService() const aiProviderService = new AIProviderService() const textToSpeechSettingsService = new TextToSpeechSettingsService() -const personalWechatRuntimeManager = new PersonalWechatRuntimeManager() const keyServiceMac = new KeyServiceMac() const keyServiceWin = new KeyServiceWin() const wechatShareConfigStore = new WechatShareConfigStore() @@ -839,11 +845,6 @@ app.whenReady().then(async () => { if (!window.isDestroyed()) window.webContents.send('voice:modelProgress', status) } }) - personalWechatRuntimeManager.setProgressListener((status) => { - for (const window of BrowserWindow.getAllWindows()) { - if (!window.isDestroyed()) window.webContents.send('wechat-personal:runtimeProgress', status) - } - }) protocol.handle('wxe-media', async (request) => { const filePath = videoAssetService?.pathForUrl(request.url) if (!filePath) return new Response('Not found', { status: 404 }) @@ -1214,25 +1215,46 @@ app.whenReady().then(async () => { // 项目规则:**所有发送都必须经过 WechatActionGateway**(审计 + 幂等 + 同一个 Send Log)。 // 手动发送在改造前从这里直连 `PersonalWechatSendService`,于是完全不留痕 —— // 排查「消息到底发没发出去」时恰好缺的就是那份证据。 - // `triggerType: 'user'` 不会命中 automation 专用的 purpose allowlist 与 3s 节流, - // 所以行为与改造前一致,只是多一条审计记录(「发送日志」里能看到)。 + // 普通手动发送保持 triggerType=user;带 postfixText 的日报图片由 Gateway 编排成 + // 两个 automation action,以复用同一条 3s 队列,并严格在图片 sent 后才发后置词。 if (request.type === 'text' || request.type === 'image') { const to = String(request.to || '').trim() + const recipient = { + type: + request.isGroup || to.endsWith('@chatroom') ? ('group' as const) : ('contact' as const), + id: to + } + if (request.type === 'image' && request.postfixText !== undefined) { + const sequence = await wechatActionGateway.executeReportImageSequence({ + recipient, + imagePath: String(request.filePath || ''), + postfixText: request.postfixText + }) + const imageResult = toPersonalWechatSendResult( + sequence.image, + await currentPersonalWechatSenderStatus() + ) + if (!imageResult.success || !sequence.postfix) return imageResult + return { + ...imageResult, + postfixSent: sequence.postfix.status === 'sent', + ...(sequence.postfix.status === 'sent' + ? {} + : { postfixError: sequence.postfix.reason || '后置词发送失败' }) + } + } const action = await wechatActionGateway.execute({ origin: 'user_manual', purpose: request.type === 'text' ? 'manual_text' : 'manual_image', triggerType: 'user', - recipient: { - type: request.isGroup || to.endsWith('@chatroom') ? 'group' : 'contact', - id: to - }, + recipient, content: request.type === 'text' ? { type: 'text', text: String(request.text || '') } : { type: 'image', path: String(request.filePath || '') } }) // 返回契约保持 `PersonalWechatSendResult`,界面判读不用改。 - return toPersonalWechatSendResult(action, await personalWechatSendService.getStatus()) + return toPersonalWechatSendResult(action, await currentPersonalWechatSenderStatus()) } // 语音仍走既有分支:它有自己的网关入口 `wechat-personal:sendGeneratedTtsVoice`, @@ -1645,7 +1667,9 @@ app.whenReady().then(async () => { ipcMain.handle('automation:deleteRule', (_, id: string) => automationRuleStore.deleteRule(id)) ipcMain.handle('automation:setRuleEnabled', (_, input: unknown) => { const payload = (input || {}) as { id?: unknown; enabled?: unknown } - return automationRuleStore.setRuleEnabled(String(payload.id || ''), payload.enabled === true) ?? null + return ( + automationRuleStore.setRuleEnabled(String(payload.id || ''), payload.enabled === true) ?? null + ) }) ipcMain.handle('automation:listExecutions', (_, query: unknown) => { const input = (query || {}) as { limit?: unknown } @@ -2528,42 +2552,16 @@ app.whenReady().then(async () => { agentHubService.clearConversations() return { success: true } }) - ipcMain.handle('wechat-personal:getStatus', () => personalWechatSendService.getStatus()) - ipcMain.handle('wechat-personal:getKeepProcess', () => - personalWechatSendService.getKeepOneBotProcess() - ) - ipcMain.handle('wechat-personal:setKeepProcess', (_, keep: boolean) => - personalWechatSendService.setKeepOneBotProcess(Boolean(keep)) - ) + ipcMain.handle('wechat-personal:getStatus', () => currentPersonalWechatSenderStatus()) ipcMain.handle('wechat-personal:checkStatus', (_, port?: string) => personalWechatSendService.checkWindowsStatus(port) ) ipcMain.handle('wechat-personal:checkVoiceEnvironment', () => personalWechatVoiceEnvironmentService.check() ) - ipcMain.handle('wechat-personal:installPilk', async () => { - const result = await personalWechatVoiceEnvironmentService.installPilk() - if (!result.success || process.platform !== 'darwin') return result - - const senderStatus = await personalWechatSendService.getStatus() - if (!senderStatus.oneBotPid) return result - try { - const restartedStatus = await personalWechatSendService.restartRuntime() - return { - ...result, - restarted: restartedStatus.state !== 'error', - ...(restartedStatus.state === 'error' - ? { restartError: restartedStatus.error || restartedStatus.message } - : {}) - } - } catch (error) { - return { - ...result, - restarted: false, - restartError: error instanceof Error ? error.message : String(error) - } - } - }) + ipcMain.handle('wechat-personal:installPilk', () => + personalWechatVoiceEnvironmentService.installPilk() + ) ipcMain.handle('wechat-personal:openVoicePythonDownload', async () => { try { await shell.openExternal('https://www.python.org/downloads/macos/') @@ -2580,23 +2578,20 @@ app.whenReady().then(async () => { return { success: false, error: '无法打开 FFmpeg 安装页面' } } }) - ipcMain.handle('wechat-personal:getRuntimeStatus', () => personalWechatRuntimeManager.getStatus()) - ipcMain.handle('wechat-personal:downloadRuntime', () => personalWechatRuntimeManager.download()) - ipcMain.handle('wechat-personal:cancelRuntimeDownload', () => ({ - success: personalWechatRuntimeManager.cancelDownload() - })) - ipcMain.handle('wechat-personal:removeRuntime', async () => { - await personalWechatSendService.terminate(true) - return personalWechatRuntimeManager.remove() + ipcMain.handle('wechat-personal:rebind', async () => { + if (process.platform !== 'darwin') return personalWechatSendService.rebind() + + const result = await macWechatRuntimeManager.bind() + const status = await macWechatRuntimeManager.buildSenderStatus() + if (result.ok) return status + + return { + ...status, + state: 'error', + message: result.message, + error: result.message + } }) - ipcMain.handle('wechat-personal:openRuntimeDirectory', async () => { - const status = await personalWechatRuntimeManager.getStatus() - const directory = status.directory || personalWechatRuntimeManager.directory - await fsPromises.mkdir(directory, { recursive: true }) - const error = await shell.openPath(directory) - return error ? { success: false, error } : { success: true } - }) - ipcMain.handle('wechat-personal:rebind', () => personalWechatSendService.rebind()) ipcMain.handle( 'wechat-personal:sendGeneratedTtsVoice', async (_, request: PersonalWechatGeneratedTtsVoiceRequest) => { @@ -2624,7 +2619,7 @@ app.whenReady().then(async () => { action.sendResult && typeof action.sendResult === 'object' ? (action.sendResult as PersonalWechatSendResult) : undefined - const status = sendResult?.status || (await personalWechatSendService.getStatus()) + const status = sendResult?.status || (await currentPersonalWechatSenderStatus()) return { action, status } } ) @@ -2699,9 +2694,11 @@ app.on('before-quit', (event) => { chat.closeChatDbForQuit().catch(() => false), voiceRecognition?.dispose().catch(() => undefined), knowledgeSearchService?.dispose().catch(() => undefined), - personalWechatSendService.terminate().catch((error) => { - console.warn('[Shutdown] personal WeChat sender cleanup failed:', error) - }) + process.platform === 'darwin' + ? macWechatRuntimeManager.shutdown().catch((error) => { + console.warn('[Shutdown] macOS native WeChat runtime cleanup failed:', error) + }) + : Promise.resolve() ]) if (!nativeCallsDrained) { console.warn('[Shutdown] WCDB async calls did not fully drain before quit') diff --git a/src/main/services/mac-wechat-runtime-artifact.ts b/src/main/services/mac-wechat-runtime-artifact.ts new file mode 100644 index 0000000..9e7f2be --- /dev/null +++ b/src/main/services/mac-wechat-runtime-artifact.ts @@ -0,0 +1,101 @@ +import { existsSync, readFileSync, statSync } from 'fs' +import { join } from 'path' + +import type { MacWechatRuntimeManifest } from '../../shared/personal-wechat-mac-runtime' + +const REQUIRED_CAPABILITIES = ['text', 'image', 'voice'] as const + +function assertFile(path: string, label: string): void { + if (!existsSync(path) || !statSync(path).isFile()) { + throw new Error(`${label} missing: ${path}`) + } +} + +function assertMachOArm64(path: string, label: string): void { + const binary = readFileSync(path) + if (binary.length < 8 || binary.readUInt32LE(0) !== 0xfeedfacf) { + throw new Error(`${label} is not a 64-bit Mach-O binary`) + } + if (binary.readUInt32LE(4) !== 0x0100000c) { + throw new Error(`${label} is not arm64`) + } +} + +export function readMacWechatRuntimeManifest( + runtimeDir: string, + architecture = process.arch +): MacWechatRuntimeManifest { + const manifestPath = join(runtimeDir, 'runtime-manifest.json') + const hostPath = join(runtimeDir, 'tm-wechat-host') + const dylibPath = join(runtimeDir, 'libtmwechat.dylib') + assertFile(manifestPath, 'runtime manifest') + assertFile(hostPath, 'runtime host') + assertFile(dylibPath, 'runtime dylib') + + let manifest: MacWechatRuntimeManifest + try { + manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as MacWechatRuntimeManifest + } catch (error) { + throw new Error(`runtime manifest is unreadable: ${String(error)}`) + } + + if (manifest.runtime !== 'tm-wechat-native') { + throw new Error(`unexpected runtime name: ${String(manifest.runtime)}`) + } + if (manifest.platform !== 'darwin-arm64') { + throw new Error(`runtime platform must be darwin-arm64: ${String(manifest.platform)}`) + } + if (manifest.architecture !== 'arm64' || architecture !== 'arm64') { + throw new Error( + `runtime architecture mismatch: artifact=${String(manifest.architecture)} process=${architecture}` + ) + } + if (manifest.protocolVersion !== 1) { + throw new Error(`unsupported runtime protocolVersion: ${String(manifest.protocolVersion)}`) + } + if (!/^[a-f0-9]{64}$/.test(manifest.tmSendSourceSha256 || '')) { + throw new Error('runtime manifest has no valid tmSendSourceSha256') + } + if (!/^[a-f0-9]{64}$/.test(manifest.addressProfileSha256 || '')) { + throw new Error('runtime manifest has no valid addressProfileSha256') + } + if ( + !Array.isArray(manifest.supportedWechatVersions) || + manifest.supportedWechatVersions.length === 0 + ) { + throw new Error('runtime manifest has no supported WeChat versions') + } + const capabilities = new Set(manifest.capabilities || []) + for (const capability of REQUIRED_CAPABILITIES) { + if (!capabilities.has(capability)) { + throw new Error(`runtime manifest capability missing: ${capability}`) + } + } + assertMachOArm64(hostPath, 'runtime host') + assertMachOArm64(dylibPath, 'runtime dylib') + if (!readFileSync(dylibPath).includes(Buffer.from(manifest.tmSendSourceSha256, 'utf8'))) { + throw new Error('runtime dylib embedded agent hash does not match manifest') + } + if (!readFileSync(dylibPath).includes(Buffer.from(manifest.addressProfileSha256, 'utf8'))) { + throw new Error('runtime dylib embedded address profile hash does not match manifest') + } + return manifest +} + +export function bindingMatchesRuntimeManifest( + binding: { + hostPid?: number + runtimeVersion?: string + embeddedAgentSha256?: string + embeddedAddressProfileSha256?: string + }, + manifest: MacWechatRuntimeManifest +): boolean { + return ( + Number.isInteger(binding.hostPid) && + Number(binding.hostPid) > 0 && + binding.runtimeVersion === `tm-wechat-runtime/${manifest.version}` && + binding.embeddedAgentSha256 === manifest.tmSendSourceSha256 && + binding.embeddedAddressProfileSha256 === manifest.addressProfileSha256 + ) +} diff --git a/src/main/services/mac-wechat-runtime-manager.ts b/src/main/services/mac-wechat-runtime-manager.ts new file mode 100644 index 0000000..2fa02ec --- /dev/null +++ b/src/main/services/mac-wechat-runtime-manager.ts @@ -0,0 +1,710 @@ +import { spawn, type ChildProcess } from 'child_process' +import { chmodSync, existsSync } from 'fs' +import { get } from 'http' +import { join } from 'path' + +import { app } from 'electron' + +import { appLogger } from '../app-logger' +import { isPackagedRuntime } from '../runtime-mode' +import type { + MacWechatBindResult, + MacWechatBindingStatus, + MacWechatRuntimeManifest, + MacWechatRuntimeLifecycle, + MacWechatRuntimeSnapshot +} from '../../shared/personal-wechat-mac-runtime' +import type { PersonalWechatSenderStatus } from '../../shared/personal-wechat' +import { + bindingMatchesRuntimeManifest, + readMacWechatRuntimeManifest +} from './mac-wechat-runtime-artifact' + +/* + * MacWechatRuntimeManager — lifecycle owner of the built-in macOS native + * WeChat runtime (tm-wechat-host + libtmwechat.dylib). + * + * Process isolation is a hard requirement: Electron NEVER dlopens the dylib. + * It spawns tm-wechat-host, which is the ONLY binding owner, and talks to it + * over 127.0.0.1 HTTP. A native crash therefore cannot take Electron down. + * + * Binding lifecycle is "bind once per WeChat process": + * - starting the host NEVER attaches to WeChat (the host is lazy) + * - bind happens only on an explicit user action + * - a host crash while bound leaves an orphaned frida agent inside WeChat + * that frida cannot remove from the outside; the only recovery is + * restarting WeChat. Surfaced as wechatRestartRequired. This was verified + * experimentally (2026-09-21) — do not "fix" it by auto-restarting and + * auto-binding, that path dead-ends in a 20s attach timeout every time. + */ + +const PORT = 4290 +const HOST = '127.0.0.1' +const START_TIMEOUT_MS = 15_000 +const REQUEST_TIMEOUT_MS = 30_000 +const CRASH_RESTART_DELAY_MS = 1_500 + +interface MacWechatSenderStatusInput { + snapshot: MacWechatRuntimeSnapshot + binding: MacWechatBindingStatus | null + manifest: MacWechatRuntimeManifest + arch?: string +} + +export function shouldClearWechatRestartRequired( + orphanWechatPid: number, + currentWechatPid: number +): boolean { + return orphanWechatPid > 0 && currentWechatPid > 0 && orphanWechatPid !== currentWechatPid +} + +export function deriveMacWechatSenderStatus({ + snapshot, + binding, + manifest, + arch = process.arch +}: MacWechatSenderStatusInput): PersonalWechatSenderStatus { + const identityMatches = binding !== null && bindingMatchesRuntimeManifest(binding, manifest) + const stale = binding?.bindingState === 'stale' + + let state: PersonalWechatSenderStatus['state'] = 'stopped' + let message = '正在初始化微信发送能力' + if (snapshot.wechatRestartRequired) { + state = 'error' + message = '微信发送组件异常退出。由于当前微信进程中可能残留上一发送会话,请重启微信后重新绑定。' + } else if (binding && !identityMatches) { + state = 'error' + message = '运行中的微信发送 host 与当前 Native Runtime 不一致,请退出旧 host 后重启 TraceMemo。' + } else if (binding && !binding.wechatRunning) { + state = 'wechat_not_running' + message = '微信未运行' + } else if (binding?.bindingState === 'ready') { + state = 'online' + message = '个人微信已准备好发送日报' + } else if (binding?.bindingState === 'bound' || binding?.bindingState === 'binding') { + state = 'hook_not_ready' + message = '已绑定,等待捕获微信发送上下文' + } else if (stale) { + state = 'stopped' + message = '检测到微信已重启,请重新绑定' + } else if (binding?.bindingState === 'failed') { + state = 'error' + message = '微信绑定失败' + } else { + state = 'stopped' + message = '尚未绑定当前微信,可点击"绑定微信"' + } + + const runtimeCapabilities = new Set(manifest.capabilities) + const bindingReady = identityMatches && state === 'online' && binding?.sendContextReady === true + const canSendText = + bindingReady && runtimeCapabilities.has('text') && binding?.canSendText === true + const canSendImage = + bindingReady && runtimeCapabilities.has('image') && binding?.canSendImage === true + const canSendVoice = + bindingReady && runtimeCapabilities.has('voice') && binding?.canSendVoice === true + const sessionAttached = identityMatches && (binding?.sessionAttached ?? false) + const scriptLoaded = identityMatches && (binding?.scriptLoaded ?? false) + + return { + state, + platform: 'darwin', + arch, + // 当前 gadget runtime 不依赖宿主 SIP 状态;该字段仅用于兼容旧 sender contract。 + sipDisabled: true, + wechatRunning: binding?.wechatRunning ?? false, + wechatPid: binding?.wechatPid ?? 0, + boundWechatPid: binding?.boundWechatPid ?? 0, + endpoint: snapshot.endpoint, + endpointReady: snapshot.lifecycle === 'online', + wechatVersion: binding?.wechatVersion, + runtimeReady: snapshot.lifecycle === 'online' && identityMatches, + attachReady: sessionAttached, + baseAddressReady: sessionAttached, + textHookInstalled: scriptLoaded, + textHookReady: identityMatches && (binding?.sendContextReady ?? false), + imageHookInstalled: identityMatches && (binding?.canSendImage ?? false), + imageHookReady: identityMatches && (binding?.canSendImage ?? false), + messageListenerReady: scriptLoaded, + canSend: canSendText || canSendImage || canSendVoice, + canSendText, + canSendImage, + canSendVoice, + message, + error: state === 'error' ? message : undefined + } +} + +class MacWechatRuntimeManager { + private child: ChildProcess | null = null + private lifecycle: MacWechatRuntimeLifecycle = 'stopped' + private wechatRestartRequired = false + /* The WeChat pid that was bound when the host died / bind hit the orphan + * wall. The orphan agent lives INSIDE that process; once WeChat is restarted + * the pid changes and the orphan is gone, so the flag must auto-clear. */ + private orphanWechatPid = 0 + private lastBinding: MacWechatBindingStatus | null = null + private manifest: MacWechatRuntimeManifest | null = null + private lastError: string | null = null + private restartTimer: NodeJS.Timeout | null = null + + getPort(): number { + return PORT + } + + getSnapshot(): MacWechatRuntimeSnapshot { + return { + lifecycle: this.lifecycle, + wechatRestartRequired: this.wechatRestartRequired, + endpoint: `http://${HOST}:${PORT}`, + port: PORT, + runtimeDir: this.resolveRuntimeDir(), + manifest: this.manifest ?? undefined, + error: this.lastError ?? undefined, + binding: this.lastBinding ?? undefined + } + } + + private resolveRuntimeDir(): string { + const base = isPackagedRuntime() ? process.resourcesPath : app.getAppPath() + return join(base, 'resources', 'runtime', 'darwin-arm64') + } + + private resolveHostPath(): string { + return join(this.resolveRuntimeDir(), 'tm-wechat-host') + } + + /** Runtime binaries present and executable? Cheap check for the settings UI. */ + isRuntimePresent(): boolean { + try { + this.manifest = readMacWechatRuntimeManifest(this.resolveRuntimeDir()) + return true + } catch { + return false + } + } + + /** + * Start the host if it is not already running. Deliberately lazy: the host + * comes up WITHOUT touching WeChat (lazy init inside the host), so "runtime + * online" and "WeChat bound" stay two independent states. + */ + async ensureStarted(): Promise { + if (this.lifecycle === 'starting') { + return this.getSnapshot() + } + + const runtimeDir = this.resolveRuntimeDir() + try { + this.manifest = readMacWechatRuntimeManifest(runtimeDir) + appLogger.write({ + level: 'info', + scope: 'mac-wechat-runtime', + message: 'runtime_artifact_validated', + details: { + runtime: this.manifest.runtime, + version: this.manifest.version, + protocolVersion: this.manifest.protocolVersion, + agentSha256: this.manifest.tmSendSourceSha256, + addressProfileSha256: this.manifest.addressProfileSha256 + } + }) + } catch (error) { + this.manifest = null + const detail = error instanceof Error ? error.message : String(error) + appLogger.write({ + level: 'warn', + scope: 'mac-wechat-runtime', + message: 'runtime_artifact_unavailable', + details: { detail } + }) + this.lastError = '暂无发送能力' + this.lifecycle = 'stopped' + return this.getSnapshot() + } + + if (this.lifecycle === 'online') { + await this.refreshBinding().catch(() => undefined) + if ( + this.lastBinding && + this.manifest && + bindingMatchesRuntimeManifest(this.lastBinding, this.manifest) + ) { + return this.getSnapshot() + } + this.lifecycle = 'stopped' + this.lastError = '运行中的 host 与 runtime manifest 不一致' + return this.getSnapshot() + } + + /* + * Adopt-first: the host is designed to OUTLIVE TraceMemo ("keep the send + * capability process"), so a previous app session may have left it running + * with a live, bound session. If something is already answering on our + * port, adopt it instead of spawning a second one (which would just die on + * the occupied port). Adopting is pure HTTP — the running host keeps its + * frida session, so the app is immediately ready with NO re-bind, exactly + * the "keep the send capability process" behaviour the user asked to carry over. + */ + if (await this.ping()) { + this.lifecycle = 'online' + this.child = null + this.wechatRestartRequired = false + await this.refreshBinding().catch(() => undefined) + if ( + !this.lastBinding || + !this.manifest || + !bindingMatchesRuntimeManifest(this.lastBinding, this.manifest) + ) { + this.lifecycle = 'stopped' + this.lastError = + '检测到无法确认身份的微信发送 host。请先退出旧 host,再重新启动 TraceMemo。' + return this.getSnapshot() + } + return this.getSnapshot() + } + if (this.lifecycle === 'crashed_bound' || this.wechatRestartRequired) { + // Restarting the host is safe (it does not touch WeChat), but binding is + // not: the orphaned agent from the previous session is still inside the + // current WeChat process. Start anyway so the UI can show live status. + appLogger.write({ + level: 'warn', + scope: 'mac-wechat-runtime', + message: 'restart_host_after_bound_crash', + details: { note: 'bind stays blocked until WeChat restarts' } + }) + } + + const hostPath = this.resolveHostPath() + if (!existsSync(hostPath)) { + this.lastError = `runtime host missing: ${hostPath}` + this.lifecycle = 'stopped' + return this.getSnapshot() + } + try { + chmodSync(hostPath, 0o755) + } catch { + /* best effort — dev checkouts usually keep the exec bit */ + } + + this.lifecycle = 'starting' + this.lastError = null + + try { + /* Host stderr goes to /dev/null (stdio ignored, see note above), so the + * host writes its own log file via TM_WECHAT_LOG_FILE. Without this the + * agent's stage beacons (send-enter / cdn-enter / …) are invisible and a + * media/CDN stall is undiagnosable. info level so stage beacons show. */ + const hostLogFile = join(app.getPath('logs'), 'tm-wechat-host.log') + this.child = spawn( + hostPath, + [ + '--port', + String(PORT), + '--manifest', + join(runtimeDir, 'runtime-manifest.json'), + '--log-level', + 'info' + ], + { + cwd: runtimeDir, + stdio: 'ignore', + detached: false, + env: { ...process.env, TM_WECHAT_LOG_FILE: hostLogFile } + } + ) + } catch (error) { + this.lifecycle = 'stopped' + this.lastError = String(error) + return this.getSnapshot() + } + + this.child.on('exit', (code) => { + const wasBound = + this.lastBinding?.bound === true || + this.lastBinding?.sessionAttached === true || + this.lastBinding?.bindingState === 'binding' + const orphanPid = this.lastBinding?.boundWechatPid || this.lastBinding?.wechatPid || 0 + this.child = null + this.lastBinding = null + if (this.lifecycle === 'stopped') return // deliberate shutdown + + if (wasBound) { + this.lifecycle = 'crashed_bound' + this.wechatRestartRequired = true + this.orphanWechatPid = orphanPid + appLogger.write({ + level: 'error', + scope: 'mac-wechat-runtime', + message: 'host_died_while_bound', + details: { code: code ?? null, orphanWechatPid: orphanPid } + }) + } else { + this.lifecycle = 'crashed_unbound' + appLogger.write({ + level: 'warn', + scope: 'mac-wechat-runtime', + message: 'host_died_while_unbound_auto_restart', + details: { code: code ?? null } + }) + if (this.restartTimer) clearTimeout(this.restartTimer) + this.restartTimer = setTimeout(() => { + void this.ensureStarted() + }, CRASH_RESTART_DELAY_MS) + } + }) + + // Wait for /healthz + const deadline = Date.now() + START_TIMEOUT_MS + while (Date.now() < deadline) { + if (await this.ping()) { + this.lifecycle = 'online' + await this.refreshBinding().catch(() => undefined) + if ( + !this.lastBinding || + !this.manifest || + !bindingMatchesRuntimeManifest(this.lastBinding, this.manifest) + ) { + this.lifecycle = 'crashed_unbound' + this.lastError = '运行中的 host 与 runtime manifest 不一致' + return this.getSnapshot() + } + return this.getSnapshot() + } + if (this.child === null) break // died during startup + await new Promise((resolve) => setTimeout(resolve, 300)) + } + + if (this.lifecycle === 'starting') { + this.lifecycle = 'crashed_unbound' + this.lastError = 'host did not answer /healthz in time' + } + return this.getSnapshot() + } + + private request( + method: string, + path: string, + timeoutMs = REQUEST_TIMEOUT_MS + ): Promise<{ status: number | null; body: string }> { + return new Promise((resolve) => { + const request = get( + { host: HOST, port: PORT, path, method, timeout: timeoutMs }, + (response) => { + let body = '' + response.on('data', (chunk) => { + body += chunk + }) + response.on('end', () => { + resolve({ status: response.statusCode ?? null, body }) + }) + } + ) + request.on('timeout', () => { + request.destroy() + resolve({ status: null, body: '' }) + }) + request.on('error', () => { + resolve({ status: null, body: '' }) + }) + request.end() + }) + } + + async ping(): Promise { + const { status } = await this.request('GET', '/healthz', 3_000) + if (status === 200) return true + + /* + * Adopted hosts have no child handle, so their death is only observable + * through failed probes. Classify here: dying while bound means WeChat + * keeps an orphaned agent (restart required); dying while unbound is + * auto-recoverable. + */ + if (this.lifecycle === 'online') { + const wasBound = + this.lastBinding?.bound === true || + this.lastBinding?.sessionAttached === true || + this.lastBinding?.bindingState === 'binding' + const orphanPid = this.lastBinding?.boundWechatPid || this.lastBinding?.wechatPid || 0 + this.lastBinding = null + if (wasBound) { + this.lifecycle = 'crashed_bound' + this.wechatRestartRequired = true + this.orphanWechatPid = orphanPid + appLogger.write({ + level: 'error', + scope: 'mac-wechat-runtime', + message: 'adopted_host_died_while_bound', + details: { note: 'WeChat restart required', orphanWechatPid: orphanPid } + }) + } else { + this.lifecycle = 'crashed_unbound' + } + } + return false + } + + /** Pull /bindingStatus and cache it for crash classification. */ + async refreshBinding(): Promise { + const { status, body } = await this.request('GET', '/bindingStatus') + if (status !== 200) return null + try { + this.lastBinding = JSON.parse(body) as MacWechatBindingStatus + /* + * A 20s bind timeout is NOT proof of the orphan-agent wall: in the + * stale-rebind path the host worker waits up to 45s for the old session + * teardown before attaching, so the HTTP response times out while the + * attach quietly succeeds afterwards. When the binding is later observed + * alive and matching, the restart-required verdict was a false positive + * and must be withdrawn — otherwise the manager keeps rejecting every + * further bind locally while WeChat is actually bound and working. + */ + if ( + this.wechatRestartRequired && + (this.lastBinding.bound === true || this.lastBinding.bindingState === 'ready') && + this.lastBinding.boundWechatPid === this.lastBinding.wechatPid + ) { + this.wechatRestartRequired = false + appLogger.write({ + level: 'warn', + scope: 'mac-wechat-runtime', + message: 'restart_required_withdrawn', + details: { note: 'binding observed alive after bind timeout' } + }) + } + return this.lastBinding + } catch { + return null + } + } + + async getBindingStatus(): Promise { + if (this.lifecycle !== 'online') return this.lastBinding + await this.refreshBinding().catch(() => null) + return this.lastBinding + } + + /* + * Build the legacy PersonalWechatSenderStatus contract from the live mac + * runtime state. Lives here (not in the capability service) so BOTH the + * capability mapping and the send path report the same facts without a + * circular import. + */ + async buildSenderStatus(): Promise { + /* Self-heal the status card too: if WeChat was restarted, drop the stale + * restartRequired before it is read into the UI as an error. */ + try { + this.manifest = readMacWechatRuntimeManifest(this.resolveRuntimeDir()) + } catch (error) { + const detail = error instanceof Error ? error.message : String(error) + appLogger.write({ + level: 'warn', + scope: 'mac-wechat-runtime', + message: 'runtime_artifact_unavailable', + details: { detail } + }) + const message = '暂无发送能力' + this.manifest = null + this.lastError = message + return { + state: 'runtime_missing', + platform: 'darwin', + arch: process.arch, + sipDisabled: true, + wechatRunning: false, + endpoint: `http://${HOST}:${PORT}`, + endpointReady: false, + runtimeReady: false, + attachReady: false, + baseAddressReady: false, + textHookInstalled: false, + textHookReady: false, + imageHookInstalled: false, + imageHookReady: false, + messageListenerReady: false, + canSend: false, + canSendText: false, + canSendImage: false, + canSendVoice: false, + message, + error: message + } + } + await this.clearRestartRequiredIfWechatRestarted() + const snapshot = this.getSnapshot() + const binding = await this.getBindingStatus() + return deriveMacWechatSenderStatus({ snapshot, binding, manifest: this.manifest }) + } + + /* + * The orphan agent lives inside ONE specific WeChat process. If the WeChat + * pid we recorded when restartRequired was raised no longer matches the + * WeChat pid the host currently sees, WeChat has been restarted and the + * orphan is gone — the flag must clear or every later bind is rejected + * forever even though the user DID restart WeChat (the reported bug). + */ + private async clearRestartRequiredIfWechatRestarted(): Promise { + if (!this.wechatRestartRequired) return + await this.refreshBinding().catch(() => null) + const currentPid = this.lastBinding?.wechatPid ?? 0 + if (shouldClearWechatRestartRequired(this.orphanWechatPid, currentPid)) { + this.wechatRestartRequired = false + this.orphanWechatPid = 0 + if (this.lifecycle === 'crashed_bound') this.lifecycle = 'online' + appLogger.write({ + level: 'info', + scope: 'mac-wechat-runtime', + message: 'restart_required_cleared_wechat_restarted', + details: { currentWechatPid: currentPid } + }) + } + } + + /** + * Explicit user action only. Idempotent on the host side: binding the same + * live WeChat pid again returns attached=false and never double-attaches. + */ + async bind(): Promise { + if (this.lifecycle !== 'online') { + const snapshot = await this.ensureStarted() + if (snapshot.lifecycle !== 'online') { + return { + ok: false, + attached: false, + state: 'runtime_offline', + message: snapshot.error ?? 'Runtime 未在线,无法绑定' + } + } + } + + /* Auto-clear if WeChat was restarted since the flag was raised — + * otherwise this branch rejects every bind forever (the reported bug). */ + await this.clearRestartRequiredIfWechatRestarted() + + if (this.wechatRestartRequired) { + return { + ok: false, + attached: false, + state: 'wechat_restart_required', + message: + 'Previous WeChat send session was not fully released. ' + 'Restart WeChat and bind again.' + } + } + + const { status, body } = await this.request('POST', '/bindWeChat') + await this.refreshBinding().catch(() => null) + + if (status !== 200) { + return { + ok: false, + attached: false, + state: 'host_error', + message: `host 返回 HTTP ${status ?? '无响应'}` + } + } + try { + const parsed = JSON.parse(body) as MacWechatBindResult + if (!parsed.ok && parsed.message?.includes('not fully released')) { + this.wechatRestartRequired = true + this.orphanWechatPid = this.lastBinding?.boundWechatPid || this.lastBinding?.wechatPid || 0 + appLogger.write({ + level: 'error', + scope: 'mac-wechat-runtime', + message: 'bind_hit_orphan_agent_wall', + details: { note: 'WeChat restart required', orphanWechatPid: this.orphanWechatPid } + }) + } + return parsed + } catch { + return { ok: false, attached: false, state: 'bad_response', message: body.slice(0, 200) } + } + } + + /* + * 重新加载发送组件:优雅停掉当前 host 进程,再启动一个新实例。 + * 用于:更新二进制后换新代码;或 host 卡死时的手动恢复。 + * 注意:若当前 host 已绑定微信,此操作会让微信里留下孤儿 agent, + * 需要重启微信后重新绑定(UI 会提示)。 + */ + async reload(): Promise { + await this.shutdown().catch(() => undefined) + await new Promise((resolve) => setTimeout(resolve, 2_000)) + return this.ensureStarted() + } + + private isProcessAlive(pid: number): boolean { + if (!Number.isInteger(pid) || pid <= 0) return false + try { + process.kill(pid, 0) + return true + } catch { + return false + } + } + + /** + * Graceful stop only. A bound host must never be force-killed: doing so can + * leave its injected agent inside WeChat and make the next attach hang. + */ + async shutdown(): Promise { + if (this.restartTimer) { + clearTimeout(this.restartTimer) + this.restartTimer = null + } + + if (this.lifecycle === 'stopped' && this.child === null) return + + const reachable = await this.ping() + if (!reachable && this.child === null) { + this.lifecycle = 'stopped' + this.lastBinding = null + return + } + + await this.refreshBinding().catch(() => null) + const child = this.child + const hostPid = this.lastBinding?.hostPid ?? 0 + this.lifecycle = 'stopped' + this.lastError = null + + const response = await this.request('POST', '/shutdown', 5_000) + if (response.status !== 200) { + this.lifecycle = 'online' + this.lastError = 'Native Runtime 未确认退出请求,已保留 host 以避免微信残留会话。' + throw new Error(this.lastError) + } + + const deadline = Date.now() + 55_000 + let transportClosedAt = 0 + while (Date.now() < deadline) { + const childExited = child !== null && child.exitCode !== null + const pidExited = hostPid > 0 && !this.isProcessAlive(hostPid) + if (childExited || pidExited) { + this.child = null + this.lastBinding = null + return + } + if (child === null && hostPid === 0) { + const transportOpen = await this.ping() + if (!transportOpen) { + if (transportClosedAt === 0) transportClosedAt = Date.now() + /* Compatibility with a pre-hostPid runtime during one upgrade: + * its teardown waits at most 45s after closing the listen socket. */ + if (Date.now() - transportClosedAt >= 46_000) { + this.lastBinding = null + return + } + } else { + transportClosedAt = 0 + } + } + await new Promise((resolve) => setTimeout(resolve, 250)) + } + + this.lastError = 'Native Runtime 安全退出超时;未强制结束绑定态 host,请先退出微信后再重试。' + throw new Error(this.lastError) + } +} + +export const macWechatRuntimeManager = new MacWechatRuntimeManager() diff --git a/src/main/services/personal-wechat-capability-service.ts b/src/main/services/personal-wechat-capability-service.ts index f843a2c..16ff966 100644 --- a/src/main/services/personal-wechat-capability-service.ts +++ b/src/main/services/personal-wechat-capability-service.ts @@ -7,15 +7,28 @@ import { personalWechatSendService, type PersonalWechatSendService } from './personal-wechat-send-service' +import { macWechatRuntimeManager } from './mac-wechat-runtime-manager' /** * Converts the detailed sender diagnostics into a small contract that other - * features can consume without knowing about OneBot, Hook or platform details. + * features can consume without knowing about the macOS native runtime, the + * Windows hook transport, or platform details. + * + * On macOS the capability is derived only from MacWechatRuntimeManager and its + * validated runtime manifest. Windows continues to use the existing sender. */ export class PersonalWechatCapabilityService { - constructor(private readonly sender: Pick) {} + constructor( + private readonly sender: Pick, + private readonly platform: NodeJS.Platform = process.platform, + private readonly getMacStatus: () => Promise = () => + macWechatRuntimeManager.buildSenderStatus() + ) {} async getPersonalWechatSendCapability(): Promise { + if (this.platform === 'darwin') { + return this.fromMacRuntime() + } const senderStatus = await this.sender.getStatus() return this.fromSenderStatus(senderStatus) } @@ -49,6 +62,16 @@ export class PersonalWechatCapabilityService { } } + /* + * macOS capability from the native runtime. Builds a senderStatus snapshot + * aligned with the mac binding state and reuses the shared mapping, so the + * settings header and the binding card can never disagree. + */ + private async fromMacRuntime(): Promise { + const senderStatus = await this.getMacStatus() + return this.fromSenderStatus(senderStatus) + } + private mapState(senderStatus: PersonalWechatSenderStatus): PersonalWechatSendCapabilityState { if (senderStatus.platform === 'win32') { if (senderStatus.canSend) return 'ready' @@ -60,6 +83,7 @@ export class PersonalWechatCapabilityService { return 'unsupported' } if (senderStatus.state === 'error') return 'error' + if (senderStatus.state === 'runtime_missing') return 'unconfigured' const hasCurrentBinding = Boolean( senderStatus.endpointReady && senderStatus.attachReady && diff --git a/src/main/services/personal-wechat-runtime-manager.ts b/src/main/services/personal-wechat-runtime-manager.ts deleted file mode 100644 index 98451bb..0000000 --- a/src/main/services/personal-wechat-runtime-manager.ts +++ /dev/null @@ -1,603 +0,0 @@ -import { createHash } from 'crypto' -import { execFile } from 'child_process' -import { app, net } from 'electron' -import { existsSync, readFileSync, writeFileSync } from 'fs' -import { chmod, copyFile, cp, mkdir, mkdtemp, open, rename, rm, stat } from 'fs/promises' -import { tmpdir } from 'os' -import { dirname, join } from 'path' -import { promisify } from 'util' -import type { - PersonalWechatRuntimeDownloadResult, - PersonalWechatRuntimeStatus -} from '../../shared/personal-wechat-runtime' -import { findPersonalWechatRuntime } from './personal-wechat-send-service' - -const execFileAsync = promisify(execFile) -const RUNTIME_VERSION = 'v0.0.18' -const ARCHIVE_NAME = 'onebot_mac_arm64.tar.gz' -const ARCHIVE_URL = `https://github.com/yincongcyincong/wechat_chatter/releases/download/${RUNTIME_VERSION}/${ARCHIVE_NAME}` -const ARCHIVE_SIZE = 66_599_785 -const ARCHIVE_SHA256 = 'ee1e11bccef7cec1cf944cd8b2ac3fadaadb9376ba24cd823e3409143e107dab' - -function patchPerSendPayload(scriptPath: string): void { - let source = readFileSync(scriptPath, 'utf8') - if (!source.includes('var activeTriggerX1Payload = ptr(0);')) return - - const activeSend = ` const payloadData = hexToByteArray(payloadHex); - activeTriggerX1Payload = Memory.alloc(payloadData.length); - activeTriggerX1Payload.writeByteArray(payloadData); - activeTriggerX1Payload.add(0x18).writePointer(info.cgiAddr); - activeTriggerX1Payload.add(0xb8).writePointer(activeTriggerX1Payload.add(0xc0)); - activeTriggerX1Payload.add(0x190).writePointer(activeTriggerX1Payload.add(0x198));` - const upstreamSend = ` const payloadData = hexToByteArray(payloadHex); - triggerX1Payload.writeByteArray(payloadData); - triggerX1Payload.add(0x18).writePointer(info.cgiAddr); - triggerX1Payload.add(0xb8).writePointer(triggerX1Payload.add(0xc0)); - triggerX1Payload.add(0x190).writePointer(triggerX1Payload.add(0x198));` - if (!source.includes(activeSend)) { - throw new Error('下载的发送组件与当前应用不兼容') - } - source = source.replace('var triggerX1Payload;\nvar activeTriggerX1Payload = ptr(0);\nvar triggerX0;', 'var triggerX1Payload;\nvar triggerX0;') - .replace(activeSend, upstreamSend) - .replace(' MMStartTask(triggerX0, activeTriggerX1Payload);', ' MMStartTask(triggerX0, triggerX1Payload);') - .replace(' activeTriggerX1Payload = ptr(0);\n console.error("[!] Error trigger " + msgType + " MMStartTask: " + e);', ' console.error("[!] Error trigger " + msgType + " MMStartTask: " + e);') - .replace('\t\t\t\tpendingSendMsgType = "";\n\t\t\t\tactiveTriggerX1Payload = ptr(0);\n\t\t\t\treturn', '\t\t\t\tpendingSendMsgType = "";\n\t\t\t\treturn') - writeFileSync(scriptPath, source) -} - -function patchSendContextCapture(scriptPath: string, strict = true): void { - let source = readFileSync(scriptPath, 'utf8') - if (source.includes('function isLikelySendContext(')) return - const original = `function AttachSendFunc() { - Interceptor.attach(sendFuncAddr.add(0x10), { - onEnter: function (args) { - - if (triggerX1Payload) { - return - } - - triggerX0 = this.context.x0; - triggerX1Payload = this.context.x1; - console.log(\`[+] 捕获到 StartTask 调用,X0:\${triggerX0}, Payload: \${triggerX1Payload}\`); - } - }) -}` - const patched = `function isLikelySendContext(candidateX0, candidateX1) { - try { - if (!isReadablePointer(candidateX0) || !isReadablePointer(candidateX1)) return false; - var manager = readPointerIfReadable(candidateX0.add(0x18)); - var cgi = readUtf8StringIfReadable(readPointerIfReadable(candidateX1.add(0x18))); - console.log("[debug] StartTask candidate x0=" + candidateX0 + " x1=" + candidateX1 + " x0+0x18=" + manager + " cgi=" + cgi); - return !manager.equals(ptr(0)); - } catch (e) { - console.error("[debug] StartTask candidate inspect failed: " + e); - return false; - } -} - -function AttachSendFunc() { - Interceptor.attach(sendFuncAddr.add(0x10), { - onEnter: function (args) { - if (triggerX1Payload) return; - var candidateX0 = this.context.x0; - var candidateX1 = this.context.x1; - if (!isLikelySendContext(candidateX0, candidateX1)) return; - triggerX0 = candidateX0; - triggerX1Payload = candidateX1; - console.log(\`[+] 捕获到有效 StartTask 上下文,X0:\${triggerX0}, Payload: \${triggerX1Payload}\`); - } - }) -}` - if (!source.includes(original)) { - if (strict) throw new Error('下载的微信版本与当前应用不兼容') - return - } - source = source.replace(original, patched) - writeFileSync(scriptPath, source) -} - -function patchVoiceAudioBuffer(scriptPath: string, strict = true): void { - let source = readFileSync(scriptPath, 'utf8') - if (source.includes('voiceAudioDataAddr = Memory.alloc(audioLen + 1);')) return - - const staticAllocation = 'voiceAudioDataAddr = Memory.alloc(5 * 1024 * 1024); // 预分配5MB' - if (!source.includes(staticAllocation)) { - if (strict) throw new Error('下载的语音组件与当前应用不兼容') - return - } - source = source.replace( - staticAllocation, - 'voiceAudioDataAddr = Memory.alloc(1); // 上传前按语音长度重新分配' - ) - const audioLengthMarker = ' const audioLen = audioBytes.length;\n' - if (!source.includes(audioLengthMarker)) { - if (strict) throw new Error('下载的语音组件与当前应用不兼容') - return - } - source = source.replace( - audioLengthMarker, - `${audioLengthMarker} voiceAudioDataAddr = Memory.alloc(audioLen + 1);\n` - ) - writeFileSync(scriptPath, source) -} - -function patchImageHookReadiness(scriptPath: string): void { - let source = readFileSync(scriptPath, 'utf8') - if ( - source.includes('捕获到图片上传上下文,uploadGlobalX0') && - source.includes('图片上传 Hook Setup Complete') - ) { - return - } - const original = `\t\t\tuploadGlobalX0 = this.context.x0;` - const patched = `\t\t\tconst capturedUploadX0 = this.context.x0; -\t\t\tif (uploadGlobalX0.equals(ptr(0)) && !capturedUploadX0.equals(ptr(0))) { -\t\t\t\tconsole.log("[+] 捕获到图片上传上下文,uploadGlobalX0:" + capturedUploadX0); -\t\t\t} -\t\t\tuploadGlobalX0 = capturedUploadX0;` - if (!source.includes(original)) throw new Error('下载的媒体组件与当前应用不兼容') - source = source.replace(original, patched) - source = source.replace( - ' })\n}\n\n\n\nfunction patchCdnOnComplete()', - ' })\n console.log("[+] 图片上传 Hook Setup Complete.");\n}\n\n\n\nfunction patchCdnOnComplete()' - ) - writeFileSync(scriptPath, source) -} - -function patchCdnColdStart(scriptPath: string, strict = true): void { - let source = readFileSync(scriptPath, 'utf8') - if (source.includes('function resolveCdnManager()')) return - - const initAddresses = ` uploadImageAddr = baseAddr.add({{.uploadImageAddr}}); - cndOnCompleteAddr = baseAddr.add({{.cndOnCompleteAddr}});` - const patchedInitAddresses = ` uploadImageAddr = baseAddr.add({{.uploadImageAddr}}); - cndOnCompleteAddr = baseAddr.add({{.cndOnCompleteAddr}}); - // 冷启动 CdnManager 解析(旧版本缺少可选键时保持 hook 捕获行为) - {{if .cdnGetServiceAddr}}cdnGetServiceAddr = baseAddr.add({{.cdnGetServiceAddr}});{{end}} - {{if .cdnManagerGetterAddr}}cdnManagerGetterAddr = baseAddr.add({{.cdnManagerGetterAddr}});{{end}}` - if (!source.includes(initAddresses)) { - if (strict) throw new Error('下载的微信版本配置与当前应用不兼容') - return - } - source = source.replace(initAddresses, patchedInitAddresses) - - const downloadChunkEnd = `} - -function fillUploadX1AndStart` - const resolver = `} - -// 上传和下载共用同一个 mars::cdn::CdnManager。冷启动时通过服务定位器 -// 取得 [ctx + 0x40],避免必须先手动发送图片才能让 Hook 捕获上下文。 -function resolveCdnManager() { - if (cdnGetServiceAddr.equals(ptr(0)) || cdnManagerGetterAddr.equals(ptr(0))) { - return ptr(0); - } - try { - // libc++ SSO 短字符串:数据在 +0,长度写在 +0x17。 - var strDefault = Memory.alloc(24); - strDefault.writeUtf8String("default"); - strDefault.add(0x17).writeU8(7); - - var getService = new NativeFunction(cdnGetServiceAddr, 'pointer', ['pointer']); - var svc = getService(strDefault); - if (!isReadablePointer(svc)) { - console.error("[!] GetService(\\"default\\") 返回不可读: " + svc); - return ptr(0); - } - var getCtx = new NativeFunction(cdnManagerGetterAddr, 'pointer', ['pointer']); - var ctx = getCtx(svc); - if (!isReadablePointer(ctx)) { - console.error("[!] CdnManager getter 返回不可读: " + ctx); - return ptr(0); - } - var mgr = readPointerIfReadable(ctx.add(0x40)); - if (!isReadablePointer(mgr)) { - console.error("[!] ctx+0x40 管理器指针不可读: ctx=" + ctx); - return ptr(0); - } - return mgr; - } catch (e) { - console.error("[!] resolveCdnManager 异常: " + e); - return ptr(0); - } -} - -function ensureCdnManagerX0() { - if (uploadGlobalX0.equals(ptr(0)) && downloadGlobalX0 && !downloadGlobalX0.equals(ptr(0))) { - uploadGlobalX0 = downloadGlobalX0; - console.log("[+] downloadGlobalX0 回填 uploadGlobalX0: " + uploadGlobalX0); - } - if ((!downloadGlobalX0 || downloadGlobalX0.equals(ptr(0))) && !uploadGlobalX0.equals(ptr(0))) { - downloadGlobalX0 = uploadGlobalX0; - console.log("[+] uploadGlobalX0 回填 downloadGlobalX0: " + downloadGlobalX0); - } - if (uploadGlobalX0.equals(ptr(0))) { - var mgr = resolveCdnManager(); - if (!mgr.equals(ptr(0))) { - uploadGlobalX0 = mgr; - if (!downloadGlobalX0 || downloadGlobalX0.equals(ptr(0))) { - downloadGlobalX0 = mgr; - } - console.log("[+] 冷启动服务定位器解析 CdnManager: " + mgr); - } - } - return !uploadGlobalX0.equals(ptr(0)); -} - -function fillUploadX1AndStart` - if (!source.includes(downloadChunkEnd)) { - if (strict) throw new Error('无法定位 wechat_chatter 媒体上传逻辑') - return - } - source = source.replace(downloadChunkEnd, resolver) - - const declarations = 'var uploadImageAddr;\n' - const patchedDeclarations = - 'var uploadImageAddr;\nvar cdnGetServiceAddr = ptr(0);\nvar cdnManagerGetterAddr = ptr(0);\n' - if (!source.includes(declarations)) { - if (strict) throw new Error('无法定位 wechat_chatter 媒体地址声明') - return - } - source = source.replace(declarations, patchedDeclarations) - - const uploadGuard = `function fillUploadX1AndStart(idAddr, pathAddr, x1Buffer, receiver, md5, filePath, payloadHex) { - if (uploadGlobalX0.equals(ptr(0))) {` - const patchedUploadGuard = `function fillUploadX1AndStart(idAddr, pathAddr, x1Buffer, receiver, md5, filePath, payloadHex) { - if (uploadGlobalX0.equals(ptr(0))) { - ensureCdnManagerX0(); - } - if (uploadGlobalX0.equals(ptr(0))) {` - if (!source.includes(uploadGuard)) { - if (strict) throw new Error('无法定位 wechat_chatter 媒体上传入口') - return - } - source = source.replace(uploadGuard, patchedUploadGuard) - - const voiceGuard = `function triggerUploadVoice(receiver, voicePath, payloadHex, audioDataHex, durationMs) { - if (uploadGlobalX0.equals(ptr(0))) {` - const patchedVoiceGuard = `function triggerUploadVoice(receiver, voicePath, payloadHex, audioDataHex, durationMs) { - if (uploadGlobalX0.equals(ptr(0))) { - ensureCdnManagerX0(); - } - if (uploadGlobalX0.equals(ptr(0))) {` - if (!source.includes(voiceGuard)) { - if (strict) throw new Error('无法定位 wechat_chatter 语音上传入口') - return - } - source = source.replace(voiceGuard, patchedVoiceGuard) - - const uploadHook = `\t\t\tuploadGlobalX0 = capturedUploadX0;` - const patchedUploadHook = `\t\t\tuploadGlobalX0 = capturedUploadX0; - if ((!downloadGlobalX0 || downloadGlobalX0.equals(ptr(0))) && !capturedUploadX0.equals(ptr(0))) { - downloadGlobalX0 = capturedUploadX0; - console.log("[+] 上传hook回填 downloadGlobalX0: " + downloadGlobalX0); - }` - if (!source.includes(uploadHook)) { - if (strict) throw new Error('无法定位 wechat_chatter 图片 Hook') - return - } - source = source.replace(uploadHook, patchedUploadHook) - - const downloadHook = ` downloadGlobalX0 = this.context.x0;` - const patchedDownloadHook = ` downloadGlobalX0 = this.context.x0; - if (uploadGlobalX0.equals(ptr(0)) && !downloadGlobalX0.equals(ptr(0))) { - uploadGlobalX0 = downloadGlobalX0; - console.log("[+] 下载hook回填 uploadGlobalX0: " + uploadGlobalX0); - }` - if (!source.includes(downloadHook)) { - if (strict) throw new Error('无法定位 wechat_chatter 下载 Hook') - return - } - source = source.replace(downloadHook, patchedDownloadHook) - - const downloadGuard = `function triggerDownload(receiver, cdnUrl, aesKey, filePath, fileType) { - if (!downloadGlobalX0) {` - const patchedDownloadGuard = `function triggerDownload(receiver, cdnUrl, aesKey, filePath, fileType) { - if (!downloadGlobalX0 || downloadGlobalX0.equals(ptr(0))) { - ensureCdnManagerX0(); - } - if (!downloadGlobalX0) {` - if (!source.includes(downloadGuard)) { - if (strict) throw new Error('无法定位 wechat_chatter 媒体下载入口') - return - } - source = source.replace(downloadGuard, patchedDownloadGuard) - - writeFileSync(scriptPath, source) -} - -function patchCdnColdStartConfig(configPath: string, strict = true): void { - let config: Record - try { - config = JSON.parse(readFileSync(configPath, 'utf8')) as Record - } catch { - if (strict) throw new Error('4.1.11.53 版本配置不是有效 JSON') - return - } - config.cdnGetServiceAddr = '0x50a15d0' - config.cdnManagerGetterAddr = '0x5259290' - writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`) -} - -function patchWechatCoreModuleBase(scriptPath: string): void { - let source = readFileSync(scriptPath, 'utf8') - if (source.includes('WeChat core module base:')) return - const initMarker = 'function initAddresses() {' - const initIndex = source.indexOf(initMarker) - if (initIndex < 0 || !source.startsWith('var targetPath = ')) { - throw new Error('下载的微信版本配置与当前应用不兼容') - } - const patchedHeader = `var targetPath = "/Applications/WeChat.app/Contents/Resources/wechat.dylib"; -var module = Process.enumerateModules().find(function(m) { - return m.path === targetPath || m.path.endsWith("/Contents/Resources/wechat.dylib"); -}); -if (!module) { - throw new Error("[-] Cannot find WeChat core module: " + targetPath); -} -var moduleBase = module.base; -var baseAddr = moduleBase; -console.log("[+] WeChat core module base: " + baseAddr + " path=" + module.path); -setImmediate(initAddresses); - -` - source = patchedHeader + source.slice(initIndex) - writeFileSync(scriptPath, source) -} - -function addModifiedWorkNotice(scriptPath: string): void { - let source = readFileSync(scriptPath, 'utf8') - if (source.includes('TraceMemo wechat_chatter compatibility modifications')) return - source = `/* - * TraceMemo wechat_chatter compatibility modifications - * Modified: 2026-08-17 - * Upstream: https://github.com/yincongcyincong/wechat_chatter - * Runtime version: v0.0.18 - * License: GNU General Public License version 3 (GPL-3.0) - * Changes: WeChat module discovery, per-send payload isolation, dynamic voice upload buffers, - * CdnManager cold-start resolution, media hook backfill, and image Hook readiness logging. - * These modifications are not provided by the upstream author. - */ - -${source}` - writeFileSync(scriptPath, source) -} - -export class PersonalWechatRuntimeManager { - private downloadController: AbortController | null = null - private downloadPromise: Promise | null = null - private downloadedBytes = 0 - private lastProgressAt = 0 - private progressListener: ((status: PersonalWechatRuntimeStatus) => void) | null = null - - get directory(): string { - return join(app.getPath('userData'), 'connectors', 'wechat-personal', 'darwin-arm64') - } - - private get archivePath(): string { - return join( - app.getPath('userData'), - 'downloads', - `wechat-chatter-${RUNTIME_VERSION}-${ARCHIVE_NAME}` - ) - } - - setProgressListener(listener: ((status: PersonalWechatRuntimeStatus) => void) | null): void { - this.progressListener = listener - } - - async getStatus(): Promise { - if (!this.isSupported()) { - return this.buildStatus( - 'unsupported', - 0, - process.platform === 'darwin' - ? 'Intel Mac 不支持个人微信发送组件' - : process.platform === 'win32' - ? 'Windows 暂不支持个人微信发送组件' - : '当前系统不支持个人微信发送组件' - ) - } - if (this.downloadPromise) return this.buildStatus('downloading', this.downloadedBytes) - - const runtime = findPersonalWechatRuntime() - if (runtime) { - try { - // Apply compatibility fixes to runtimes installed before this version. - patchWechatCoreModuleBase(join(runtime.workingDirectory, 'script.js')) - patchPerSendPayload(join(runtime.workingDirectory, 'script.js')) - patchSendContextCapture(join(runtime.workingDirectory, 'script.js'), false) - patchVoiceAudioBuffer(join(runtime.workingDirectory, 'script.js'), false) - patchImageHookReadiness(join(runtime.workingDirectory, 'script.js')) - patchCdnColdStart(join(runtime.workingDirectory, 'script.js'), false) - addModifiedWorkNotice(join(runtime.workingDirectory, 'script.js')) - patchCdnColdStartConfig(join(runtime.root, 'wechat_version', '4_1_11_53_mac.json'), false) - } catch { - // Status discovery should remain available even if an old runtime is read-only. - } - return this.buildStatus('ready', ARCHIVE_SIZE, undefined, runtime.root) - } - - const hasPartialInstall = await this.hasPartialInstall() - return this.buildStatus( - hasPartialInstall ? 'invalid' : 'missing', - 0, - hasPartialInstall ? '发送组件文件不完整,请重新下载' : undefined, - hasPartialInstall ? this.directory : undefined - ) - } - - download(): Promise { - if (!this.isSupported()) { - return this.getStatus().then((status) => ({ success: false, status, error: status.error })) - } - if (this.downloadPromise) return this.downloadPromise - this.downloadedBytes = 0 - this.downloadController = new AbortController() - this.downloadPromise = this.runDownload(this.downloadController.signal).finally(() => { - this.downloadPromise = null - this.downloadController = null - }) - return this.downloadPromise - } - - cancelDownload(): boolean { - if (!this.downloadController) return false - this.downloadController.abort() - return true - } - - async remove(): Promise { - if (this.downloadPromise) return this.buildStatus('downloading', this.downloadedBytes) - await Promise.all([ - rm(this.directory, { recursive: true, force: true }), - rm(this.archivePath, { force: true }), - rm(`${this.archivePath}.partial`, { force: true }) - ]) - return this.getStatus() - } - - private async runDownload(signal: AbortSignal): Promise { - const archive = this.archivePath - const downloadsDirectory = dirname(archive) - const partial = `${archive}.partial` - let extractionDirectory = '' - let stagedDirectory = '' - try { - await mkdir(downloadsDirectory, { recursive: true }) - await rm(partial, { force: true }) - const response = await net.fetch(ARCHIVE_URL, { signal }) - if (!response.ok || !response.body) { - throw new Error(`发送组件下载失败:HTTP ${response.status}`) - } - - const handle = await open(partial, 'w') - const hash = createHash('sha256') - try { - const reader = response.body.getReader() - while (true) { - const { done, value } = await reader.read() - if (done) break - if (signal.aborted) throw new DOMException('Download cancelled', 'AbortError') - const chunk = Buffer.from(value) - await handle.write(chunk) - hash.update(chunk) - this.downloadedBytes += chunk.length - this.reportProgress(this.buildStatus('downloading', this.downloadedBytes)) - } - } finally { - await handle.close() - } - - if (this.downloadedBytes !== ARCHIVE_SIZE || hash.digest('hex') !== ARCHIVE_SHA256) { - throw new Error('发送组件校验失败,请重新下载') - } - await rm(archive, { force: true }) - await rename(partial, archive) - - extractionDirectory = await mkdtemp(join(tmpdir(), 'wechat-chatter-extract-')) - await execFileAsync('/usr/bin/tar', ['-xzf', archive, '-C', extractionDirectory]) - stagedDirectory = `${this.directory}.installing-${process.pid}` - await rm(stagedDirectory, { recursive: true, force: true }) - await mkdir(dirname(stagedDirectory), { recursive: true }) - await mkdir(join(stagedDirectory, 'onebot'), { recursive: true }) - - const sourceOneBot = join(extractionDirectory, 'onebot') - const sourceVersions = join(extractionDirectory, 'wechat_version') - await Promise.all([ - cp(sourceVersions, join(stagedDirectory, 'wechat_version'), { - recursive: true, - force: true - }), - copyFile(join(sourceOneBot, 'onebot'), join(stagedDirectory, 'onebot', 'onebot')), - copyFile(join(sourceOneBot, 'script.js'), join(stagedDirectory, 'onebot', 'script.js')) - ]) - - const executable = join(stagedDirectory, 'onebot', 'onebot') - const script = join(stagedDirectory, 'onebot', 'script.js') - const configPath = join(stagedDirectory, 'wechat_version', '4_1_11_53_mac.json') - patchCdnColdStartConfig(configPath) - patchWechatCoreModuleBase(script) - patchPerSendPayload(script) - patchSendContextCapture(script) - patchVoiceAudioBuffer(script) - patchImageHookReadiness(script) - patchCdnColdStart(script) - addModifiedWorkNotice(script) - await chmod(executable, 0o755) - - for (const required of [ - executable, - script, - join(stagedDirectory, 'wechat_version', '4_1_11_53_mac.json') - ]) { - if (!existsSync(required)) throw new Error('发送组件解压后文件不完整') - } - - await rm(this.directory, { recursive: true, force: true }) - await rename(stagedDirectory, this.directory) - stagedDirectory = '' - const status = this.buildStatus('ready', ARCHIVE_SIZE, undefined, this.directory) - this.reportProgress(status, true) - return { success: true, status } - } catch (error) { - const cancelled = signal.aborted - const message = cancelled - ? '发送组件下载已取消' - : error instanceof Error - ? error.message - : String(error) - await rm(partial, { force: true }) - const status = this.buildStatus( - cancelled ? 'missing' : 'error', - this.downloadedBytes, - message - ) - this.reportProgress(status, true) - return { success: false, status, error: message } - } finally { - if (extractionDirectory) await rm(extractionDirectory, { recursive: true, force: true }) - if (stagedDirectory) await rm(stagedDirectory, { recursive: true, force: true }) - } - } - - private async hasPartialInstall(): Promise { - try { - await stat(this.directory) - return true - } catch { - return false - } - } - - private isSupported(): boolean { - return process.platform === 'darwin' && process.arch === 'arm64' - } - - private buildStatus( - state: PersonalWechatRuntimeStatus['state'], - downloadedBytes: number, - error?: string, - directory?: string - ): PersonalWechatRuntimeStatus { - return { - version: RUNTIME_VERSION, - state, - downloadedBytes, - totalBytes: ARCHIVE_SIZE, - progress: ARCHIVE_SIZE ? Math.min(1, downloadedBytes / ARCHIVE_SIZE) : 0, - platform: process.platform, - architecture: process.arch, - supported: this.isSupported(), - removable: directory === this.directory, - ...(directory ? { directory } : {}), - ...(error ? { error } : {}) - } - } - - private reportProgress(status: PersonalWechatRuntimeStatus, force = false): void { - const now = Date.now() - if (!force && now - this.lastProgressAt < 100) return - this.lastProgressAt = now - this.progressListener?.(status) - } -} diff --git a/src/main/services/personal-wechat-send-service.ts b/src/main/services/personal-wechat-send-service.ts index 3ad4ecf..57749d2 100644 --- a/src/main/services/personal-wechat-send-service.ts +++ b/src/main/services/personal-wechat-send-service.ts @@ -1,5 +1,4 @@ -import { app } from 'electron' -import { execFile, spawn, type ChildProcess } from 'child_process' +import { execFile } from 'child_process' import { createHash, randomBytes, randomUUID } from 'crypto' import { copyFileSync, @@ -11,7 +10,6 @@ import { unlinkSync, writeFileSync } from 'fs' -import { createConnection } from 'net' import { homedir, tmpdir } from 'os' import { delimiter, dirname, extname, join, sep } from 'path' import ffmpegStaticPath from 'ffmpeg-static' @@ -22,30 +20,21 @@ import type { PersonalWechatSenderStatus, PersonalWechatVoiceDiagnostic } from '../../shared/personal-wechat' -import { isPackagedRuntime } from '../runtime-mode' -import { loadSettings, updateSettings } from './settings-store' +import { loadSettings } from './settings-store' import { SilkAudioDecoder, SilkAudioEncoder } from '../voice-pipeline/audio-decoder' -import { - validateVoicePcm, - validateVoiceSilkMetadata, - VOICE_FRAME_BYTES, - type VoicePcmMetadata -} from '../voice-pipeline/voice-quality' import { appLogger } from '../app-logger' +import { readLocalAccountIdentity } from './local-account-identity' +import { macWechatRuntimeManager } from './mac-wechat-runtime-manager' const execFileAsync = promisify(execFile) -const DEFAULT_HOST = '127.0.0.1:58080' const WINDOWS_LOOPBACK_HOST = '127.0.0.1' -const START_TIMEOUT_MS = 20_000 const REQUEST_TIMEOUT_MS = 20_000 -const STOP_TIMEOUT_MS = 3_000 const MAX_IMAGE_BYTES = 20 * 1024 * 1024 const MAX_VOICE_BYTES = 20 * 1024 * 1024 -const MAX_PCM_BYTES = 64 * 1024 * 1024 -const VOICE_ENCODER_NAME = 'go-silk' -const VOICE_ENCODER_VERSION = 'wechat_chatter-v0.0.18' +// 语音编码由 native runtime 内的 SILK 实现完成,不再是外部编码器进程。 +const VOICE_ENCODER_NAME = 'silk' +const VOICE_ENCODER_VERSION = 'tm-wechat-native' let latestVoiceDiagnostic: PersonalWechatVoiceDiagnostic | null = null -const WECHAT_APP_PATH = '/Applications/WeChat.app' const WECHAT_FILES_ROOT = join( homedir(), 'Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files' @@ -73,226 +62,6 @@ function windowsHookHost(port?: unknown): string | null { return configuredPort ? `${WINDOWS_LOOPBACK_HOST}:${configuredPort}` : null } -export interface RuntimeLayout { - root: string - executable: string - workingDirectory: string - configDirectory: string - logPath: string -} - -export type PersonalWechatHookReadiness = 'unknown' | 'initializing' | 'ready' | 'failed' - -export function parsePersonalWechatHookLog(log: string): { - readiness: PersonalWechatHookReadiness - attached: boolean - baseAddress?: string - textHookInstalled: boolean - textHookReady: boolean - imageHookInstalled: boolean - imageHookReady: boolean - messageListenerReady: boolean - boundWechatPid?: number - error?: string -} { - let readiness: PersonalWechatHookReadiness = 'unknown' - let attached = false - let baseAddress: string | undefined - let textHookInstalled = false - let textHookReady = false - let imageHookInstalled = false - let imageHookReady = false - let messageListenerReady = false - let boundWechatPid: number | undefined - let error: string | undefined - for (const line of log.split(/\r?\n/)) { - if (!line.trim()) continue - try { - const entry = JSON.parse(line) as { - payload?: string - err?: string - message?: string - PID?: number - type?: string - result?: string | number - } - const text = `${entry.payload || ''} ${entry.err || ''} ${entry.message || ''}` - // wechat_chatter writes the task receipt and its result as two separate - // JSON log records: the first has type=send_image, while the next has - // the result and the Chinese result message but no type field. - if ( - (entry.type === 'send_image' || text.includes('发送图片任务执行结果')) && - String(entry.result) === '1' - ) { - imageHookInstalled = true - imageHookReady = true - } - if ( - (entry.type === 'image' || text.includes('上传图片任务执行结果')) && - String(entry.result) === '0' - ) { - imageHookInstalled = true - } - if (text.includes('使用指定的微信进程 PID')) { - readiness = 'unknown' - attached = false - baseAddress = undefined - textHookInstalled = false - textHookReady = false - imageHookInstalled = false - imageHookReady = false - messageListenerReady = false - boundWechatPid = Number(entry.PID) || undefined - error = undefined - } else if ( - text.includes("Cannot find 'req2buf' keyword") || - text.includes('Attach 失败') || - text.includes('unable to intercept function') - ) { - readiness = 'failed' - error = entry.err || entry.message - } else if (text.includes('成功 Attach 微信进程')) { - attached = true - boundWechatPid = Number(entry.PID) || boundWechatPid - } else if (text.includes('Base address from range:')) { - baseAddress = text.match(/Base address from range:\s*(0x[0-9a-f]+)/i)?.[1] - } else if (text.includes('WeChat core module base:')) { - baseAddress = text.match(/WeChat core module base:\s*(0x[0-9a-f]+)/i)?.[1] - } else if (text.includes('triggerX0 或 triggerX1Payload 尚未初始化')) { - readiness = 'initializing' - error = '微信发送能力尚未就绪,消息没有发出' - } else if ( - text.includes('捕获到有效 StartTask 上下文') || - text.includes('捕获到 StartTask 调用') - ) { - readiness = 'ready' - textHookReady = true - error = undefined - } else if (text.includes('Dynamic Text Message Setup Complete')) { - textHookInstalled = true - if (readiness === 'unknown') readiness = 'initializing' - } else if (text.includes('捕获到图片上传上下文')) { - imageHookReady = true - } else if (text.includes('图片上传 Hook Setup Complete')) { - imageHookInstalled = true - } else if (text.includes('HTTP 服务启动在')) { - messageListenerReady = true - } else if (text.includes('发送数据')) { - messageListenerReady = true - } - } catch { - // Ignore non-JSON or partially written log lines. - } - } - return { - readiness, - attached, - ...(baseAddress ? { baseAddress } : {}), - textHookInstalled, - textHookReady, - imageHookInstalled, - imageHookReady, - messageListenerReady, - ...(boundWechatPid ? { boundWechatPid } : {}), - ...(error ? { error } : {}) - } -} - -interface PreflightResult { - status: PersonalWechatSenderStatus - runtime?: RuntimeLayout -} - -export function deriveMacSendCapabilities(input: { - attached: boolean - baseAddressReady: boolean - textHookInstalled: boolean - textHookReady: boolean - imageHookInstalled: boolean - imagePathBound: boolean -}): Pick { - const baseReady = input.attached && input.baseAddressReady && input.textHookInstalled - const canSendText = baseReady && input.textHookReady - const mediaUploadReady = canSendText && input.imageHookInstalled - const canSendImage = mediaUploadReady && input.imagePathBound - const canSendVoice = mediaUploadReady - return { - canSend: canSendText || canSendImage || canSendVoice, - canSendText, - canSendImage, - canSendVoice - } -} - -function toConfigFileName(version: string): string { - return `${version.replace(/\./g, '_')}_mac.json` -} - -function runtimeCandidates(): string[] { - const override = String(process.env['WECHAT_CHATTER_RUNTIME_DIR'] || '').trim() - const relative = ['connectors', 'wechat-personal', `${process.platform}-${process.arch}`] - const downloaded = join(app.getPath('userData'), ...relative) - const packaged = join(process.resourcesPath, 'resources', ...relative) - const development = join(app.getAppPath(), 'resources', ...relative) - const ordered = isPackagedRuntime() - ? [downloaded, packaged, development] - : [downloaded, development, packaged] - return override ? [override, ...ordered] : ordered -} - -export function findPersonalWechatRuntime(candidates = runtimeCandidates()): RuntimeLayout | null { - for (const root of candidates) { - const nestedExecutable = join(root, 'onebot', 'onebot') - const flatExecutable = join(root, 'onebot') - if (existsSync(nestedExecutable) && existsSync(join(root, 'onebot', 'script.js'))) { - return { - root, - executable: nestedExecutable, - workingDirectory: join(root, 'onebot'), - configDirectory: join(root, 'wechat_version'), - logPath: join(root, 'onebot', 'log', 'macos.log') - } - } - if (existsSync(flatExecutable) && existsSync(join(root, 'script.js'))) { - return { - root, - executable: flatExecutable, - workingDirectory: root, - configDirectory: join(root, 'wechat_version'), - logPath: join(root, 'log', 'macos.log') - } - } - } - return null -} - -export function buildPersonalWechatOneBotRequest( - request: PersonalWechatSendRequest, - fileBase64?: string -): { - endpoint: string - body: Record -} { - const target = request.to.trim() - const isGroup = request.isGroup || target.endsWith('@chatroom') - const message = - request.type === 'text' - ? [{ type: 'text', data: { text: request.text.trim() } }] - : [ - { - type: request.type === 'voice' ? 'record' : 'image', - data: { file: `base64://${fileBase64 || ''}` } - } - ] - return { - endpoint: isGroup ? '/send_group_msg' : '/send_private_msg', - body: { - ...(isGroup ? { group_id: target } : { user_id: target }), - message - } - } -} - export function buildPersonalWechatRuntimePath(): string { const existing = String(process.env['PATH'] || '') const bundledFfmpeg = String(ffmpegStaticPath || '') @@ -302,163 +71,18 @@ export function buildPersonalWechatRuntimePath(): string { return [dirname(bundledFfmpeg), existing].filter(Boolean).join(delimiter) } -export function buildPersonalWechatRuntimePythonPath(runtimeRoot: string): string { - return [join(runtimeRoot, 'python'), String(process.env['PYTHONPATH'] || '')] - .filter(Boolean) - .join(delimiter) -} - /** - * Build the same environment used when TraceMemo starts OneBot. - * Keep this in one place so runtime checks cannot accidentally inspect a - * different Python or ffmpeg than the sender process. + * Build the environment used by TraceMemo's local voice encoding helpers. + * Keep this in one place so environment checks cannot accidentally inspect a + * different ffmpeg than the sender process. */ -export function buildPersonalWechatRuntimeEnvironment(runtimeRoot?: string): NodeJS.ProcessEnv { +export function buildPersonalWechatRuntimeEnvironment(): NodeJS.ProcessEnv { return { ...process.env, - PATH: buildPersonalWechatRuntimePath(), - ...(runtimeRoot ? { PYTHONPATH: buildPersonalWechatRuntimePythonPath(runtimeRoot) } : {}) + PATH: buildPersonalWechatRuntimePath() } } -function bundledFfmpegExecutable(): string { - const bundledFfmpeg = String(ffmpegStaticPath || '') - .replace('app.asar', 'app.asar.unpacked') - .trim() - return bundledFfmpeg && existsSync(bundledFfmpeg) ? bundledFfmpeg : 'ffmpeg' -} - -async function convertAudioToPcm(audioData: Buffer): Promise { - return new Promise((resolve, reject) => { - const child = spawn( - bundledFfmpegExecutable(), - ['-v', 'error', '-i', 'pipe:0', '-f', 's16le', '-ar', '16000', '-ac', '1', 'pipe:1'], - { - env: buildPersonalWechatRuntimeEnvironment(), - stdio: ['pipe', 'pipe', 'pipe'], - windowsHide: true - } - ) - const chunks: Buffer[] = [] - let total = 0 - let stderr = '' - child.stdout.on('data', (chunk: Buffer) => { - total += chunk.length - if (total > MAX_PCM_BYTES) { - child.kill() - reject(new Error('转换后的语音 PCM 过大')) - return - } - chunks.push(chunk) - }) - child.stderr.on('data', (chunk: Buffer) => { - stderr += chunk.toString('utf8').slice(0, 2_000) - }) - child.once('error', (error) => reject(new Error(`ffmpeg 转换失败:${error.message}`))) - child.once('close', (code) => { - if (code !== 0) { - reject(new Error(`ffmpeg 转换失败${stderr ? `:${stderr.trim()}` : ''}`)) - return - } - resolve(Buffer.concat(chunks)) - }) - child.stdin.end(audioData) - }) -} - -async function runtimeVoiceLogSnapshot(): Promise<{ path: string; offset: number } | undefined> { - const runtime = findPersonalWechatRuntime() - const runningOneBot = await readOneBotProcessInfo() - // Prefer the runtime discovered by TraceMemo itself. Its path may contain - // spaces (for example, "Application Support"), so parsing `ps` output with - // a whitespace-delimited regex can produce a non-existent log path. - const executable = - runtime?.executable && - runningOneBot && - (runningOneBot.command === runtime.executable || - runningOneBot.command.startsWith(`${runtime.executable} `)) - ? runtime.executable - : runningOneBot?.command.match(/^(.*\/onebot(?:\/onebot)?)(?:\s|$)/)?.[1] - const processLogPath = executable ? join(dirname(executable), 'log', 'macos.log') : undefined - const logPath = processLogPath && existsSync(processLogPath) ? processLogPath : runtime?.logPath - if (!logPath || !existsSync(logPath)) return undefined - try { - return { path: logPath, offset: statSync(logPath).size } - } catch { - return undefined - } -} - -function readVoiceRuntimeEvidence(snapshot?: { path: string; offset: number }): { - uploadResult?: string - uploadDataLen?: number - durationMs?: number - sendResult?: string -} { - if (!snapshot || !existsSync(snapshot.path)) return {} - try { - const data = readFileSync(snapshot.path) - const lines = data - .subarray(Math.min(snapshot.offset, data.length)) - .toString('utf8') - .split(/\r?\n/) - const evidence: { - uploadResult?: string - uploadDataLen?: number - durationMs?: number - sendResult?: string - } = {} - for (const line of lines) { - if (!line.trim()) continue - try { - const entry = JSON.parse(line) as Record - const message = `${String(entry.msg || '')} ${String(entry.message || '')}` - if (message.includes('上传语音任务执行结果')) { - if (entry.result !== undefined) evidence.uploadResult = String(entry.result) - if (entry.silk_len !== undefined) evidence.uploadDataLen = Number(entry.silk_len) - if (entry.duration_ms !== undefined) evidence.durationMs = Number(entry.duration_ms) - } - if (message.includes('发送语音任务执行结果') && entry.result !== undefined) { - evidence.sendResult = String(entry.result) - } - } catch { - // Ignore a partially-written runtime log line. - } - } - return evidence - } catch { - return {} - } -} - -async function waitForVoiceRuntimeEvidence( - snapshot: { path: string; offset: number } | undefined, - timeoutMs = 6_000 -): Promise<{ - uploadResult?: string - uploadDataLen?: number - durationMs?: number - sendResult?: string -}> { - if (!snapshot) return {} - const startedAt = Date.now() - let evidence = readVoiceRuntimeEvidence(snapshot) - while (Date.now() - startedAt < timeoutMs) { - // The OneBot HTTP handler acknowledges the task before its worker writes - // the upload/send callbacks to disk. Poll the request's log suffix so a - // successful asynchronous send is not reported as a validation failure. - if ( - evidence.uploadResult !== undefined && - (evidence.uploadResult !== '0' || evidence.sendResult !== undefined) - ) { - return evidence - } - await new Promise((resolve) => setTimeout(resolve, 100)) - evidence = readVoiceRuntimeEvidence(snapshot) - } - return evidence -} - const VOICE_DIAGNOSTIC_KEYS = new Set([ 'input_bytes', 'normalized_input_bytes', @@ -536,35 +160,6 @@ function logVoiceAttempt( }) } -function createWavBuffer(pcm: Buffer, sampleRate: number, channels: number): Buffer { - const header = Buffer.alloc(44) - header.write('RIFF', 0) - header.writeUInt32LE(36 + pcm.length, 4) - header.write('WAVE', 8) - header.write('fmt ', 12) - header.writeUInt32LE(16, 16) - header.writeUInt16LE(1, 20) - header.writeUInt16LE(channels, 22) - header.writeUInt32LE(sampleRate, 24) - header.writeUInt32LE(sampleRate * channels * 2, 28) - header.writeUInt16LE(channels * 2, 32) - header.writeUInt16LE(16, 34) - header.write('data', 36) - header.writeUInt32LE(pcm.length, 40) - return Buffer.concat([header, pcm]) -} - -async function prepareVoiceFile(filePath: string): Promise { - const data = readFileSync(filePath) - if (!data.subarray(0, 10).equals(Buffer.from('\x02#!SILK_V3'))) return data - const decoded = await new SilkAudioDecoder().decode({ - data, - codec: 'silk', - sourceHash: createHash('sha256').update(data).digest('hex') - }) - return createWavBuffer(decoded.pcm, decoded.sampleRate, decoded.channels) -} - async function detectVoiceDurationMs(filePath: string): Promise { const data = readFileSync(filePath) if (data.subarray(0, 10).equals(Buffer.from('\x02#!SILK_V3'))) { @@ -683,26 +278,36 @@ export function prepareWindowsImageFile(filePath: string): { return { filePath: temporaryPath, temporary: true } } -async function readWechatVersion(): Promise { - const { stdout } = await execFileAsync('/usr/libexec/PlistBuddy', [ - '-c', - 'Print :WeChatBundleVersion', - join(WECHAT_APP_PATH, 'Contents', 'Info.plist') - ]) - return stdout.trim() -} - -async function readWechatPid(): Promise { - try { - const { stdout } = await execFileAsync('/usr/bin/pgrep', ['-x', 'WeChat']) - const pid = Number(stdout.trim().split(/\s+/)[0]) - return Number.isInteger(pid) && pid > 0 ? pid : undefined - } catch { - return undefined +function prepareMacWechatImageFile(filePath: string): { filePath: string; md5: string } { + /* Stage the image in WeChat's temporary image directory using the + * filename/hash layout required by the macOS native send path: + * - stage inside the active account's ImageTemp tree selected by + * findWechatImagePath(), not an arbitrary readable sandbox directory; + * - append a per-send salt before calculating MD5, so the staged image and + * the MD5 passed to the CDN task describe the same unique byte stream. */ + const temporaryRoot = findWechatImagePath() + if (!temporaryRoot) throw new Error('无法定位当前微信账号的图片临时目录') + mkdirSync(temporaryRoot, { recursive: true }) + const extension = extname(filePath).toLowerCase() + const safeExtension = /^\.[a-z0-9]{1,8}$/.test(extension) ? extension.slice(1) : 'png' + const source = readFileSync(filePath) + const salt = Buffer.from( + `\n#md5_salt_${process.hrtime.bigint()}_${Math.floor(Math.random() * 10_000)}#`, + 'utf8' + ) + const staged = Buffer.concat([source, salt]) + const temporaryPath = join( + temporaryRoot, + `${randomBytes(8).toString('hex')}_${Math.floor(Date.now() / 1000)}.${safeExtension}` + ) + writeFileSync(temporaryPath, staged, { mode: 0o644 }) + return { + filePath: temporaryPath, + md5: createHash('md5').update(staged).digest('hex') } } -/** Locate the current account's temporary image directory required by the upstream hook. */ +/** Locate the current account's temporary image directory consumed by the image send path. */ export function findWechatImagePath( root = WECHAT_FILES_ROOT, now = new Date() @@ -738,80 +343,6 @@ export function findWechatImagePath( } } -async function isSipDisabled(): Promise { - try { - const { stdout, stderr } = await execFileAsync('/usr/bin/csrutil', ['status']) - return `${stdout}\n${stderr}`.toLowerCase().includes('disabled') - } catch { - return false - } -} - -interface OneBotProcessInfo { - pid: number - boundWechatPid?: number - imagePath?: string - command: string -} - -async function readOneBotProcessInfo(): Promise { - try { - const { stdout } = await execFileAsync('/usr/sbin/lsof', [ - '-nP', - '-t', - `-iTCP:${DEFAULT_HOST.split(':')[1]}`, - '-sTCP:LISTEN' - ]) - const pid = Number(stdout.trim().split(/\s+/)[0]) - if (!Number.isInteger(pid) || pid <= 0) return undefined - const { stdout: commandOutput } = await execFileAsync('/bin/ps', [ - '-p', - String(pid), - '-o', - 'command=' - ]) - const command = commandOutput.trim() - if (!/(^|\/)onebot(?:\s|$)/.test(command)) return undefined - const boundWechatPid = Number(command.match(/-wechat_pid=(\d+)/)?.[1]) || undefined - const imagePath = command.match(/-image_path=(\S+)/)?.[1] - return { - pid, - ...(boundWechatPid ? { boundWechatPid } : {}), - ...(imagePath ? { imagePath } : {}), - command - } - } catch { - return undefined - } -} - -async function terminateOneBot(info: OneBotProcessInfo): Promise { - if (!/(^|\/)onebot(?:\s|$)/.test(info.command)) return - await terminateProcess(info.pid) -} - -async function terminateProcess(pid: number): Promise { - try { - process.kill(pid, 'SIGTERM') - } catch { - return - } - const startedAt = Date.now() - while (Date.now() - startedAt < STOP_TIMEOUT_MS) { - try { - process.kill(pid, 0) - await new Promise((resolve) => setTimeout(resolve, 100)) - } catch { - return - } - } - try { - process.kill(pid, 'SIGKILL') - } catch { - // The process exited between the last liveness check and the forced stop. - } -} - async function requestWithTimeout( url: string, init?: RequestInit, @@ -948,7 +479,10 @@ function safeFileSize(target: string): number { * **隐私约束**:只允许输出类型、字节数、HTTP 状态码、hook 的 `ret` 这类结构性字段。 * 文件名、完整路径、wxid、群名、消息正文不进日志。 */ -function logSendDiagnostic(stage: string, detail: Record): void { +function logSendDiagnostic( + stage: string, + detail: Record +): void { try { if (!loadSettings().debugEnabled) return } catch { @@ -1015,94 +549,11 @@ async function requestWindowsHook( } export class PersonalWechatSendService { - private child: ChildProcess | null = null - private startPromise: Promise | null = null - private lastError = '' - private voiceSendTail: Promise = Promise.resolve() - private keepOneBotProcess = Boolean(loadSettings().keepPersonalWechatProcess) - - getKeepOneBotProcess(): boolean { - return this.keepOneBotProcess - } - - setKeepOneBotProcess(keep: boolean): boolean { - this.keepOneBotProcess = keep - updateSettings({ keepPersonalWechatProcess: keep }) - return this.keepOneBotProcess - } - async getStatus(): Promise { - if (process.platform === 'win32') return this.getWindowsStatus() - const preflight = await this.preflight() - const [endpointReady, oneBot] = await Promise.all([ - this.isEndpointOnline(), - readOneBotProcessInfo() - ]) - const hook = this.readHookReadiness(preflight.runtime) - const boundWechatPid = oneBot?.boundWechatPid || hook.boundWechatPid - const boundToCurrentWechat = Boolean( - preflight.status.wechatPid && boundWechatPid === preflight.status.wechatPid - ) - const common = { - ...preflight.status, - endpointReady, - ...(oneBot?.pid ? { oneBotPid: oneBot.pid } : {}), - ...(boundWechatPid ? { boundWechatPid } : {}), - attachReady: hook.attached, - ...(hook.baseAddress ? { baseAddress: hook.baseAddress } : {}), - baseAddressReady: Boolean(hook.baseAddress), - textHookInstalled: hook.textHookInstalled, - textHookReady: hook.textHookReady, - imageHookInstalled: hook.imageHookInstalled, - imageHookReady: hook.imageHookReady, - messageListenerReady: hook.messageListenerReady - } - if (!endpointReady) return common - if (!boundToCurrentWechat) { - return { - ...common, - state: 'hook_not_ready', - canSend: false, - canSendText: false, - canSendImage: false, - message: 'OneBot 仍绑定旧微信进程,请点击“绑定微信”' - } - } - if (hook.readiness === 'failed') { - return { - ...common, - state: 'error', - canSend: false, - canSendText: false, - canSendImage: false, - message: '微信发送能力初始化失败,请尝试重新绑定', - ...(hook.error ? { error: hook.error } : {}) - } - } - const imagePathBound = Boolean(oneBot?.imagePath) - // All send types use the captured native task context. Media no longer - // waits for a real image upload event because the runtime resolves the - // CDN manager during cold start. - const capabilities = deriveMacSendCapabilities({ - attached: hook.attached, - baseAddressReady: Boolean(hook.baseAddress), - textHookInstalled: hook.textHookInstalled, - textHookReady: hook.textHookReady, - imageHookInstalled: hook.imageHookInstalled, - imagePathBound - }) - return { - ...common, - state: capabilities.canSend ? 'online' : 'hook_not_ready', - ...capabilities, - message: - hook.imageHookInstalled && preflight.status.imagePath && !imagePathBound - ? 'OneBot 尚未绑定微信图片目录,请点击“绑定微信”' - : capabilities.canSend - ? '个人微信发送能力已就绪' - : '个人微信已绑定,正在初始化发送能力…', - ...(hook.error ? { error: hook.error } : {}) - } + // macOS 发送能力完全由 native runtime(tm-wechat-host)提供, + // 这里保持同源,避免出现第二套状态判断。 + if (process.platform === 'darwin') return macWechatRuntimeManager.buildSenderStatus() + return this.getWindowsStatus() } async checkWindowsStatus(port?: string): Promise { @@ -1126,300 +577,20 @@ export class PersonalWechatSendService { async send(request: PersonalWechatSendRequest): Promise { if (process.platform === 'win32') return this.sendWindows(request) - const requestId = randomUUID() - if (request.type !== 'voice') return this.sendInternal(request, requestId) - - // OneBot's voice upload/callback bridge still uses process-wide Frida - // fields. Serialize voice requests here so duration, Silk length and CDN - // callback data cannot cross between two concurrent sends. - const previous = this.voiceSendTail - let release!: () => void - this.voiceSendTail = new Promise((resolve) => { - release = resolve - }) - await previous - try { - return await this.sendInternal(request, requestId) - } finally { - release() + if (process.platform === 'darwin') { + if (request.type === 'text') return this.sendMacText(request) + if (request.type === 'image') return this.sendMacImage(request) + if (request.type === 'voice') return this.sendMacVoice(request) } - } - - private async sendInternal( - request: PersonalWechatSendRequest, - requestId: string - ): Promise { - const to = String(request?.to || '').trim() - if (!to) { - const status = await this.getStatus() - return { success: false, status, error: '接收者不能为空' } - } - let fileBase64: string | undefined - let voicePcm: VoicePcmMetadata | undefined - let runtimeSnapshot: { path: string; offset: number } | undefined - if (request.type === 'text') { - const text = String(request.text || '').trim() - if (!text) { - const status = await this.getStatus() - return { success: false, status, error: '文字内容不能为空' } - } - if (text.length > 2_000) { - const status = await this.getStatus() - return { success: false, status, error: '消息不能超过 2000 个字符' } - } - request = { ...request, to, text } - } else { - const filePath = String(request.filePath || '').trim() - if (!filePath || !existsSync(filePath)) { - const status = await this.getStatus() - return { - success: false, - status, - error: `请选择有效的${request.type === 'voice' ? '语音' : '图片'}文件` - } - } - const size = statSync(filePath).size - const maxBytes = request.type === 'voice' ? MAX_VOICE_BYTES : MAX_IMAGE_BYTES - if (size <= 0 || size > maxBytes) { - const status = await this.getStatus() - return { - success: false, - status, - error: `${request.type === 'voice' ? '语音' : '图片'}必须小于 20 MB` - } - } - let fileData: Buffer - try { - fileData = - request.type === 'voice' ? await prepareVoiceFile(filePath) : readFileSync(filePath) - const useLegacyVoicePath = request.type === 'voice' && request.voiceSendMode === 'legacy' - if (request.type === 'voice' && !useLegacyVoicePath) { - const sourceInputBytes = fileData.length - const pcm = await convertAudioToPcm(fileData) - const alignedPcmBytes = Math.floor(pcm.length / VOICE_FRAME_BYTES) * VOICE_FRAME_BYTES - const alignedPcm = pcm.subarray(0, alignedPcmBytes) - voicePcm = validateVoicePcm(alignedPcm) - // The bundled Go encoder consumes 20ms frames. Sending an aligned - // WAV makes the duration in the eventual protobuf match its output. - fileData = createWavBuffer(alignedPcm, voicePcm.sampleRate, voicePcm.channels) - logVoiceAttempt(requestId, 'prepared', { - input_bytes: sourceInputBytes, - normalized_input_bytes: fileData.length, - pcm_size: voicePcm.pcmSize, - sample_rate: voicePcm.sampleRate, - channels: voicePcm.channels, - input_duration_ms: voicePcm.durationMs, - voice_send_mode: 'normalized' - }) - } else if (request.type === 'voice') { - logVoiceAttempt(requestId, 'prepared', { - input_bytes: fileData.length, - normalized_input_bytes: fileData.length, - voice_send_mode: 'legacy' - }) - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - if (request.type === 'voice') { - logVoiceAttempt(requestId, 'failed', { failure_phase: 'pcm_validation', error: message }) - } - const status = await this.getStatus() - return { success: false, status, error: message } - } - fileBase64 = fileData.toString('base64') - request = { ...request, to, filePath } - } - - const status = await this.ensureRunning() - const typeReady = - request.type === 'text' - ? status.canSendText - : request.type === 'voice' - ? status.canSendVoice - : status.canSendImage - if (!typeReady) { - const guidance = status.message || '个人微信发送能力正在初始化,请稍后重试' - return { success: false, status, error: status.error || guidance } - } - - const oneBot = buildPersonalWechatOneBotRequest(request, fileBase64) - if (request.type === 'voice') runtimeSnapshot = await runtimeVoiceLogSnapshot() - try { - const response = await requestWithTimeout( - `http://${DEFAULT_HOST}${oneBot.endpoint}`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(oneBot.body) - }, - request.type === 'voice' ? 60_000 : REQUEST_TIMEOUT_MS - ) - const responseText = await response.text() - if (!response.ok) throw new Error(responseText || `HTTP ${response.status}`) - const parsed = responseText ? (JSON.parse(responseText) as { status?: string }) : {} - if (parsed.status && parsed.status !== 'ok') throw new Error(responseText) - if (request.type === 'voice') { - const evidence = await waitForVoiceRuntimeEvidence(runtimeSnapshot) - try { - if (evidence.uploadResult !== '0') throw new Error('未确认微信语音上传结果') - if (evidence.sendResult !== '1') throw new Error('未确认微信语音发送结果') - if (evidence.uploadDataLen === undefined || evidence.durationMs === undefined) { - throw new Error('未获取到微信 Silk 音频元数据') - } - validateVoiceSilkMetadata(evidence.uploadDataLen, evidence.durationMs) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - logVoiceAttempt(requestId, 'failed', { - failure_phase: 'silk_validation', - upload_result: evidence.uploadResult, - upload_data_len: evidence.uploadDataLen, - silk_duration_ms: evidence.durationMs, - send_result: evidence.sendResult, - error: message - }) - const failedStatus = await this.getStatus() - return { - success: false, - status: { ...failedStatus, state: 'error', error: message }, - error: message - } - } - logVoiceAttempt(requestId, 'completed', { - pcm_size: voicePcm?.pcmSize, - input_duration_ms: voicePcm?.durationMs, - upload_result: evidence.uploadResult, - upload_data_len: evidence.uploadDataLen, - silk_duration_ms: evidence.durationMs, - send_result: evidence.sendResult - }) - } - return { success: true, status: await this.getStatus() } - } catch (error) { - this.lastError = error instanceof Error ? error.message : String(error) - if (request.type === 'voice') { - const evidence = await waitForVoiceRuntimeEvidence(runtimeSnapshot, 1_000) - // OneBot may finish the asynchronous upload/send after its HTTP - // request has timed out. If the runtime log proves that this voice was - // uploaded and sent successfully, do not report a false failure. - const runtimeSendSucceeded = - evidence.uploadResult === '0' && - evidence.sendResult === '1' && - evidence.uploadDataLen !== undefined && - evidence.durationMs !== undefined - if (runtimeSendSucceeded) { - try { - validateVoiceSilkMetadata(evidence.uploadDataLen!, evidence.durationMs!) - this.lastError = '' - logVoiceAttempt(requestId, 'completed', { - pcm_size: voicePcm?.pcmSize, - input_duration_ms: voicePcm?.durationMs, - upload_result: evidence.uploadResult, - upload_data_len: evidence.uploadDataLen, - silk_duration_ms: evidence.durationMs, - send_result: evidence.sendResult - }) - return { success: true, status: await this.getStatus() } - } catch { - // Keep the transport error below when the runtime metadata is - // present but fails the same Silk validation as the normal path. - } - } - logVoiceAttempt(requestId, 'failed', { - failure_phase: 'http_send', - pcm_size: voicePcm?.pcmSize, - input_duration_ms: voicePcm?.durationMs, - upload_result: evidence.uploadResult, - upload_data_len: evidence.uploadDataLen, - silk_duration_ms: evidence.durationMs, - send_result: evidence.sendResult, - error: this.lastError - }) - } - const failedStatus = await this.getStatus() - return { - success: false, - status: { ...failedStatus, state: 'error', error: this.lastError }, - error: `发送失败:${this.lastError}` - } + return { + success: false, + status: await this.getStatus(), + error: '当前系统暂不支持个人微信发送' } } async rebind(): Promise { - if (process.platform === 'win32') return this.getWindowsStatus() - const preflight = await this.preflight() - if (!preflight.runtime || !preflight.status.configPath || !preflight.status.wechatPid) { - return preflight.status - } - const currentStatus = await this.getStatus() - const oneBot = await readOneBotProcessInfo() - if ( - oneBot?.boundWechatPid === preflight.status.wechatPid && - currentStatus.attachReady && - currentStatus.baseAddressReady && - currentStatus.textHookInstalled && - currentStatus.imageHookInstalled && - (!preflight.status.imagePath || Boolean(oneBot.imagePath)) && - currentStatus.state !== 'error' - ) { - return { - ...currentStatus, - message: '当前 OneBot 已绑定此微信进程,无需重复注入' - } - } - if (oneBot) await terminateOneBot(oneBot) - this.child = null - this.startPromise = null - this.lastError = '' - let status = await this.startRuntime() - const retryableHookFailure = - status.state === 'error' && - /unable to intercept function|cannot find ['"]req2buf|hook 初始化失败/i.test( - `${status.error || ''} ${status.message || ''}` - ) - if (!retryableHookFailure) return status - const failedOneBot = await readOneBotProcessInfo() - if (failedOneBot) await terminateOneBot(failedOneBot) - this.child = null - this.lastError = '' - await new Promise((resolve) => setTimeout(resolve, 1_500)) - status = await this.startRuntime() - return status - } - - async terminate(force = false): Promise { - if (process.platform === 'win32') return - if (this.keepOneBotProcess && !force) { - this.child = null - this.startPromise = null - this.lastError = '' - return - } - const trackedPid = this.child?.pid - const oneBot = await readOneBotProcessInfo() - if (oneBot) await terminateOneBot(oneBot) - if (trackedPid && trackedPid !== oneBot?.pid) await terminateProcess(trackedPid) - this.child = null - this.startPromise = null - this.lastError = '' - } - - /** Restart the existing OneBot runtime after an environment change. */ - async restartRuntime(): Promise { - if (process.platform === 'win32') return this.getWindowsStatus() - await this.terminate(true) - return this.rebind() - } - - private async ensureRunning(): Promise { - const currentStatus = await this.getStatus() - if (currentStatus.state === 'online' || currentStatus.state === 'hook_not_ready') { - return currentStatus - } - if (this.startPromise) return this.startPromise - this.startPromise = this.startRuntime().finally(() => { - this.startPromise = null - }) - return this.startPromise + return this.getWindowsStatus() } private async getWindowsStatus(port?: string): Promise { @@ -1478,6 +649,207 @@ export class PersonalWechatSendService { } } + /* + * macOS native runtime 的文字发送:HTTP POST /sendText 到 tm-wechat-host。 + * 绑定状态由 host 侧保证;未绑定/未就绪时返回与旧契约一致的结果结构。 + */ + private async sendMacText( + request: Extract + ): Promise { + await macWechatRuntimeManager.ensureStarted() + const initialStatus = await macWechatRuntimeManager.buildSenderStatus() + if (!initialStatus.canSendText) { + return { + success: false, + status: initialStatus, + error: initialStatus.message || '微信发送能力未就绪' + } + } + + const payload = JSON.stringify({ + to: request.to, + text: request.text, + isGroup: Boolean(request.isGroup) + }) + const response = await fetch(`http://127.0.0.1:${macWechatRuntimeManager.getPort()}/sendText`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: payload, + signal: AbortSignal.timeout(40_000) + }).catch(() => null) + + if (response === null || !response.ok) { + const status = await macWechatRuntimeManager.buildSenderStatus() + return { + success: false, + status, + error: `发送请求失败(HTTP ${response?.status ?? '无响应'})` + } + } + + const result = (await response.json()) as { code: number; ok: boolean; message: string } + const resultStatus = await macWechatRuntimeManager.buildSenderStatus() + if (!result.ok) { + return { success: false, status: resultStatus, error: result.message || '发送失败' } + } + return { success: true, status: resultStatus } + } + + /* + * Resolve the bound account's own wxid from the local WeChat data root. + * The media proto embeds the sender, and the mac runtime does not learn it + * from inbound traffic, so it is read from + * the on-disk account identity instead. + */ + private async resolveMacSenderWxid(): Promise { + const identity = readLocalAccountIdentity(WECHAT_FILES_ROOT) + return identity?.wxid ?? '' + } + + private async postMacHost( + path: string, + body: Record, + timeoutMs: number + ): Promise<{ code: number; ok: boolean; message: string } | null> { + const response = await fetch(`http://127.0.0.1:${macWechatRuntimeManager.getPort()}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(timeoutMs) + }).catch(() => null) + if (response === null || !response.ok) return null + return (await response.json().catch(() => null)) as { + code: number + ok: boolean + message: string + } | null + } + + private async sendMacImage( + request: Extract + ): Promise { + await macWechatRuntimeManager.ensureStarted() + const initialStatus = await macWechatRuntimeManager.buildSenderStatus() + const fail = async (error: string): Promise => { + const status = await macWechatRuntimeManager.buildSenderStatus() + return { success: false, status, error } + } + if (!initialStatus.canSendImage) { + return fail('微信发送能力未就绪') + } + const filePath = String(request.filePath || '').trim() + if (!filePath || !existsSync(filePath)) return fail('请选择有效的图片文件') + + const sender = await this.resolveMacSenderWxid() + if (!sender) return fail('无法识别当前微信账号 wxid') + + let uploadPath = '' + try { + const prepared = prepareMacWechatImageFile(filePath) + uploadPath = prepared.filePath + const upload = await this.postMacHost( + '/uploadImage', + { to: request.to, md5: prepared.md5, filePath: uploadPath }, + 20_000 + ) + if (!upload || !upload.ok) { + return fail(upload?.message || '图片上传失败') + } + const sent = await this.postMacHost('/sendImage', { sender, to: request.to }, 50_000) + if (!sent || !sent.ok) { + return fail(sent?.message || '图片发送失败') + } + const status = await macWechatRuntimeManager.buildSenderStatus() + return { success: true, status } + } catch (error) { + return fail(error instanceof Error ? error.message : String(error)) + } finally { + if (uploadPath) { + try { + unlinkSync(uploadPath) + } catch { + // Upload staging cleanup is best effort. + } + } + } + } + + private async sendMacVoice( + request: Extract + ): Promise { + const requestId = randomUUID() + await macWechatRuntimeManager.ensureStarted() + const initialStatus = await macWechatRuntimeManager.buildSenderStatus() + const fail = async ( + error: string, + failurePhase = 'preflight' + ): Promise => { + logVoiceAttempt(requestId, 'failed', { failure_phase: failurePhase, error }) + const status = await macWechatRuntimeManager.buildSenderStatus() + return { success: false, status, error } + } + if (!initialStatus.canSendVoice) { + return fail('微信发送能力未就绪') + } + const filePath = String(request.filePath || '').trim() + if (!filePath || !existsSync(filePath)) return fail('请选择有效的语音文件') + + const sender = await this.resolveMacSenderWxid() + if (!sender) return fail('无法识别当前微信账号 wxid') + + /* Reuse the Windows SILK preparation: it already normalizes any input to + * SILK and reports the duration. */ + let temporaryVoicePath: string | undefined + try { + const prepared = await prepareWindowsVoiceFile(filePath) + temporaryVoicePath = prepared.temporary ? prepared.filePath : undefined + const silkData = readFileSync(prepared.filePath) + const requestedDuration = Number(request.durationMs) + const rawDuration = + Number.isFinite(requestedDuration) && requestedDuration > 0 + ? requestedDuration + : prepared.durationMs + if (!rawDuration || !Number.isFinite(rawDuration)) { + return fail('无法计算语音时长', 'duration') + } + const durationMs = Math.max(1, Math.min(60_000, Math.round(rawDuration))) + logVoiceAttempt(requestId, 'prepared', { + input_bytes: statSync(filePath).size, + normalized_input_bytes: silkData.length, + silk_duration_ms: durationMs + }) + + const upload = await this.postMacHost( + '/uploadVoice', + { + to: request.to, + filePath: prepared.filePath, + audioDataHex: silkData.toString('hex'), + voiceDurationMs: durationMs + }, + 30_000 + ) + if (!upload || !upload.ok) { + return fail(upload?.message || '语音上传失败', 'upload') + } + const sent = await this.postMacHost('/sendVoice', { sender, to: request.to }, 50_000) + if (!sent || !sent.ok) { + return fail(sent?.message || '语音发送失败', 'send') + } + const status = await macWechatRuntimeManager.buildSenderStatus() + logVoiceAttempt(requestId, 'completed', { send_result: '0' }) + return { success: true, status } + } finally { + if (temporaryVoicePath) { + try { + unlinkSync(temporaryVoicePath) + } catch { + // 临时语音只做尽力清理,清理失败不应覆盖真实发送结果。 + } + } + } + } + private async sendWindows(request: PersonalWechatSendRequest): Promise { const to = String(request?.to || '').trim() if (!to) { @@ -1602,273 +974,6 @@ export class PersonalWechatSendService { } } } - - private async startRuntime(): Promise { - const preflight = await this.preflight() - if (!preflight.runtime || !preflight.status.configPath) { - return preflight.status - } - - try { - const { personalWechatVoiceEnvironmentService } = - await import('./personal-wechat-voice-environment-service') - const environment = await personalWechatVoiceEnvironmentService.check() - if (!environment.pilk.ready) { - appLogger.write({ - level: 'warn', - scope: 'VoiceRuntime', - message: 'WARNING: pilk unavailable, OneBot may fallback to go-silk.', - details: { - python: environment.python.executable, - pythonReady: environment.python.ready, - ffmpegReady: environment.ffmpeg.ready, - runtimeReady: environment.runtimeReady - } - }) - } - } catch (error) { - appLogger.write({ - level: 'warn', - scope: 'VoiceRuntime', - message: 'Voice encoding environment check failed before OneBot start', - details: { error: error instanceof Error ? error.message : String(error) } - }) - } - - const pid = preflight.status.wechatPid - const imagePath = findWechatImagePath() - this.lastError = '' - const child = spawn( - preflight.runtime.executable, - [ - '-type=local', - `-receive_host=${DEFAULT_HOST}`, - `-wechat_conf=${preflight.status.configPath}`, - `-wechat_pid=${pid}`, - ...(imagePath ? [`-image_path=${imagePath}`] : []), - '-send_interval=1000', - '-log_level=info' - ], - { - cwd: preflight.runtime.workingDirectory, - detached: true, - stdio: 'ignore', - windowsHide: true, - env: buildPersonalWechatRuntimeEnvironment(preflight.runtime.root) - } - ) - this.child = child - child.unref() - child.once('error', (error) => { - this.lastError = error.message - }) - child.once('exit', (code) => { - if (this.child === child) this.child = null - if (code && !this.lastError) this.lastError = `发送服务退出(code=${code})` - }) - - const startedAt = Date.now() - while (Date.now() - startedAt < START_TIMEOUT_MS) { - const status = await this.getStatus() - if (status.state === 'online') return status - if (status.state === 'hook_not_ready' && status.attachReady && status.baseAddressReady) { - return status - } - if (status.state === 'error') return status - if (this.child?.exitCode !== null && this.child?.exitCode !== undefined) break - await new Promise((resolve) => setTimeout(resolve, 300)) - } - const status = await this.preflight() - return { - ...status.status, - state: 'error', - canSend: false, - message: '个人微信发送服务启动失败', - error: this.lastError || '启动超时,请查看应用日志' - } - } - - private async preflight(): Promise { - const base = { - platform: process.platform, - arch: process.arch, - sipDisabled: false, - wechatRunning: false, - runtimeReady: false, - endpoint: DEFAULT_HOST, - endpointReady: false, - attachReady: false, - baseAddressReady: false, - textHookInstalled: false, - textHookReady: false, - imageHookInstalled: false, - imageHookReady: false, - messageListenerReady: false, - canSend: false, - canSendText: false, - canSendImage: false, - canSendVoice: false - } - if (process.platform !== 'darwin' || process.arch !== 'arm64') { - return { - status: { - ...base, - state: 'unsupported_platform', - message: - process.platform === 'darwin' - ? 'Intel Mac 不支持个人微信发送' - : '当前系统不支持个人微信发送' - } - } - } - - const [sipDisabled, wechatPid] = await Promise.all([isSipDisabled(), readWechatPid()]) - if (!wechatPid) { - return { - status: { - ...base, - sipDisabled, - state: 'wechat_not_running', - message: '请先启动并登录 macOS 微信' - } - } - } - if (!sipDisabled) { - return { - status: { - ...base, - wechatRunning: true, - wechatPid, - state: 'sip_enabled', - message: '当前 SIP 未关闭,无法直接连接微信进程' - } - } - } - - let wechatVersion = '' - try { - wechatVersion = await readWechatVersion() - } catch (error) { - return { - status: { - ...base, - sipDisabled, - wechatRunning: true, - wechatPid, - state: 'error', - message: '无法读取微信精确版本', - error: error instanceof Error ? error.message : String(error) - } - } - } - - const runtime = findPersonalWechatRuntime() - if (!runtime) { - return { - status: { - ...base, - sipDisabled, - wechatRunning: true, - wechatPid, - wechatVersion, - state: 'runtime_missing', - message: '个人微信发送组件尚未安装,请前往“设置 → 智能能力 → 微信发送”下载' - } - } - } - const configPath = join(runtime.configDirectory, toConfigFileName(wechatVersion)) - if (!existsSync(configPath)) { - return { - runtime, - status: { - ...base, - sipDisabled, - wechatRunning: true, - wechatPid, - wechatVersion, - runtimeReady: true, - executablePath: runtime.executable, - state: 'unsupported_version', - message: `当前微信版本 ${wechatVersion} 暂不支持,请前往微信发送设置查看支持的版本`, - error: `缺少 ${configPath}` - } - } - } - - const imagePath = findWechatImagePath() - return { - runtime, - status: { - ...base, - sipDisabled, - wechatRunning: true, - wechatPid, - wechatVersion, - runtimeReady: true, - executablePath: runtime.executable, - configPath, - ...(imagePath ? { imagePath } : {}), - state: this.child && this.child.exitCode === null ? 'starting' : 'stopped', - message: this.lastError || '尚未绑定当前微信,可点击“绑定微信”' - } - } - } - - private async isEndpointOnline(): Promise { - try { - const [host, portText] = DEFAULT_HOST.split(':') - const port = Number(portText) - await new Promise((resolve, reject) => { - const socket = createConnection({ host, port }) - const timer = setTimeout(() => { - socket.destroy() - reject(new Error('timeout')) - }, 800) - socket.once('connect', () => { - clearTimeout(timer) - socket.destroy() - resolve() - }) - socket.once('error', (error) => { - clearTimeout(timer) - socket.destroy() - reject(error) - }) - }) - return true - } catch { - return false - } - } - - private readHookReadiness( - runtime?: RuntimeLayout - ): ReturnType { - if (!runtime || !existsSync(runtime.logPath)) { - return { - readiness: 'unknown', - attached: false, - textHookInstalled: false, - textHookReady: false, - imageHookInstalled: false, - imageHookReady: false, - messageListenerReady: false - } - } - try { - return parsePersonalWechatHookLog(readFileSync(runtime.logPath, 'utf8')) - } catch { - return { - readiness: 'unknown', - attached: false, - textHookInstalled: false, - textHookReady: false, - imageHookInstalled: false, - imageHookReady: false, - messageListenerReady: false - } - } - } } export const personalWechatSendService = new PersonalWechatSendService() diff --git a/src/main/services/personal-wechat-voice-environment-service.ts b/src/main/services/personal-wechat-voice-environment-service.ts index 77823cb..f4f6583 100644 --- a/src/main/services/personal-wechat-voice-environment-service.ts +++ b/src/main/services/personal-wechat-voice-environment-service.ts @@ -7,11 +7,8 @@ import type { PersonalWechatVoiceRuntimeComponent } from '../../shared/personal-wechat-voice-runtime' import { PERSONAL_WECHAT_PILK_VERSION } from '../../shared/personal-wechat-voice-runtime' -import { - buildPersonalWechatRuntimeEnvironment, - findPersonalWechatRuntime -} from './personal-wechat-send-service' -import type { RuntimeLayout } from './personal-wechat-send-service' +import { buildPersonalWechatRuntimeEnvironment } from './personal-wechat-send-service' +import { macWechatRuntimeManager } from './mac-wechat-runtime-manager' import { appLogger } from '../app-logger' const execFileAsync = promisify(execFile) @@ -37,8 +34,8 @@ type CommandRunner = ( interface PersonalWechatVoiceEnvironmentServiceOptions { platform?: NodeJS.Platform architecture?: string - findRuntime?: () => RuntimeLayout | null - buildEnvironment?: (runtimeRoot?: string) => NodeJS.ProcessEnv + isRuntimePresent?: () => boolean + buildEnvironment?: () => NodeJS.ProcessEnv runCommand?: CommandRunner now?: () => Date } @@ -100,18 +97,26 @@ function unsupportedEnvironment(): PersonalWechatVoiceEncodingEnvironment { } } +function defaultIsRuntimePresent(): boolean { + try { + return macWechatRuntimeManager.isRuntimePresent() + } catch { + return false + } +} + export class PersonalWechatVoiceEnvironmentService { private readonly platform: NodeJS.Platform private readonly architecture: string - private readonly findRuntime: () => RuntimeLayout | null - private readonly buildEnvironment: (runtimeRoot?: string) => NodeJS.ProcessEnv + private readonly isRuntimePresent: () => boolean + private readonly buildEnvironment: () => NodeJS.ProcessEnv private readonly runCommand: CommandRunner private readonly now: () => Date constructor(options: PersonalWechatVoiceEnvironmentServiceOptions = {}) { this.platform = options.platform || process.platform this.architecture = options.architecture || process.arch - this.findRuntime = options.findRuntime || findPersonalWechatRuntime + this.isRuntimePresent = options.isRuntimePresent || defaultIsRuntimePresent this.buildEnvironment = options.buildEnvironment || buildPersonalWechatRuntimeEnvironment this.runCommand = options.runCommand || runCommand this.now = options.now || (() => new Date()) @@ -123,8 +128,8 @@ export class PersonalWechatVoiceEnvironmentService { } logEnvironmentLine('Checking voice encoding environment') - const runtime = this.findRuntime() - const environment = this.buildEnvironment(runtime?.root) + const runtimeReady = this.isRuntimePresent() + const environment = this.buildEnvironment() const python = blankComponent() const pilk = blankComponent() const ffmpeg = blankComponent() @@ -197,26 +202,25 @@ export class PersonalWechatVoiceEnvironmentService { logEnvironmentWarning('ffmpeg: unavailable', { error: ffmpeg.error }) } - const ready = Boolean(runtime && python.ready && pilk.ready && ffmpeg.ready) + const ready = Boolean(runtimeReady && python.ready && pilk.ready && ffmpeg.ready) const result: PersonalWechatVoiceEncodingEnvironment = { state: ready ? 'ready' : 'incomplete', ready, checkedAt: this.now().toISOString(), - runtimeReady: Boolean(runtime), - ...(runtime ? { runtimeRoot: runtime.root } : {}), + runtimeReady, python, pilk, ffmpeg, encoder: pilk.ready ? 'pilk' - : runtime && python.ready && ffmpeg.ready - ? 'go-silk' + : runtimeReady && python.ready && ffmpeg.ready + ? 'silk' : 'unavailable', message: ready ? '语音编码环境正常,可以使用 pilk 编码' - : runtime - ? '语音编码环境不完整,OneBot 可能回退到 go-silk' - : '微信发送组件尚未安装,请先准备 OneBot 运行时' + : runtimeReady + ? '语音编码环境不完整,语音将回退到内置 SILK 编码' + : '当前版本暂未提供微信消息发送功能' } logEnvironmentLine( ready ? 'Voice encoding environment is ready' : 'Voice encoding environment is NOT ready', @@ -245,7 +249,7 @@ export class PersonalWechatVoiceEnvironmentService { pythonExecutable, ['-m', 'pip', 'install', '--user', `pilk==${PERSONAL_WECHAT_PILK_VERSION}`], { - env: this.buildEnvironment(before.runtimeRoot || undefined), + env: this.buildEnvironment(), timeout: INSTALL_TIMEOUT_MS } ) diff --git a/src/main/services/settings-store.ts b/src/main/services/settings-store.ts index 8ef4ded..9f77bf1 100644 --- a/src/main/services/settings-store.ts +++ b/src/main/services/settings-store.ts @@ -39,9 +39,8 @@ export interface AppSettings { showStartupProgress: boolean ttsSelectedVoiceId: string ttsModel: TextToSpeechModel - /** Keep a running personal-WeChat OneBot process across app restarts. */ - keepPersonalWechatProcess?: boolean windowsWechatPort: string + reportImagePostfixText: string /** * Query Agent 是否为桌面「问问微信」与 Agent Hub 查询类问题的主路径。 * 默认开启;关闭后回退到 Legacy AI Search Pipeline(仅作 runtime regression 时的回退开关, @@ -130,8 +129,8 @@ const DEFAULT_SETTINGS: AppSettings = { showStartupProgress: true, ttsSelectedVoiceId: '', ttsModel: 's2.1-pro-free', - keepPersonalWechatProcess: false, windowsWechatPort: '', + reportImagePostfixText: '今日日报', queryAgentEnabled: true } @@ -150,7 +149,10 @@ export function loadSettings(): AppSettings { if (cache) return cache try { if (fs.existsSync(SETTINGS_FILE)) { - const raw = fs.readJsonSync(SETTINGS_FILE) as Partial + const stored = fs.readJsonSync(SETTINGS_FILE) as Partial & { + keepPersonalWechatProcess?: unknown + } + const { keepPersonalWechatProcess: retiredKeepProcess, ...raw } = stored cache = { ...DEFAULT_SETTINGS, ...raw } if (raw.autoLogin === undefined) { const hasSavedDatabaseKey = fs.existsSync( @@ -176,7 +178,7 @@ export function loadSettings(): AppSettings { } // 防撤回已下线(设置入口已隐藏):历史版本可能把它持久化为 true。 // 这里强制收敛为 false 并回写磁盘,确保旧的撤回监听与撤回日志不会继续运行。 - if (cache.recallProtectionEnabled) { + if (cache.recallProtectionEnabled || retiredKeepProcess !== undefined) { cache.recallProtectionEnabled = false saveSettings(cache) } @@ -192,7 +194,11 @@ export function loadSettings(): AppSettings { export function saveSettings(next: AppSettings): AppSettings { // 防撤回已下线:所有写入路径(含 settings:set 补丁)统一收敛为 false, // 避免遗留入口或旧版本把它重新打开。 - cache = { ...next, recallProtectionEnabled: false } + const sanitized = { ...next } as AppSettings & { + keepPersonalWechatProcess?: unknown + } + delete sanitized.keepPersonalWechatProcess + cache = { ...sanitized, recallProtectionEnabled: false } try { ensureDir() fs.writeJsonSync(SETTINGS_FILE, cache, { spaces: 2 }) diff --git a/src/main/services/wechat-action-gateway.ts b/src/main/services/wechat-action-gateway.ts index adef5e5..a29608f 100644 --- a/src/main/services/wechat-action-gateway.ts +++ b/src/main/services/wechat-action-gateway.ts @@ -30,9 +30,22 @@ const AUTOMATION_PURPOSE_ALLOWLIST = new Set([ // `AUTOMATION_SEND_PURPOSE` 保持一致,否则会被下面 evaluateWechatActionPolicy // 以 ACTION_NOT_ALLOWED 拦下 —— 那是有意的闸门,不是 bug。 'automation_reply', - 'automation_report' + 'automation_report', + 'manual_report_image', + 'manual_report_postfix' ]) +export interface ReportImageSequenceRequest { + recipient: WechatActionRequest['recipient'] + imagePath: string + postfixText: string +} + +export interface ReportImageSequenceResult { + image: WechatActionResult + postfix?: WechatActionResult +} + export interface WechatActionGatewayDependencies { getCapability?: () => Promise send?: (request: PersonalWechatSendRequest) => Promise @@ -111,6 +124,35 @@ export class WechatActionGateway { this.memberEvents.clear() } + /** + * 用户确认后的日报发送序列。两步都进入 automation 发送队列,从而复用既有 3 秒间隔; + * 后置词 action 只在图片明确 sent 后创建,图片失败/blocked 时严格短路。 + */ + async executeReportImageSequence( + request: ReportImageSequenceRequest + ): Promise { + const image = await this.execute({ + origin: 'user_manual', + purpose: 'manual_report_image', + triggerType: 'automation', + recipient: request.recipient, + content: { type: 'image', path: String(request.imagePath || '') } + }) + if (image.status !== 'sent') return { image } + + const postfixText = String(request.postfixText || '').trim() + if (!postfixText) return { image } + + const postfix = await this.execute({ + origin: 'user_manual', + purpose: 'manual_report_postfix', + triggerType: 'automation', + recipient: request.recipient, + content: { type: 'text', text: postfixText } + }) + return { image, postfix } + } + listAuditRecords(): WechatActionAuditRecord[] { return this.loadAuditState().records.map((record) => ({ ...record })) } diff --git a/src/main/voice-pipeline/voice-quality.ts b/src/main/voice-pipeline/voice-quality.ts index 2c78264..375393d 100644 --- a/src/main/voice-pipeline/voice-quality.ts +++ b/src/main/voice-pipeline/voice-quality.ts @@ -19,9 +19,9 @@ export interface VoiceSilkMetadata { } /** - * Validate the exact PCM contract consumed by the bundled OneBot encoder. - * The Go Silk encoder emits a header-only payload for sub-frame input, which - * is accepted by the old path but produces an unplayable WeChat voice. + * Validate the exact PCM contract consumed by the bundled SILK encoder. + * The encoder emits a header-only payload for sub-frame input, which would + * produce an unplayable WeChat voice. */ export function validateVoicePcm( pcm: Uint8Array, diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 4583653..118150e 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -92,11 +92,6 @@ import type { ScheduledReportTask, ScheduledReportUpdateInput } from '../shared/scheduled-report' -import type { - PersonalWechatRuntimeDownloadResult, - PersonalWechatRuntimeProgressEvent, - PersonalWechatRuntimeStatus -} from '../shared/personal-wechat-runtime' import type { PersonalWechatVoiceEncodingEnvironment, PersonalWechatVoiceEncodingEnvironmentResult @@ -322,7 +317,10 @@ declare global { getAutomationStatus: () => Promise listAutomationRules: () => Promise createAutomationRule: (draft: AutomationRuleDraft) => Promise - updateAutomationRule: (id: string, draft: AutomationRuleDraft) => Promise + updateAutomationRule: ( + id: string, + draft: AutomationRuleDraft + ) => Promise deleteAutomationRule: (id: string) => Promise setAutomationRuleEnabled: (id: string, enabled: boolean) => Promise listAutomationExecutions: (query?: { limit?: number }) => Promise @@ -544,6 +542,7 @@ declare global { ttsSelectedVoiceId: string ttsModel: import('../shared/text-to-speech').TextToSpeechModel windowsWechatPort: string + reportImagePostfixText: string imageXorKey: string imageAesKey: string } @@ -584,6 +583,7 @@ declare global { ttsSelectedVoiceId: string ttsModel: import('../shared/text-to-speech').TextToSpeechModel windowsWechatPort: string + reportImagePostfixText: string imageXorKey: string imageAesKey: string } @@ -607,6 +607,7 @@ declare global { ttsSelectedVoiceId: string ttsModel: import('../shared/text-to-speech').TextToSpeechModel windowsWechatPort: string + reportImagePostfixText: string imageXorKey: string imageAesKey: string }> @@ -720,21 +721,11 @@ declare global { onImageTextIndexStatus: (callback: (status: ImageTextIndexStatus) => void) => () => void getPersonalWechatSenderStatus: () => Promise getPersonalWechatSendCapability: () => Promise - getPersonalWechatKeepOneBotProcess: () => Promise - setPersonalWechatKeepOneBotProcess: (keep: boolean) => Promise checkPersonalWechatSenderStatus: (port?: string) => Promise checkPersonalWechatVoiceEncodingEnvironment: () => Promise installPersonalWechatPilk: () => Promise openPersonalWechatVoicePythonDownload: () => Promise<{ success: boolean; error?: string }> openPersonalWechatVoiceFfmpegDownload: () => Promise<{ success: boolean; error?: string }> - getPersonalWechatRuntimeStatus: () => Promise - downloadPersonalWechatRuntime: () => Promise - cancelPersonalWechatRuntimeDownload: () => Promise<{ success: boolean }> - removePersonalWechatRuntime: () => Promise - openPersonalWechatRuntimeDirectory: () => Promise<{ success: boolean; error?: string }> - onPersonalWechatRuntimeProgress: ( - callback: (status: PersonalWechatRuntimeProgressEvent) => void - ) => () => void rebindPersonalWechatSender: () => Promise sendGeneratedTtsVoice: ( request: PersonalWechatGeneratedTtsVoiceRequest diff --git a/src/preload/index.ts b/src/preload/index.ts index f44bb41..6af72c7 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -60,11 +60,6 @@ import type { ScheduledReportTask, ScheduledReportUpdateInput } from '../shared/scheduled-report' -import type { - PersonalWechatRuntimeDownloadResult, - PersonalWechatRuntimeProgressEvent, - PersonalWechatRuntimeStatus -} from '../shared/personal-wechat-runtime' import type { PersonalWechatVoiceEncodingEnvironment, PersonalWechatVoiceEncodingEnvironmentResult @@ -554,10 +549,6 @@ const api = { ipcRenderer.invoke('wechat-personal:getStatus'), getPersonalWechatSendCapability: (): Promise => ipcRenderer.invoke('wechat-personal:getSendCapability'), - getPersonalWechatKeepOneBotProcess: (): Promise => - ipcRenderer.invoke('wechat-personal:getKeepProcess'), - setPersonalWechatKeepOneBotProcess: (keep: boolean): Promise => - ipcRenderer.invoke('wechat-personal:setKeepProcess', keep), checkPersonalWechatSenderStatus: (port?: string): Promise => ipcRenderer.invoke('wechat-personal:checkStatus', port), checkPersonalWechatVoiceEncodingEnvironment: @@ -569,26 +560,6 @@ const api = { ipcRenderer.invoke('wechat-personal:openVoicePythonDownload'), openPersonalWechatVoiceFfmpegDownload: (): Promise<{ success: boolean; error?: string }> => ipcRenderer.invoke('wechat-personal:openVoiceFfmpegDownload'), - getPersonalWechatRuntimeStatus: (): Promise => - ipcRenderer.invoke('wechat-personal:getRuntimeStatus'), - downloadPersonalWechatRuntime: (): Promise => - ipcRenderer.invoke('wechat-personal:downloadRuntime'), - cancelPersonalWechatRuntimeDownload: (): Promise<{ success: boolean }> => - ipcRenderer.invoke('wechat-personal:cancelRuntimeDownload'), - removePersonalWechatRuntime: (): Promise => - ipcRenderer.invoke('wechat-personal:removeRuntime'), - openPersonalWechatRuntimeDirectory: (): Promise<{ success: boolean; error?: string }> => - ipcRenderer.invoke('wechat-personal:openRuntimeDirectory'), - onPersonalWechatRuntimeProgress: ( - callback: (status: PersonalWechatRuntimeProgressEvent) => void - ) => { - const listener = ( - _event: Electron.IpcRendererEvent, - status: PersonalWechatRuntimeProgressEvent - ): void => callback(status) - ipcRenderer.on('wechat-personal:runtimeProgress', listener) - return () => ipcRenderer.removeListener('wechat-personal:runtimeProgress', listener) - }, rebindPersonalWechatSender: (): Promise => ipcRenderer.invoke('wechat-personal:rebind'), sendGeneratedTtsVoice: ( diff --git a/src/renderer/src/components/chat/PersonalWechatSendDialog.tsx b/src/renderer/src/components/chat/PersonalWechatSendDialog.tsx index 3b0d1bd..529cfb9 100644 --- a/src/renderer/src/components/chat/PersonalWechatSendDialog.tsx +++ b/src/renderer/src/components/chat/PersonalWechatSendDialog.tsx @@ -5,16 +5,14 @@ import type { PersonalWechatSenderStatus, PersonalWechatVoiceDiagnostic } from '../../../../shared/personal-wechat' -import type { - PersonalWechatRuntimeProgressEvent, - PersonalWechatRuntimeStatus -} from '../../../../shared/personal-wechat-runtime' import { Button, Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui' import { isMac, isWindows } from '../../utils/runtime-environment' import { PersonalWechatChatComposer, type ChatMessage } from './PersonalWechatChatComposer' import { PersonalWechatSetupGuide } from './PersonalWechatSetupGuide' import { PersonalWechatVoiceDiagnosticDialog } from './PersonalWechatVoiceDiagnosticDialog' import { PersonalWechatWindowsSendDialog } from './PersonalWechatWindowsSendDialog' +import { ReportImagePostfixInput } from './ReportImagePostfixInput' +import { useReportImagePostfixSetting } from './useReportImagePostfixSetting' type SelectedLocalFile = { path: string; name: string } @@ -36,7 +34,7 @@ function fallbackStatus(error: unknown): PersonalWechatSenderStatus { sipDisabled: false, wechatRunning: false, runtimeReady: false, - endpoint: '127.0.0.1:58080', + endpoint: '', endpointReady: false, attachReady: false, baseAddressReady: false, @@ -67,18 +65,14 @@ function PersonalWechatMacSendDialog({ initialImage = null }: PersonalWechatSendDialogProps): React.ReactElement { const [senderStatus, setSenderStatus] = useState(null) - const [runtimeStatus, setRuntimeStatus] = useState(null) - const [runtimeProgress, setRuntimeProgress] = useState( - null - ) const [detecting, setDetecting] = useState(true) const [binding, setBinding] = useState(false) - const [runtimeBusy, setRuntimeBusy] = useState(false) const [sendBusy, setSendBusy] = useState(false) - // 状态可能来自之前的 OneBot 进程或日志;发送入口只信任当前语音能力状态。 + // 发送入口只信任当前 native runtime 上报的能力状态。 const [sessionBound, setSessionBound] = useState(false) const [messages, setMessages] = useState([]) const [sendError, setSendError] = useState(null) + const [sendSuccess, setSendSuccess] = useState(null) const [voiceDiagnostic, setVoiceDiagnostic] = useState(null) const [voiceDiagnosticOpen, setVoiceDiagnosticOpen] = useState(false) const requestIdRef = useRef(0) @@ -86,23 +80,19 @@ function PersonalWechatMacSendDialog({ const closingRef = useRef(false) const displayName = contact.m_nsNickName || contact.m_nsUsrName || '未命名会话' const targetId = contact.m_nsUsrName - const isBusy = binding || runtimeBusy || sendBusy + const isBusy = binding || sendBusy const setupReady = Boolean(initialImage ? senderStatus?.canSendImage : senderStatus?.canSendVoice) + const { postfixText, setPostfixText, persistPostfixText } = useReportImagePostfixSetting( + Boolean(initialImage) + ) const refreshStatus = useCallback(async (): Promise => { const requestId = ++requestIdRef.current setDetecting(true) setSendError(null) try { - const [nextRuntime, nextSender] = await Promise.all([ - isMac - ? window.api.getPersonalWechatRuntimeStatus?.() || Promise.resolve(null) - : Promise.resolve(null), - window.api.getPersonalWechatSenderStatus() - ]) + const nextSender = await window.api.getPersonalWechatSenderStatus() if (requestId !== requestIdRef.current) return - setRuntimeStatus(nextRuntime) - setRuntimeProgress(nextRuntime?.state === 'downloading' ? nextRuntime : null) setSenderStatus(nextSender) setSessionBound( nextSender.state === 'online' || @@ -122,13 +112,6 @@ function PersonalWechatMacSendDialog({ useEffect(() => { void refreshStatus() - if (!isMac) return undefined - const unsubscribe = window.api.onPersonalWechatRuntimeProgress?.((status) => { - setRuntimeProgress(status) - setRuntimeStatus(status) - if (status.state === 'ready') void refreshStatus() - }) - return unsubscribe }, [refreshStatus]) useEffect(() => { @@ -146,22 +129,6 @@ function PersonalWechatMacSendDialog({ return () => window.clearInterval(timer) }, [refreshStatus, senderStatus]) - const handleDownloadRuntime = async (): Promise => { - if (runtimeBusy) return - setRuntimeBusy(true) - setSendError(null) - try { - const result = await window.api.downloadPersonalWechatRuntime() - setRuntimeStatus(result.status) - if (!result.success && result.error) setSendError(result.error) - if (result.success) await refreshStatus() - } catch (error) { - setSendError(error instanceof Error ? error.message : String(error)) - } finally { - setRuntimeBusy(false) - } - } - const handleBind = async (): Promise => { if (binding) return setBinding(true) @@ -232,18 +199,27 @@ function PersonalWechatMacSendDialog({ if (!initialImage || !senderStatus?.canSendImage || sendBusy) return setSendBusy(true) setSendError(null) + setSendSuccess(null) try { + await persistPostfixText() const response = await window.api.sendPersonalWechatMessage({ type: 'image', to: targetId, isGroup: isGroupChat, - filePath: initialImage.path + filePath: initialImage.path, + postfixText } satisfies PersonalWechatSendRequest) setSenderStatus(response.status) if (!response.success) { setSendError(response.error || '日报图片发送失败') return } + if (response.postfixError) { + setSendError(`日报图片已发送,但${response.postfixError}`) + setSendSuccess('日报图片发送成功') + } else { + setSendSuccess(postfixText.trim() ? '日报图片和后置词发送成功' : '日报图片发送成功') + } setMessages((current) => [ ...current, { @@ -287,7 +263,7 @@ function PersonalWechatMacSendDialog({ {displayName.slice(0, 1)}
- 文字转语音 + {initialImage ? '发送日报图片' : '文字转语音'} 发送给 {displayName} · {setupReady ? '微信已连接' : '配置微信发送能力'} @@ -319,14 +295,10 @@ function PersonalWechatMacSendDialog({ {!setupReady && senderStatus && ( void handleDownloadRuntime()} onBind={() => void handleBind()} onStartSending={() => undefined} onOpenTextToSpeechSettings={handleOpenSettings} @@ -336,6 +308,16 @@ function PersonalWechatMacSendDialog({ {setupReady && initialImage && (

已准备日报图片:{initialImage.name}

+ + void persistPostfixText().catch((error) => + setSendError(error instanceof Error ? error.message : '发送后置词保存失败') + ) + } + disabled={sendBusy} + /> @@ -358,8 +340,13 @@ function PersonalWechatMacSendDialog({ {sendError}
)} + {sendSuccess && ( +
+ {sendSuccess} +
+ )} - {setupReady && isMac && ( + {setupReady && isMac && !initialImage && (
    -
  1. +
  2. {runtimeReady ? '✓' : '1'}
    准备 {runtimeLabel} -

    个人微信发送需要 {runtimeLabel},首次使用时下载一次即可。

    - {runtimeDownloading ? ( -
    -
    - 正在准备 {runtimeLabel} - {progressPercent}% -
    -
    - -
    -
    - ) : runtimeReady ? ( - ✓ {runtimeLabel}已准备 +

    + {runtimeUnavailable + ? `个人微信发送需要${runtimeLabel},授权后即可使用。` + : `${runtimeLabel}随 TraceMemo 一起提供,无需额外安装。`} +

    + {runtimeReady ? ( + ✓ {runtimeLabel}已就绪 + ) : runtimeUnavailable ? ( + 当前版本暂未提供微信消息发送功能 ) : ( - - )} - {runtimeStatus?.error && !runtimeDownloading && !runtimeReady && ( -

    - 运行时准备失败,请重试或查看微信发送设置。 -

    + 正在检查 {runtimeLabel}… )}
  3. @@ -139,7 +103,7 @@ export function PersonalWechatSetupGuide({ {connected ? '✓' : '2'}
    绑定个人微信 -

    请启动并登录当前微信,TraceMemo 会自动绑定正在使用的账号。

    +

    请保持微信未登录窗口状态,点击“绑定微信”后,再点击微信窗口登录。

    {connected ? ( ✓ 微信已绑定 ) : ( @@ -147,15 +111,28 @@ export function PersonalWechatSetupGuide({ size="sm" variant="outline" onClick={onBind} - disabled={!runtimeReady || binding || runtimeDownloading} + disabled={runtimeUnavailable || !runtimeReady || binding} > {binding ? '正在绑定…' : '绑定微信'} )} - {!connected && senderStatus && ( + {!connected && senderStatus && !runtimeUnavailable && (

    {bindingHint(senderStatus)}

    )} - {!connected && ( + {!connected && runtimeUnavailable && ( +
    + ! +

    + 发送能力属授权制,需要联系群主。请先加入交流群,然后在群内添加群主申请授权。 + 进群请点击{' '} + + 这里 + {' '} + 跳转。 +

    +
    + )} + {!connected && !runtimeUnavailable && (

    绑定微信可能导致当前微信异常闪退,这是正常现象。若微信退出,请重新启动微信后,再回到这里重新检测/绑定。
    @@ -163,14 +140,6 @@ export function PersonalWechatSetupGuide({ 通用”,取消勾选“有更新时自动升级微信”,否则版本变化后可能无法绑定。

    )} -
    @@ -225,7 +194,7 @@ export function PersonalWechatSetupGuide({
- {!runtimeReady && runtimeStatus?.state === 'unsupported' && onOpenTextToSpeechSettings && ( + {!runtimeReady && runtimeUnavailable && onOpenTextToSpeechSettings && ( @@ -271,6 +301,16 @@ export function PersonalWechatWindowsSendDialog({ busy={sendBusy} /> ) : null} + {sendError ? ( +
+ {sendError} +
+ ) : null} + {sendSuccess ? ( +
+ {sendSuccess} +
+ ) : null} ) : null} diff --git a/src/renderer/src/components/chat/ReportImagePostfixInput.tsx b/src/renderer/src/components/chat/ReportImagePostfixInput.tsx new file mode 100644 index 0000000..f156816 --- /dev/null +++ b/src/renderer/src/components/chat/ReportImagePostfixInput.tsx @@ -0,0 +1,32 @@ +import { DEFAULT_REPORT_IMAGE_POSTFIX_TEXT } from '../../../../shared/personal-wechat' + +export function ReportImagePostfixInput({ + value, + onChange, + onBlur, + disabled +}: { + value: string + onChange: (value: string) => void + onBlur: () => void + disabled: boolean +}): React.ReactElement { + return ( + + ) +} diff --git a/src/renderer/src/components/chat/useReportImagePostfixSetting.ts b/src/renderer/src/components/chat/useReportImagePostfixSetting.ts new file mode 100644 index 0000000..d2efa89 --- /dev/null +++ b/src/renderer/src/components/chat/useReportImagePostfixSetting.ts @@ -0,0 +1,36 @@ +import { useCallback, useEffect, useState } from 'react' +import { DEFAULT_REPORT_IMAGE_POSTFIX_TEXT } from '../../../../shared/personal-wechat' + +export function useReportImagePostfixSetting(enabled: boolean): { + postfixText: string + setPostfixText: (value: string) => void + persistPostfixText: () => Promise +} { + const [postfixText, setPostfixText] = useState(DEFAULT_REPORT_IMAGE_POSTFIX_TEXT) + + useEffect(() => { + if (!enabled) return + const getSettings = window.api.getSettings + if (typeof getSettings !== 'function') return + let active = true + void getSettings() + .then((result) => { + if (!active) return + setPostfixText( + String(result.settings.reportImagePostfixText ?? DEFAULT_REPORT_IMAGE_POSTFIX_TEXT) + ) + }) + .catch(() => undefined) + return () => { + active = false + } + }, [enabled]) + + const persistPostfixText = useCallback(async (): Promise => { + const setSettings = window.api.setSettings + if (typeof setSettings !== 'function') return + await setSettings({ reportImagePostfixText: postfixText }) + }, [postfixText]) + + return { postfixText, setPostfixText, persistPostfixText } +} diff --git a/src/renderer/src/features/settings/components/VoiceEncodingEnvironmentSection.tsx b/src/renderer/src/features/settings/components/VoiceEncodingEnvironmentSection.tsx index d04ab22..e7f3260 100644 --- a/src/renderer/src/features/settings/components/VoiceEncodingEnvironmentSection.tsx +++ b/src/renderer/src/features/settings/components/VoiceEncodingEnvironmentSection.tsx @@ -77,12 +77,8 @@ export function VoiceEncodingEnvironmentSection({ onNotice( result.environment.message || 'pilk 已安装,但仍有语音编码环境未就绪。请重新检查。' ) - } else if (result.restarted) { - onNotice('语音编码环境已修复,语音发送服务已重启') - } else if (result.restartError) { - onNotice(`语音编码环境已修复,但语音发送服务重启失败:${result.restartError}`) } else { - onNotice('语音编码环境已修复。重新启动语音发送服务后生效。') + onNotice('语音编码环境已修复。') } } catch (error) { onNotice(error instanceof Error ? error.message : 'pilk 安装失败') diff --git a/src/renderer/src/features/settings/pages/PersonalWechatSendPage.tsx b/src/renderer/src/features/settings/pages/PersonalWechatSendPage.tsx index a4f5460..b05abe6 100644 --- a/src/renderer/src/features/settings/pages/PersonalWechatSendPage.tsx +++ b/src/renderer/src/features/settings/pages/PersonalWechatSendPage.tsx @@ -1,41 +1,12 @@ -import { useCallback, useEffect, useRef, useState, type ReactElement } from 'react' +import { useCallback, useEffect, useState, type ReactElement } from 'react' import type { PersonalWechatSendCapability, PersonalWechatSenderStatus } from '../../../../../shared/personal-wechat' -import type { - PersonalWechatRuntimeProgressEvent, - PersonalWechatRuntimeStatus -} from '../../../../../shared/personal-wechat-runtime' -import { - Button, - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - Input, - Skeleton, - Switch -} from '../../../components/ui' +import { Button, Input, Skeleton } from '../../../components/ui' import { PersonalWechatSetupGuide } from '../../../components/chat/PersonalWechatSetupGuide' -import { PersonalWechatSupportedVersionsContent } from '../../../components/chat/PersonalWechatSupportedVersionsContent' import { isMac, isWindows } from '../../../utils/runtime-environment' -const RUNTIME_STATUS_LABELS: Record = { - missing: '未下载', - downloading: '下载中', - ready: '已就绪', - invalid: '需要修复', - error: '下载失败', - unsupported: '暂不支持' -} - -function formatBytes(value: number): string { - if (value < 1024 * 1024) return `${Math.round(value / 1024)} KB` - return `${(value / 1024 / 1024).toFixed(1)} MB` -} - const REPOSITORY_URL = 'https://github.com/Wxw-Gu/TraceMemo' // README 就是仓库首页,跳转后需要的正是「交流与反馈」这一节(含群二维码)。 const GROUP_README_URL = `${REPOSITORY_URL}#-交流与反馈` @@ -69,6 +40,26 @@ function boundToCurrentWechat(status: PersonalWechatSenderStatus | null): boolea ) } +function SendCapabilityAuthorizationCard(): ReactElement { + return ( +
+

发送能力属授权制,需要联系群主。请先加入交流群,然后在群内添加群主申请授权。

+

+ 进群请点击{' '} + + 这里 + {' '} + 跳转。 +

+
+ ) +} + export function PersonalWechatSendPage({ onNotice, onOpenTextToSpeechSettings @@ -78,12 +69,7 @@ export function PersonalWechatSendPage({ }): ReactElement { const [capability, setCapability] = useState(null) const [senderStatus, setSenderStatus] = useState(null) - const [runtimeStatus, setRuntimeStatus] = useState(null) - const [runtimeProgress, setRuntimeProgress] = useState( - null - ) const [loading, setLoading] = useState(true) - const [runtimeBusy, setRuntimeBusy] = useState(false) const [binding, setBinding] = useState(false) const [detecting, setDetecting] = useState(false) const [error, setError] = useState('') @@ -93,12 +79,6 @@ export function PersonalWechatSendPage({ const [windowsPortInput, setWindowsPortInput] = useState('') const [windowsDetectedPort, setWindowsDetectedPort] = useState('') const [windowsEndpointBusy, setWindowsEndpointBusy] = useState(false) - const [keepOneBotProcess, setKeepOneBotProcess] = useState(false) - const [keepProcessBusy, setKeepProcessBusy] = useState(false) - const [showWechatVersions, setShowWechatVersions] = useState(false) - const runtimeVersionsTriggerRef = useRef(null) - const personalWechatRuntimeSupported = isMac && Boolean(runtimeStatus?.supported) - const refresh = useCallback(async (): Promise => { if (isWindows) { setLoading(false) @@ -106,15 +86,12 @@ export function PersonalWechatSendPage({ } setError('') try { - const [nextCapability, nextSender, nextRuntime] = await Promise.all([ + const [nextCapability, nextSender] = await Promise.all([ window.api.getPersonalWechatSendCapability(), - window.api.getPersonalWechatSenderStatus(), - window.api.getPersonalWechatRuntimeStatus() + window.api.getPersonalWechatSenderStatus() ]) setCapability(nextCapability) setSenderStatus(nextSender) - setRuntimeStatus(nextRuntime) - setRuntimeProgress(nextRuntime.state === 'downloading' ? nextRuntime : null) } catch (reason) { setError(reason instanceof Error ? reason.message : '微信发送能力读取失败') } finally { @@ -125,31 +102,8 @@ export function PersonalWechatSendPage({ useEffect(() => { if (isWindows) return void refresh() - const subscribe = window.api.onPersonalWechatRuntimeProgress - if (!subscribe) return - const unsubscribe = subscribe((status) => { - setRuntimeProgress(status) - setRuntimeStatus(status) - if (status.state === 'ready') void refresh() - }) - return () => unsubscribe?.() }, [refresh]) - useEffect(() => { - if (!isMac) return undefined - let active = true - const readKeepProcess = window.api.getPersonalWechatKeepOneBotProcess - if (typeof readKeepProcess !== 'function') return undefined - void readKeepProcess() - .then((keep) => { - if (active && typeof keep === 'boolean') setKeepOneBotProcess(keep) - }) - .catch(() => undefined) - return () => { - active = false - } - }, []) - useEffect(() => { if (isWindows || !senderStatus || senderStatus.canSend || senderStatus.state === 'error') { return undefined @@ -187,80 +141,6 @@ export function PersonalWechatSendPage({ } }, [refresh]) - const downloadRuntime = async (): Promise => { - if (runtimeBusy || !personalWechatRuntimeSupported) return - setRuntimeBusy(true) - setError('') - setRuntimeStatus((current) => - current ? { ...current, state: 'downloading', downloadedBytes: 0, progress: 0 } : current - ) - try { - const result = await window.api.downloadPersonalWechatRuntime() - setRuntimeStatus(result.status) - if (!result.success) setError(result.error || '微信发送组件准备失败') - else onNotice('微信发送组件已准备好') - } catch (reason) { - setError(reason instanceof Error ? reason.message : '微信发送组件准备失败') - } finally { - setRuntimeBusy(false) - } - } - - const cancelRuntimeDownload = async (): Promise => { - if (!personalWechatRuntimeSupported) return - await window.api.cancelPersonalWechatRuntimeDownload() - onNotice('正在取消发送组件下载') - } - - const removeRuntime = async (): Promise => { - if (!personalWechatRuntimeSupported || !runtimeStatus?.removable || runtimeBusy) return - if (!window.confirm('卸载微信发送组件?以后需要发送个人微信消息时可以重新下载。')) return - setRuntimeBusy(true) - try { - setRuntimeStatus(await window.api.removePersonalWechatRuntime()) - onNotice('微信发送组件已卸载') - } catch (reason) { - onNotice(reason instanceof Error ? `发送组件卸载失败:${reason.message}` : '发送组件卸载失败') - } finally { - setRuntimeBusy(false) - } - } - - const openRuntimeDirectory = async (): Promise => { - if (!personalWechatRuntimeSupported) return - const result = await window.api.openPersonalWechatRuntimeDirectory() - if (!result.success) onNotice(result.error || '无法打开发送组件目录') - } - - const refreshRuntime = async (): Promise => { - if (!isMac || runtimeBusy) return - try { - setRuntimeStatus(await window.api.getPersonalWechatRuntimeStatus()) - } catch (reason) { - setError(reason instanceof Error ? reason.message : '微信发送组件状态读取失败') - } - } - - const handleKeepOneBotProcessChange = async (keep: boolean): Promise => { - if (!isMac || keepProcessBusy) return - const saveKeepProcess = window.api.setPersonalWechatKeepOneBotProcess - if (typeof saveKeepProcess !== 'function') { - onNotice('请重启 TraceMemo 后再使用“保留 OneBot 进程”') - return - } - setKeepProcessBusy(true) - setKeepOneBotProcess(keep) - try { - const saved = await saveKeepProcess(keep) - if (typeof saved === 'boolean') setKeepOneBotProcess(saved) - } catch (reason) { - setKeepOneBotProcess(!keep) - onNotice(reason instanceof Error ? reason.message : String(reason)) - } finally { - setKeepProcessBusy(false) - } - } - const bindWechat = async (): Promise => { if (binding) return setBinding(true) @@ -386,6 +266,7 @@ export function PersonalWechatSendPage({ const pageMessage = isWindows ? windowsSenderStatus?.message || '请输入端口并检测 Windows 微信发送能力' : capability?.message || '微信发送能力暂不可用' + const macUnavailable = isMac && senderStatus?.state === 'runtime_missing' return ( <> @@ -398,7 +279,7 @@ export function PersonalWechatSendPage({ - {loading ? '检测中' : capabilityLabel[pageStatus]} + {loading ? '检测中' : macUnavailable ? '暂不可用' : capabilityLabel[pageStatus]}
@@ -407,48 +288,55 @@ export function PersonalWechatSendPage({ ) : ( <> -

发送能力

-
-
-
- 个人微信 - {pageMessage} -

- {pageStatus === 'unsupported' && isMac - ? 'Intel Mac 不支持个人微信发送。' - : pageStatus === 'unsupported' && !isWindows - ? '微信消息发送目前仅支持 macOS 和 Windows。' - : isWindows - ? 'Windows 通过本机微信发送接口工作,请先配置并检测接口端口。' - : '档案中的文字、图片和语音发送,以及定时日报发送,都会使用这项能力。'} -

-
- -
-
- {( - [ - ['文字', pageCapabilities.text], - ['图片', pageCapabilities.image], - ['语音', pageCapabilities.voice] - ] as const - ).map(([label, available]) => ( -
- {label} - - {available ? '可发送' : '未就绪'} - + {/* macOS 走下方「配置状态」SetupGuide 统一展示,这里只在 Windows/其它 + * 平台显示旧「发送能力」卡(Mac 上两者重复,已按用户要求移除)。 */} + {!isMac ? ( + <> +

发送能力

+
+
+
+ 个人微信 + {pageMessage} +

+ {pageStatus === 'unsupported' + ? '微信消息发送目前仅支持 macOS 和 Windows。' + : isWindows + ? 'Windows 通过本机微信发送接口工作,请先配置并检测接口端口。' + : '档案中的文字、图片和语音发送,以及定时日报发送,都会使用这项能力。'} +

+
+
- ))} -
- {error ? ( -

- {error} -

- ) : null} -
+
+ {( + [ + ['文字', pageCapabilities.text], + ['图片', pageCapabilities.image], + ['语音', pageCapabilities.voice] + ] as const + ).map(([label, available]) => ( +
+ {label} + + {available ? '可发送' : '未就绪'} + +
+ ))} +
+ {error ? ( +

+ {error} +

+ ) : null} + + + ) : null} {isWindows ? ( <> @@ -515,156 +403,16 @@ export function PersonalWechatSendPage({

发送能力授权

-
-

发送能力属授权制,需要联系群主。请先加入交流群,然后在群内添加群主申请授权。

-

- 进群请点击{' '} - - 这里 - {' '} - 跳转。 -

-
+ ) : isMac ? ( <> -

OneBot 运行时

-
-
- - OneBot {runtimeStatus?.version || 'v0.0.18'} - - - {runtimeStatus?.state === 'downloading' - ? `正在下载 ${Math.round(runtimeStatus.progress * 100)}%` - : runtimeStatus - ? RUNTIME_STATUS_LABELS[runtimeStatus.state] - : '正在检测'} - - - {!runtimeStatus - ? '正在检测当前平台与组件状态' - : personalWechatRuntimeSupported - ? `仅用于连接 macOS 微信 · ${formatBytes(runtimeStatus.totalBytes)}` - : '当前 Mac 环境不满足个人微信发送组件要求'} - - {runtimeStatus?.error ? ( -

{runtimeStatus.error}

- ) : null} -
- -
- {personalWechatRuntimeSupported && - runtimeStatus?.state === 'downloading' ? ( - - ) : personalWechatRuntimeSupported && runtimeStatus?.state === 'ready' ? ( - <> - {runtimeStatus.directory ? ( - - ) : null} - {runtimeStatus.removable ? ( - - ) : null} - - ) : personalWechatRuntimeSupported ? ( - - ) : null} - - {personalWechatRuntimeSupported ? ( - - ) : null} -
- - {personalWechatRuntimeSupported && runtimeStatus?.state === 'downloading' ? ( -
-
- {Math.round(runtimeStatus.progress * 100)}% - - {formatBytes(runtimeStatus.downloadedBytes)} /{' '} - {formatBytes(runtimeStatus.totalBytes)} - -
- -
- ) : null} -
-
- 保留 OneBot 进程 -

- 退出 TraceMemo 后继续保留进程,重新打开时可以复用。 -

-
- void handleKeepOneBotProcessChange(checked)} - aria-label="保留 OneBot 进程" - /> -
-
-

配置状态

void downloadRuntime()} onBind={() => void bindWechat()} onStartSending={() => onNotice('微信消息发送能力已就绪,请在档案中选择会话开始发送。') @@ -678,24 +426,6 @@ export function PersonalWechatSendPage({
- - { - const trigger = runtimeVersionsTriggerRef.current - if (!trigger) return - event.preventDefault() - trigger.focus() - runtimeVersionsTriggerRef.current = null - }} - > - - 支持的微信版本 - 请安装下列完整版本之一。 - - - - ) } diff --git a/src/renderer/src/styles/chat.scss b/src/renderer/src/styles/chat.scss index 46f336f..51b62d2 100644 --- a/src/renderer/src/styles/chat.scss +++ b/src/renderer/src/styles/chat.scss @@ -833,6 +833,41 @@ line-height: 1.55; } +.personal-wechat-authorization-warning { + display: grid; + grid-template-columns: 20px minmax(0, 1fr); + gap: 8px; + align-items: start; + border-radius: var(--wxex-radius-md); + background: color-mix(in srgb, var(--wxex-danger) 9%, var(--wxex-bg-elevated)); + padding: 9px 11px; + + >span { + display: grid; + width: 18px; + height: 18px; + place-items: center; + border: 1px solid currentColor; + border-radius: 50%; + color: var(--wxex-danger); + font-size: 11px; + font-weight: 750; + line-height: 1; + } + + p { + margin: 0; + color: var(--wxex-text-secondary); + font-size: 11px; + line-height: 1.55; + } + + a { + color: var(--wxex-brand); + text-decoration: underline; + } +} + .personal-wechat-step-error, .personal-wechat-global-error { color: var(--wxex-danger) !important; @@ -908,55 +943,6 @@ } } -.personal-wechat-diagnostics { - border-top: 1px solid var(--wxex-border); - padding-top: 12px; - - summary { - cursor: pointer; - color: var(--wxex-text-secondary); - font-size: 11px; - font-weight: 600; - } - - >p { - margin: 8px 0; - color: var(--wxex-text-muted); - font-size: 10px; - } - - dl { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 1px; - overflow: hidden; - margin: 0; - border: 1px solid var(--wxex-border); - border-radius: var(--wxex-radius-md); - background: var(--wxex-border); - } - - dl>div { - min-width: 0; - background: var(--wxex-bg-main); - padding: 7px 9px; - } - - dt { - color: var(--wxex-text-muted); - font-size: 10px; - } - - dd { - overflow: hidden; - margin: 2px 0 0; - color: var(--wxex-text-secondary); - font-size: 10px; - text-overflow: ellipsis; - white-space: nowrap; - } -} - .personal-wechat-composer { display: grid; gap: 12px; @@ -1109,6 +1095,13 @@ font-size: 11px; } +.personal-wechat-global-success { + border-top: 1px solid color-mix(in srgb, var(--wxex-success) 24%, transparent); + padding: 10px 20px 0; + color: var(--wxex-success); + font-size: 11px; +} + .personal-wechat-chat-footer { display: flex; justify-content: flex-end; @@ -2522,4 +2515,4 @@ .database-account-status { text-align: right; -} \ No newline at end of file +} diff --git a/src/shared/personal-wechat-mac-runtime.ts b/src/shared/personal-wechat-mac-runtime.ts new file mode 100644 index 0000000..bc09c44 --- /dev/null +++ b/src/shared/personal-wechat-mac-runtime.ts @@ -0,0 +1,95 @@ +/** + * Wire types for the built-in macOS native WeChat runtime + * (tm-wechat-host + libtmwechat.dylib), managed by MacWechatRuntimeManager. + * + * Binding lifecycle is "bind once per WeChat process": frida's unload/detach + * inside WeChat is unreliable, so rebind is NOT a product path. When the host + * dies while bound, the WeChat process keeps an orphaned agent and must be + * restarted before binding again — surfaced as wechatRestartRequired. + */ + +export type MacWechatRuntimeLifecycle = + | 'stopped' // host not running (or never started) + | 'starting' // spawn issued, waiting for /healthz + | 'online' // host answering; WeChat NOT touched (lazy) + | 'crashed_unbound' // died while unbound — safe to auto-restart + | 'crashed_bound' // died while bound — WeChat keeps an orphan agent + +export type MacWechatHostRuntimeState = + | 'not_initialized' + | 'wechat_not_running' + | 'not_logged_in' + | 'unsupported_version' + | 'initializing' + | 'ready' + | 'error' + | 'unknown' + +export type MacWechatBindingState = + | 'unbound' + | 'binding' + | 'bound' + | 'ready' + | 'failed' + | 'stale' + | 'unknown' + +export interface MacWechatRuntimeManifest { + runtime: 'tm-wechat-native' + version: string + platform: 'darwin-arm64' + architecture: 'arm64' + protocolVersion: number + buildCommit: string + tmSendSourceSha256: string + addressProfileSha256: string + supportedWechatVersions: string[] + capabilities: Array<'text' | 'image' | 'voice'> +} + +export interface MacWechatBindingStatus { + runtime: boolean + hostPid?: number + runtimeState: MacWechatHostRuntimeState + runtimeVersion?: string + embeddedAgentSha256?: string + embeddedAddressProfileSha256?: string + abiVersion?: number + dylibPath?: string + wechatRunning: boolean + wechatPid: number + wechatVersion?: string + supported: boolean + supportedVersions?: string + bindingState: MacWechatBindingState + bound: boolean + boundWechatPid: number + sessionAttached: boolean + scriptLoaded: boolean + sendContextReady: boolean + canSendText?: boolean + canSendImage?: boolean + canSendVoice?: boolean + attachCount: number + lastAttachAt?: string + lastDetachReason?: string +} + +export interface MacWechatRuntimeSnapshot { + lifecycle: MacWechatRuntimeLifecycle + /** True when the last host death happened while bound: WeChat must be restarted. */ + wechatRestartRequired: boolean + endpoint: string + port: number + runtimeDir?: string + manifest?: MacWechatRuntimeManifest + error?: string + binding?: MacWechatBindingStatus +} + +export interface MacWechatBindResult { + ok: boolean + attached: boolean + state?: 'wechat_restart_required' | 'runtime_offline' | 'host_error' | 'bad_response' + message: string +} diff --git a/src/shared/personal-wechat-runtime.ts b/src/shared/personal-wechat-runtime.ts deleted file mode 100644 index a9d66bb..0000000 --- a/src/shared/personal-wechat-runtime.ts +++ /dev/null @@ -1,29 +0,0 @@ -export type PersonalWechatRuntimeState = - | 'missing' - | 'downloading' - | 'ready' - | 'invalid' - | 'error' - | 'unsupported' - -export interface PersonalWechatRuntimeStatus { - version: string - state: PersonalWechatRuntimeState - downloadedBytes: number - totalBytes: number - progress: number - platform: NodeJS.Platform - architecture: string - supported: boolean - removable: boolean - directory?: string - error?: string -} - -export interface PersonalWechatRuntimeDownloadResult { - success: boolean - status: PersonalWechatRuntimeStatus - error?: string -} - -export interface PersonalWechatRuntimeProgressEvent extends PersonalWechatRuntimeStatus {} diff --git a/src/shared/personal-wechat-voice-runtime.ts b/src/shared/personal-wechat-voice-runtime.ts index b1059ad..8f17023 100644 --- a/src/shared/personal-wechat-voice-runtime.ts +++ b/src/shared/personal-wechat-voice-runtime.ts @@ -20,11 +20,10 @@ export interface PersonalWechatVoiceEncodingEnvironment { ready: boolean checkedAt?: string runtimeReady: boolean - runtimeRoot?: string python: PersonalWechatVoiceRuntimeComponent pilk: PersonalWechatVoiceRuntimeComponent ffmpeg: PersonalWechatVoiceRuntimeComponent - encoder: 'pilk' | 'go-silk' | 'unavailable' + encoder: 'pilk' | 'silk' | 'unavailable' message: string } @@ -32,6 +31,4 @@ export interface PersonalWechatVoiceEncodingEnvironmentResult { success: boolean environment: PersonalWechatVoiceEncodingEnvironment error?: string - restarted?: boolean - restartError?: string } diff --git a/src/shared/personal-wechat.ts b/src/shared/personal-wechat.ts index c6dd8d4..18c4792 100644 --- a/src/shared/personal-wechat.ts +++ b/src/shared/personal-wechat.ts @@ -20,7 +20,6 @@ export interface PersonalWechatSenderStatus { wechatRunning: boolean wechatPid?: number boundWechatPid?: number - oneBotPid?: number endpoint: string endpointReady: boolean wechatVersion?: string @@ -80,6 +79,8 @@ export interface PersonalWechatSendTextRequest extends PersonalWechatSendBaseReq export interface PersonalWechatSendImageRequest extends PersonalWechatSendBaseRequest { type: 'image' filePath: string + /** 日报图片成功后再发送的文字;undefined 表示普通图片发送,不启用日报序列。 */ + postfixText?: string } export interface PersonalWechatSendVoiceRequest extends PersonalWechatSendBaseRequest { @@ -112,8 +113,12 @@ export interface PersonalWechatSendResult { success: boolean status: PersonalWechatSenderStatus error?: string + postfixSent?: boolean + postfixError?: string } +export const DEFAULT_REPORT_IMAGE_POSTFIX_TEXT = '今日日报' + /** Safe, user-copyable metadata for the most recent voice send attempt. */ export interface PersonalWechatVoiceDiagnostic { request_id: string diff --git a/src/shared/wechat-action.ts b/src/shared/wechat-action.ts index 4908c0d..c25cff0 100644 --- a/src/shared/wechat-action.ts +++ b/src/shared/wechat-action.ts @@ -23,6 +23,10 @@ export type WechatActionPurpose = | 'manual_image' /** 用户手动发送文本。 */ | 'manual_text' + /** 用户确认后的日报图片;进入 automation 队列以复用 3 秒发送间隔。 */ + | 'manual_report_image' + /** 日报图片成功后的后置词;只有图片 sent 后才会创建。 */ + | 'manual_report_postfix' /** * Automation v1 的两个用途。 * diff --git a/tests/component/personal-wechat-send-dialog.test.tsx b/tests/component/personal-wechat-send-dialog.test.tsx index 6d509da..c0b9857 100644 --- a/tests/component/personal-wechat-send-dialog.test.tsx +++ b/tests/component/personal-wechat-send-dialog.test.tsx @@ -12,16 +12,17 @@ vi.mock('../../src/renderer/src/utils/runtime-environment', () => ({ })) const getStatus = vi.fn() -const getRuntimeStatus = vi.fn() -const onRuntimeProgress = vi.fn(() => vi.fn()) const rebind = vi.fn() const sendGeneratedTtsVoice = vi.fn() +const sendPersonalWechatMessage = vi.fn() const getTextToSpeechSettings = vi.fn() const listTextToSpeechVoices = vi.fn() const synthesizeTextToSpeech = vi.fn() const removeGeneratedTextToSpeechAudio = vi.fn() const getPersonalWechatVoiceDiagnostic = vi.fn() const copyText = vi.fn() +const getSettings = vi.fn() +const setSettings = vi.fn() const contact = { m_nsUsrName: 'fixture-room@chatroom', @@ -38,8 +39,7 @@ const readyStatus = { wechatRunning: true, wechatPid: 4668, boundWechatPid: 4668, - oneBotPid: 5401, - endpoint: '127.0.0.1:58080', + endpoint: '127.0.0.1:4290', endpointReady: true, wechatVersion: '4.1.11.53', runtimeReady: true, @@ -58,18 +58,6 @@ const readyStatus = { message: '个人微信已绑定' } -const readyRuntime = { - version: 'v0.0.18', - state: 'ready' as const, - downloadedBytes: 1, - totalBytes: 1, - progress: 1, - platform: 'darwin' as NodeJS.Platform, - architecture: 'arm64', - supported: true, - removable: true -} - function renderDialog( props: Partial> = {} ): React.ReactElement { @@ -85,13 +73,24 @@ async function startComposer(): Promise { describe('PersonalWechatSendDialog', () => { beforeEach(() => { getStatus.mockReset().mockResolvedValue(readyStatus) - getRuntimeStatus.mockReset().mockResolvedValue(readyRuntime) - onRuntimeProgress.mockReset().mockReturnValue(vi.fn()) rebind.mockReset().mockResolvedValue(readyStatus) sendGeneratedTtsVoice.mockReset().mockResolvedValue({ action: { status: 'sent' }, status: readyStatus }) + sendPersonalWechatMessage.mockReset().mockResolvedValue({ + success: true, + status: readyStatus, + postfixSent: true + }) + getSettings.mockReset().mockResolvedValue({ + settings: { reportImagePostfixText: '今日日报' }, + settingsPath: '/tmp/settings.json' + }) + setSettings.mockReset().mockResolvedValue({ + settings: { reportImagePostfixText: '今日日报' }, + settingsPath: '/tmp/settings.json' + }) getTextToSpeechSettings.mockReset().mockResolvedValue({ success: true, settings: { @@ -125,16 +124,17 @@ describe('PersonalWechatSendDialog', () => { configurable: true, value: { getPersonalWechatSenderStatus: getStatus, - getPersonalWechatRuntimeStatus: getRuntimeStatus, - onPersonalWechatRuntimeProgress: onRuntimeProgress, rebindPersonalWechatSender: rebind, sendGeneratedTtsVoice, + sendPersonalWechatMessage, getTextToSpeechSettings, listTextToSpeechVoices, synthesizeTextToSpeech, removeGeneratedTextToSpeechAudio, getPersonalWechatVoiceDiagnostic, - copyText + copyText, + getSettings, + setSettings } }) }) @@ -143,7 +143,6 @@ describe('PersonalWechatSendDialog', () => { renderDialog() await startComposer() expect(screen.getByRole('dialog')).toHaveTextContent('文字转语音') - expect(screen.queryByRole('switch', { name: '保留 OneBot 进程' })).not.toBeInTheDocument() expect(screen.queryByText('验证消息能力')).not.toBeInTheDocument() expect(screen.getByRole('button', { name: '生成语音' })).toBeDisabled() }) @@ -167,6 +166,55 @@ describe('PersonalWechatSendDialog', () => { expect(screen.getByLabelText('消息列表')).toHaveTextContent('你好 TraceMemo') }) + it('shows image send success feedback after the host confirms the send', async () => { + const user = userEvent.setup() + renderDialog({ initialImage: { path: '/tmp/report.png', name: '测试群日报.png' } }) + expect(await screen.findByRole('textbox', { name: '发送后置词' })).toHaveValue('今日日报') + const sendButton = await screen.findByRole('button', { name: '发送日报图片' }) + await user.click(sendButton) + + await waitFor(() => + expect(sendPersonalWechatMessage).toHaveBeenCalledWith({ + type: 'image', + to: 'fixture-room@chatroom', + isGroup: true, + filePath: '/tmp/report.png', + postfixText: '今日日报' + }) + ) + expect(await screen.findByRole('status')).toHaveTextContent('日报图片和后置词发送成功') + }) + + it('persists a customized report postfix and sends it with the image request', async () => { + const user = userEvent.setup() + renderDialog({ initialImage: { path: '/tmp/report.png', name: '测试群日报.png' } }) + const input = await screen.findByRole('textbox', { name: '发送后置词' }) + await user.clear(input) + await user.type(input, '今日技术日报') + await user.tab() + await user.click(screen.getByRole('button', { name: '发送日报图片' })) + + expect(setSettings).toHaveBeenCalledWith({ reportImagePostfixText: '今日技术日报' }) + await waitFor(() => + expect(sendPersonalWechatMessage).toHaveBeenCalledWith( + expect.objectContaining({ postfixText: '今日技术日报' }) + ) + ) + }) + + it('shows an error when the report postfix cannot be persisted', async () => { + setSettings.mockRejectedValueOnce(new Error('设置保存失败')) + const user = userEvent.setup() + renderDialog({ initialImage: { path: '/tmp/report.png', name: '测试群日报.png' } }) + const input = await screen.findByRole('textbox', { name: '发送后置词' }) + + await user.clear(input) + await user.type(input, '今日技术日报') + await user.tab() + + expect(await screen.findByRole('alert')).toHaveTextContent('设置保存失败') + }) + it('keeps the setup guide voice-only when voice capability is unavailable', async () => { getStatus.mockResolvedValue({ ...readyStatus, canSend: false, canSendVoice: false }) renderDialog({ onOpenPersonalWechatSettings: vi.fn() }) diff --git a/tests/component/personal-wechat-send-page.test.tsx b/tests/component/personal-wechat-send-page.test.tsx index efac1db..836ea7f 100644 --- a/tests/component/personal-wechat-send-page.test.tsx +++ b/tests/component/personal-wechat-send-page.test.tsx @@ -1,5 +1,4 @@ -import { render, screen, waitFor } from '@testing-library/react' -import userEvent from '@testing-library/user-event' +import { render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { PersonalWechatSendPage } from '../../src/renderer/src/features/settings/pages/PersonalWechatSendPage' @@ -12,15 +11,7 @@ vi.mock('../../src/renderer/src/utils/runtime-environment', () => ({ const getCapability = vi.fn() const getSenderStatus = vi.fn() -const getRuntimeStatus = vi.fn() -const onRuntimeProgress = vi.fn(() => vi.fn()) -const downloadRuntime = vi.fn() -const cancelRuntimeDownload = vi.fn() -const removeRuntime = vi.fn() -const openRuntimeDirectory = vi.fn() const rebindSender = vi.fn() -const getKeepOneBotProcess = vi.fn() -const setKeepOneBotProcess = vi.fn() const senderStatus = { state: 'online' as const, @@ -30,8 +21,7 @@ const senderStatus = { wechatRunning: true, wechatPid: 4668, boundWechatPid: 4668, - oneBotPid: 5401, - endpoint: '127.0.0.1:58080', + endpoint: '127.0.0.1:4290', endpointReady: true, wechatVersion: '4.1.11.53', runtimeReady: true, @@ -50,19 +40,6 @@ const senderStatus = { message: '个人微信已绑定' } -const readyRuntime = { - version: 'v0.0.18', - state: 'ready' as const, - downloadedBytes: 66_599_785, - totalBytes: 66_599_785, - progress: 1, - platform: 'darwin' as NodeJS.Platform, - architecture: 'arm64', - supported: true, - removable: true, - directory: '/tmp/personal-wechat-runtime' -} - const capability = { supported: true, ready: true, @@ -76,85 +53,23 @@ describe('PersonalWechatSendPage on macOS', () => { beforeEach(() => { getCapability.mockReset().mockResolvedValue(capability) getSenderStatus.mockReset().mockResolvedValue(senderStatus) - getRuntimeStatus.mockReset().mockResolvedValue(readyRuntime) - onRuntimeProgress.mockReset().mockReturnValue(vi.fn()) - downloadRuntime.mockReset().mockResolvedValue({ success: true, status: readyRuntime }) - cancelRuntimeDownload.mockReset().mockResolvedValue({ success: true }) - removeRuntime.mockReset().mockResolvedValue({ - ...readyRuntime, - state: 'missing', - downloadedBytes: 0, - progress: 0, - removable: false, - directory: undefined - }) - openRuntimeDirectory.mockReset().mockResolvedValue({ success: true }) rebindSender.mockReset().mockResolvedValue(senderStatus) - getKeepOneBotProcess.mockReset().mockResolvedValue(false) - setKeepOneBotProcess.mockReset().mockImplementation(async (keep: boolean) => keep) - vi.spyOn(window, 'confirm').mockReturnValue(true) Object.defineProperty(window, 'api', { configurable: true, value: { getPersonalWechatSendCapability: getCapability, getPersonalWechatSenderStatus: getSenderStatus, - getPersonalWechatRuntimeStatus: getRuntimeStatus, - onPersonalWechatRuntimeProgress: onRuntimeProgress, - downloadPersonalWechatRuntime: downloadRuntime, - cancelPersonalWechatRuntimeDownload: cancelRuntimeDownload, - removePersonalWechatRuntime: removeRuntime, - openPersonalWechatRuntimeDirectory: openRuntimeDirectory, - rebindPersonalWechatSender: rebindSender, - getPersonalWechatKeepOneBotProcess: getKeepOneBotProcess, - setPersonalWechatKeepOneBotProcess: setKeepOneBotProcess + rebindPersonalWechatSender: rebindSender } }) }) - it('keeps OneBot runtime management on the WeChat send page', async () => { - const user = userEvent.setup() + it('shows the native runtime readiness', async () => { render() - expect(await screen.findByText('OneBot v0.0.18')).toBeVisible() + expect(await screen.findByText('微信消息发送已配置完成')).toBeVisible() expect(screen.getAllByText('已就绪')).not.toHaveLength(0) - await user.click(screen.getByRole('button', { name: '打开目录' })) - expect(openRuntimeDirectory).toHaveBeenCalledOnce() - - await user.click(screen.getByRole('button', { name: '卸载组件' })) - await waitFor(() => expect(removeRuntime).toHaveBeenCalledOnce()) - - await user.click(screen.getByRole('button', { name: '支持版本' })) - const versionsDialog = screen.getByRole('dialog', { name: '支持的微信版本' }) - expect(versionsDialog).toBeVisible() - expect(versionsDialog).toHaveTextContent('4.1.11.53') - }) - - it('keeps the OneBot process setting on the macOS WeChat send page', async () => { - const user = userEvent.setup() - render() - - const keepProcess = await screen.findByRole('switch', { name: '保留 OneBot 进程' }) - expect(keepProcess).not.toBeChecked() - await user.click(keepProcess) - - await waitFor(() => expect(setKeepOneBotProcess).toHaveBeenCalledWith(true)) - expect(keepProcess).toBeChecked() - }) - - it('downloads a missing macOS runtime from the send page', async () => { - getRuntimeStatus.mockResolvedValue({ - ...readyRuntime, - state: 'missing', - downloadedBytes: 0, - progress: 0, - removable: false, - directory: undefined - }) - const onNotice = vi.fn() - render() - - await userEvent.setup().click(await screen.findByRole('button', { name: '下载组件' })) - await waitFor(() => expect(downloadRuntime).toHaveBeenCalledOnce()) - expect(onNotice).toHaveBeenCalledWith('微信发送组件已准备好') + expect(screen.queryByText('查看支持的微信版本')).not.toBeInTheDocument() + expect(screen.queryByText('高级诊断')).not.toBeInTheDocument() }) }) diff --git a/tests/component/personal-wechat-unavailable.test.tsx b/tests/component/personal-wechat-unavailable.test.tsx new file mode 100644 index 0000000..d5c28d5 --- /dev/null +++ b/tests/component/personal-wechat-unavailable.test.tsx @@ -0,0 +1,96 @@ +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { PersonalWechatSetupGuide } from '../../src/renderer/src/components/chat/PersonalWechatSetupGuide' +import { PersonalWechatSendPage } from '../../src/renderer/src/features/settings/pages/PersonalWechatSendPage' +import type { PersonalWechatSenderStatus } from '../../src/shared/personal-wechat' + +vi.mock('../../src/renderer/src/utils/runtime-environment', () => ({ + isMac: true, + isWindows: false, + runtimePlatform: 'darwin', + supportsPersonalWechatSend: true +})) + +const unavailableStatus: PersonalWechatSenderStatus = { + state: 'runtime_missing', + platform: 'darwin', + arch: 'arm64', + sipDisabled: true, + wechatRunning: false, + endpoint: 'http://127.0.0.1:4290', + endpointReady: false, + runtimeReady: false, + attachReady: false, + baseAddressReady: false, + textHookInstalled: false, + textHookReady: false, + imageHookInstalled: false, + imageHookReady: false, + messageListenerReady: false, + canSend: false, + canSendText: false, + canSendImage: false, + canSendVoice: false, + message: '暂无发送能力', + error: '暂无发送能力' +} + +describe('personal WeChat unavailable product state', () => { + beforeEach(() => { + Object.defineProperty(window, 'api', { + configurable: true, + value: { + getPersonalWechatSendCapability: vi.fn(async () => ({ + supported: true, + ready: false, + status: 'unconfigured', + capabilities: { text: false, image: false, voice: false }, + senderStatus: unavailableStatus, + message: '暂无发送能力', + error: '暂无发送能力' + })), + getPersonalWechatSenderStatus: vi.fn(async () => unavailableStatus) + } + }) + }) + + it('keeps the setup flow visible and disables binding when the runtime is unavailable', () => { + render( + + ) + + expect(screen.getByRole('heading', { name: '先准备好微信消息功能' })).toBeVisible() + expect(screen.getByRole('button', { name: '绑定微信' })).toBeDisabled() + expect(screen.getByRole('alert')).toHaveTextContent('发送能力属授权制,需要联系群主') + expect(screen.getByText(/发送能力属授权制,需要联系群主/)).toBeVisible() + expect(screen.getByRole('link', { name: '这里' })).toHaveAttribute( + 'href', + 'https://github.com/Wxw-Gu/TraceMemo#-交流与反馈' + ) + expect(screen.queryByRole('button', { name: '下载运行时' })).not.toBeInTheDocument() + expect(screen.queryByText('查看支持的微信版本')).not.toBeInTheDocument() + expect(screen.queryByText('高级诊断')).not.toBeInTheDocument() + expect(screen.queryByText(/manifest|dylib|runtime missing|文件缺失/i)).not.toBeInTheDocument() + }) + + it('shows the disabled binding flow on the settings page', async () => { + render() + + expect(await screen.findByRole('heading', { name: '先准备好微信消息功能' })).toBeVisible() + expect(screen.getByRole('button', { name: '绑定微信' })).toBeDisabled() + expect(screen.getByText(/发送能力属授权制,需要联系群主/)).toBeVisible() + expect(screen.getByRole('link', { name: '这里' })).toHaveAttribute( + 'href', + 'https://github.com/Wxw-Gu/TraceMemo#-交流与反馈' + ) + expect(screen.queryByText(/manifest|dylib|runtime missing|文件缺失/i)).not.toBeInTheDocument() + }) +}) diff --git a/tests/component/report-features.test.tsx b/tests/component/report-features.test.tsx index 307e544..1ae28b2 100644 --- a/tests/component/report-features.test.tsx +++ b/tests/component/report-features.test.tsx @@ -54,7 +54,7 @@ describe('daily report controls', () => { sipDisabled: true, wechatRunning: true, runtimeReady: true, - endpoint: '127.0.0.1:58080', + endpoint: '127.0.0.1:4290', endpointReady: true, attachReady: true, baseAddressReady: true, @@ -618,6 +618,10 @@ describe('daily report controls', () => { fireEvent.click(screen.getByRole('button', { name: '发送到当前群聊' })) expect(await screen.findByRole('dialog', { name: '测试群' })).toBeVisible() + expect(screen.getByRole('heading', { name: '发送日报图片' })).toBeVisible() + expect(screen.queryByText('文字转语音')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: '语音发送诊断' })).not.toBeInTheDocument() + expect(screen.getByRole('textbox', { name: '发送后置词' })).toHaveValue('今日日报') const detect = screen.queryByRole('button', { name: '重新检测' }) if (detect) fireEvent.click(detect) expect(screen.getByRole('region', { name: '日报图片发送' })).toBeVisible() diff --git a/tests/component/text-to-speech-page-windows.test.tsx b/tests/component/text-to-speech-page-windows.test.tsx index bfb193f..f6a5814 100644 --- a/tests/component/text-to-speech-page-windows.test.tsx +++ b/tests/component/text-to-speech-page-windows.test.tsx @@ -17,7 +17,6 @@ const openFishAudioApiKeys = vi.fn() const getSettings = vi.fn() const setSettings = vi.fn() const getPersonalWechatSenderStatus = vi.fn() -const getPersonalWechatRuntimeStatus = vi.fn() const checkPersonalWechatSenderStatus = vi.fn() const textToSpeechSettings = { @@ -70,7 +69,6 @@ describe('PersonalWechatSendPage on Windows', () => { settings: { windowsWechatPort: '4567' } }) getPersonalWechatSenderStatus.mockReset() - getPersonalWechatRuntimeStatus.mockReset().mockResolvedValue(null) checkPersonalWechatSenderStatus.mockReset().mockResolvedValue(windowsStatus) Object.defineProperty(window, 'api', { configurable: true, @@ -81,7 +79,6 @@ describe('PersonalWechatSendPage on Windows', () => { getSettings, setSettings, getPersonalWechatSenderStatus, - getPersonalWechatRuntimeStatus, checkPersonalWechatSenderStatus } }) @@ -128,7 +125,7 @@ describe('PersonalWechatSendPage on Windows', () => { expect(onNotice).toHaveBeenCalledWith('微信发送能力端口已清除') }) - it('keeps the Windows send dialog free of macOS OneBot controls', async () => { + it('keeps the Windows send dialog free of macOS-only controls', async () => { getPersonalWechatSenderStatus.mockResolvedValue({ ...windowsStatus, state: 'error', @@ -156,9 +153,6 @@ describe('PersonalWechatSendPage on Windows', () => { expect( await screen.findByText('未检测到 Windows 微信发送能力,请先启动并登录微信') ).toBeVisible() - expect(getPersonalWechatRuntimeStatus).not.toHaveBeenCalled() - expect(screen.queryByText(/OneBot/i)).not.toBeInTheDocument() - expect(screen.queryByRole('switch', { name: '保留 OneBot 进程' })).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: '语音发送诊断' })).not.toBeInTheDocument() expect(screen.getByRole('heading', { name: '文字转语音' })).toBeVisible() expect(screen.getByRole('region', { name: '文字转语音' })).toBeVisible() @@ -188,7 +182,6 @@ describe('PersonalWechatSendPage on Windows', () => { render() expect(await screen.findByText('API 设置')).toBeVisible() - expect(screen.queryByText(/OneBot/i)).not.toBeInTheDocument() expect(screen.queryByText('微信发送组件')).not.toBeInTheDocument() }) @@ -196,7 +189,6 @@ describe('PersonalWechatSendPage on Windows', () => { render() expect(await screen.findByRole('spinbutton', { name: '微信发送能力端口' })).toBeVisible() - expect(screen.queryByRole('switch', { name: '保留 OneBot 进程' })).not.toBeInTheDocument() }) it('shows the authorization notice with a README link on the Windows WeChat send page', async () => { diff --git a/tests/component/text-to-speech-page.test.tsx b/tests/component/text-to-speech-page.test.tsx index f430f5a..ce0aa68 100644 --- a/tests/component/text-to-speech-page.test.tsx +++ b/tests/component/text-to-speech-page.test.tsx @@ -148,12 +148,11 @@ describe('TextToSpeechPage', () => { await waitFor(() => expect(saveSettings).toHaveBeenCalledWith({ clearApiKey: true })) }) - it('shows the macOS voice encoding environment entry without exposing OneBot controls', async () => { + it('shows the macOS voice encoding environment entry', async () => { render() expect(await screen.findByText('API 设置')).toBeVisible() expect(screen.getByText('语音编码环境')).toBeVisible() - expect(screen.queryByText(/OneBot/i)).not.toBeInTheDocument() expect(screen.queryByText('支持的微信版本')).not.toBeInTheDocument() }) @@ -166,8 +165,8 @@ describe('TextToSpeechPage', () => { python: { ready: true, executable: '/usr/local/bin/python3', version: '3.12.11' }, pilk: { ready: false, error: '未安装' }, ffmpeg: { ready: true, executable: '/runtime/ffmpeg', version: '6.1' }, - encoder: 'go-silk' as const, - message: '语音编码环境不完整,OneBot 可能回退到 go-silk' + encoder: 'silk' as const, + message: '语音编码环境不完整,语音将回退到内置 SILK 编码' } const readyEnvironment = { ...incompleteEnvironment, @@ -187,7 +186,7 @@ describe('TextToSpeechPage', () => { fireEvent.click(screen.getByRole('button', { name: '安装 pilk' })) await waitFor(() => expect(installPilk).toHaveBeenCalledTimes(1)) - expect(onNotice).toHaveBeenCalledWith('语音编码环境已修复。重新启动语音发送服务后生效。') + expect(onNotice).toHaveBeenCalledWith('语音编码环境已修复。') expect(await screen.findByText('✓ 编码环境正常')).toBeVisible() }) }) diff --git a/tests/e2e/app.spec.ts b/tests/e2e/app.spec.ts index 87b649f..bd40fce 100644 --- a/tests/e2e/app.spec.ts +++ b/tests/e2e/app.spec.ts @@ -429,7 +429,7 @@ test('GUIDE-01 first-use welcome is keyboard-safe and fits the viewport', async } }) -test('SETTINGS-01 supported WeChat versions dialog is keyboard-safe and fits the viewport', async () => { +test('SETTINGS-01 macOS WeChat setup keeps the binding guidance focused', async () => { test.skip(process.platform !== 'darwin', 'The personal WeChat runtime is currently macOS-only') const fixture = await launchTestApp() const pageErrors: Error[] = [] @@ -441,22 +441,17 @@ test('SETTINGS-01 supported WeChat versions dialog is keyboard-safe and fits the .click() await fixture.page.getByRole('button', { name: '微信发送' }).click() - const trigger = fixture.page.getByRole('button', { name: '支持版本' }) - await expect(trigger).toBeVisible() - await trigger.click() - const dialog = fixture.page.getByRole('dialog', { name: '支持的微信版本' }) - await expect(dialog).toBeVisible() - await expect(dialog.getByText('4.1.6.12')).toBeVisible() - await expect(dialog.getByText('4.1.11.53')).toBeVisible() + await expect( + fixture.page.getByText('请保持微信未登录窗口状态,点击“绑定微信”后,再点击微信窗口登录。') + ).toBeVisible() + await expect(fixture.page.getByRole('button', { name: '支持版本' })).toHaveCount(0) + await expect(fixture.page.getByText('查看支持的微信版本')).toHaveCount(0) + await expect(fixture.page.getByText('高级诊断')).toHaveCount(0) expect( await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth) ).toBe(true) expect(pageErrors).toEqual([]) - await fixture.page.keyboard.press('Escape') - await expect(dialog).toHaveCount(0) - await expect(trigger).toBeFocused() - await fixture.page.getByRole('button', { name: '文字转语音' }).click() const modelSelect = fixture.page.getByRole('combobox', { name: '合成模型' }) await modelSelect.scrollIntoViewIfNeeded() diff --git a/tests/e2e/support/electron-main.cjs b/tests/e2e/support/electron-main.cjs index a20e109..9473668 100644 --- a/tests/e2e/support/electron-main.cjs +++ b/tests/e2e/support/electron-main.cjs @@ -21,7 +21,6 @@ const personalWechatSupported = process.platform === 'darwin' || process.platfor const unsignedMacUpdate = process.env.WXE_E2E_UNSIGNED_MAC_UPDATE === '1' const fixtureNowMs = Number.isFinite(configuredNow) && configuredNow > 0 ? configuredNow : Date.now() -let keepOneBotProcess = false const formatFixtureDateTime = (timestampSeconds) => { const date = new Date(timestampSeconds * 1000) @@ -400,17 +399,6 @@ handle('tts:getSettings', () => ({ }, voices: [] })) -handle('wechat-personal:getRuntimeStatus', () => ({ - version: 'v0.0.18', - state: 'ready', - downloadedBytes: 100, - totalBytes: 100, - progress: 1, - platform: 'darwin', - architecture: 'arm64', - supported: true, - removable: true -})) handle('wechat-personal:getStatus', () => ({ state: 'online', platform: process.platform, @@ -419,8 +407,7 @@ handle('wechat-personal:getStatus', () => ({ wechatRunning: true, wechatPid: 4668, boundWechatPid: 4668, - oneBotPid: 5401, - endpoint: '127.0.0.1:58080', + endpoint: '127.0.0.1:4290', endpointReady: true, wechatVersion: '4.1.11.53', runtimeReady: true, @@ -454,7 +441,7 @@ handle('wechat-personal:getSendCapability', () => ({ sipDisabled: true, wechatRunning: true, boundWechatPid: 4668, - endpoint: '127.0.0.1:58080', + endpoint: '127.0.0.1:4290', endpointReady: true, runtimeReady: true, attachReady: true, @@ -474,11 +461,6 @@ handle('wechat-personal:getSendCapability', () => ({ ? '个人微信已准备好发送日报' : '微信消息发送目前仅支持 macOS 和 Windows' })) -handle('wechat-personal:getKeepProcess', () => keepOneBotProcess) -handle('wechat-personal:setKeepProcess', (keep) => { - keepOneBotProcess = Boolean(keep) - return keepOneBotProcess -}) handle('scheduled-report:list', () => [...scheduledReportTasks]) handle('scheduled-report:listExecutions', (taskId) => taskId diff --git a/tests/unit/mac-wechat-native-runtime.test.ts b/tests/unit/mac-wechat-native-runtime.test.ts new file mode 100644 index 0000000..740467d --- /dev/null +++ b/tests/unit/mac-wechat-native-runtime.test.ts @@ -0,0 +1,127 @@ +import { createRequire } from 'module' +import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { describe, expect, it } from 'vitest' + +import { + bindingMatchesRuntimeManifest, + readMacWechatRuntimeManifest +} from '../../src/main/services/mac-wechat-runtime-artifact' + +const nodeRequire = createRequire(import.meta.url) +const { readAndValidateArtifact, syncArtifact } = nodeRequire( + '../../scripts/prepare-wechat-native-runtime.cjs' +) as { + readAndValidateArtifact: (sourceDir: string) => { manifest: { tmSendSourceSha256: string } } + syncArtifact: (sourceDir: string, targetDir: string) => unknown +} + +const AGENT_HASH = 'a'.repeat(64) +const ADDRESS_PROFILE_HASH = 'b'.repeat(64) + +function writeMachOArm64(filePath: string, suffix = Buffer.alloc(0)): void { + const header = Buffer.alloc(32) + header.writeUInt32LE(0xfeedfacf, 0) + header.writeUInt32LE(0x0100000c, 4) + writeFileSync(filePath, Buffer.concat([header, suffix])) +} + +function createArtifact(root: string): void { + writeMachOArm64(join(root, 'tm-wechat-host')) + writeMachOArm64( + join(root, 'libtmwechat.dylib'), + Buffer.from(`${AGENT_HASH}${ADDRESS_PROFILE_HASH}`) + ) + writeFileSync( + join(root, 'runtime-manifest.json'), + JSON.stringify({ + runtime: 'tm-wechat-native', + version: '0.1.0', + platform: 'darwin-arm64', + architecture: 'arm64', + protocolVersion: 1, + buildCommit: 'test', + tmSendSourceSha256: AGENT_HASH, + addressProfileSha256: ADDRESS_PROFILE_HASH, + supportedWechatVersions: ['4.1.11.53'], + capabilities: ['text', 'image', 'voice'] + }) + ) +} + +describe('macOS self-contained WeChat runtime artifact', () => { + it('validates the manifest, architecture, capabilities and embedded agent identity', () => { + const root = mkdtempSync(join(tmpdir(), 'tm-native-runtime-')) + createArtifact(root) + + const manifest = readMacWechatRuntimeManifest(root, 'arm64') + expect(manifest.tmSendSourceSha256).toBe(AGENT_HASH) + expect( + bindingMatchesRuntimeManifest( + { + hostPid: 123, + runtimeVersion: 'tm-wechat-runtime/0.1.0', + embeddedAgentSha256: AGENT_HASH, + embeddedAddressProfileSha256: ADDRESS_PROFILE_HASH + }, + manifest + ) + ).toBe(true) + expect( + bindingMatchesRuntimeManifest( + { hostPid: 123, runtimeVersion: 'tm-wechat-runtime/0.1.0' }, + manifest + ) + ).toBe(false) + expect( + bindingMatchesRuntimeManifest( + { + runtimeVersion: 'tm-wechat-runtime/0.1.0', + embeddedAgentSha256: AGENT_HASH + }, + manifest + ) + ).toBe(false) + }) + + it('rejects a manifest whose embedded address profile hash is absent from the dylib', () => { + const root = mkdtempSync(join(tmpdir(), 'tm-native-runtime-profile-mismatch-')) + createArtifact(root) + writeFileSync( + join(root, 'runtime-manifest.json'), + JSON.stringify({ + runtime: 'tm-wechat-native', + version: '0.1.0', + platform: 'darwin-arm64', + architecture: 'arm64', + protocolVersion: 1, + buildCommit: 'test', + tmSendSourceSha256: AGENT_HASH, + addressProfileSha256: 'c'.repeat(64), + supportedWechatVersions: ['4.1.11.53'], + capabilities: ['text', 'image', 'voice'] + }) + ) + expect(() => readMacWechatRuntimeManifest(root, 'arm64')).toThrow(/address profile hash/) + }) + + it('prepares an exact runtime tree and removes old external agent and table directories', () => { + const source = mkdtempSync(join(tmpdir(), 'tm-native-source-')) + const target = mkdtempSync(join(tmpdir(), 'tm-native-target-parent-')) + const targetRuntime = join(target, 'darwin-arm64') + createArtifact(source) + mkdirSync(join(targetRuntime, 'scripts'), { recursive: true }) + writeFileSync(join(targetRuntime, 'scripts', 'tm_send.js'), 'old external agent') + mkdirSync(join(targetRuntime, 'wechat_version'), { recursive: true }) + writeFileSync(join(targetRuntime, 'wechat_version', '4_1_11_53_mac.json'), '{}') + + expect(readAndValidateArtifact(source).manifest.tmSendSourceSha256).toBe(AGENT_HASH) + syncArtifact(source, targetRuntime) + + expect(existsSync(join(targetRuntime, 'scripts'))).toBe(false) + expect(existsSync(join(targetRuntime, 'wechat_version'))).toBe(false) + expect(existsSync(join(targetRuntime, 'runtime-manifest.json'))).toBe(true) + expect(readAndValidateArtifact(targetRuntime).manifest.tmSendSourceSha256).toBe(AGENT_HASH) + }) +}) diff --git a/tests/unit/mac-wechat-runtime-manager.test.ts b/tests/unit/mac-wechat-runtime-manager.test.ts new file mode 100644 index 0000000..e60ecb5 --- /dev/null +++ b/tests/unit/mac-wechat-runtime-manager.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ + app: { + getAppPath: () => '/fixture/app', + isPackaged: false + } +})) + +import { + deriveMacWechatSenderStatus, + shouldClearWechatRestartRequired +} from '../../src/main/services/mac-wechat-runtime-manager' +import type { + MacWechatBindingStatus, + MacWechatRuntimeManifest, + MacWechatRuntimeSnapshot +} from '../../src/shared/personal-wechat-mac-runtime' + +const AGENT_HASH = 'a'.repeat(64) +const ADDRESS_PROFILE_HASH = 'b'.repeat(64) + +const manifest: MacWechatRuntimeManifest = { + runtime: 'tm-wechat-native', + version: '0.1.0', + platform: 'darwin-arm64', + architecture: 'arm64', + protocolVersion: 1, + buildCommit: 'test', + tmSendSourceSha256: AGENT_HASH, + addressProfileSha256: ADDRESS_PROFILE_HASH, + supportedWechatVersions: ['4.1.11.53'], + capabilities: ['text', 'image', 'voice'] +} + +function snapshot(overrides: Partial = {}): MacWechatRuntimeSnapshot { + return { + lifecycle: 'online', + wechatRestartRequired: false, + endpoint: 'http://127.0.0.1:4290', + port: 4290, + manifest, + ...overrides + } +} + +function binding(overrides: Partial = {}): MacWechatBindingStatus { + return { + runtime: true, + hostPid: 123, + runtimeState: 'ready', + runtimeVersion: 'tm-wechat-runtime/0.1.0', + embeddedAgentSha256: AGENT_HASH, + embeddedAddressProfileSha256: ADDRESS_PROFILE_HASH, + wechatRunning: true, + wechatPid: 456, + wechatVersion: '4.1.11.53', + supported: true, + bindingState: 'ready', + bound: true, + boundWechatPid: 456, + sessionAttached: true, + scriptLoaded: true, + sendContextReady: true, + canSendText: true, + canSendImage: true, + canSendVoice: true, + attachCount: 1, + ...overrides + } +} + +describe('macOS native runtime sender status mapping', () => { + it('exposes capabilities only for a ready binding with matching runtime identity', () => { + expect( + deriveMacWechatSenderStatus({ snapshot: snapshot(), binding: binding(), manifest }) + ).toMatchObject({ + state: 'online', + runtimeReady: true, + attachReady: true, + baseAddressReady: true, + messageListenerReady: true, + canSend: true, + canSendText: true, + canSendImage: true, + canSendVoice: true + }) + }) + + it('rejects a live host whose embedded runtime identity does not match the manifest', () => { + const status = deriveMacWechatSenderStatus({ + snapshot: snapshot(), + binding: binding({ embeddedAgentSha256: 'b'.repeat(64) }), + manifest + }) + + expect(status).toMatchObject({ + state: 'error', + runtimeReady: false, + canSend: false, + canSendText: false, + canSendImage: false, + canSendVoice: false + }) + expect(status.error).toContain('Native Runtime 不一致') + }) + + it('maps a stale binding to a rebind-required stopped state', () => { + expect( + deriveMacWechatSenderStatus({ + snapshot: snapshot(), + binding: binding({ bindingState: 'stale', sendContextReady: false }), + manifest + }) + ).toMatchObject({ + state: 'stopped', + message: '检测到微信已重启,请重新绑定', + canSend: false + }) + }) + + it('keeps restart-required errors above otherwise ready binding state', () => { + expect( + deriveMacWechatSenderStatus({ + snapshot: snapshot({ wechatRestartRequired: true }), + binding: binding(), + manifest + }) + ).toMatchObject({ state: 'error', canSend: false }) + }) + + it('clears restart-required only after WeChat has a different positive pid', () => { + expect(shouldClearWechatRestartRequired(100, 200)).toBe(true) + expect(shouldClearWechatRestartRequired(100, 100)).toBe(false) + expect(shouldClearWechatRestartRequired(0, 200)).toBe(false) + expect(shouldClearWechatRestartRequired(100, 0)).toBe(false) + }) +}) diff --git a/tests/unit/personal-wechat-capability-service.test.ts b/tests/unit/personal-wechat-capability-service.test.ts index f0eeeb0..6657bb0 100644 --- a/tests/unit/personal-wechat-capability-service.test.ts +++ b/tests/unit/personal-wechat-capability-service.test.ts @@ -12,7 +12,7 @@ const status = ( sipDisabled: true, wechatRunning: true, wechatPid: 123, - endpoint: '127.0.0.1:58080', + endpoint: '127.0.0.1:4290', endpointReady: true, runtimeReady: true, attachReady: true, @@ -31,6 +31,27 @@ const status = ( }) describe('PersonalWechatCapabilityService', () => { + it('uses only the native runtime status on macOS', async () => { + const legacyGetStatus = vi.fn(async () => status({ state: 'error' })) + const nativeStatus = status({ + state: 'online', + boundWechatPid: 123, + canSend: true, + canSendText: true + }) + const service = new PersonalWechatCapabilityService( + { getStatus: legacyGetStatus }, + 'darwin', + async () => nativeStatus + ) + + await expect(service.getPersonalWechatSendCapability()).resolves.toMatchObject({ + status: 'ready', + capabilities: { text: true } + }) + expect(legacyGetStatus).not.toHaveBeenCalled() + }) + it.each([ ['unsupported', status({ platform: 'linux' })], ['unconfigured', status({ runtimeReady: false, boundWechatPid: undefined })], @@ -64,7 +85,11 @@ describe('PersonalWechatCapabilityService', () => { }) ] ])('maps %s', (expected, senderStatus) => { - const service = new PersonalWechatCapabilityService({ getStatus: async () => senderStatus }) + const service = new PersonalWechatCapabilityService( + { getStatus: async () => senderStatus }, + senderStatus.platform as NodeJS.Platform, + async () => senderStatus + ) return expect(service.getPersonalWechatSendCapability()).resolves.toMatchObject({ status: expected, ready: expected === 'ready', diff --git a/tests/unit/personal-wechat-send-service.test.ts b/tests/unit/personal-wechat-send-service.test.ts index 7bf3152..8db3089 100644 --- a/tests/unit/personal-wechat-send-service.test.ts +++ b/tests/unit/personal-wechat-send-service.test.ts @@ -22,14 +22,10 @@ vi.mock('electron', () => ({ import { buildPersonalWechatVoiceDiagnostic, buildWindowsWechatRequest, - buildPersonalWechatOneBotRequest, findWechatImagePath, - findPersonalWechatRuntime, normalizeWindowsWechatPort, parseWindowsLoginStatus, parseWindowsHookResponse, - parsePersonalWechatHookLog, - deriveMacSendCapabilities, prepareWindowsImageFile, WindowsHookHttpError } from '../../src/main/services/personal-wechat-send-service' @@ -48,7 +44,7 @@ afterEach(() => { } }) -describe('personal WeChat OneBot request', () => { +describe('personal WeChat voice diagnostics', () => { it('keeps voice diagnostics to the safe metadata allowlist', () => { const diagnostic = buildPersonalWechatVoiceDiagnostic('request-1', 'completed', { input_bytes: 10, @@ -79,70 +75,6 @@ describe('personal WeChat OneBot request', () => { expect(diagnostic.error).not.toContain('token-value') expect(diagnostic.error).not.toContain('json-secret') }) - - it('builds a private text message request', () => { - expect( - buildPersonalWechatOneBotRequest({ - to: ' wxid_fixture ', - type: 'text', - text: ' 测试发送 ', - isGroup: false - }) - ).toEqual({ - endpoint: '/send_private_msg', - body: { - user_id: 'wxid_fixture', - message: [{ type: 'text', data: { text: '测试发送' } }] - } - }) - }) - - it('uses the group endpoint for chatroom targets', () => { - expect( - buildPersonalWechatOneBotRequest({ - to: 'fixture-room@chatroom', - type: 'text', - text: '群聊测试', - isGroup: false - }) - ).toEqual({ - endpoint: '/send_group_msg', - body: { - group_id: 'fixture-room@chatroom', - message: [{ type: 'text', data: { text: '群聊测试' } }] - } - }) - }) - - it('builds a base64 image request without mixing text content', () => { - expect( - buildPersonalWechatOneBotRequest( - { to: 'fixture-room@chatroom', type: 'image', filePath: '/tmp/test.png', isGroup: false }, - 'aGVsbG8=' - ) - ).toEqual({ - endpoint: '/send_group_msg', - body: { - group_id: 'fixture-room@chatroom', - message: [{ type: 'image', data: { file: 'base64://aGVsbG8=' } }] - } - }) - }) - - it('builds a base64 voice record request', () => { - expect( - buildPersonalWechatOneBotRequest( - { to: 'filehelper', type: 'voice', filePath: '/tmp/test.silk', isGroup: false }, - 'dm9pY2U=' - ) - ).toEqual({ - endpoint: '/send_private_msg', - body: { - user_id: 'filehelper', - message: [{ type: 'record', data: { file: 'base64://dm9pY2U=' } }] - } - }) - }) }) describe('Windows request', () => { @@ -292,157 +224,4 @@ describe('personal WeChat runtime discovery', () => { `${join(tempRoot, 'ImageTemp', '2026-08')}${sep}` ) }) - - it('finds the nested release archive layout', () => { - const root = temporaryDirectory() - mkdirSync(join(root, 'onebot'), { recursive: true }) - mkdirSync(join(root, 'wechat_version'), { recursive: true }) - writeFileSync(join(root, 'onebot', 'onebot'), 'fixture') - writeFileSync(join(root, 'onebot', 'script.js'), 'fixture') - - expect(findPersonalWechatRuntime([root])).toEqual({ - root, - executable: join(root, 'onebot', 'onebot'), - workingDirectory: join(root, 'onebot'), - configDirectory: join(root, 'wechat_version'), - logPath: join(root, 'onebot', 'log', 'macos.log') - }) - }) - - it('finds a flat runtime layout and rejects incomplete directories', () => { - const incomplete = temporaryDirectory() - writeFileSync(join(incomplete, 'onebot'), 'fixture') - - const root = temporaryDirectory() - mkdirSync(join(root, 'wechat_version'), { recursive: true }) - writeFileSync(join(root, 'onebot'), 'fixture') - writeFileSync(join(root, 'script.js'), 'fixture') - - expect(findPersonalWechatRuntime([incomplete, root])).toEqual({ - root, - executable: join(root, 'onebot'), - workingDirectory: root, - configDirectory: join(root, 'wechat_version'), - logPath: join(root, 'log', 'macos.log') - }) - }) -}) - -describe('personal WeChat hook diagnostics', () => { - it('marks media ready from initialized hooks without an upload capture event', () => { - expect( - deriveMacSendCapabilities({ - attached: true, - baseAddressReady: true, - textHookInstalled: true, - textHookReady: true, - imageHookInstalled: true, - imagePathBound: true - }) - ).toEqual({ canSend: true, canSendText: true, canSendImage: true, canSendVoice: true }) - }) - - it('keeps sending unavailable until the native send context is captured', () => { - expect( - deriveMacSendCapabilities({ - attached: true, - baseAddressReady: true, - textHookInstalled: true, - textHookReady: false, - imageHookInstalled: true, - imagePathBound: true - }).canSend - ).toBe(false) - }) - - it('does not treat a listening service as hook-ready after req2buf scan failure', () => { - expect( - parsePersonalWechatHookLog( - [ - JSON.stringify({ time: '2026-08-06T14:08:10+08:00', payload: '[+] HTTP 服务启动在' }), - JSON.stringify({ - time: '2026-08-06T14:08:11+08:00', - err: "Error: [-] Cannot find 'req2buf' keyword in a range > 100MB" - }) - ].join('\n') - ) - ).toMatchObject({ readiness: 'failed' }) - }) - - it('requires StartTask capture before reporting ready', () => { - expect( - parsePersonalWechatHookLog( - JSON.stringify({ payload: '[+] Dynamic Text Message Setup Complete.' }) - ) - ).toMatchObject({ readiness: 'initializing', textHookInstalled: true }) - expect( - parsePersonalWechatHookLog( - JSON.stringify({ payload: '[+] 捕获到有效 StartTask 上下文,X0:0x1, Payload: 0x2' }) - ) - ).toMatchObject({ readiness: 'ready', textHookReady: true }) - }) - - it('resets stale hook results when a new WeChat process attach starts', () => { - expect( - parsePersonalWechatHookLog( - [ - JSON.stringify({ payload: '[+] 捕获到 StartTask 调用,X0:0x1, Payload: 0x2' }), - JSON.stringify({ message: '使用指定的微信进程 PID', PID: 123 }), - JSON.stringify({ payload: '[+] Dynamic Text Message Setup Complete.' }) - ].join('\n') - ) - ).toMatchObject({ - readiness: 'initializing', - boundWechatPid: 123, - textHookInstalled: true, - textHookReady: false - }) - }) - - it('tracks base, image Hook and message listener diagnostics', () => { - expect( - parsePersonalWechatHookLog( - [ - JSON.stringify({ message: '使用指定的微信进程 PID', PID: 4668 }), - JSON.stringify({ message: '成功 Attach 微信进程', PID: 4668 }), - JSON.stringify({ payload: '[+] Base address from range: 0x114ef8000' }), - JSON.stringify({ payload: '[+] Dynamic Text Message Setup Complete.' }), - JSON.stringify({ payload: '[+] 图片上传 Hook Setup Complete.' }), - JSON.stringify({ payload: '[+] 捕获到图片上传上下文,uploadGlobalX0:0x1' }), - JSON.stringify({ message: '发送数据' }) - ].join('\n') - ) - ).toMatchObject({ - attached: true, - baseAddress: '0x114ef8000', - textHookInstalled: true, - imageHookInstalled: true, - imageHookReady: true, - messageListenerReady: true, - boundWechatPid: 4668 - }) - }) - - it('recognizes a successful legacy image send without the new Hook marker', () => { - expect( - parsePersonalWechatHookLog( - JSON.stringify({ type: 'send_image', result: '1', message: '发送图片任务执行结果' }) - ) - ).toMatchObject({ imageHookInstalled: true, imageHookReady: true }) - }) - - it('recognizes the real onebot two-record image result format', () => { - expect( - parsePersonalWechatHookLog( - [ - JSON.stringify({ task_id: 536870915, type: 'send_image' }), - JSON.stringify({ - result: '1', - task_id: 536870915, - message: '📩 发送图片任务执行结果' - }) - ].join('\n') - ) - ).toMatchObject({ imageHookInstalled: true, imageHookReady: true }) - }) }) diff --git a/tests/unit/personal-wechat-voice-environment-service.test.ts b/tests/unit/personal-wechat-voice-environment-service.test.ts index 739e092..6bf53d9 100644 --- a/tests/unit/personal-wechat-voice-environment-service.test.ts +++ b/tests/unit/personal-wechat-voice-environment-service.test.ts @@ -1,27 +1,21 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { findRuntime, buildEnvironment, writeLog } = vi.hoisted(() => ({ - findRuntime: vi.fn(), +const { isRuntimePresent, buildEnvironment, writeLog } = vi.hoisted(() => ({ + isRuntimePresent: vi.fn(), buildEnvironment: vi.fn(), writeLog: vi.fn() })) vi.mock('../../src/main/services/personal-wechat-send-service', () => ({ - findPersonalWechatRuntime: findRuntime, buildPersonalWechatRuntimeEnvironment: buildEnvironment })) +vi.mock('../../src/main/services/mac-wechat-runtime-manager', () => ({ + macWechatRuntimeManager: { isRuntimePresent: () => true } +})) vi.mock('../../src/main/app-logger', () => ({ appLogger: { write: writeLog } })) import { PersonalWechatVoiceEnvironmentService } from '../../src/main/services/personal-wechat-voice-environment-service' -const runtime = { - root: '/runtime', - executable: '/runtime/onebot/onebot', - workingDirectory: '/runtime/onebot', - configDirectory: '/runtime/wechat_version', - logPath: '/runtime/onebot/log/macos.log' -} - describe('PersonalWechatVoiceEnvironmentService', () => { let pilkAvailable = true const commands: Array<{ executable: string; args: string[]; env: NodeJS.ProcessEnv }> = [] @@ -29,11 +23,8 @@ describe('PersonalWechatVoiceEnvironmentService', () => { beforeEach(() => { pilkAvailable = true commands.length = 0 - findRuntime.mockReset().mockReturnValue(runtime) - buildEnvironment.mockReset().mockReturnValue({ - PATH: '/runtime/bin', - PYTHONPATH: '/runtime/python' - }) + isRuntimePresent.mockReset().mockReturnValue(true) + buildEnvironment.mockReset().mockReturnValue({ PATH: '/runtime/bin' }) writeLog.mockReset() }) @@ -41,7 +32,7 @@ describe('PersonalWechatVoiceEnvironmentService', () => { return new PersonalWechatVoiceEnvironmentService({ platform: 'darwin', architecture: 'arm64', - findRuntime, + isRuntimePresent, buildEnvironment, now: () => new Date('2026-08-31T00:00:00.000Z'), runCommand: async (executable, args, options) => { @@ -74,7 +65,7 @@ describe('PersonalWechatVoiceEnvironmentService', () => { }) } - it('checks pilk with the Python and environment used by OneBot', async () => { + it('checks pilk with the environment used by the native runtime', async () => { const environment = await createService().check() expect(environment).toMatchObject({ @@ -87,8 +78,7 @@ describe('PersonalWechatVoiceEnvironmentService', () => { ffmpeg: { ready: true, executable: '/runtime/bin/ffmpeg', version: '7.0' } }) expect(commands.find((command) => command.executable === 'python3')?.env).toEqual({ - PATH: '/runtime/bin', - PYTHONPATH: '/runtime/python' + PATH: '/runtime/bin' }) expect(commands.some((command) => command.executable === '/runtime/python3')).toBe(true) expect(writeLog).toHaveBeenCalledWith( @@ -99,12 +89,12 @@ describe('PersonalWechatVoiceEnvironmentService', () => { ) }) - it('reports go-silk fallback and installs the exact pilk version with the detected Python', async () => { + it('reports the built-in SILK fallback and installs the exact pilk version', async () => { pilkAvailable = false const service = createService() const before = await service.check() - expect(before).toMatchObject({ state: 'incomplete', ready: false, encoder: 'go-silk' }) + expect(before).toMatchObject({ state: 'incomplete', ready: false, encoder: 'silk' }) expect(before.pilk.ready).toBe(false) const result = await service.installPilk() @@ -123,7 +113,7 @@ describe('PersonalWechatVoiceEnvironmentService', () => { const service = new PersonalWechatVoiceEnvironmentService({ platform: 'darwin', architecture: 'arm64', - findRuntime, + isRuntimePresent, buildEnvironment, runCommand: async () => { throw new Error('spawn python3 ENOENT') diff --git a/tests/unit/security-and-errors.test.ts b/tests/unit/security-and-errors.test.ts index 183f3c5..b741ad6 100644 --- a/tests/unit/security-and-errors.test.ts +++ b/tests/unit/security-and-errors.test.ts @@ -53,7 +53,7 @@ describe('sticker HTTP failures', () => { }) describe('personal WeChat runtime security invariants', () => { - it('waits for sender termination during application shutdown', () => { + it('waits for the macOS native runtime to detach during application shutdown', () => { const mainSource = readFileSync(resolve('src/main/index.ts'), 'utf8') const shutdownStart = mainSource.indexOf("app.on('before-quit'") const shutdownEnd = mainSource.indexOf('function showMainWindow', shutdownStart) @@ -62,39 +62,16 @@ describe('personal WeChat runtime security invariants', () => { expect(shutdownStart).toBeGreaterThanOrEqual(0) expect(shutdownEnd).toBeGreaterThan(shutdownStart) expect(shutdownSource).toContain('await Promise.all([') - expect(shutdownSource).toContain('personalWechatSendService.terminate()') - expect(shutdownSource).not.toContain('personalWechatSendService.stop()') - - const senderSource = readFileSync( - resolve('src/main/services/personal-wechat-send-service.ts'), - 'utf8' - ) - expect(senderSource).toContain("process.kill(pid, 'SIGTERM')") - expect(senderSource).toContain("process.kill(pid, 'SIGKILL')") - expect(senderSource).toContain('const trackedPid = this.child?.pid') + expect(shutdownSource).toContain('macWechatRuntimeManager.shutdown()') }) - it('does not install Python packages while preparing the sender runtime', () => { - const runtimeManagerSource = readFileSync( - resolve('src/main/services/personal-wechat-runtime-manager.ts'), - 'utf8' - ) + it('does not install Python packages while preparing the native runtime', () => { const preparationScriptSource = readFileSync( - resolve('scripts/prepare-wechat-chatter-runtime.cjs'), + resolve('scripts/prepare-wechat-native-runtime.cjs'), 'utf8' ) - for (const source of [runtimeManagerSource, preparationScriptSource]) { - expect(source).not.toContain('pilk==') - expect(source).not.toMatch(/['"]pip['"]/) - expect(source).toContain('voiceAudioDataAddr = Memory.alloc(audioLen + 1);') - expect(source).toContain('上传前按语音长度重新分配') - } - - const senderSource = readFileSync( - resolve('src/main/services/personal-wechat-send-service.ts'), - 'utf8' - ) - expect(senderSource).toContain('buildPersonalWechatRuntimeEnvironment(preflight.runtime.root)') + expect(preparationScriptSource).not.toContain('pilk==') + expect(preparationScriptSource).not.toMatch(/['"]pip['"]/) }) }) diff --git a/tests/unit/settings-store-recall-migration.test.ts b/tests/unit/settings-store-recall-migration.test.ts index 2acd042..4a74ced 100644 --- a/tests/unit/settings-store-recall-migration.test.ts +++ b/tests/unit/settings-store-recall-migration.test.ts @@ -9,9 +9,9 @@ vi.mock('electron', () => ({ app: { getPath: () => '/tmp/tracememo-settings-test * settings-store 在模块加载时确定 settings.json 路径并缓存设置对象, * 所以每个用例都要用独立的目录 + 重新加载模块。 */ -async function importStore(directory: string): Promise< - typeof import('../../src/main/services/settings-store') -> { +async function importStore( + directory: string +): Promise { process.env.WE_SETTINGS_DIR = directory vi.resetModules() return import('../../src/main/services/settings-store') @@ -68,4 +68,18 @@ describe('recall protection retirement migration', () => { expect(store.loadSettings().recallProtectionEnabled).toBe(false) }) + + it('removes the retired keep-process setting from old settings files', async () => { + const directory = createDirectory() + writeFileSync( + join(directory, 'settings.json'), + JSON.stringify({ keepPersonalWechatProcess: true }), + 'utf8' + ) + + const store = await importStore(directory) + + expect(store.loadSettings()).not.toHaveProperty('keepPersonalWechatProcess') + expect(readSettingsFile(directory)).not.toHaveProperty('keepPersonalWechatProcess') + }) }) diff --git a/tests/unit/wechat-action-gateway.test.ts b/tests/unit/wechat-action-gateway.test.ts index 7a56f2c..5000895 100644 --- a/tests/unit/wechat-action-gateway.test.ts +++ b/tests/unit/wechat-action-gateway.test.ts @@ -307,6 +307,55 @@ describe('WechatActionGateway', () => { vi.useRealTimers() }) + it('sends a report postfix only after the image succeeds and keeps the 3s interval', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-09-23T09:00:00.000Z')) + const gateway = createGateway() + const sent: Array<{ type: string; at: number }> = [] + mocks.sender.send.mockImplementation(async (request: { type: string }) => { + sent.push({ type: request.type, at: Date.now() }) + return { success: true, status: {} } + }) + + const pending = gateway.executeReportImageSequence({ + recipient: { type: 'group', id: 'room@chatroom' }, + imagePath: '/tmp/report.png', + postfixText: '今日日报' + }) + await vi.advanceTimersByTimeAsync(0) + expect(sent.map((item) => item.type)).toEqual(['image']) + + await vi.advanceTimersByTimeAsync(AUTOMATION_SEND_INTERVAL_MS - 1) + expect(sent).toHaveLength(1) + await vi.advanceTimersByTimeAsync(1) + const result = await pending + + expect(result.image.status).toBe('sent') + expect(result.postfix?.status).toBe('sent') + expect(sent.map((item) => item.type)).toEqual(['image', 'text']) + expect(sent[1].at - sent[0].at).toBe(AUTOMATION_SEND_INTERVAL_MS) + vi.useRealTimers() + }) + + it('does not create the report postfix action when the image fails', async () => { + const gateway = createGateway() + mocks.sender.send.mockResolvedValueOnce({ + success: false, + status: {}, + error: 'image failed' + }) + + const result = await gateway.executeReportImageSequence({ + recipient: { type: 'group', id: 'room@chatroom' }, + imagePath: '/tmp/report.png', + postfixText: '今日日报' + }) + + expect(result.image.status).toBe('failed') + expect(result.postfix).toBeUndefined() + expect(mocks.sender.send).toHaveBeenCalledOnce() + }) + it('lets a user action send immediately while an automatic action is waiting', async () => { vi.useFakeTimers() vi.setSystemTime(new Date('2026-09-03T09:30:00.000Z'))