feat: mac发送能力重构为libtmwechat

- 发送改走本地host, 在微信登录时触发
- 删除旧OneBot及对应UI控制面
This commit is contained in:
Wxw-Gu
2026-09-24 03:01:03 +08:00
parent 069d836dea
commit 7268188e40
50 changed files with 2442 additions and 4141 deletions
+2 -1
View File
@@ -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
-1
View File
@@ -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)
## 版本说明
-674
View File
@@ -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.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
-28
View File
@@ -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`。
-1
View File
@@ -34,7 +34,6 @@ extraResources:
to: resources
filter:
- '**/*'
- '!connectors/wechat-personal/**'
- from: docs/skill/tracememo-reader
to: skill/tracememo-reader
filter:
+2 -2
View File
@@ -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"
]
}
}
}
-436
View File
@@ -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] 运行时准备完成')
+201
View File
@@ -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 <dir>, 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
}
}
+63 -66
View File
@@ -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<PersonalWechatSenderStatus> {
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')
@@ -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
)
}
@@ -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<MacWechatRuntimeSnapshot> {
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<boolean> {
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<MacWechatBindingStatus | null> {
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<MacWechatBindingStatus | null> {
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<PersonalWechatSenderStatus> {
/* 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<void> {
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<MacWechatBindResult> {
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<MacWechatRuntimeSnapshot> {
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<void> {
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()
@@ -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<PersonalWechatSendService, 'getStatus'>) {}
constructor(
private readonly sender: Pick<PersonalWechatSendService, 'getStatus'>,
private readonly platform: NodeJS.Platform = process.platform,
private readonly getMacStatus: () => Promise<PersonalWechatSenderStatus> = () =>
macWechatRuntimeManager.buildSenderStatus()
) {}
async getPersonalWechatSendCapability(): Promise<PersonalWechatSendCapability> {
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<PersonalWechatSendCapability> {
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 &&
@@ -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<string, unknown>
try {
config = JSON.parse(readFileSync(configPath, 'utf8')) as Record<string, unknown>
} 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<PersonalWechatRuntimeDownloadResult> | 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<PersonalWechatRuntimeStatus> {
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<PersonalWechatRuntimeDownloadResult> {
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<PersonalWechatRuntimeStatus> {
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<PersonalWechatRuntimeDownloadResult> {
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<boolean> {
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)
}
}
File diff suppressed because it is too large Load Diff
@@ -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
}
)
+12 -6
View File
@@ -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<AppSettings>
const stored = fs.readJsonSync(SETTINGS_FILE) as Partial<AppSettings> & {
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 })
+43 -1
View File
@@ -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<PersonalWechatSendCapability>
send?: (request: PersonalWechatSendRequest) => Promise<PersonalWechatSendResult>
@@ -111,6 +124,35 @@ export class WechatActionGateway {
this.memberEvents.clear()
}
/**
* 用户确认后的日报发送序列。两步都进入 automation 发送队列,从而复用既有 3 秒间隔;
* 后置词 action 只在图片明确 sent 后创建,图片失败/blocked 时严格短路。
*/
async executeReportImageSequence(
request: ReportImageSequenceRequest
): Promise<ReportImageSequenceResult> {
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 }))
}
+3 -3
View File
@@ -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,
+7 -16
View File
@@ -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<AutomationStatusSummary>
listAutomationRules: () => Promise<AutomationRule[]>
createAutomationRule: (draft: AutomationRuleDraft) => Promise<AutomationRule>
updateAutomationRule: (id: string, draft: AutomationRuleDraft) => Promise<AutomationRule | null>
updateAutomationRule: (
id: string,
draft: AutomationRuleDraft
) => Promise<AutomationRule | null>
deleteAutomationRule: (id: string) => Promise<boolean>
setAutomationRuleEnabled: (id: string, enabled: boolean) => Promise<AutomationRule | null>
listAutomationExecutions: (query?: { limit?: number }) => Promise<AutomationExecution[]>
@@ -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<PersonalWechatSenderStatus>
getPersonalWechatSendCapability: () => Promise<PersonalWechatSendCapability>
getPersonalWechatKeepOneBotProcess: () => Promise<boolean>
setPersonalWechatKeepOneBotProcess: (keep: boolean) => Promise<boolean>
checkPersonalWechatSenderStatus: (port?: string) => Promise<PersonalWechatSenderStatus>
checkPersonalWechatVoiceEncodingEnvironment: () => Promise<PersonalWechatVoiceEncodingEnvironment>
installPersonalWechatPilk: () => Promise<PersonalWechatVoiceEncodingEnvironmentResult>
openPersonalWechatVoicePythonDownload: () => Promise<{ success: boolean; error?: string }>
openPersonalWechatVoiceFfmpegDownload: () => Promise<{ success: boolean; error?: string }>
getPersonalWechatRuntimeStatus: () => Promise<PersonalWechatRuntimeStatus>
downloadPersonalWechatRuntime: () => Promise<PersonalWechatRuntimeDownloadResult>
cancelPersonalWechatRuntimeDownload: () => Promise<{ success: boolean }>
removePersonalWechatRuntime: () => Promise<PersonalWechatRuntimeStatus>
openPersonalWechatRuntimeDirectory: () => Promise<{ success: boolean; error?: string }>
onPersonalWechatRuntimeProgress: (
callback: (status: PersonalWechatRuntimeProgressEvent) => void
) => () => void
rebindPersonalWechatSender: () => Promise<PersonalWechatSenderStatus>
sendGeneratedTtsVoice: (
request: PersonalWechatGeneratedTtsVoiceRequest
-29
View File
@@ -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<PersonalWechatSendCapability> =>
ipcRenderer.invoke('wechat-personal:getSendCapability'),
getPersonalWechatKeepOneBotProcess: (): Promise<boolean> =>
ipcRenderer.invoke('wechat-personal:getKeepProcess'),
setPersonalWechatKeepOneBotProcess: (keep: boolean): Promise<boolean> =>
ipcRenderer.invoke('wechat-personal:setKeepProcess', keep),
checkPersonalWechatSenderStatus: (port?: string): Promise<PersonalWechatSenderStatus> =>
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<PersonalWechatRuntimeStatus> =>
ipcRenderer.invoke('wechat-personal:getRuntimeStatus'),
downloadPersonalWechatRuntime: (): Promise<PersonalWechatRuntimeDownloadResult> =>
ipcRenderer.invoke('wechat-personal:downloadRuntime'),
cancelPersonalWechatRuntimeDownload: (): Promise<{ success: boolean }> =>
ipcRenderer.invoke('wechat-personal:cancelRuntimeDownload'),
removePersonalWechatRuntime: (): Promise<PersonalWechatRuntimeStatus> =>
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<PersonalWechatSenderStatus> =>
ipcRenderer.invoke('wechat-personal:rebind'),
sendGeneratedTtsVoice: (
@@ -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<PersonalWechatSenderStatus | null>(null)
const [runtimeStatus, setRuntimeStatus] = useState<PersonalWechatRuntimeStatus | null>(null)
const [runtimeProgress, setRuntimeProgress] = useState<PersonalWechatRuntimeProgressEvent | null>(
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<ChatMessage[]>([])
const [sendError, setSendError] = useState<string | null>(null)
const [sendSuccess, setSendSuccess] = useState<string | null>(null)
const [voiceDiagnostic, setVoiceDiagnostic] = useState<PersonalWechatVoiceDiagnostic | null>(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<void> => {
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<void> => {
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<void> => {
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)}
</div>
<div className="personal-wechat-chat-heading">
<DialogTitle>文字转语音</DialogTitle>
<DialogTitle>{initialImage ? '发送日报图片' : '文字转语音'}</DialogTitle>
<DialogDescription>
发送给 {displayName} · {setupReady ? '微信已连接' : '配置微信发送能力'}
</DialogDescription>
@@ -319,14 +295,10 @@ function PersonalWechatMacSendDialog({
{!setupReady && senderStatus && (
<PersonalWechatSetupGuide
runtimeStatus={runtimeStatus}
senderStatus={senderStatus}
runtimeProgress={runtimeProgress}
runtimeBusy={runtimeBusy}
binding={binding}
detecting={detecting}
sessionBound={sessionBound}
onDownloadRuntime={() => void handleDownloadRuntime()}
onBind={() => void handleBind()}
onStartSending={() => undefined}
onOpenTextToSpeechSettings={handleOpenSettings}
@@ -336,6 +308,16 @@ function PersonalWechatMacSendDialog({
{setupReady && initialImage && (
<section className="personal-wechat-composer" aria-label="日报图片发送">
<p>已准备日报图片:{initialImage.name}</p>
<ReportImagePostfixInput
value={postfixText}
onChange={setPostfixText}
onBlur={() =>
void persistPostfixText().catch((error) =>
setSendError(error instanceof Error ? error.message : '发送后置词保存失败')
)
}
disabled={sendBusy}
/>
<Button size="sm" onClick={() => void handleSendReportImage()} disabled={sendBusy}>
{sendBusy ? '发送中…' : '发送日报图片'}
</Button>
@@ -358,8 +340,13 @@ function PersonalWechatMacSendDialog({
{sendError}
</div>
)}
{sendSuccess && (
<div className="personal-wechat-global-success" role="status">
{sendSuccess}
</div>
)}
</div>
{setupReady && isMac && (
{setupReady && isMac && !initialImage && (
<div className="personal-wechat-chat-footer flex items-center justify-end">
<Button variant="link" size="sm" onClick={() => void handleOpenVoiceDiagnostic()}>
语音发送诊断
@@ -1,22 +1,13 @@
import { useState } from 'react'
import type { PersonalWechatSenderStatus } from '../../../../shared/personal-wechat'
import type {
PersonalWechatRuntimeProgressEvent,
PersonalWechatRuntimeStatus
} from '../../../../shared/personal-wechat-runtime'
import { Button, Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui'
import { isMac } from '../../utils/runtime-environment'
import { PersonalWechatSupportedVersionsContent } from './PersonalWechatSupportedVersionsContent'
import { Button } from '../ui'
const GROUP_README_URL = 'https://github.com/Wxw-Gu/TraceMemo#-交流与反馈'
interface PersonalWechatSetupGuideProps {
runtimeStatus: PersonalWechatRuntimeStatus | null
senderStatus: PersonalWechatSenderStatus | null
runtimeProgress: PersonalWechatRuntimeProgressEvent | null
runtimeBusy: boolean
binding: boolean
detecting: boolean
sessionBound: boolean
onDownloadRuntime: () => void
onBind: () => void
onStartSending: () => void
onOpenTextToSpeechSettings?: () => void
@@ -27,21 +18,18 @@ function capabilityLabel(ready: boolean, initializing: boolean): string {
return initializing ? '初始化中' : '未就绪'
}
function diagnosticValue(value: unknown): string {
if (value === undefined || value === null || value === '') return '未检测到'
return String(value)
}
function bindingHint(status: PersonalWechatSenderStatus): string {
if (status.state === 'wechat_not_running') return '请先启动并登录微信。'
if (status.state === 'wechat_not_running') {
return '请保持微信未登录窗口状态,点击“绑定微信”后,再点击微信窗口登录。'
}
if (status.state === 'unsupported_platform') return '当前系统暂不支持个人微信发送。'
if (status.state === 'unsupported_version')
return '当前微信版本暂不支持,请查看微信发送设置中的支持版本。'
return '当前微信版本暂不支持,请联系群主确认可用版本。'
if (status.state === 'runtime_missing') {
return isMac ? '请先完成 OneBot 运行时准备。' : '请先完成发送运行时准备。'
return '请先完成发送运行时准备。'
}
if (status.state === 'error') return '连接微信时遇到问题,请稍后重试。'
return '请启动并登录当前微信,TraceMemo 会自动绑定正在使用的账号。'
return '请保持微信未登录窗口状态,点击“绑定微信”后,再点击微信窗口登录。'
}
function isWechatBound(status: PersonalWechatSenderStatus): boolean {
@@ -54,28 +42,21 @@ function isWechatBound(status: PersonalWechatSenderStatus): boolean {
}
export function PersonalWechatSetupGuide({
runtimeStatus,
senderStatus,
runtimeProgress,
runtimeBusy,
binding,
detecting,
sessionBound,
onDownloadRuntime,
onBind,
onStartSending,
onOpenTextToSpeechSettings
}: PersonalWechatSetupGuideProps): React.ReactElement {
const [showSupportedVersions, setShowSupportedVersions] = useState(false)
const runtimeLabel = isMac ? 'OneBot 运行时' : '发送运行时'
const runtimeReady = runtimeStatus?.state === 'ready' || senderStatus?.runtimeReady === true
const runtimeDownloading = runtimeBusy || runtimeStatus?.state === 'downloading'
const runtimeUnavailable = senderStatus?.state === 'runtime_missing'
const runtimeLabel = '发送运行时'
const runtimeReady = senderStatus?.runtimeReady === true
const connected = sessionBound && (senderStatus ? isWechatBound(senderStatus) : false)
const canSendVoice = Boolean(senderStatus?.canSendVoice)
const initializing = connected && !canSendVoice && senderStatus?.state !== 'error'
const allReady = connected && Boolean(senderStatus?.canSend)
const progress = runtimeProgress || runtimeStatus
const progressPercent = Math.max(0, Math.min(100, Math.round((progress?.progress || 0) * 100)))
return (
<section className="personal-wechat-setup" aria-label="微信消息功能配置">
@@ -89,38 +70,21 @@ export function PersonalWechatSetupGuide({
</div>
<ol className="personal-wechat-steps">
<li
className={runtimeReady ? 'is-complete' : runtimeDownloading ? 'is-active' : 'is-current'}
>
<li className={runtimeReady ? 'is-complete' : 'is-current'}>
<span className="personal-wechat-step-number">{runtimeReady ? '✓' : '1'}</span>
<div className="personal-wechat-step-content">
<strong>准备 {runtimeLabel}</strong>
<p>个人微信发送需要 {runtimeLabel},首次使用时下载一次即可。</p>
{runtimeDownloading ? (
<div className="personal-wechat-download-progress" aria-live="polite">
<div>
<span>正在准备 {runtimeLabel}</span>
<span>{progressPercent}%</span>
</div>
<div className="personal-wechat-progress-track">
<span style={{ width: `${progressPercent}%` }} />
</div>
</div>
) : runtimeReady ? (
<span className="personal-wechat-step-status">✓ {runtimeLabel}已准备</span>
<p>
{runtimeUnavailable
? `个人微信发送需要${runtimeLabel},授权后即可使用。`
: `${runtimeLabel}随 TraceMemo 一起提供,无需额外安装。`}
</p>
{runtimeReady ? (
<span className="personal-wechat-step-status">✓ {runtimeLabel}已就绪</span>
) : runtimeUnavailable ? (
<span className="personal-wechat-step-error">当前版本暂未提供微信消息发送功能</span>
) : (
<Button
size="sm"
onClick={onDownloadRuntime}
disabled={runtimeBusy || runtimeStatus?.state === 'unsupported'}
>
下载运行时
</Button>
)}
{runtimeStatus?.error && !runtimeDownloading && !runtimeReady && (
<p className="personal-wechat-step-error">
运行时准备失败,请重试或查看微信发送设置。
</p>
<span className="personal-wechat-step-status">正在检查 {runtimeLabel}…</span>
)}
</div>
</li>
@@ -139,7 +103,7 @@ export function PersonalWechatSetupGuide({
<span className="personal-wechat-step-number">{connected ? '✓' : '2'}</span>
<div className="personal-wechat-step-content">
<strong>绑定个人微信</strong>
<p>请启动并登录当前微信,TraceMemo 会自动绑定正在使用的账号。</p>
<p>请保持微信未登录窗口状态,点击“绑定微信”后,再点击微信窗口登录。</p>
{connected ? (
<span className="personal-wechat-step-status">✓ 微信已绑定</span>
) : (
@@ -147,15 +111,28 @@ export function PersonalWechatSetupGuide({
size="sm"
variant="outline"
onClick={onBind}
disabled={!runtimeReady || binding || runtimeDownloading}
disabled={runtimeUnavailable || !runtimeReady || binding}
>
{binding ? '正在绑定…' : '绑定微信'}
</Button>
)}
{!connected && senderStatus && (
{!connected && senderStatus && !runtimeUnavailable && (
<p className="personal-wechat-step-hint">{bindingHint(senderStatus)}</p>
)}
{!connected && (
{!connected && runtimeUnavailable && (
<div className="personal-wechat-authorization-warning" role="alert">
<span aria-hidden>!</span>
<p>
发送能力属授权制,需要联系群主。请先加入交流群,然后在群内添加群主申请授权。
进群请点击{' '}
<a href={GROUP_README_URL} target="_blank" rel="noreferrer">
这里
</a>{' '}
跳转。
</p>
</div>
)}
{!connected && !runtimeUnavailable && (
<p className="personal-wechat-step-warning" role="note">
绑定微信可能导致当前微信异常闪退,这是正常现象。若微信退出,请重新启动微信后,再回到这里重新检测/绑定。
<br />
@@ -163,14 +140,6 @@ export function PersonalWechatSetupGuide({
通用”,取消勾选“有更新时自动升级微信”,否则版本变化后可能无法绑定。
</p>
)}
<Button
className="w-fit"
variant="link"
size="sm"
onClick={() => setShowSupportedVersions(true)}
>
查看支持的微信版本
</Button>
</div>
</li>
@@ -225,7 +194,7 @@ export function PersonalWechatSetupGuide({
</li>
</ol>
{!runtimeReady && runtimeStatus?.state === 'unsupported' && onOpenTextToSpeechSettings && (
{!runtimeReady && runtimeUnavailable && onOpenTextToSpeechSettings && (
<Button
variant="link"
size="sm"
@@ -235,54 +204,6 @@ export function PersonalWechatSetupGuide({
查看语音设置
</Button>
)}
<Dialog open={showSupportedVersions} onOpenChange={setShowSupportedVersions}>
<DialogContent className="max-h-[calc(100vh-3rem)] max-w-[620px] overflow-y-auto">
<DialogHeader className="pr-8">
<DialogTitle className="text-lg">支持的微信版本</DialogTitle>
<DialogDescription>请安装下列完整版本之一。</DialogDescription>
</DialogHeader>
<PersonalWechatSupportedVersionsContent />
</DialogContent>
</Dialog>
<details className="personal-wechat-diagnostics">
<summary>高级诊断</summary>
<p>仅用于排查连接问题,普通使用无需关注这些信息。</p>
<dl>
{[
['微信进程', senderStatus?.wechatPid ? `PID ${senderStatus.wechatPid}` : '未检测到'],
...(isMac
? [['OneBot', senderStatus?.oneBotPid ? `PID ${senderStatus.oneBotPid}` : '未启动']]
: []),
['绑定进程', diagnosticValue(senderStatus?.boundWechatPid)],
[
'接口监听',
`${diagnosticValue(senderStatus?.endpoint)} · ${senderStatus?.endpointReady ? '监听中' : '未监听'}`
],
[
'基址扫描',
senderStatus?.baseAddress || (senderStatus?.baseAddressReady ? '已完成' : '未完成')
],
['文字 Hook', senderStatus?.textHookReady ? '已就绪' : '未就绪'],
['图片 Hook', senderStatus?.imageHookReady ? '已就绪' : '未就绪'],
['语音能力', senderStatus?.canSendVoice ? '可发送' : '未就绪'],
['消息监听', senderStatus?.messageListenerReady ? '正常' : '未就绪'],
['微信版本', diagnosticValue(senderStatus?.wechatVersion)],
[
'运行时',
runtimeStatus?.version
? `${runtimeStatus.version} · ${runtimeStatus.state}`
: diagnosticValue(senderStatus?.runtimeReady)
]
].map(([label, value]) => (
<div key={label}>
<dt>{label}</dt>
<dd>{value}</dd>
</div>
))}
</dl>
</details>
</section>
)
}
@@ -1,46 +0,0 @@
import type React from 'react'
export const WECHAT_VERSION_DOWNLOAD_URL = 'https://github.com/zsbai/wechat-versions/releases'
export const BUNDLED_WECHAT_VERSIONS = [
'4.1.6.12',
'4.1.6.46',
'4.1.6.47',
'4.1.7.31',
'4.1.7.55',
'4.1.7.57',
'4.1.8.28',
'4.1.8.29',
'4.1.8.104',
'4.1.8.107',
'4.1.9.52',
'4.1.9.55',
'4.1.9.58',
'4.1.10.53',
'4.1.11.53'
] as const
export function PersonalWechatSupportedVersionsContent(): React.ReactElement {
return (
<>
<a
className="w-fit rounded-md border border-primary/30 bg-primary/10 px-3 py-2 text-xs font-semibold text-primary hover:border-primary"
href={WECHAT_VERSION_DOWNLOAD_URL}
target="_blank"
rel="noreferrer"
>
下载微信历史版本 ↗
</a>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{BUNDLED_WECHAT_VERSIONS.map((version) => (
<span
className="rounded-md border border-border bg-background px-2 py-2 text-center text-xs text-muted-foreground"
key={version}
>
{version}
</span>
))}
</div>
</>
)
}
@@ -6,6 +6,8 @@ import type {
import { Button, Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui'
import { PersonalWechatChatComposer, type ChatMessage } from './PersonalWechatChatComposer'
import type { PersonalWechatSendDialogProps } from './PersonalWechatSendDialog'
import { ReportImagePostfixInput } from './ReportImagePostfixInput'
import { useReportImagePostfixSetting } from './useReportImagePostfixSetting'
function fallbackStatus(error: unknown): PersonalWechatSenderStatus {
return {
@@ -45,7 +47,7 @@ function statusLabel(status: PersonalWechatSenderStatus | null): string {
}
function statusText(value: string): string {
return value.replace(/OneBot|Hook/gi, '微信发送能力').replace(/个人微信发送组件/g, '微信发送能力')
return value.replace(/Hook/gi, '微信发送能力').replace(/个人微信发送组件/g, '微信发送能力')
}
function statusDescription(status: PersonalWechatSenderStatus | null): string {
@@ -64,12 +66,17 @@ export function PersonalWechatWindowsSendDialog({
const [status, setStatus] = useState<PersonalWechatSenderStatus | null>(null)
const [detecting, setDetecting] = useState(true)
const [sendBusy, setSendBusy] = useState(false)
const [sendError, setSendError] = useState<string | null>(null)
const [sendSuccess, setSendSuccess] = useState<string | null>(null)
const [messages, setMessages] = useState<ChatMessage[]>([])
const requestIdRef = useRef(0)
const restoreFocusRef = useRef<HTMLElement | null>(null)
const closingRef = useRef(false)
const displayName = contact.m_nsNickName || contact.m_nsUsrName || '未命名会话'
const targetId = contact.m_nsUsrName
const { postfixText, setPostfixText, persistPostfixText } = useReportImagePostfixSetting(
Boolean(initialImage)
)
const refreshStatus = useCallback(async (): Promise<void> => {
const requestId = ++requestIdRef.current
@@ -103,9 +110,7 @@ export function PersonalWechatWindowsSendDialog({
onOpenPersonalWechatSettings()
}
const handleSend = async (
filePath: string
): Promise<{ success: boolean; error?: string }> => {
const handleSend = async (filePath: string): Promise<{ success: boolean; error?: string }> => {
setSendBusy(true)
try {
const response = await window.api.sendGeneratedTtsVoice({
@@ -137,15 +142,28 @@ export function PersonalWechatWindowsSendDialog({
const handleSendReportImage = async (): Promise<void> => {
if (!initialImage || !status?.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)
setStatus(response.status)
if (!response.success) return
if (!response.success) {
setSendError(response.error || '日报图片发送失败')
return
}
if (response.postfixError) {
setSendError(`日报图片已发送,但${response.postfixError}`)
setSendSuccess('日报图片发送成功')
} else {
setSendSuccess(postfixText.trim() ? '日报图片和后置词发送成功' : '日报图片发送成功')
}
setMessages((current) => [
...current,
{
@@ -156,6 +174,8 @@ export function PersonalWechatWindowsSendDialog({
outgoing: true
}
])
} catch (error) {
setSendError(error instanceof Error ? error.message : String(error))
} finally {
setSendBusy(false)
}
@@ -183,7 +203,7 @@ export function PersonalWechatWindowsSendDialog({
<DialogHeader className="flex-row items-center justify-between space-y-0 pr-10">
<div>
<span className="text-[11px] font-bold tracking-normal text-primary">实验性功能</span>
<DialogTitle className="mt-0.5 text-[19px] leading-[26px] tracking-normal">
<DialogTitle className="mt-0.5 text-[19px] leading-[26px] tracking-normal">
文字转语音
</DialogTitle>
</div>
@@ -201,7 +221,7 @@ export function PersonalWechatWindowsSendDialog({
</div>
<div className="personal-wechat-send-target">
<span>{isGroupChat ? '发送到群聊' : '发送给联系人'}</span>
<span>{isGroupChat ? '发送到群聊' : '发送给联系人'}</span>
<strong>{displayName}</strong>
<code>{targetId}</code>
</div>
@@ -246,7 +266,7 @@ export function PersonalWechatWindowsSendDialog({
key={message.id}
className={`personal-wechat-message-bubble ${message.outgoing ? 'is-outgoing' : ''}`}
>
<span className="personal-wechat-message-kind">语音</span>
<span className="personal-wechat-message-kind">语音</span>
<span>{message.text || message.fileName}</span>
</div>
))}
@@ -255,6 +275,16 @@ export function PersonalWechatWindowsSendDialog({
{initialImage && status.canSendImage ? (
<section className="personal-wechat-composer" aria-label="日报图片发送">
<p>已准备日报图片:{initialImage.name}</p>
<ReportImagePostfixInput
value={postfixText}
onChange={setPostfixText}
onBlur={() =>
void persistPostfixText().catch((error) =>
setSendError(error instanceof Error ? error.message : '发送后置词保存失败')
)
}
disabled={sendBusy}
/>
<Button size="sm" onClick={() => void handleSendReportImage()} disabled={sendBusy}>
{sendBusy ? '发送中…' : '发送日报图片'}
</Button>
@@ -271,6 +301,16 @@ export function PersonalWechatWindowsSendDialog({
busy={sendBusy}
/>
) : null}
{sendError ? (
<div className="personal-wechat-global-error" role="alert">
{sendError}
</div>
) : null}
{sendSuccess ? (
<div className="personal-wechat-global-success" role="status">
{sendSuccess}
</div>
) : null}
</>
) : null}
</DialogContent>
@@ -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 (
<label className="flex flex-col gap-1.5 text-sm">
<span className="font-medium">发送后置词</span>
<input
aria-label="发送后置词"
className="h-9 rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary disabled:opacity-60"
value={value}
maxLength={200}
disabled={disabled}
onChange={(event) => onChange(event.target.value)}
onBlur={onBlur}
placeholder={DEFAULT_REPORT_IMAGE_POSTFIX_TEXT}
/>
<span className="text-xs text-muted-foreground">
图片确认发送成功后,会发送一条文本消息;留空则只发图片。
</span>
</label>
)
}
@@ -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<void>
} {
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<void> => {
const setSettings = window.api.setSettings
if (typeof setSettings !== 'function') return
await setSettings({ reportImagePostfixText: postfixText })
}, [postfixText])
return { postfixText, setPostfixText, persistPostfixText }
}
@@ -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 安装失败')
@@ -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<PersonalWechatRuntimeStatus['state'], string> = {
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 (
<section className="settings-card grid gap-2">
<p>发送能力属授权制,需要联系群主。请先加入交流群,然后在群内添加群主申请授权。</p>
<p className="settings-footnote">
进群请点击{' '}
<a
className="text-primary hover:underline"
href={GROUP_README_URL}
target="_blank"
rel="noreferrer"
>
这里
</a>{' '}
跳转。
</p>
</section>
)
}
export function PersonalWechatSendPage({
onNotice,
onOpenTextToSpeechSettings
@@ -78,12 +69,7 @@ export function PersonalWechatSendPage({
}): ReactElement {
const [capability, setCapability] = useState<PersonalWechatSendCapability | null>(null)
const [senderStatus, setSenderStatus] = useState<PersonalWechatSenderStatus | null>(null)
const [runtimeStatus, setRuntimeStatus] = useState<PersonalWechatRuntimeStatus | null>(null)
const [runtimeProgress, setRuntimeProgress] = useState<PersonalWechatRuntimeProgressEvent | null>(
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<HTMLButtonElement | null>(null)
const personalWechatRuntimeSupported = isMac && Boolean(runtimeStatus?.supported)
const refresh = useCallback(async (): Promise<void> => {
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<void> => {
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<void> => {
if (!personalWechatRuntimeSupported) return
await window.api.cancelPersonalWechatRuntimeDownload()
onNotice('正在取消发送组件下载')
}
const removeRuntime = async (): Promise<void> => {
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<void> => {
if (!personalWechatRuntimeSupported) return
const result = await window.api.openPersonalWechatRuntimeDirectory()
if (!result.success) onNotice(result.error || '无法打开发送组件目录')
}
const refreshRuntime = async (): Promise<void> => {
if (!isMac || runtimeBusy) return
try {
setRuntimeStatus(await window.api.getPersonalWechatRuntimeStatus())
} catch (reason) {
setError(reason instanceof Error ? reason.message : '微信发送组件状态读取失败')
}
}
const handleKeepOneBotProcessChange = async (keep: boolean): Promise<void> => {
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<void> => {
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({
<span
className={`settings-status-badge ${loading ? 'checking' : pageReady ? '' : pageStatus === 'unsupported' ? 'unavailable' : 'warning'}`}
>
{loading ? '检测中' : capabilityLabel[pageStatus]}
{loading ? '检测中' : macUnavailable ? '暂不可用' : capabilityLabel[pageStatus]}
</span>
</header>
<div className="settings-page-scroll">
@@ -407,48 +288,55 @@ export function PersonalWechatSendPage({
<Skeleton className="h-28 w-full" />
) : (
<>
<h2 className="settings-section-heading">发送能力</h2>
<section className="settings-card">
<div className="flex items-start justify-between gap-4">
<div>
<span className="settings-card-kicker">个人微信</span>
<strong className="mt-1 block text-base">{pageMessage}</strong>
<p className="mt-2 text-sm text-muted-foreground">
{pageStatus === 'unsupported' && isMac
? 'Intel Mac 不支持个人微信发送。'
: pageStatus === 'unsupported' && !isWindows
? '微信消息发送目前仅支持 macOS 和 Windows。'
: isWindows
? 'Windows 通过本机微信发送接口工作,请先配置并检测接口端口。'
: '档案中的文字、图片和语音发送,以及定时日报发送,都会使用这项能力。'}
</p>
</div>
<Button variant="outline" size="sm" onClick={() => void detectCapability()}>
{detecting ? '刷新中…' : isWindows ? '重新检测' : '刷新状态'}
</Button>
</div>
<div className="mt-5 grid grid-cols-3 gap-2" aria-label="微信发送能力明细">
{(
[
['文字', pageCapabilities.text],
['图片', pageCapabilities.image],
['语音', pageCapabilities.voice]
] as const
).map(([label, available]) => (
<div key={label} className="rounded-lg border border-border-subtle px-3 py-2">
<span className="block text-xs text-muted-foreground">{label}</span>
<strong className="mt-1 block text-sm">
{available ? '可发送' : '未就绪'}
</strong>
{/* macOS 走下方「配置状态」SetupGuide 统一展示,这里只在 Windows/其它
* 平台显示旧「发送能力」卡(Mac 上两者重复,已按用户要求移除)。 */}
{!isMac ? (
<>
<h2 className="settings-section-heading">发送能力</h2>
<section className="settings-card">
<div className="flex items-start justify-between gap-4">
<div>
<span className="settings-card-kicker">个人微信</span>
<strong className="mt-1 block text-base">{pageMessage}</strong>
<p className="mt-2 text-sm text-muted-foreground">
{pageStatus === 'unsupported'
? '微信消息发送目前仅支持 macOS 和 Windows。'
: isWindows
? 'Windows 通过本机微信发送接口工作,请先配置并检测接口端口。'
: '档案中的文字、图片和语音发送,以及定时日报发送,都会使用这项能力。'}
</p>
</div>
<Button variant="outline" size="sm" onClick={() => void detectCapability()}>
{detecting ? '刷新中…' : isWindows ? '重新检测' : '刷新状态'}
</Button>
</div>
))}
</div>
{error ? (
<p className="mt-3 text-sm text-destructive" role="alert">
{error}
</p>
) : null}
</section>
<div className="mt-5 grid grid-cols-3 gap-2" aria-label="微信发送能力明细">
{(
[
['文字', pageCapabilities.text],
['图片', pageCapabilities.image],
['语音', pageCapabilities.voice]
] as const
).map(([label, available]) => (
<div
key={label}
className="rounded-lg border border-border-subtle px-3 py-2"
>
<span className="block text-xs text-muted-foreground">{label}</span>
<strong className="mt-1 block text-sm">
{available ? '可发送' : '未就绪'}
</strong>
</div>
))}
</div>
{error ? (
<p className="mt-3 text-sm text-destructive" role="alert">
{error}
</p>
) : null}
</section>
</>
) : null}
{isWindows ? (
<>
@@ -515,156 +403,16 @@ export function PersonalWechatSendPage({
</section>
<h2 className="settings-section-heading">发送能力授权</h2>
<section className="settings-card grid gap-2">
<p>发送能力属授权制,需要联系群主。请先加入交流群,然后在群内添加群主申请授权。</p>
<p className="settings-footnote">
进群请点击{' '}
<a
className="text-primary hover:underline"
href={GROUP_README_URL}
target="_blank"
rel="noreferrer"
>
这里
</a>{' '}
跳转。
</p>
</section>
<SendCapabilityAuthorizationCard />
</>
) : isMac ? (
<>
<h2 className="settings-section-heading">OneBot 运行时</h2>
<section className="settings-card tts-runtime-card">
<div className="tts-runtime-summary">
<span className="settings-card-kicker">
OneBot {runtimeStatus?.version || 'v0.0.18'}
</span>
<strong>
{runtimeStatus?.state === 'downloading'
? `正在下载 ${Math.round(runtimeStatus.progress * 100)}%`
: runtimeStatus
? RUNTIME_STATUS_LABELS[runtimeStatus.state]
: '正在检测'}
</strong>
<small>
{!runtimeStatus
? '正在检测当前平台与组件状态'
: personalWechatRuntimeSupported
? `仅用于连接 macOS 微信 · ${formatBytes(runtimeStatus.totalBytes)}`
: '当前 Mac 环境不满足个人微信发送组件要求'}
</small>
{runtimeStatus?.error ? (
<p className="tts-runtime-error">{runtimeStatus.error}</p>
) : null}
</div>
<div className="tts-runtime-actions">
{personalWechatRuntimeSupported &&
runtimeStatus?.state === 'downloading' ? (
<Button
variant="outline"
size="sm"
onClick={() => void cancelRuntimeDownload()}
>
取消下载
</Button>
) : personalWechatRuntimeSupported && runtimeStatus?.state === 'ready' ? (
<>
{runtimeStatus.directory ? (
<Button
variant="outline"
size="sm"
onClick={() => void openRuntimeDirectory()}
>
打开目录
</Button>
) : null}
{runtimeStatus.removable ? (
<Button
variant="destructive"
size="sm"
disabled={runtimeBusy}
onClick={() => void removeRuntime()}
>
卸载组件
</Button>
) : null}
</>
) : personalWechatRuntimeSupported ? (
<Button
size="sm"
disabled={runtimeBusy}
onClick={() => void downloadRuntime()}
>
{runtimeStatus?.state === 'invalid' || runtimeStatus?.state === 'error'
? '重新下载'
: '下载组件'}
</Button>
) : null}
<Button
variant="outline"
size="sm"
disabled={runtimeBusy}
onClick={() => void refreshRuntime()}
>
重新检测组件
</Button>
{personalWechatRuntimeSupported ? (
<Button
variant="outline"
size="sm"
onClick={(event) => {
runtimeVersionsTriggerRef.current = event.currentTarget
setShowWechatVersions(true)
}}
>
支持版本
</Button>
) : null}
</div>
{personalWechatRuntimeSupported && runtimeStatus?.state === 'downloading' ? (
<div className="tts-runtime-progress">
<div>
<span>{Math.round(runtimeStatus.progress * 100)}%</span>
<small>
{formatBytes(runtimeStatus.downloadedBytes)} /{' '}
{formatBytes(runtimeStatus.totalBytes)}
</small>
</div>
<progress
value={runtimeStatus.progress}
max={1}
aria-label="微信发送组件下载进度"
/>
</div>
) : null}
<div className="col-span-full flex items-center justify-between gap-4 border-t border-border-subtle pt-4">
<div>
<strong className="block text-sm">保留 OneBot 进程</strong>
<p className="mt-1 text-xs text-muted-foreground">
退出 TraceMemo 后继续保留进程,重新打开时可以复用。
</p>
</div>
<Switch
checked={keepOneBotProcess}
disabled={keepProcessBusy}
onCheckedChange={(checked) => void handleKeepOneBotProcessChange(checked)}
aria-label="保留 OneBot 进程"
/>
</div>
</section>
<h2 className="settings-section-heading">配置状态</h2>
<PersonalWechatSetupGuide
runtimeStatus={runtimeStatus}
senderStatus={senderStatus}
runtimeProgress={runtimeProgress}
runtimeBusy={runtimeBusy}
binding={binding}
detecting={detecting}
sessionBound={boundToCurrentWechat(senderStatus)}
onDownloadRuntime={() => void downloadRuntime()}
onBind={() => void bindWechat()}
onStartSending={() =>
onNotice('微信消息发送能力已就绪,请在档案中选择会话开始发送。')
@@ -678,24 +426,6 @@ export function PersonalWechatSendPage({
</div>
</div>
</div>
<Dialog open={showWechatVersions} onOpenChange={setShowWechatVersions}>
<DialogContent
className="max-h-[calc(100vh-3rem)] max-w-[620px] overflow-y-auto"
onCloseAutoFocus={(event) => {
const trigger = runtimeVersionsTriggerRef.current
if (!trigger) return
event.preventDefault()
trigger.focus()
runtimeVersionsTriggerRef.current = null
}}
>
<DialogHeader className="pr-8">
<DialogTitle className="text-lg">支持的微信版本</DialogTitle>
<DialogDescription>请安装下列完整版本之一。</DialogDescription>
</DialogHeader>
<PersonalWechatSupportedVersionsContent />
</DialogContent>
</Dialog>
</>
)
}
+43 -50
View File
@@ -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;
}
}
+95
View File
@@ -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
}
-29
View File
@@ -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 {}
+1 -4
View File
@@ -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
}
+6 -1
View File
@@ -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
+4
View File
@@ -23,6 +23,10 @@ export type WechatActionPurpose =
| 'manual_image'
/** 用户手动发送文本。 */
| 'manual_text'
/** 用户确认后的日报图片;进入 automation 队列以复用 3 秒发送间隔。 */
| 'manual_report_image'
/** 日报图片成功后的后置词;只有图片 sent 后才会创建。 */
| 'manual_report_postfix'
/**
* Automation v1 的两个用途。
*
@@ -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.ComponentProps<typeof PersonalWechatSendDialog>> = {}
): React.ReactElement {
@@ -85,13 +73,24 @@ async function startComposer(): Promise<void> {
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() })
@@ -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(<PersonalWechatSendPage onNotice={vi.fn()} />)
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(<PersonalWechatSendPage onNotice={vi.fn()} />)
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(<PersonalWechatSendPage onNotice={onNotice} />)
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()
})
})
@@ -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(
<PersonalWechatSetupGuide
senderStatus={unavailableStatus}
binding={false}
detecting={false}
sessionBound={false}
onBind={vi.fn()}
onStartSending={vi.fn()}
/>
)
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(<PersonalWechatSendPage onNotice={vi.fn()} />)
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()
})
})
+5 -1
View File
@@ -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()
@@ -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(<TextToSpeechPage onNotice={vi.fn()} />)
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(<PersonalWechatSendPage onNotice={vi.fn()} />)
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 () => {
+4 -5
View File
@@ -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(<TextToSpeechPage onNotice={vi.fn()} />)
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()
})
})
+7 -12
View File
@@ -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()
+2 -20
View File
@@ -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
@@ -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)
})
})
@@ -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> = {}): MacWechatRuntimeSnapshot {
return {
lifecycle: 'online',
wechatRestartRequired: false,
endpoint: 'http://127.0.0.1:4290',
port: 4290,
manifest,
...overrides
}
}
function binding(overrides: Partial<MacWechatBindingStatus> = {}): 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)
})
})
@@ -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',
+1 -222
View File
@@ -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 })
})
})
@@ -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')
+6 -29
View File
@@ -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['"]/)
})
})
@@ -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<typeof import('../../src/main/services/settings-store')> {
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')
})
})
+49
View File
@@ -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'))