mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-04 03:01:42 +08:00
feat: mac发送能力重构为libtmwechat
- 发送改走本地host, 在微信登录时触发 - 删除旧OneBot及对应UI控制面
This commit is contained in:
+2
-1
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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`。
|
||||
@@ -34,7 +34,6 @@ extraResources:
|
||||
to: resources
|
||||
filter:
|
||||
- '**/*'
|
||||
- '!connectors/wechat-personal/**'
|
||||
- from: docs/skill/tracememo-reader
|
||||
to: skill/tracememo-reader
|
||||
filter:
|
||||
|
||||
+2
-2
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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] 运行时准备完成')
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Vendored
+7
-16
@@ -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
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'))
|
||||
|
||||
Reference in New Issue
Block a user