mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-08 15:43:22 +08:00
feat: mac发送能力重构为libtmwechat
- 发送改走本地host, 在微信登录时触发 - 删除旧OneBot及对应UI控制面
This commit is contained in:
+2
-1
@@ -10,7 +10,8 @@ out
|
|||||||
coverage/
|
coverage/
|
||||||
playwright-report/
|
playwright-report/
|
||||||
test-results/
|
test-results/
|
||||||
resources/connectors/wechat-personal/
|
resources/runtime/darwin-arm64
|
||||||
|
.native-runtime-source
|
||||||
.omc
|
.omc
|
||||||
.codex/
|
.codex/
|
||||||
skills-lock.json
|
skills-lock.json
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ Agent Hub 让微信机器人调用本机 TraceMemo;Reader Skill / Local HTTP A
|
|||||||
- [实验性:自托管微信分享卡片](./deployment/experimental-wechat-share-card.md)
|
- [实验性:自托管微信分享卡片](./deployment/experimental-wechat-share-card.md)
|
||||||
- [微信分享卡片自动部署 Skill](./skill/setup-wechat-share-card/SKILL.md)
|
- [微信分享卡片自动部署 Skill](./skill/setup-wechat-share-card/SKILL.md)
|
||||||
- [TraceMemo Reader Skill 文件](./skill/tracememo-reader/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
|
to: resources
|
||||||
filter:
|
filter:
|
||||||
- '**/*'
|
- '**/*'
|
||||||
- '!connectors/wechat-personal/**'
|
|
||||||
- from: docs/skill/tracememo-reader
|
- from: docs/skill/tracememo-reader
|
||||||
to: skill/tracememo-reader
|
to: skill/tracememo-reader
|
||||||
filter:
|
filter:
|
||||||
|
|||||||
+1
-1
@@ -39,7 +39,7 @@
|
|||||||
"prepare:ffmpeg:mac:arm64": "node scripts/prepare-electron-runtime.cjs --platform darwin --arch arm64",
|
"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: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: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",
|
"start": "electron-vite preview",
|
||||||
"predev": "node scripts/ensure-electron-binary.cjs",
|
"predev": "node scripts/ensure-electron-binary.cjs",
|
||||||
"dev": "node scripts/ensure-env.cjs && electron-vite dev",
|
"dev": "node scripts/ensure-env.cjs && electron-vite dev",
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+62
-65
@@ -120,14 +120,15 @@ import { GroupStatsService } from './services/group-stats-service'
|
|||||||
import { wechatActionLogService } from './services/wechat-action-log-service'
|
import { wechatActionLogService } from './services/wechat-action-log-service'
|
||||||
import { toPersonalWechatSendResult, wechatActionGateway } from './services/wechat-action-gateway'
|
import { toPersonalWechatSendResult, wechatActionGateway } from './services/wechat-action-gateway'
|
||||||
import { personalWechatSendService } from './services/personal-wechat-send-service'
|
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 { getPersonalWechatSendCapability } from './services/personal-wechat-capability-service'
|
||||||
import { scheduledReportService } from './services/scheduled-report-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 { personalWechatVoiceEnvironmentService } from './services/personal-wechat-voice-environment-service'
|
||||||
import type {
|
import type {
|
||||||
PersonalWechatGeneratedTtsVoiceRequest,
|
PersonalWechatGeneratedTtsVoiceRequest,
|
||||||
PersonalWechatSendRequest,
|
PersonalWechatSendRequest,
|
||||||
PersonalWechatSendResult
|
PersonalWechatSendResult,
|
||||||
|
PersonalWechatSenderStatus
|
||||||
} from '../shared/personal-wechat'
|
} from '../shared/personal-wechat'
|
||||||
import type {
|
import type {
|
||||||
ScheduledReportCreateInput,
|
ScheduledReportCreateInput,
|
||||||
@@ -201,6 +202,12 @@ import type {
|
|||||||
WechatShareServiceConfig
|
WechatShareServiceConfig
|
||||||
} from '../shared/wechat-share-card'
|
} 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.
|
// 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
|
// Plain console.error then throws EPIPE on a closed pipe and crashes the IPC
|
||||||
// handler. Wrap console.* before any other module logs anything.
|
// handler. Wrap console.* before any other module logs anything.
|
||||||
@@ -228,7 +235,6 @@ const databaseKeyStore = new DatabaseKeyStore()
|
|||||||
const imageKeyConfigService = new ImageKeyConfigService()
|
const imageKeyConfigService = new ImageKeyConfigService()
|
||||||
const aiProviderService = new AIProviderService()
|
const aiProviderService = new AIProviderService()
|
||||||
const textToSpeechSettingsService = new TextToSpeechSettingsService()
|
const textToSpeechSettingsService = new TextToSpeechSettingsService()
|
||||||
const personalWechatRuntimeManager = new PersonalWechatRuntimeManager()
|
|
||||||
const keyServiceMac = new KeyServiceMac()
|
const keyServiceMac = new KeyServiceMac()
|
||||||
const keyServiceWin = new KeyServiceWin()
|
const keyServiceWin = new KeyServiceWin()
|
||||||
const wechatShareConfigStore = new WechatShareConfigStore()
|
const wechatShareConfigStore = new WechatShareConfigStore()
|
||||||
@@ -839,11 +845,6 @@ app.whenReady().then(async () => {
|
|||||||
if (!window.isDestroyed()) window.webContents.send('voice:modelProgress', status)
|
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) => {
|
protocol.handle('wxe-media', async (request) => {
|
||||||
const filePath = videoAssetService?.pathForUrl(request.url)
|
const filePath = videoAssetService?.pathForUrl(request.url)
|
||||||
if (!filePath) return new Response('Not found', { status: 404 })
|
if (!filePath) return new Response('Not found', { status: 404 })
|
||||||
@@ -1214,25 +1215,46 @@ app.whenReady().then(async () => {
|
|||||||
// 项目规则:**所有发送都必须经过 WechatActionGateway**(审计 + 幂等 + 同一个 Send Log)。
|
// 项目规则:**所有发送都必须经过 WechatActionGateway**(审计 + 幂等 + 同一个 Send Log)。
|
||||||
// 手动发送在改造前从这里直连 `PersonalWechatSendService`,于是完全不留痕 ——
|
// 手动发送在改造前从这里直连 `PersonalWechatSendService`,于是完全不留痕 ——
|
||||||
// 排查「消息到底发没发出去」时恰好缺的就是那份证据。
|
// 排查「消息到底发没发出去」时恰好缺的就是那份证据。
|
||||||
// `triggerType: 'user'` 不会命中 automation 专用的 purpose allowlist 与 3s 节流,
|
// 普通手动发送保持 triggerType=user;带 postfixText 的日报图片由 Gateway 编排成
|
||||||
// 所以行为与改造前一致,只是多一条审计记录(「发送日志」里能看到)。
|
// 两个 automation action,以复用同一条 3s 队列,并严格在图片 sent 后才发后置词。
|
||||||
if (request.type === 'text' || request.type === 'image') {
|
if (request.type === 'text' || request.type === 'image') {
|
||||||
const to = String(request.to || '').trim()
|
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({
|
const action = await wechatActionGateway.execute({
|
||||||
origin: 'user_manual',
|
origin: 'user_manual',
|
||||||
purpose: request.type === 'text' ? 'manual_text' : 'manual_image',
|
purpose: request.type === 'text' ? 'manual_text' : 'manual_image',
|
||||||
triggerType: 'user',
|
triggerType: 'user',
|
||||||
recipient: {
|
recipient,
|
||||||
type: request.isGroup || to.endsWith('@chatroom') ? 'group' : 'contact',
|
|
||||||
id: to
|
|
||||||
},
|
|
||||||
content:
|
content:
|
||||||
request.type === 'text'
|
request.type === 'text'
|
||||||
? { type: 'text', text: String(request.text || '') }
|
? { type: 'text', text: String(request.text || '') }
|
||||||
: { type: 'image', path: String(request.filePath || '') }
|
: { type: 'image', path: String(request.filePath || '') }
|
||||||
})
|
})
|
||||||
// 返回契约保持 `PersonalWechatSendResult`,界面判读不用改。
|
// 返回契约保持 `PersonalWechatSendResult`,界面判读不用改。
|
||||||
return toPersonalWechatSendResult(action, await personalWechatSendService.getStatus())
|
return toPersonalWechatSendResult(action, await currentPersonalWechatSenderStatus())
|
||||||
}
|
}
|
||||||
|
|
||||||
// 语音仍走既有分支:它有自己的网关入口 `wechat-personal:sendGeneratedTtsVoice`,
|
// 语音仍走既有分支:它有自己的网关入口 `wechat-personal:sendGeneratedTtsVoice`,
|
||||||
@@ -1645,7 +1667,9 @@ app.whenReady().then(async () => {
|
|||||||
ipcMain.handle('automation:deleteRule', (_, id: string) => automationRuleStore.deleteRule(id))
|
ipcMain.handle('automation:deleteRule', (_, id: string) => automationRuleStore.deleteRule(id))
|
||||||
ipcMain.handle('automation:setRuleEnabled', (_, input: unknown) => {
|
ipcMain.handle('automation:setRuleEnabled', (_, input: unknown) => {
|
||||||
const payload = (input || {}) as { id?: unknown; enabled?: 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) => {
|
ipcMain.handle('automation:listExecutions', (_, query: unknown) => {
|
||||||
const input = (query || {}) as { limit?: unknown }
|
const input = (query || {}) as { limit?: unknown }
|
||||||
@@ -2528,42 +2552,16 @@ app.whenReady().then(async () => {
|
|||||||
agentHubService.clearConversations()
|
agentHubService.clearConversations()
|
||||||
return { success: true }
|
return { success: true }
|
||||||
})
|
})
|
||||||
ipcMain.handle('wechat-personal:getStatus', () => personalWechatSendService.getStatus())
|
ipcMain.handle('wechat-personal:getStatus', () => currentPersonalWechatSenderStatus())
|
||||||
ipcMain.handle('wechat-personal:getKeepProcess', () =>
|
|
||||||
personalWechatSendService.getKeepOneBotProcess()
|
|
||||||
)
|
|
||||||
ipcMain.handle('wechat-personal:setKeepProcess', (_, keep: boolean) =>
|
|
||||||
personalWechatSendService.setKeepOneBotProcess(Boolean(keep))
|
|
||||||
)
|
|
||||||
ipcMain.handle('wechat-personal:checkStatus', (_, port?: string) =>
|
ipcMain.handle('wechat-personal:checkStatus', (_, port?: string) =>
|
||||||
personalWechatSendService.checkWindowsStatus(port)
|
personalWechatSendService.checkWindowsStatus(port)
|
||||||
)
|
)
|
||||||
ipcMain.handle('wechat-personal:checkVoiceEnvironment', () =>
|
ipcMain.handle('wechat-personal:checkVoiceEnvironment', () =>
|
||||||
personalWechatVoiceEnvironmentService.check()
|
personalWechatVoiceEnvironmentService.check()
|
||||||
)
|
)
|
||||||
ipcMain.handle('wechat-personal:installPilk', async () => {
|
ipcMain.handle('wechat-personal:installPilk', () =>
|
||||||
const result = await personalWechatVoiceEnvironmentService.installPilk()
|
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:openVoicePythonDownload', async () => {
|
ipcMain.handle('wechat-personal:openVoicePythonDownload', async () => {
|
||||||
try {
|
try {
|
||||||
await shell.openExternal('https://www.python.org/downloads/macos/')
|
await shell.openExternal('https://www.python.org/downloads/macos/')
|
||||||
@@ -2580,23 +2578,20 @@ app.whenReady().then(async () => {
|
|||||||
return { success: false, error: '无法打开 FFmpeg 安装页面' }
|
return { success: false, error: '无法打开 FFmpeg 安装页面' }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
ipcMain.handle('wechat-personal:getRuntimeStatus', () => personalWechatRuntimeManager.getStatus())
|
ipcMain.handle('wechat-personal:rebind', async () => {
|
||||||
ipcMain.handle('wechat-personal:downloadRuntime', () => personalWechatRuntimeManager.download())
|
if (process.platform !== 'darwin') return personalWechatSendService.rebind()
|
||||||
ipcMain.handle('wechat-personal:cancelRuntimeDownload', () => ({
|
|
||||||
success: personalWechatRuntimeManager.cancelDownload()
|
const result = await macWechatRuntimeManager.bind()
|
||||||
}))
|
const status = await macWechatRuntimeManager.buildSenderStatus()
|
||||||
ipcMain.handle('wechat-personal:removeRuntime', async () => {
|
if (result.ok) return status
|
||||||
await personalWechatSendService.terminate(true)
|
|
||||||
return personalWechatRuntimeManager.remove()
|
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(
|
ipcMain.handle(
|
||||||
'wechat-personal:sendGeneratedTtsVoice',
|
'wechat-personal:sendGeneratedTtsVoice',
|
||||||
async (_, request: PersonalWechatGeneratedTtsVoiceRequest) => {
|
async (_, request: PersonalWechatGeneratedTtsVoiceRequest) => {
|
||||||
@@ -2624,7 +2619,7 @@ app.whenReady().then(async () => {
|
|||||||
action.sendResult && typeof action.sendResult === 'object'
|
action.sendResult && typeof action.sendResult === 'object'
|
||||||
? (action.sendResult as PersonalWechatSendResult)
|
? (action.sendResult as PersonalWechatSendResult)
|
||||||
: undefined
|
: undefined
|
||||||
const status = sendResult?.status || (await personalWechatSendService.getStatus())
|
const status = sendResult?.status || (await currentPersonalWechatSenderStatus())
|
||||||
return { action, status }
|
return { action, status }
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -2699,9 +2694,11 @@ app.on('before-quit', (event) => {
|
|||||||
chat.closeChatDbForQuit().catch(() => false),
|
chat.closeChatDbForQuit().catch(() => false),
|
||||||
voiceRecognition?.dispose().catch(() => undefined),
|
voiceRecognition?.dispose().catch(() => undefined),
|
||||||
knowledgeSearchService?.dispose().catch(() => undefined),
|
knowledgeSearchService?.dispose().catch(() => undefined),
|
||||||
personalWechatSendService.terminate().catch((error) => {
|
process.platform === 'darwin'
|
||||||
console.warn('[Shutdown] personal WeChat sender cleanup failed:', error)
|
? macWechatRuntimeManager.shutdown().catch((error) => {
|
||||||
|
console.warn('[Shutdown] macOS native WeChat runtime cleanup failed:', error)
|
||||||
})
|
})
|
||||||
|
: Promise.resolve()
|
||||||
])
|
])
|
||||||
if (!nativeCallsDrained) {
|
if (!nativeCallsDrained) {
|
||||||
console.warn('[Shutdown] WCDB async calls did not fully drain before quit')
|
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,
|
personalWechatSendService,
|
||||||
type PersonalWechatSendService
|
type PersonalWechatSendService
|
||||||
} from './personal-wechat-send-service'
|
} from './personal-wechat-send-service'
|
||||||
|
import { macWechatRuntimeManager } from './mac-wechat-runtime-manager'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts the detailed sender diagnostics into a small contract that other
|
* 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 {
|
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> {
|
async getPersonalWechatSendCapability(): Promise<PersonalWechatSendCapability> {
|
||||||
|
if (this.platform === 'darwin') {
|
||||||
|
return this.fromMacRuntime()
|
||||||
|
}
|
||||||
const senderStatus = await this.sender.getStatus()
|
const senderStatus = await this.sender.getStatus()
|
||||||
return this.fromSenderStatus(senderStatus)
|
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 {
|
private mapState(senderStatus: PersonalWechatSenderStatus): PersonalWechatSendCapabilityState {
|
||||||
if (senderStatus.platform === 'win32') {
|
if (senderStatus.platform === 'win32') {
|
||||||
if (senderStatus.canSend) return 'ready'
|
if (senderStatus.canSend) return 'ready'
|
||||||
@@ -60,6 +83,7 @@ export class PersonalWechatCapabilityService {
|
|||||||
return 'unsupported'
|
return 'unsupported'
|
||||||
}
|
}
|
||||||
if (senderStatus.state === 'error') return 'error'
|
if (senderStatus.state === 'error') return 'error'
|
||||||
|
if (senderStatus.state === 'runtime_missing') return 'unconfigured'
|
||||||
const hasCurrentBinding = Boolean(
|
const hasCurrentBinding = Boolean(
|
||||||
senderStatus.endpointReady &&
|
senderStatus.endpointReady &&
|
||||||
senderStatus.attachReady &&
|
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
|
PersonalWechatVoiceRuntimeComponent
|
||||||
} from '../../shared/personal-wechat-voice-runtime'
|
} from '../../shared/personal-wechat-voice-runtime'
|
||||||
import { PERSONAL_WECHAT_PILK_VERSION } from '../../shared/personal-wechat-voice-runtime'
|
import { PERSONAL_WECHAT_PILK_VERSION } from '../../shared/personal-wechat-voice-runtime'
|
||||||
import {
|
import { buildPersonalWechatRuntimeEnvironment } from './personal-wechat-send-service'
|
||||||
buildPersonalWechatRuntimeEnvironment,
|
import { macWechatRuntimeManager } from './mac-wechat-runtime-manager'
|
||||||
findPersonalWechatRuntime
|
|
||||||
} from './personal-wechat-send-service'
|
|
||||||
import type { RuntimeLayout } from './personal-wechat-send-service'
|
|
||||||
import { appLogger } from '../app-logger'
|
import { appLogger } from '../app-logger'
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile)
|
const execFileAsync = promisify(execFile)
|
||||||
@@ -37,8 +34,8 @@ type CommandRunner = (
|
|||||||
interface PersonalWechatVoiceEnvironmentServiceOptions {
|
interface PersonalWechatVoiceEnvironmentServiceOptions {
|
||||||
platform?: NodeJS.Platform
|
platform?: NodeJS.Platform
|
||||||
architecture?: string
|
architecture?: string
|
||||||
findRuntime?: () => RuntimeLayout | null
|
isRuntimePresent?: () => boolean
|
||||||
buildEnvironment?: (runtimeRoot?: string) => NodeJS.ProcessEnv
|
buildEnvironment?: () => NodeJS.ProcessEnv
|
||||||
runCommand?: CommandRunner
|
runCommand?: CommandRunner
|
||||||
now?: () => Date
|
now?: () => Date
|
||||||
}
|
}
|
||||||
@@ -100,18 +97,26 @@ function unsupportedEnvironment(): PersonalWechatVoiceEncodingEnvironment {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function defaultIsRuntimePresent(): boolean {
|
||||||
|
try {
|
||||||
|
return macWechatRuntimeManager.isRuntimePresent()
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class PersonalWechatVoiceEnvironmentService {
|
export class PersonalWechatVoiceEnvironmentService {
|
||||||
private readonly platform: NodeJS.Platform
|
private readonly platform: NodeJS.Platform
|
||||||
private readonly architecture: string
|
private readonly architecture: string
|
||||||
private readonly findRuntime: () => RuntimeLayout | null
|
private readonly isRuntimePresent: () => boolean
|
||||||
private readonly buildEnvironment: (runtimeRoot?: string) => NodeJS.ProcessEnv
|
private readonly buildEnvironment: () => NodeJS.ProcessEnv
|
||||||
private readonly runCommand: CommandRunner
|
private readonly runCommand: CommandRunner
|
||||||
private readonly now: () => Date
|
private readonly now: () => Date
|
||||||
|
|
||||||
constructor(options: PersonalWechatVoiceEnvironmentServiceOptions = {}) {
|
constructor(options: PersonalWechatVoiceEnvironmentServiceOptions = {}) {
|
||||||
this.platform = options.platform || process.platform
|
this.platform = options.platform || process.platform
|
||||||
this.architecture = options.architecture || process.arch
|
this.architecture = options.architecture || process.arch
|
||||||
this.findRuntime = options.findRuntime || findPersonalWechatRuntime
|
this.isRuntimePresent = options.isRuntimePresent || defaultIsRuntimePresent
|
||||||
this.buildEnvironment = options.buildEnvironment || buildPersonalWechatRuntimeEnvironment
|
this.buildEnvironment = options.buildEnvironment || buildPersonalWechatRuntimeEnvironment
|
||||||
this.runCommand = options.runCommand || runCommand
|
this.runCommand = options.runCommand || runCommand
|
||||||
this.now = options.now || (() => new Date())
|
this.now = options.now || (() => new Date())
|
||||||
@@ -123,8 +128,8 @@ export class PersonalWechatVoiceEnvironmentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logEnvironmentLine('Checking voice encoding environment')
|
logEnvironmentLine('Checking voice encoding environment')
|
||||||
const runtime = this.findRuntime()
|
const runtimeReady = this.isRuntimePresent()
|
||||||
const environment = this.buildEnvironment(runtime?.root)
|
const environment = this.buildEnvironment()
|
||||||
const python = blankComponent()
|
const python = blankComponent()
|
||||||
const pilk = blankComponent()
|
const pilk = blankComponent()
|
||||||
const ffmpeg = blankComponent()
|
const ffmpeg = blankComponent()
|
||||||
@@ -197,26 +202,25 @@ export class PersonalWechatVoiceEnvironmentService {
|
|||||||
logEnvironmentWarning('ffmpeg: unavailable', { error: ffmpeg.error })
|
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 = {
|
const result: PersonalWechatVoiceEncodingEnvironment = {
|
||||||
state: ready ? 'ready' : 'incomplete',
|
state: ready ? 'ready' : 'incomplete',
|
||||||
ready,
|
ready,
|
||||||
checkedAt: this.now().toISOString(),
|
checkedAt: this.now().toISOString(),
|
||||||
runtimeReady: Boolean(runtime),
|
runtimeReady,
|
||||||
...(runtime ? { runtimeRoot: runtime.root } : {}),
|
|
||||||
python,
|
python,
|
||||||
pilk,
|
pilk,
|
||||||
ffmpeg,
|
ffmpeg,
|
||||||
encoder: pilk.ready
|
encoder: pilk.ready
|
||||||
? 'pilk'
|
? 'pilk'
|
||||||
: runtime && python.ready && ffmpeg.ready
|
: runtimeReady && python.ready && ffmpeg.ready
|
||||||
? 'go-silk'
|
? 'silk'
|
||||||
: 'unavailable',
|
: 'unavailable',
|
||||||
message: ready
|
message: ready
|
||||||
? '语音编码环境正常,可以使用 pilk 编码'
|
? '语音编码环境正常,可以使用 pilk 编码'
|
||||||
: runtime
|
: runtimeReady
|
||||||
? '语音编码环境不完整,OneBot 可能回退到 go-silk'
|
? '语音编码环境不完整,语音将回退到内置 SILK 编码'
|
||||||
: '微信发送组件尚未安装,请先准备 OneBot 运行时'
|
: '当前版本暂未提供微信消息发送功能'
|
||||||
}
|
}
|
||||||
logEnvironmentLine(
|
logEnvironmentLine(
|
||||||
ready ? 'Voice encoding environment is ready' : 'Voice encoding environment is NOT ready',
|
ready ? 'Voice encoding environment is ready' : 'Voice encoding environment is NOT ready',
|
||||||
@@ -245,7 +249,7 @@ export class PersonalWechatVoiceEnvironmentService {
|
|||||||
pythonExecutable,
|
pythonExecutable,
|
||||||
['-m', 'pip', 'install', '--user', `pilk==${PERSONAL_WECHAT_PILK_VERSION}`],
|
['-m', 'pip', 'install', '--user', `pilk==${PERSONAL_WECHAT_PILK_VERSION}`],
|
||||||
{
|
{
|
||||||
env: this.buildEnvironment(before.runtimeRoot || undefined),
|
env: this.buildEnvironment(),
|
||||||
timeout: INSTALL_TIMEOUT_MS
|
timeout: INSTALL_TIMEOUT_MS
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -39,9 +39,8 @@ export interface AppSettings {
|
|||||||
showStartupProgress: boolean
|
showStartupProgress: boolean
|
||||||
ttsSelectedVoiceId: string
|
ttsSelectedVoiceId: string
|
||||||
ttsModel: TextToSpeechModel
|
ttsModel: TextToSpeechModel
|
||||||
/** Keep a running personal-WeChat OneBot process across app restarts. */
|
|
||||||
keepPersonalWechatProcess?: boolean
|
|
||||||
windowsWechatPort: string
|
windowsWechatPort: string
|
||||||
|
reportImagePostfixText: string
|
||||||
/**
|
/**
|
||||||
* Query Agent 是否为桌面「问问微信」与 Agent Hub 查询类问题的主路径。
|
* Query Agent 是否为桌面「问问微信」与 Agent Hub 查询类问题的主路径。
|
||||||
* 默认开启;关闭后回退到 Legacy AI Search Pipeline(仅作 runtime regression 时的回退开关,
|
* 默认开启;关闭后回退到 Legacy AI Search Pipeline(仅作 runtime regression 时的回退开关,
|
||||||
@@ -130,8 +129,8 @@ const DEFAULT_SETTINGS: AppSettings = {
|
|||||||
showStartupProgress: true,
|
showStartupProgress: true,
|
||||||
ttsSelectedVoiceId: '',
|
ttsSelectedVoiceId: '',
|
||||||
ttsModel: 's2.1-pro-free',
|
ttsModel: 's2.1-pro-free',
|
||||||
keepPersonalWechatProcess: false,
|
|
||||||
windowsWechatPort: '',
|
windowsWechatPort: '',
|
||||||
|
reportImagePostfixText: '今日日报',
|
||||||
queryAgentEnabled: true
|
queryAgentEnabled: true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +149,10 @@ export function loadSettings(): AppSettings {
|
|||||||
if (cache) return cache
|
if (cache) return cache
|
||||||
try {
|
try {
|
||||||
if (fs.existsSync(SETTINGS_FILE)) {
|
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 }
|
cache = { ...DEFAULT_SETTINGS, ...raw }
|
||||||
if (raw.autoLogin === undefined) {
|
if (raw.autoLogin === undefined) {
|
||||||
const hasSavedDatabaseKey = fs.existsSync(
|
const hasSavedDatabaseKey = fs.existsSync(
|
||||||
@@ -176,7 +178,7 @@ export function loadSettings(): AppSettings {
|
|||||||
}
|
}
|
||||||
// 防撤回已下线(设置入口已隐藏):历史版本可能把它持久化为 true。
|
// 防撤回已下线(设置入口已隐藏):历史版本可能把它持久化为 true。
|
||||||
// 这里强制收敛为 false 并回写磁盘,确保旧的撤回监听与撤回日志不会继续运行。
|
// 这里强制收敛为 false 并回写磁盘,确保旧的撤回监听与撤回日志不会继续运行。
|
||||||
if (cache.recallProtectionEnabled) {
|
if (cache.recallProtectionEnabled || retiredKeepProcess !== undefined) {
|
||||||
cache.recallProtectionEnabled = false
|
cache.recallProtectionEnabled = false
|
||||||
saveSettings(cache)
|
saveSettings(cache)
|
||||||
}
|
}
|
||||||
@@ -192,7 +194,11 @@ export function loadSettings(): AppSettings {
|
|||||||
export function saveSettings(next: AppSettings): AppSettings {
|
export function saveSettings(next: AppSettings): AppSettings {
|
||||||
// 防撤回已下线:所有写入路径(含 settings:set 补丁)统一收敛为 false,
|
// 防撤回已下线:所有写入路径(含 settings:set 补丁)统一收敛为 false,
|
||||||
// 避免遗留入口或旧版本把它重新打开。
|
// 避免遗留入口或旧版本把它重新打开。
|
||||||
cache = { ...next, recallProtectionEnabled: false }
|
const sanitized = { ...next } as AppSettings & {
|
||||||
|
keepPersonalWechatProcess?: unknown
|
||||||
|
}
|
||||||
|
delete sanitized.keepPersonalWechatProcess
|
||||||
|
cache = { ...sanitized, recallProtectionEnabled: false }
|
||||||
try {
|
try {
|
||||||
ensureDir()
|
ensureDir()
|
||||||
fs.writeJsonSync(SETTINGS_FILE, cache, { spaces: 2 })
|
fs.writeJsonSync(SETTINGS_FILE, cache, { spaces: 2 })
|
||||||
|
|||||||
@@ -30,9 +30,22 @@ const AUTOMATION_PURPOSE_ALLOWLIST = new Set([
|
|||||||
// `AUTOMATION_SEND_PURPOSE` 保持一致,否则会被下面 evaluateWechatActionPolicy
|
// `AUTOMATION_SEND_PURPOSE` 保持一致,否则会被下面 evaluateWechatActionPolicy
|
||||||
// 以 ACTION_NOT_ALLOWED 拦下 —— 那是有意的闸门,不是 bug。
|
// 以 ACTION_NOT_ALLOWED 拦下 —— 那是有意的闸门,不是 bug。
|
||||||
'automation_reply',
|
'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 {
|
export interface WechatActionGatewayDependencies {
|
||||||
getCapability?: () => Promise<PersonalWechatSendCapability>
|
getCapability?: () => Promise<PersonalWechatSendCapability>
|
||||||
send?: (request: PersonalWechatSendRequest) => Promise<PersonalWechatSendResult>
|
send?: (request: PersonalWechatSendRequest) => Promise<PersonalWechatSendResult>
|
||||||
@@ -111,6 +124,35 @@ export class WechatActionGateway {
|
|||||||
this.memberEvents.clear()
|
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[] {
|
listAuditRecords(): WechatActionAuditRecord[] {
|
||||||
return this.loadAuditState().records.map((record) => ({ ...record }))
|
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.
|
* Validate the exact PCM contract consumed by the bundled SILK encoder.
|
||||||
* The Go Silk encoder emits a header-only payload for sub-frame input, which
|
* The encoder emits a header-only payload for sub-frame input, which would
|
||||||
* is accepted by the old path but produces an unplayable WeChat voice.
|
* produce an unplayable WeChat voice.
|
||||||
*/
|
*/
|
||||||
export function validateVoicePcm(
|
export function validateVoicePcm(
|
||||||
pcm: Uint8Array,
|
pcm: Uint8Array,
|
||||||
|
|||||||
Vendored
+7
-16
@@ -92,11 +92,6 @@ import type {
|
|||||||
ScheduledReportTask,
|
ScheduledReportTask,
|
||||||
ScheduledReportUpdateInput
|
ScheduledReportUpdateInput
|
||||||
} from '../shared/scheduled-report'
|
} from '../shared/scheduled-report'
|
||||||
import type {
|
|
||||||
PersonalWechatRuntimeDownloadResult,
|
|
||||||
PersonalWechatRuntimeProgressEvent,
|
|
||||||
PersonalWechatRuntimeStatus
|
|
||||||
} from '../shared/personal-wechat-runtime'
|
|
||||||
import type {
|
import type {
|
||||||
PersonalWechatVoiceEncodingEnvironment,
|
PersonalWechatVoiceEncodingEnvironment,
|
||||||
PersonalWechatVoiceEncodingEnvironmentResult
|
PersonalWechatVoiceEncodingEnvironmentResult
|
||||||
@@ -322,7 +317,10 @@ declare global {
|
|||||||
getAutomationStatus: () => Promise<AutomationStatusSummary>
|
getAutomationStatus: () => Promise<AutomationStatusSummary>
|
||||||
listAutomationRules: () => Promise<AutomationRule[]>
|
listAutomationRules: () => Promise<AutomationRule[]>
|
||||||
createAutomationRule: (draft: AutomationRuleDraft) => 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>
|
deleteAutomationRule: (id: string) => Promise<boolean>
|
||||||
setAutomationRuleEnabled: (id: string, enabled: boolean) => Promise<AutomationRule | null>
|
setAutomationRuleEnabled: (id: string, enabled: boolean) => Promise<AutomationRule | null>
|
||||||
listAutomationExecutions: (query?: { limit?: number }) => Promise<AutomationExecution[]>
|
listAutomationExecutions: (query?: { limit?: number }) => Promise<AutomationExecution[]>
|
||||||
@@ -544,6 +542,7 @@ declare global {
|
|||||||
ttsSelectedVoiceId: string
|
ttsSelectedVoiceId: string
|
||||||
ttsModel: import('../shared/text-to-speech').TextToSpeechModel
|
ttsModel: import('../shared/text-to-speech').TextToSpeechModel
|
||||||
windowsWechatPort: string
|
windowsWechatPort: string
|
||||||
|
reportImagePostfixText: string
|
||||||
imageXorKey: string
|
imageXorKey: string
|
||||||
imageAesKey: string
|
imageAesKey: string
|
||||||
}
|
}
|
||||||
@@ -584,6 +583,7 @@ declare global {
|
|||||||
ttsSelectedVoiceId: string
|
ttsSelectedVoiceId: string
|
||||||
ttsModel: import('../shared/text-to-speech').TextToSpeechModel
|
ttsModel: import('../shared/text-to-speech').TextToSpeechModel
|
||||||
windowsWechatPort: string
|
windowsWechatPort: string
|
||||||
|
reportImagePostfixText: string
|
||||||
imageXorKey: string
|
imageXorKey: string
|
||||||
imageAesKey: string
|
imageAesKey: string
|
||||||
}
|
}
|
||||||
@@ -607,6 +607,7 @@ declare global {
|
|||||||
ttsSelectedVoiceId: string
|
ttsSelectedVoiceId: string
|
||||||
ttsModel: import('../shared/text-to-speech').TextToSpeechModel
|
ttsModel: import('../shared/text-to-speech').TextToSpeechModel
|
||||||
windowsWechatPort: string
|
windowsWechatPort: string
|
||||||
|
reportImagePostfixText: string
|
||||||
imageXorKey: string
|
imageXorKey: string
|
||||||
imageAesKey: string
|
imageAesKey: string
|
||||||
}>
|
}>
|
||||||
@@ -720,21 +721,11 @@ declare global {
|
|||||||
onImageTextIndexStatus: (callback: (status: ImageTextIndexStatus) => void) => () => void
|
onImageTextIndexStatus: (callback: (status: ImageTextIndexStatus) => void) => () => void
|
||||||
getPersonalWechatSenderStatus: () => Promise<PersonalWechatSenderStatus>
|
getPersonalWechatSenderStatus: () => Promise<PersonalWechatSenderStatus>
|
||||||
getPersonalWechatSendCapability: () => Promise<PersonalWechatSendCapability>
|
getPersonalWechatSendCapability: () => Promise<PersonalWechatSendCapability>
|
||||||
getPersonalWechatKeepOneBotProcess: () => Promise<boolean>
|
|
||||||
setPersonalWechatKeepOneBotProcess: (keep: boolean) => Promise<boolean>
|
|
||||||
checkPersonalWechatSenderStatus: (port?: string) => Promise<PersonalWechatSenderStatus>
|
checkPersonalWechatSenderStatus: (port?: string) => Promise<PersonalWechatSenderStatus>
|
||||||
checkPersonalWechatVoiceEncodingEnvironment: () => Promise<PersonalWechatVoiceEncodingEnvironment>
|
checkPersonalWechatVoiceEncodingEnvironment: () => Promise<PersonalWechatVoiceEncodingEnvironment>
|
||||||
installPersonalWechatPilk: () => Promise<PersonalWechatVoiceEncodingEnvironmentResult>
|
installPersonalWechatPilk: () => Promise<PersonalWechatVoiceEncodingEnvironmentResult>
|
||||||
openPersonalWechatVoicePythonDownload: () => Promise<{ success: boolean; error?: string }>
|
openPersonalWechatVoicePythonDownload: () => Promise<{ success: boolean; error?: string }>
|
||||||
openPersonalWechatVoiceFfmpegDownload: () => 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>
|
rebindPersonalWechatSender: () => Promise<PersonalWechatSenderStatus>
|
||||||
sendGeneratedTtsVoice: (
|
sendGeneratedTtsVoice: (
|
||||||
request: PersonalWechatGeneratedTtsVoiceRequest
|
request: PersonalWechatGeneratedTtsVoiceRequest
|
||||||
|
|||||||
@@ -60,11 +60,6 @@ import type {
|
|||||||
ScheduledReportTask,
|
ScheduledReportTask,
|
||||||
ScheduledReportUpdateInput
|
ScheduledReportUpdateInput
|
||||||
} from '../shared/scheduled-report'
|
} from '../shared/scheduled-report'
|
||||||
import type {
|
|
||||||
PersonalWechatRuntimeDownloadResult,
|
|
||||||
PersonalWechatRuntimeProgressEvent,
|
|
||||||
PersonalWechatRuntimeStatus
|
|
||||||
} from '../shared/personal-wechat-runtime'
|
|
||||||
import type {
|
import type {
|
||||||
PersonalWechatVoiceEncodingEnvironment,
|
PersonalWechatVoiceEncodingEnvironment,
|
||||||
PersonalWechatVoiceEncodingEnvironmentResult
|
PersonalWechatVoiceEncodingEnvironmentResult
|
||||||
@@ -554,10 +549,6 @@ const api = {
|
|||||||
ipcRenderer.invoke('wechat-personal:getStatus'),
|
ipcRenderer.invoke('wechat-personal:getStatus'),
|
||||||
getPersonalWechatSendCapability: (): Promise<PersonalWechatSendCapability> =>
|
getPersonalWechatSendCapability: (): Promise<PersonalWechatSendCapability> =>
|
||||||
ipcRenderer.invoke('wechat-personal:getSendCapability'),
|
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> =>
|
checkPersonalWechatSenderStatus: (port?: string): Promise<PersonalWechatSenderStatus> =>
|
||||||
ipcRenderer.invoke('wechat-personal:checkStatus', port),
|
ipcRenderer.invoke('wechat-personal:checkStatus', port),
|
||||||
checkPersonalWechatVoiceEncodingEnvironment:
|
checkPersonalWechatVoiceEncodingEnvironment:
|
||||||
@@ -569,26 +560,6 @@ const api = {
|
|||||||
ipcRenderer.invoke('wechat-personal:openVoicePythonDownload'),
|
ipcRenderer.invoke('wechat-personal:openVoicePythonDownload'),
|
||||||
openPersonalWechatVoiceFfmpegDownload: (): Promise<{ success: boolean; error?: string }> =>
|
openPersonalWechatVoiceFfmpegDownload: (): Promise<{ success: boolean; error?: string }> =>
|
||||||
ipcRenderer.invoke('wechat-personal:openVoiceFfmpegDownload'),
|
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> =>
|
rebindPersonalWechatSender: (): Promise<PersonalWechatSenderStatus> =>
|
||||||
ipcRenderer.invoke('wechat-personal:rebind'),
|
ipcRenderer.invoke('wechat-personal:rebind'),
|
||||||
sendGeneratedTtsVoice: (
|
sendGeneratedTtsVoice: (
|
||||||
|
|||||||
@@ -5,16 +5,14 @@ import type {
|
|||||||
PersonalWechatSenderStatus,
|
PersonalWechatSenderStatus,
|
||||||
PersonalWechatVoiceDiagnostic
|
PersonalWechatVoiceDiagnostic
|
||||||
} from '../../../../shared/personal-wechat'
|
} from '../../../../shared/personal-wechat'
|
||||||
import type {
|
|
||||||
PersonalWechatRuntimeProgressEvent,
|
|
||||||
PersonalWechatRuntimeStatus
|
|
||||||
} from '../../../../shared/personal-wechat-runtime'
|
|
||||||
import { Button, Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui'
|
import { Button, Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui'
|
||||||
import { isMac, isWindows } from '../../utils/runtime-environment'
|
import { isMac, isWindows } from '../../utils/runtime-environment'
|
||||||
import { PersonalWechatChatComposer, type ChatMessage } from './PersonalWechatChatComposer'
|
import { PersonalWechatChatComposer, type ChatMessage } from './PersonalWechatChatComposer'
|
||||||
import { PersonalWechatSetupGuide } from './PersonalWechatSetupGuide'
|
import { PersonalWechatSetupGuide } from './PersonalWechatSetupGuide'
|
||||||
import { PersonalWechatVoiceDiagnosticDialog } from './PersonalWechatVoiceDiagnosticDialog'
|
import { PersonalWechatVoiceDiagnosticDialog } from './PersonalWechatVoiceDiagnosticDialog'
|
||||||
import { PersonalWechatWindowsSendDialog } from './PersonalWechatWindowsSendDialog'
|
import { PersonalWechatWindowsSendDialog } from './PersonalWechatWindowsSendDialog'
|
||||||
|
import { ReportImagePostfixInput } from './ReportImagePostfixInput'
|
||||||
|
import { useReportImagePostfixSetting } from './useReportImagePostfixSetting'
|
||||||
|
|
||||||
type SelectedLocalFile = { path: string; name: string }
|
type SelectedLocalFile = { path: string; name: string }
|
||||||
|
|
||||||
@@ -36,7 +34,7 @@ function fallbackStatus(error: unknown): PersonalWechatSenderStatus {
|
|||||||
sipDisabled: false,
|
sipDisabled: false,
|
||||||
wechatRunning: false,
|
wechatRunning: false,
|
||||||
runtimeReady: false,
|
runtimeReady: false,
|
||||||
endpoint: '127.0.0.1:58080',
|
endpoint: '',
|
||||||
endpointReady: false,
|
endpointReady: false,
|
||||||
attachReady: false,
|
attachReady: false,
|
||||||
baseAddressReady: false,
|
baseAddressReady: false,
|
||||||
@@ -67,18 +65,14 @@ function PersonalWechatMacSendDialog({
|
|||||||
initialImage = null
|
initialImage = null
|
||||||
}: PersonalWechatSendDialogProps): React.ReactElement {
|
}: PersonalWechatSendDialogProps): React.ReactElement {
|
||||||
const [senderStatus, setSenderStatus] = useState<PersonalWechatSenderStatus | null>(null)
|
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 [detecting, setDetecting] = useState(true)
|
||||||
const [binding, setBinding] = useState(false)
|
const [binding, setBinding] = useState(false)
|
||||||
const [runtimeBusy, setRuntimeBusy] = useState(false)
|
|
||||||
const [sendBusy, setSendBusy] = useState(false)
|
const [sendBusy, setSendBusy] = useState(false)
|
||||||
// 状态可能来自之前的 OneBot 进程或日志;发送入口只信任当前语音能力状态。
|
// 发送入口只信任当前 native runtime 上报的能力状态。
|
||||||
const [sessionBound, setSessionBound] = useState(false)
|
const [sessionBound, setSessionBound] = useState(false)
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||||
const [sendError, setSendError] = useState<string | null>(null)
|
const [sendError, setSendError] = useState<string | null>(null)
|
||||||
|
const [sendSuccess, setSendSuccess] = useState<string | null>(null)
|
||||||
const [voiceDiagnostic, setVoiceDiagnostic] = useState<PersonalWechatVoiceDiagnostic | null>(null)
|
const [voiceDiagnostic, setVoiceDiagnostic] = useState<PersonalWechatVoiceDiagnostic | null>(null)
|
||||||
const [voiceDiagnosticOpen, setVoiceDiagnosticOpen] = useState(false)
|
const [voiceDiagnosticOpen, setVoiceDiagnosticOpen] = useState(false)
|
||||||
const requestIdRef = useRef(0)
|
const requestIdRef = useRef(0)
|
||||||
@@ -86,23 +80,19 @@ function PersonalWechatMacSendDialog({
|
|||||||
const closingRef = useRef(false)
|
const closingRef = useRef(false)
|
||||||
const displayName = contact.m_nsNickName || contact.m_nsUsrName || '未命名会话'
|
const displayName = contact.m_nsNickName || contact.m_nsUsrName || '未命名会话'
|
||||||
const targetId = 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 setupReady = Boolean(initialImage ? senderStatus?.canSendImage : senderStatus?.canSendVoice)
|
||||||
|
const { postfixText, setPostfixText, persistPostfixText } = useReportImagePostfixSetting(
|
||||||
|
Boolean(initialImage)
|
||||||
|
)
|
||||||
|
|
||||||
const refreshStatus = useCallback(async (): Promise<void> => {
|
const refreshStatus = useCallback(async (): Promise<void> => {
|
||||||
const requestId = ++requestIdRef.current
|
const requestId = ++requestIdRef.current
|
||||||
setDetecting(true)
|
setDetecting(true)
|
||||||
setSendError(null)
|
setSendError(null)
|
||||||
try {
|
try {
|
||||||
const [nextRuntime, nextSender] = await Promise.all([
|
const nextSender = await window.api.getPersonalWechatSenderStatus()
|
||||||
isMac
|
|
||||||
? window.api.getPersonalWechatRuntimeStatus?.() || Promise.resolve(null)
|
|
||||||
: Promise.resolve(null),
|
|
||||||
window.api.getPersonalWechatSenderStatus()
|
|
||||||
])
|
|
||||||
if (requestId !== requestIdRef.current) return
|
if (requestId !== requestIdRef.current) return
|
||||||
setRuntimeStatus(nextRuntime)
|
|
||||||
setRuntimeProgress(nextRuntime?.state === 'downloading' ? nextRuntime : null)
|
|
||||||
setSenderStatus(nextSender)
|
setSenderStatus(nextSender)
|
||||||
setSessionBound(
|
setSessionBound(
|
||||||
nextSender.state === 'online' ||
|
nextSender.state === 'online' ||
|
||||||
@@ -122,13 +112,6 @@ function PersonalWechatMacSendDialog({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refreshStatus()
|
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])
|
}, [refreshStatus])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -146,22 +129,6 @@ function PersonalWechatMacSendDialog({
|
|||||||
return () => window.clearInterval(timer)
|
return () => window.clearInterval(timer)
|
||||||
}, [refreshStatus, senderStatus])
|
}, [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> => {
|
const handleBind = async (): Promise<void> => {
|
||||||
if (binding) return
|
if (binding) return
|
||||||
setBinding(true)
|
setBinding(true)
|
||||||
@@ -232,18 +199,27 @@ function PersonalWechatMacSendDialog({
|
|||||||
if (!initialImage || !senderStatus?.canSendImage || sendBusy) return
|
if (!initialImage || !senderStatus?.canSendImage || sendBusy) return
|
||||||
setSendBusy(true)
|
setSendBusy(true)
|
||||||
setSendError(null)
|
setSendError(null)
|
||||||
|
setSendSuccess(null)
|
||||||
try {
|
try {
|
||||||
|
await persistPostfixText()
|
||||||
const response = await window.api.sendPersonalWechatMessage({
|
const response = await window.api.sendPersonalWechatMessage({
|
||||||
type: 'image',
|
type: 'image',
|
||||||
to: targetId,
|
to: targetId,
|
||||||
isGroup: isGroupChat,
|
isGroup: isGroupChat,
|
||||||
filePath: initialImage.path
|
filePath: initialImage.path,
|
||||||
|
postfixText
|
||||||
} satisfies PersonalWechatSendRequest)
|
} satisfies PersonalWechatSendRequest)
|
||||||
setSenderStatus(response.status)
|
setSenderStatus(response.status)
|
||||||
if (!response.success) {
|
if (!response.success) {
|
||||||
setSendError(response.error || '日报图片发送失败')
|
setSendError(response.error || '日报图片发送失败')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (response.postfixError) {
|
||||||
|
setSendError(`日报图片已发送,但${response.postfixError}`)
|
||||||
|
setSendSuccess('日报图片发送成功')
|
||||||
|
} else {
|
||||||
|
setSendSuccess(postfixText.trim() ? '日报图片和后置词发送成功' : '日报图片发送成功')
|
||||||
|
}
|
||||||
setMessages((current) => [
|
setMessages((current) => [
|
||||||
...current,
|
...current,
|
||||||
{
|
{
|
||||||
@@ -287,7 +263,7 @@ function PersonalWechatMacSendDialog({
|
|||||||
{displayName.slice(0, 1)}
|
{displayName.slice(0, 1)}
|
||||||
</div>
|
</div>
|
||||||
<div className="personal-wechat-chat-heading">
|
<div className="personal-wechat-chat-heading">
|
||||||
<DialogTitle>文字转语音</DialogTitle>
|
<DialogTitle>{initialImage ? '发送日报图片' : '文字转语音'}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
发送给 {displayName} · {setupReady ? '微信已连接' : '配置微信发送能力'}
|
发送给 {displayName} · {setupReady ? '微信已连接' : '配置微信发送能力'}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
@@ -319,14 +295,10 @@ function PersonalWechatMacSendDialog({
|
|||||||
|
|
||||||
{!setupReady && senderStatus && (
|
{!setupReady && senderStatus && (
|
||||||
<PersonalWechatSetupGuide
|
<PersonalWechatSetupGuide
|
||||||
runtimeStatus={runtimeStatus}
|
|
||||||
senderStatus={senderStatus}
|
senderStatus={senderStatus}
|
||||||
runtimeProgress={runtimeProgress}
|
|
||||||
runtimeBusy={runtimeBusy}
|
|
||||||
binding={binding}
|
binding={binding}
|
||||||
detecting={detecting}
|
detecting={detecting}
|
||||||
sessionBound={sessionBound}
|
sessionBound={sessionBound}
|
||||||
onDownloadRuntime={() => void handleDownloadRuntime()}
|
|
||||||
onBind={() => void handleBind()}
|
onBind={() => void handleBind()}
|
||||||
onStartSending={() => undefined}
|
onStartSending={() => undefined}
|
||||||
onOpenTextToSpeechSettings={handleOpenSettings}
|
onOpenTextToSpeechSettings={handleOpenSettings}
|
||||||
@@ -336,6 +308,16 @@ function PersonalWechatMacSendDialog({
|
|||||||
{setupReady && initialImage && (
|
{setupReady && initialImage && (
|
||||||
<section className="personal-wechat-composer" aria-label="日报图片发送">
|
<section className="personal-wechat-composer" aria-label="日报图片发送">
|
||||||
<p>已准备日报图片:{initialImage.name}</p>
|
<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}>
|
<Button size="sm" onClick={() => void handleSendReportImage()} disabled={sendBusy}>
|
||||||
{sendBusy ? '发送中…' : '发送日报图片'}
|
{sendBusy ? '发送中…' : '发送日报图片'}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -358,8 +340,13 @@ function PersonalWechatMacSendDialog({
|
|||||||
{sendError}
|
{sendError}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{sendSuccess && (
|
||||||
|
<div className="personal-wechat-global-success" role="status">
|
||||||
|
{sendSuccess}
|
||||||
</div>
|
</div>
|
||||||
{setupReady && isMac && (
|
)}
|
||||||
|
</div>
|
||||||
|
{setupReady && isMac && !initialImage && (
|
||||||
<div className="personal-wechat-chat-footer flex items-center justify-end">
|
<div className="personal-wechat-chat-footer flex items-center justify-end">
|
||||||
<Button variant="link" size="sm" onClick={() => void handleOpenVoiceDiagnostic()}>
|
<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 { PersonalWechatSenderStatus } from '../../../../shared/personal-wechat'
|
||||||
import type {
|
import { Button } from '../ui'
|
||||||
PersonalWechatRuntimeProgressEvent,
|
|
||||||
PersonalWechatRuntimeStatus
|
const GROUP_README_URL = 'https://github.com/Wxw-Gu/TraceMemo#-交流与反馈'
|
||||||
} from '../../../../shared/personal-wechat-runtime'
|
|
||||||
import { Button, Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui'
|
|
||||||
import { isMac } from '../../utils/runtime-environment'
|
|
||||||
import { PersonalWechatSupportedVersionsContent } from './PersonalWechatSupportedVersionsContent'
|
|
||||||
|
|
||||||
interface PersonalWechatSetupGuideProps {
|
interface PersonalWechatSetupGuideProps {
|
||||||
runtimeStatus: PersonalWechatRuntimeStatus | null
|
|
||||||
senderStatus: PersonalWechatSenderStatus | null
|
senderStatus: PersonalWechatSenderStatus | null
|
||||||
runtimeProgress: PersonalWechatRuntimeProgressEvent | null
|
|
||||||
runtimeBusy: boolean
|
|
||||||
binding: boolean
|
binding: boolean
|
||||||
detecting: boolean
|
detecting: boolean
|
||||||
sessionBound: boolean
|
sessionBound: boolean
|
||||||
onDownloadRuntime: () => void
|
|
||||||
onBind: () => void
|
onBind: () => void
|
||||||
onStartSending: () => void
|
onStartSending: () => void
|
||||||
onOpenTextToSpeechSettings?: () => void
|
onOpenTextToSpeechSettings?: () => void
|
||||||
@@ -27,21 +18,18 @@ function capabilityLabel(ready: boolean, initializing: boolean): string {
|
|||||||
return initializing ? '初始化中' : '未就绪'
|
return initializing ? '初始化中' : '未就绪'
|
||||||
}
|
}
|
||||||
|
|
||||||
function diagnosticValue(value: unknown): string {
|
|
||||||
if (value === undefined || value === null || value === '') return '未检测到'
|
|
||||||
return String(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
function bindingHint(status: PersonalWechatSenderStatus): string {
|
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_platform') return '当前系统暂不支持个人微信发送。'
|
||||||
if (status.state === 'unsupported_version')
|
if (status.state === 'unsupported_version')
|
||||||
return '当前微信版本暂不支持,请查看微信发送设置中的支持版本。'
|
return '当前微信版本暂不支持,请联系群主确认可用版本。'
|
||||||
if (status.state === 'runtime_missing') {
|
if (status.state === 'runtime_missing') {
|
||||||
return isMac ? '请先完成 OneBot 运行时准备。' : '请先完成发送运行时准备。'
|
return '请先完成发送运行时准备。'
|
||||||
}
|
}
|
||||||
if (status.state === 'error') return '连接微信时遇到问题,请稍后重试。'
|
if (status.state === 'error') return '连接微信时遇到问题,请稍后重试。'
|
||||||
return '请启动并登录当前微信,TraceMemo 会自动绑定正在使用的账号。'
|
return '请保持微信未登录窗口状态,点击“绑定微信”后,再点击微信窗口登录。'
|
||||||
}
|
}
|
||||||
|
|
||||||
function isWechatBound(status: PersonalWechatSenderStatus): boolean {
|
function isWechatBound(status: PersonalWechatSenderStatus): boolean {
|
||||||
@@ -54,28 +42,21 @@ function isWechatBound(status: PersonalWechatSenderStatus): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PersonalWechatSetupGuide({
|
export function PersonalWechatSetupGuide({
|
||||||
runtimeStatus,
|
|
||||||
senderStatus,
|
senderStatus,
|
||||||
runtimeProgress,
|
|
||||||
runtimeBusy,
|
|
||||||
binding,
|
binding,
|
||||||
detecting,
|
detecting,
|
||||||
sessionBound,
|
sessionBound,
|
||||||
onDownloadRuntime,
|
|
||||||
onBind,
|
onBind,
|
||||||
onStartSending,
|
onStartSending,
|
||||||
onOpenTextToSpeechSettings
|
onOpenTextToSpeechSettings
|
||||||
}: PersonalWechatSetupGuideProps): React.ReactElement {
|
}: PersonalWechatSetupGuideProps): React.ReactElement {
|
||||||
const [showSupportedVersions, setShowSupportedVersions] = useState(false)
|
const runtimeUnavailable = senderStatus?.state === 'runtime_missing'
|
||||||
const runtimeLabel = isMac ? 'OneBot 运行时' : '发送运行时'
|
const runtimeLabel = '发送运行时'
|
||||||
const runtimeReady = runtimeStatus?.state === 'ready' || senderStatus?.runtimeReady === true
|
const runtimeReady = senderStatus?.runtimeReady === true
|
||||||
const runtimeDownloading = runtimeBusy || runtimeStatus?.state === 'downloading'
|
|
||||||
const connected = sessionBound && (senderStatus ? isWechatBound(senderStatus) : false)
|
const connected = sessionBound && (senderStatus ? isWechatBound(senderStatus) : false)
|
||||||
const canSendVoice = Boolean(senderStatus?.canSendVoice)
|
const canSendVoice = Boolean(senderStatus?.canSendVoice)
|
||||||
const initializing = connected && !canSendVoice && senderStatus?.state !== 'error'
|
const initializing = connected && !canSendVoice && senderStatus?.state !== 'error'
|
||||||
const allReady = connected && Boolean(senderStatus?.canSend)
|
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 (
|
return (
|
||||||
<section className="personal-wechat-setup" aria-label="微信消息功能配置">
|
<section className="personal-wechat-setup" aria-label="微信消息功能配置">
|
||||||
@@ -89,38 +70,21 @@ export function PersonalWechatSetupGuide({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ol className="personal-wechat-steps">
|
<ol className="personal-wechat-steps">
|
||||||
<li
|
<li className={runtimeReady ? 'is-complete' : 'is-current'}>
|
||||||
className={runtimeReady ? 'is-complete' : runtimeDownloading ? 'is-active' : 'is-current'}
|
|
||||||
>
|
|
||||||
<span className="personal-wechat-step-number">{runtimeReady ? '✓' : '1'}</span>
|
<span className="personal-wechat-step-number">{runtimeReady ? '✓' : '1'}</span>
|
||||||
<div className="personal-wechat-step-content">
|
<div className="personal-wechat-step-content">
|
||||||
<strong>准备 {runtimeLabel}</strong>
|
<strong>准备 {runtimeLabel}</strong>
|
||||||
<p>个人微信发送需要 {runtimeLabel},首次使用时下载一次即可。</p>
|
<p>
|
||||||
{runtimeDownloading ? (
|
{runtimeUnavailable
|
||||||
<div className="personal-wechat-download-progress" aria-live="polite">
|
? `个人微信发送需要${runtimeLabel},授权后即可使用。`
|
||||||
<div>
|
: `${runtimeLabel}随 TraceMemo 一起提供,无需额外安装。`}
|
||||||
<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>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={onDownloadRuntime}
|
|
||||||
disabled={runtimeBusy || runtimeStatus?.state === 'unsupported'}
|
|
||||||
>
|
|
||||||
下载运行时
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{runtimeStatus?.error && !runtimeDownloading && !runtimeReady && (
|
|
||||||
<p className="personal-wechat-step-error">
|
|
||||||
运行时准备失败,请重试或查看微信发送设置。
|
|
||||||
</p>
|
</p>
|
||||||
|
{runtimeReady ? (
|
||||||
|
<span className="personal-wechat-step-status">✓ {runtimeLabel}已就绪</span>
|
||||||
|
) : runtimeUnavailable ? (
|
||||||
|
<span className="personal-wechat-step-error">当前版本暂未提供微信消息发送功能</span>
|
||||||
|
) : (
|
||||||
|
<span className="personal-wechat-step-status">正在检查 {runtimeLabel}…</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@@ -139,7 +103,7 @@ export function PersonalWechatSetupGuide({
|
|||||||
<span className="personal-wechat-step-number">{connected ? '✓' : '2'}</span>
|
<span className="personal-wechat-step-number">{connected ? '✓' : '2'}</span>
|
||||||
<div className="personal-wechat-step-content">
|
<div className="personal-wechat-step-content">
|
||||||
<strong>绑定个人微信</strong>
|
<strong>绑定个人微信</strong>
|
||||||
<p>请启动并登录当前微信,TraceMemo 会自动绑定正在使用的账号。</p>
|
<p>请保持微信未登录窗口状态,点击“绑定微信”后,再点击微信窗口登录。</p>
|
||||||
{connected ? (
|
{connected ? (
|
||||||
<span className="personal-wechat-step-status">✓ 微信已绑定</span>
|
<span className="personal-wechat-step-status">✓ 微信已绑定</span>
|
||||||
) : (
|
) : (
|
||||||
@@ -147,15 +111,28 @@ export function PersonalWechatSetupGuide({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={onBind}
|
onClick={onBind}
|
||||||
disabled={!runtimeReady || binding || runtimeDownloading}
|
disabled={runtimeUnavailable || !runtimeReady || binding}
|
||||||
>
|
>
|
||||||
{binding ? '正在绑定…' : '绑定微信'}
|
{binding ? '正在绑定…' : '绑定微信'}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{!connected && senderStatus && (
|
{!connected && senderStatus && !runtimeUnavailable && (
|
||||||
<p className="personal-wechat-step-hint">{bindingHint(senderStatus)}</p>
|
<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">
|
<p className="personal-wechat-step-warning" role="note">
|
||||||
绑定微信可能导致当前微信异常闪退,这是正常现象。若微信退出,请重新启动微信后,再回到这里重新检测/绑定。
|
绑定微信可能导致当前微信异常闪退,这是正常现象。若微信退出,请重新启动微信后,再回到这里重新检测/绑定。
|
||||||
<br />
|
<br />
|
||||||
@@ -163,14 +140,6 @@ export function PersonalWechatSetupGuide({
|
|||||||
通用”,取消勾选“有更新时自动升级微信”,否则版本变化后可能无法绑定。
|
通用”,取消勾选“有更新时自动升级微信”,否则版本变化后可能无法绑定。
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<Button
|
|
||||||
className="w-fit"
|
|
||||||
variant="link"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowSupportedVersions(true)}
|
|
||||||
>
|
|
||||||
查看支持的微信版本
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
@@ -225,7 +194,7 @@ export function PersonalWechatSetupGuide({
|
|||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
|
|
||||||
{!runtimeReady && runtimeStatus?.state === 'unsupported' && onOpenTextToSpeechSettings && (
|
{!runtimeReady && runtimeUnavailable && onOpenTextToSpeechSettings && (
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -235,54 +204,6 @@ export function PersonalWechatSetupGuide({
|
|||||||
查看语音设置
|
查看语音设置
|
||||||
</Button>
|
</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>
|
</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 { Button, Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui'
|
||||||
import { PersonalWechatChatComposer, type ChatMessage } from './PersonalWechatChatComposer'
|
import { PersonalWechatChatComposer, type ChatMessage } from './PersonalWechatChatComposer'
|
||||||
import type { PersonalWechatSendDialogProps } from './PersonalWechatSendDialog'
|
import type { PersonalWechatSendDialogProps } from './PersonalWechatSendDialog'
|
||||||
|
import { ReportImagePostfixInput } from './ReportImagePostfixInput'
|
||||||
|
import { useReportImagePostfixSetting } from './useReportImagePostfixSetting'
|
||||||
|
|
||||||
function fallbackStatus(error: unknown): PersonalWechatSenderStatus {
|
function fallbackStatus(error: unknown): PersonalWechatSenderStatus {
|
||||||
return {
|
return {
|
||||||
@@ -45,7 +47,7 @@ function statusLabel(status: PersonalWechatSenderStatus | null): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function statusText(value: string): 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 {
|
function statusDescription(status: PersonalWechatSenderStatus | null): string {
|
||||||
@@ -64,12 +66,17 @@ export function PersonalWechatWindowsSendDialog({
|
|||||||
const [status, setStatus] = useState<PersonalWechatSenderStatus | null>(null)
|
const [status, setStatus] = useState<PersonalWechatSenderStatus | null>(null)
|
||||||
const [detecting, setDetecting] = useState(true)
|
const [detecting, setDetecting] = useState(true)
|
||||||
const [sendBusy, setSendBusy] = useState(false)
|
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 [messages, setMessages] = useState<ChatMessage[]>([])
|
||||||
const requestIdRef = useRef(0)
|
const requestIdRef = useRef(0)
|
||||||
const restoreFocusRef = useRef<HTMLElement | null>(null)
|
const restoreFocusRef = useRef<HTMLElement | null>(null)
|
||||||
const closingRef = useRef(false)
|
const closingRef = useRef(false)
|
||||||
const displayName = contact.m_nsNickName || contact.m_nsUsrName || '未命名会话'
|
const displayName = contact.m_nsNickName || contact.m_nsUsrName || '未命名会话'
|
||||||
const targetId = contact.m_nsUsrName
|
const targetId = contact.m_nsUsrName
|
||||||
|
const { postfixText, setPostfixText, persistPostfixText } = useReportImagePostfixSetting(
|
||||||
|
Boolean(initialImage)
|
||||||
|
)
|
||||||
|
|
||||||
const refreshStatus = useCallback(async (): Promise<void> => {
|
const refreshStatus = useCallback(async (): Promise<void> => {
|
||||||
const requestId = ++requestIdRef.current
|
const requestId = ++requestIdRef.current
|
||||||
@@ -103,9 +110,7 @@ export function PersonalWechatWindowsSendDialog({
|
|||||||
onOpenPersonalWechatSettings()
|
onOpenPersonalWechatSettings()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSend = async (
|
const handleSend = async (filePath: string): Promise<{ success: boolean; error?: string }> => {
|
||||||
filePath: string
|
|
||||||
): Promise<{ success: boolean; error?: string }> => {
|
|
||||||
setSendBusy(true)
|
setSendBusy(true)
|
||||||
try {
|
try {
|
||||||
const response = await window.api.sendGeneratedTtsVoice({
|
const response = await window.api.sendGeneratedTtsVoice({
|
||||||
@@ -137,15 +142,28 @@ export function PersonalWechatWindowsSendDialog({
|
|||||||
const handleSendReportImage = async (): Promise<void> => {
|
const handleSendReportImage = async (): Promise<void> => {
|
||||||
if (!initialImage || !status?.canSendImage || sendBusy) return
|
if (!initialImage || !status?.canSendImage || sendBusy) return
|
||||||
setSendBusy(true)
|
setSendBusy(true)
|
||||||
|
setSendError(null)
|
||||||
|
setSendSuccess(null)
|
||||||
try {
|
try {
|
||||||
|
await persistPostfixText()
|
||||||
const response = await window.api.sendPersonalWechatMessage({
|
const response = await window.api.sendPersonalWechatMessage({
|
||||||
type: 'image',
|
type: 'image',
|
||||||
to: targetId,
|
to: targetId,
|
||||||
isGroup: isGroupChat,
|
isGroup: isGroupChat,
|
||||||
filePath: initialImage.path
|
filePath: initialImage.path,
|
||||||
|
postfixText
|
||||||
} satisfies PersonalWechatSendRequest)
|
} satisfies PersonalWechatSendRequest)
|
||||||
setStatus(response.status)
|
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) => [
|
setMessages((current) => [
|
||||||
...current,
|
...current,
|
||||||
{
|
{
|
||||||
@@ -156,6 +174,8 @@ export function PersonalWechatWindowsSendDialog({
|
|||||||
outgoing: true
|
outgoing: true
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
|
} catch (error) {
|
||||||
|
setSendError(error instanceof Error ? error.message : String(error))
|
||||||
} finally {
|
} finally {
|
||||||
setSendBusy(false)
|
setSendBusy(false)
|
||||||
}
|
}
|
||||||
@@ -255,6 +275,16 @@ export function PersonalWechatWindowsSendDialog({
|
|||||||
{initialImage && status.canSendImage ? (
|
{initialImage && status.canSendImage ? (
|
||||||
<section className="personal-wechat-composer" aria-label="日报图片发送">
|
<section className="personal-wechat-composer" aria-label="日报图片发送">
|
||||||
<p>已准备日报图片:{initialImage.name}</p>
|
<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}>
|
<Button size="sm" onClick={() => void handleSendReportImage()} disabled={sendBusy}>
|
||||||
{sendBusy ? '发送中…' : '发送日报图片'}
|
{sendBusy ? '发送中…' : '发送日报图片'}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -271,6 +301,16 @@ export function PersonalWechatWindowsSendDialog({
|
|||||||
busy={sendBusy}
|
busy={sendBusy}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : 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}
|
) : null}
|
||||||
</DialogContent>
|
</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(
|
onNotice(
|
||||||
result.environment.message || 'pilk 已安装,但仍有语音编码环境未就绪。请重新检查。'
|
result.environment.message || 'pilk 已安装,但仍有语音编码环境未就绪。请重新检查。'
|
||||||
)
|
)
|
||||||
} else if (result.restarted) {
|
|
||||||
onNotice('语音编码环境已修复,语音发送服务已重启')
|
|
||||||
} else if (result.restartError) {
|
|
||||||
onNotice(`语音编码环境已修复,但语音发送服务重启失败:${result.restartError}`)
|
|
||||||
} else {
|
} else {
|
||||||
onNotice('语音编码环境已修复。重新启动语音发送服务后生效。')
|
onNotice('语音编码环境已修复。')
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onNotice(error instanceof Error ? error.message : 'pilk 安装失败')
|
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 {
|
import type {
|
||||||
PersonalWechatSendCapability,
|
PersonalWechatSendCapability,
|
||||||
PersonalWechatSenderStatus
|
PersonalWechatSenderStatus
|
||||||
} from '../../../../../shared/personal-wechat'
|
} from '../../../../../shared/personal-wechat'
|
||||||
import type {
|
import { Button, Input, Skeleton } from '../../../components/ui'
|
||||||
PersonalWechatRuntimeProgressEvent,
|
|
||||||
PersonalWechatRuntimeStatus
|
|
||||||
} from '../../../../../shared/personal-wechat-runtime'
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
Input,
|
|
||||||
Skeleton,
|
|
||||||
Switch
|
|
||||||
} from '../../../components/ui'
|
|
||||||
import { PersonalWechatSetupGuide } from '../../../components/chat/PersonalWechatSetupGuide'
|
import { PersonalWechatSetupGuide } from '../../../components/chat/PersonalWechatSetupGuide'
|
||||||
import { PersonalWechatSupportedVersionsContent } from '../../../components/chat/PersonalWechatSupportedVersionsContent'
|
|
||||||
import { isMac, isWindows } from '../../../utils/runtime-environment'
|
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'
|
const REPOSITORY_URL = 'https://github.com/Wxw-Gu/TraceMemo'
|
||||||
// README 就是仓库首页,跳转后需要的正是「交流与反馈」这一节(含群二维码)。
|
// README 就是仓库首页,跳转后需要的正是「交流与反馈」这一节(含群二维码)。
|
||||||
const GROUP_README_URL = `${REPOSITORY_URL}#-交流与反馈`
|
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({
|
export function PersonalWechatSendPage({
|
||||||
onNotice,
|
onNotice,
|
||||||
onOpenTextToSpeechSettings
|
onOpenTextToSpeechSettings
|
||||||
@@ -78,12 +69,7 @@ export function PersonalWechatSendPage({
|
|||||||
}): ReactElement {
|
}): ReactElement {
|
||||||
const [capability, setCapability] = useState<PersonalWechatSendCapability | null>(null)
|
const [capability, setCapability] = useState<PersonalWechatSendCapability | null>(null)
|
||||||
const [senderStatus, setSenderStatus] = useState<PersonalWechatSenderStatus | 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 [loading, setLoading] = useState(true)
|
||||||
const [runtimeBusy, setRuntimeBusy] = useState(false)
|
|
||||||
const [binding, setBinding] = useState(false)
|
const [binding, setBinding] = useState(false)
|
||||||
const [detecting, setDetecting] = useState(false)
|
const [detecting, setDetecting] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
@@ -93,12 +79,6 @@ export function PersonalWechatSendPage({
|
|||||||
const [windowsPortInput, setWindowsPortInput] = useState('')
|
const [windowsPortInput, setWindowsPortInput] = useState('')
|
||||||
const [windowsDetectedPort, setWindowsDetectedPort] = useState('')
|
const [windowsDetectedPort, setWindowsDetectedPort] = useState('')
|
||||||
const [windowsEndpointBusy, setWindowsEndpointBusy] = useState(false)
|
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> => {
|
const refresh = useCallback(async (): Promise<void> => {
|
||||||
if (isWindows) {
|
if (isWindows) {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
@@ -106,15 +86,12 @@ export function PersonalWechatSendPage({
|
|||||||
}
|
}
|
||||||
setError('')
|
setError('')
|
||||||
try {
|
try {
|
||||||
const [nextCapability, nextSender, nextRuntime] = await Promise.all([
|
const [nextCapability, nextSender] = await Promise.all([
|
||||||
window.api.getPersonalWechatSendCapability(),
|
window.api.getPersonalWechatSendCapability(),
|
||||||
window.api.getPersonalWechatSenderStatus(),
|
window.api.getPersonalWechatSenderStatus()
|
||||||
window.api.getPersonalWechatRuntimeStatus()
|
|
||||||
])
|
])
|
||||||
setCapability(nextCapability)
|
setCapability(nextCapability)
|
||||||
setSenderStatus(nextSender)
|
setSenderStatus(nextSender)
|
||||||
setRuntimeStatus(nextRuntime)
|
|
||||||
setRuntimeProgress(nextRuntime.state === 'downloading' ? nextRuntime : null)
|
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
setError(reason instanceof Error ? reason.message : '微信发送能力读取失败')
|
setError(reason instanceof Error ? reason.message : '微信发送能力读取失败')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -125,31 +102,8 @@ export function PersonalWechatSendPage({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isWindows) return
|
if (isWindows) return
|
||||||
void refresh()
|
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])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (isWindows || !senderStatus || senderStatus.canSend || senderStatus.state === 'error') {
|
if (isWindows || !senderStatus || senderStatus.canSend || senderStatus.state === 'error') {
|
||||||
return undefined
|
return undefined
|
||||||
@@ -187,80 +141,6 @@ export function PersonalWechatSendPage({
|
|||||||
}
|
}
|
||||||
}, [refresh])
|
}, [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> => {
|
const bindWechat = async (): Promise<void> => {
|
||||||
if (binding) return
|
if (binding) return
|
||||||
setBinding(true)
|
setBinding(true)
|
||||||
@@ -386,6 +266,7 @@ export function PersonalWechatSendPage({
|
|||||||
const pageMessage = isWindows
|
const pageMessage = isWindows
|
||||||
? windowsSenderStatus?.message || '请输入端口并检测 Windows 微信发送能力'
|
? windowsSenderStatus?.message || '请输入端口并检测 Windows 微信发送能力'
|
||||||
: capability?.message || '微信发送能力暂不可用'
|
: capability?.message || '微信发送能力暂不可用'
|
||||||
|
const macUnavailable = isMac && senderStatus?.state === 'runtime_missing'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -398,7 +279,7 @@ export function PersonalWechatSendPage({
|
|||||||
<span
|
<span
|
||||||
className={`settings-status-badge ${loading ? 'checking' : pageReady ? '' : pageStatus === 'unsupported' ? 'unavailable' : 'warning'}`}
|
className={`settings-status-badge ${loading ? 'checking' : pageReady ? '' : pageStatus === 'unsupported' ? 'unavailable' : 'warning'}`}
|
||||||
>
|
>
|
||||||
{loading ? '检测中' : capabilityLabel[pageStatus]}
|
{loading ? '检测中' : macUnavailable ? '暂不可用' : capabilityLabel[pageStatus]}
|
||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
<div className="settings-page-scroll">
|
<div className="settings-page-scroll">
|
||||||
@@ -406,6 +287,10 @@ export function PersonalWechatSendPage({
|
|||||||
{loading && !capability ? (
|
{loading && !capability ? (
|
||||||
<Skeleton className="h-28 w-full" />
|
<Skeleton className="h-28 w-full" />
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
|
{/* macOS 走下方「配置状态」SetupGuide 统一展示,这里只在 Windows/其它
|
||||||
|
* 平台显示旧「发送能力」卡(Mac 上两者重复,已按用户要求移除)。 */}
|
||||||
|
{!isMac ? (
|
||||||
<>
|
<>
|
||||||
<h2 className="settings-section-heading">发送能力</h2>
|
<h2 className="settings-section-heading">发送能力</h2>
|
||||||
<section className="settings-card">
|
<section className="settings-card">
|
||||||
@@ -414,9 +299,7 @@ export function PersonalWechatSendPage({
|
|||||||
<span className="settings-card-kicker">个人微信</span>
|
<span className="settings-card-kicker">个人微信</span>
|
||||||
<strong className="mt-1 block text-base">{pageMessage}</strong>
|
<strong className="mt-1 block text-base">{pageMessage}</strong>
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
{pageStatus === 'unsupported' && isMac
|
{pageStatus === 'unsupported'
|
||||||
? 'Intel Mac 不支持个人微信发送。'
|
|
||||||
: pageStatus === 'unsupported' && !isWindows
|
|
||||||
? '微信消息发送目前仅支持 macOS 和 Windows。'
|
? '微信消息发送目前仅支持 macOS 和 Windows。'
|
||||||
: isWindows
|
: isWindows
|
||||||
? 'Windows 通过本机微信发送接口工作,请先配置并检测接口端口。'
|
? 'Windows 通过本机微信发送接口工作,请先配置并检测接口端口。'
|
||||||
@@ -435,7 +318,10 @@ export function PersonalWechatSendPage({
|
|||||||
['语音', pageCapabilities.voice]
|
['语音', pageCapabilities.voice]
|
||||||
] as const
|
] as const
|
||||||
).map(([label, available]) => (
|
).map(([label, available]) => (
|
||||||
<div key={label} className="rounded-lg border border-border-subtle px-3 py-2">
|
<div
|
||||||
|
key={label}
|
||||||
|
className="rounded-lg border border-border-subtle px-3 py-2"
|
||||||
|
>
|
||||||
<span className="block text-xs text-muted-foreground">{label}</span>
|
<span className="block text-xs text-muted-foreground">{label}</span>
|
||||||
<strong className="mt-1 block text-sm">
|
<strong className="mt-1 block text-sm">
|
||||||
{available ? '可发送' : '未就绪'}
|
{available ? '可发送' : '未就绪'}
|
||||||
@@ -449,6 +335,8 @@ export function PersonalWechatSendPage({
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{isWindows ? (
|
{isWindows ? (
|
||||||
<>
|
<>
|
||||||
@@ -515,156 +403,16 @@ export function PersonalWechatSendPage({
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<h2 className="settings-section-heading">发送能力授权</h2>
|
<h2 className="settings-section-heading">发送能力授权</h2>
|
||||||
<section className="settings-card grid gap-2">
|
<SendCapabilityAuthorizationCard />
|
||||||
<p>发送能力属授权制,需要联系群主。请先加入交流群,然后在群内添加群主申请授权。</p>
|
|
||||||
<p className="settings-footnote">
|
|
||||||
进群请点击{' '}
|
|
||||||
<a
|
|
||||||
className="text-primary hover:underline"
|
|
||||||
href={GROUP_README_URL}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
这里
|
|
||||||
</a>{' '}
|
|
||||||
跳转。
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
</>
|
</>
|
||||||
) : isMac ? (
|
) : 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>
|
<h2 className="settings-section-heading">配置状态</h2>
|
||||||
<PersonalWechatSetupGuide
|
<PersonalWechatSetupGuide
|
||||||
runtimeStatus={runtimeStatus}
|
|
||||||
senderStatus={senderStatus}
|
senderStatus={senderStatus}
|
||||||
runtimeProgress={runtimeProgress}
|
|
||||||
runtimeBusy={runtimeBusy}
|
|
||||||
binding={binding}
|
binding={binding}
|
||||||
detecting={detecting}
|
detecting={detecting}
|
||||||
sessionBound={boundToCurrentWechat(senderStatus)}
|
sessionBound={boundToCurrentWechat(senderStatus)}
|
||||||
onDownloadRuntime={() => void downloadRuntime()}
|
|
||||||
onBind={() => void bindWechat()}
|
onBind={() => void bindWechat()}
|
||||||
onStartSending={() =>
|
onStartSending={() =>
|
||||||
onNotice('微信消息发送能力已就绪,请在档案中选择会话开始发送。')
|
onNotice('微信消息发送能力已就绪,请在档案中选择会话开始发送。')
|
||||||
@@ -678,24 +426,6 @@ export function PersonalWechatSendPage({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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;
|
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-step-error,
|
||||||
.personal-wechat-global-error {
|
.personal-wechat-global-error {
|
||||||
color: var(--wxex-danger) !important;
|
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 {
|
.personal-wechat-composer {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -1109,6 +1095,13 @@
|
|||||||
font-size: 11px;
|
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 {
|
.personal-wechat-chat-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
|||||||
@@ -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
|
ready: boolean
|
||||||
checkedAt?: string
|
checkedAt?: string
|
||||||
runtimeReady: boolean
|
runtimeReady: boolean
|
||||||
runtimeRoot?: string
|
|
||||||
python: PersonalWechatVoiceRuntimeComponent
|
python: PersonalWechatVoiceRuntimeComponent
|
||||||
pilk: PersonalWechatVoiceRuntimeComponent
|
pilk: PersonalWechatVoiceRuntimeComponent
|
||||||
ffmpeg: PersonalWechatVoiceRuntimeComponent
|
ffmpeg: PersonalWechatVoiceRuntimeComponent
|
||||||
encoder: 'pilk' | 'go-silk' | 'unavailable'
|
encoder: 'pilk' | 'silk' | 'unavailable'
|
||||||
message: string
|
message: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,6 +31,4 @@ export interface PersonalWechatVoiceEncodingEnvironmentResult {
|
|||||||
success: boolean
|
success: boolean
|
||||||
environment: PersonalWechatVoiceEncodingEnvironment
|
environment: PersonalWechatVoiceEncodingEnvironment
|
||||||
error?: string
|
error?: string
|
||||||
restarted?: boolean
|
|
||||||
restartError?: string
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ export interface PersonalWechatSenderStatus {
|
|||||||
wechatRunning: boolean
|
wechatRunning: boolean
|
||||||
wechatPid?: number
|
wechatPid?: number
|
||||||
boundWechatPid?: number
|
boundWechatPid?: number
|
||||||
oneBotPid?: number
|
|
||||||
endpoint: string
|
endpoint: string
|
||||||
endpointReady: boolean
|
endpointReady: boolean
|
||||||
wechatVersion?: string
|
wechatVersion?: string
|
||||||
@@ -80,6 +79,8 @@ export interface PersonalWechatSendTextRequest extends PersonalWechatSendBaseReq
|
|||||||
export interface PersonalWechatSendImageRequest extends PersonalWechatSendBaseRequest {
|
export interface PersonalWechatSendImageRequest extends PersonalWechatSendBaseRequest {
|
||||||
type: 'image'
|
type: 'image'
|
||||||
filePath: string
|
filePath: string
|
||||||
|
/** 日报图片成功后再发送的文字;undefined 表示普通图片发送,不启用日报序列。 */
|
||||||
|
postfixText?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PersonalWechatSendVoiceRequest extends PersonalWechatSendBaseRequest {
|
export interface PersonalWechatSendVoiceRequest extends PersonalWechatSendBaseRequest {
|
||||||
@@ -112,8 +113,12 @@ export interface PersonalWechatSendResult {
|
|||||||
success: boolean
|
success: boolean
|
||||||
status: PersonalWechatSenderStatus
|
status: PersonalWechatSenderStatus
|
||||||
error?: string
|
error?: string
|
||||||
|
postfixSent?: boolean
|
||||||
|
postfixError?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_REPORT_IMAGE_POSTFIX_TEXT = '今日日报'
|
||||||
|
|
||||||
/** Safe, user-copyable metadata for the most recent voice send attempt. */
|
/** Safe, user-copyable metadata for the most recent voice send attempt. */
|
||||||
export interface PersonalWechatVoiceDiagnostic {
|
export interface PersonalWechatVoiceDiagnostic {
|
||||||
request_id: string
|
request_id: string
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ export type WechatActionPurpose =
|
|||||||
| 'manual_image'
|
| 'manual_image'
|
||||||
/** 用户手动发送文本。 */
|
/** 用户手动发送文本。 */
|
||||||
| 'manual_text'
|
| 'manual_text'
|
||||||
|
/** 用户确认后的日报图片;进入 automation 队列以复用 3 秒发送间隔。 */
|
||||||
|
| 'manual_report_image'
|
||||||
|
/** 日报图片成功后的后置词;只有图片 sent 后才会创建。 */
|
||||||
|
| 'manual_report_postfix'
|
||||||
/**
|
/**
|
||||||
* Automation v1 的两个用途。
|
* Automation v1 的两个用途。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -12,16 +12,17 @@ vi.mock('../../src/renderer/src/utils/runtime-environment', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
const getStatus = vi.fn()
|
const getStatus = vi.fn()
|
||||||
const getRuntimeStatus = vi.fn()
|
|
||||||
const onRuntimeProgress = vi.fn(() => vi.fn())
|
|
||||||
const rebind = vi.fn()
|
const rebind = vi.fn()
|
||||||
const sendGeneratedTtsVoice = vi.fn()
|
const sendGeneratedTtsVoice = vi.fn()
|
||||||
|
const sendPersonalWechatMessage = vi.fn()
|
||||||
const getTextToSpeechSettings = vi.fn()
|
const getTextToSpeechSettings = vi.fn()
|
||||||
const listTextToSpeechVoices = vi.fn()
|
const listTextToSpeechVoices = vi.fn()
|
||||||
const synthesizeTextToSpeech = vi.fn()
|
const synthesizeTextToSpeech = vi.fn()
|
||||||
const removeGeneratedTextToSpeechAudio = vi.fn()
|
const removeGeneratedTextToSpeechAudio = vi.fn()
|
||||||
const getPersonalWechatVoiceDiagnostic = vi.fn()
|
const getPersonalWechatVoiceDiagnostic = vi.fn()
|
||||||
const copyText = vi.fn()
|
const copyText = vi.fn()
|
||||||
|
const getSettings = vi.fn()
|
||||||
|
const setSettings = vi.fn()
|
||||||
|
|
||||||
const contact = {
|
const contact = {
|
||||||
m_nsUsrName: 'fixture-room@chatroom',
|
m_nsUsrName: 'fixture-room@chatroom',
|
||||||
@@ -38,8 +39,7 @@ const readyStatus = {
|
|||||||
wechatRunning: true,
|
wechatRunning: true,
|
||||||
wechatPid: 4668,
|
wechatPid: 4668,
|
||||||
boundWechatPid: 4668,
|
boundWechatPid: 4668,
|
||||||
oneBotPid: 5401,
|
endpoint: '127.0.0.1:4290',
|
||||||
endpoint: '127.0.0.1:58080',
|
|
||||||
endpointReady: true,
|
endpointReady: true,
|
||||||
wechatVersion: '4.1.11.53',
|
wechatVersion: '4.1.11.53',
|
||||||
runtimeReady: true,
|
runtimeReady: true,
|
||||||
@@ -58,18 +58,6 @@ const readyStatus = {
|
|||||||
message: '个人微信已绑定'
|
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(
|
function renderDialog(
|
||||||
props: Partial<React.ComponentProps<typeof PersonalWechatSendDialog>> = {}
|
props: Partial<React.ComponentProps<typeof PersonalWechatSendDialog>> = {}
|
||||||
): React.ReactElement {
|
): React.ReactElement {
|
||||||
@@ -85,13 +73,24 @@ async function startComposer(): Promise<void> {
|
|||||||
describe('PersonalWechatSendDialog', () => {
|
describe('PersonalWechatSendDialog', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
getStatus.mockReset().mockResolvedValue(readyStatus)
|
getStatus.mockReset().mockResolvedValue(readyStatus)
|
||||||
getRuntimeStatus.mockReset().mockResolvedValue(readyRuntime)
|
|
||||||
onRuntimeProgress.mockReset().mockReturnValue(vi.fn())
|
|
||||||
rebind.mockReset().mockResolvedValue(readyStatus)
|
rebind.mockReset().mockResolvedValue(readyStatus)
|
||||||
sendGeneratedTtsVoice.mockReset().mockResolvedValue({
|
sendGeneratedTtsVoice.mockReset().mockResolvedValue({
|
||||||
action: { status: 'sent' },
|
action: { status: 'sent' },
|
||||||
status: readyStatus
|
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({
|
getTextToSpeechSettings.mockReset().mockResolvedValue({
|
||||||
success: true,
|
success: true,
|
||||||
settings: {
|
settings: {
|
||||||
@@ -125,16 +124,17 @@ describe('PersonalWechatSendDialog', () => {
|
|||||||
configurable: true,
|
configurable: true,
|
||||||
value: {
|
value: {
|
||||||
getPersonalWechatSenderStatus: getStatus,
|
getPersonalWechatSenderStatus: getStatus,
|
||||||
getPersonalWechatRuntimeStatus: getRuntimeStatus,
|
|
||||||
onPersonalWechatRuntimeProgress: onRuntimeProgress,
|
|
||||||
rebindPersonalWechatSender: rebind,
|
rebindPersonalWechatSender: rebind,
|
||||||
sendGeneratedTtsVoice,
|
sendGeneratedTtsVoice,
|
||||||
|
sendPersonalWechatMessage,
|
||||||
getTextToSpeechSettings,
|
getTextToSpeechSettings,
|
||||||
listTextToSpeechVoices,
|
listTextToSpeechVoices,
|
||||||
synthesizeTextToSpeech,
|
synthesizeTextToSpeech,
|
||||||
removeGeneratedTextToSpeechAudio,
|
removeGeneratedTextToSpeechAudio,
|
||||||
getPersonalWechatVoiceDiagnostic,
|
getPersonalWechatVoiceDiagnostic,
|
||||||
copyText
|
copyText,
|
||||||
|
getSettings,
|
||||||
|
setSettings
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -143,7 +143,6 @@ describe('PersonalWechatSendDialog', () => {
|
|||||||
renderDialog()
|
renderDialog()
|
||||||
await startComposer()
|
await startComposer()
|
||||||
expect(screen.getByRole('dialog')).toHaveTextContent('文字转语音')
|
expect(screen.getByRole('dialog')).toHaveTextContent('文字转语音')
|
||||||
expect(screen.queryByRole('switch', { name: '保留 OneBot 进程' })).not.toBeInTheDocument()
|
|
||||||
expect(screen.queryByText('验证消息能力')).not.toBeInTheDocument()
|
expect(screen.queryByText('验证消息能力')).not.toBeInTheDocument()
|
||||||
expect(screen.getByRole('button', { name: '生成语音' })).toBeDisabled()
|
expect(screen.getByRole('button', { name: '生成语音' })).toBeDisabled()
|
||||||
})
|
})
|
||||||
@@ -167,6 +166,55 @@ describe('PersonalWechatSendDialog', () => {
|
|||||||
expect(screen.getByLabelText('消息列表')).toHaveTextContent('你好 TraceMemo')
|
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 () => {
|
it('keeps the setup guide voice-only when voice capability is unavailable', async () => {
|
||||||
getStatus.mockResolvedValue({ ...readyStatus, canSend: false, canSendVoice: false })
|
getStatus.mockResolvedValue({ ...readyStatus, canSend: false, canSendVoice: false })
|
||||||
renderDialog({ onOpenPersonalWechatSettings: vi.fn() })
|
renderDialog({ onOpenPersonalWechatSettings: vi.fn() })
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { render, screen, waitFor } from '@testing-library/react'
|
import { render, screen } from '@testing-library/react'
|
||||||
import userEvent from '@testing-library/user-event'
|
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { PersonalWechatSendPage } from '../../src/renderer/src/features/settings/pages/PersonalWechatSendPage'
|
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 getCapability = vi.fn()
|
||||||
const getSenderStatus = 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 rebindSender = vi.fn()
|
||||||
const getKeepOneBotProcess = vi.fn()
|
|
||||||
const setKeepOneBotProcess = vi.fn()
|
|
||||||
|
|
||||||
const senderStatus = {
|
const senderStatus = {
|
||||||
state: 'online' as const,
|
state: 'online' as const,
|
||||||
@@ -30,8 +21,7 @@ const senderStatus = {
|
|||||||
wechatRunning: true,
|
wechatRunning: true,
|
||||||
wechatPid: 4668,
|
wechatPid: 4668,
|
||||||
boundWechatPid: 4668,
|
boundWechatPid: 4668,
|
||||||
oneBotPid: 5401,
|
endpoint: '127.0.0.1:4290',
|
||||||
endpoint: '127.0.0.1:58080',
|
|
||||||
endpointReady: true,
|
endpointReady: true,
|
||||||
wechatVersion: '4.1.11.53',
|
wechatVersion: '4.1.11.53',
|
||||||
runtimeReady: true,
|
runtimeReady: true,
|
||||||
@@ -50,19 +40,6 @@ const senderStatus = {
|
|||||||
message: '个人微信已绑定'
|
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 = {
|
const capability = {
|
||||||
supported: true,
|
supported: true,
|
||||||
ready: true,
|
ready: true,
|
||||||
@@ -76,85 +53,23 @@ describe('PersonalWechatSendPage on macOS', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
getCapability.mockReset().mockResolvedValue(capability)
|
getCapability.mockReset().mockResolvedValue(capability)
|
||||||
getSenderStatus.mockReset().mockResolvedValue(senderStatus)
|
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)
|
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', {
|
Object.defineProperty(window, 'api', {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
value: {
|
value: {
|
||||||
getPersonalWechatSendCapability: getCapability,
|
getPersonalWechatSendCapability: getCapability,
|
||||||
getPersonalWechatSenderStatus: getSenderStatus,
|
getPersonalWechatSenderStatus: getSenderStatus,
|
||||||
getPersonalWechatRuntimeStatus: getRuntimeStatus,
|
rebindPersonalWechatSender: rebindSender
|
||||||
onPersonalWechatRuntimeProgress: onRuntimeProgress,
|
|
||||||
downloadPersonalWechatRuntime: downloadRuntime,
|
|
||||||
cancelPersonalWechatRuntimeDownload: cancelRuntimeDownload,
|
|
||||||
removePersonalWechatRuntime: removeRuntime,
|
|
||||||
openPersonalWechatRuntimeDirectory: openRuntimeDirectory,
|
|
||||||
rebindPersonalWechatSender: rebindSender,
|
|
||||||
getPersonalWechatKeepOneBotProcess: getKeepOneBotProcess,
|
|
||||||
setPersonalWechatKeepOneBotProcess: setKeepOneBotProcess
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps OneBot runtime management on the WeChat send page', async () => {
|
it('shows the native runtime readiness', async () => {
|
||||||
const user = userEvent.setup()
|
|
||||||
render(<PersonalWechatSendPage onNotice={vi.fn()} />)
|
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)
|
expect(screen.getAllByText('已就绪')).not.toHaveLength(0)
|
||||||
await user.click(screen.getByRole('button', { name: '打开目录' }))
|
expect(screen.queryByText('查看支持的微信版本')).not.toBeInTheDocument()
|
||||||
expect(openRuntimeDirectory).toHaveBeenCalledOnce()
|
expect(screen.queryByText('高级诊断')).not.toBeInTheDocument()
|
||||||
|
|
||||||
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('微信发送组件已准备好')
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,
|
sipDisabled: true,
|
||||||
wechatRunning: true,
|
wechatRunning: true,
|
||||||
runtimeReady: true,
|
runtimeReady: true,
|
||||||
endpoint: '127.0.0.1:58080',
|
endpoint: '127.0.0.1:4290',
|
||||||
endpointReady: true,
|
endpointReady: true,
|
||||||
attachReady: true,
|
attachReady: true,
|
||||||
baseAddressReady: true,
|
baseAddressReady: true,
|
||||||
@@ -618,6 +618,10 @@ describe('daily report controls', () => {
|
|||||||
fireEvent.click(screen.getByRole('button', { name: '发送到当前群聊' }))
|
fireEvent.click(screen.getByRole('button', { name: '发送到当前群聊' }))
|
||||||
|
|
||||||
expect(await screen.findByRole('dialog', { name: '测试群' })).toBeVisible()
|
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: '重新检测' })
|
const detect = screen.queryByRole('button', { name: '重新检测' })
|
||||||
if (detect) fireEvent.click(detect)
|
if (detect) fireEvent.click(detect)
|
||||||
expect(screen.getByRole('region', { name: '日报图片发送' })).toBeVisible()
|
expect(screen.getByRole('region', { name: '日报图片发送' })).toBeVisible()
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ const openFishAudioApiKeys = vi.fn()
|
|||||||
const getSettings = vi.fn()
|
const getSettings = vi.fn()
|
||||||
const setSettings = vi.fn()
|
const setSettings = vi.fn()
|
||||||
const getPersonalWechatSenderStatus = vi.fn()
|
const getPersonalWechatSenderStatus = vi.fn()
|
||||||
const getPersonalWechatRuntimeStatus = vi.fn()
|
|
||||||
const checkPersonalWechatSenderStatus = vi.fn()
|
const checkPersonalWechatSenderStatus = vi.fn()
|
||||||
|
|
||||||
const textToSpeechSettings = {
|
const textToSpeechSettings = {
|
||||||
@@ -70,7 +69,6 @@ describe('PersonalWechatSendPage on Windows', () => {
|
|||||||
settings: { windowsWechatPort: '4567' }
|
settings: { windowsWechatPort: '4567' }
|
||||||
})
|
})
|
||||||
getPersonalWechatSenderStatus.mockReset()
|
getPersonalWechatSenderStatus.mockReset()
|
||||||
getPersonalWechatRuntimeStatus.mockReset().mockResolvedValue(null)
|
|
||||||
checkPersonalWechatSenderStatus.mockReset().mockResolvedValue(windowsStatus)
|
checkPersonalWechatSenderStatus.mockReset().mockResolvedValue(windowsStatus)
|
||||||
Object.defineProperty(window, 'api', {
|
Object.defineProperty(window, 'api', {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
@@ -81,7 +79,6 @@ describe('PersonalWechatSendPage on Windows', () => {
|
|||||||
getSettings,
|
getSettings,
|
||||||
setSettings,
|
setSettings,
|
||||||
getPersonalWechatSenderStatus,
|
getPersonalWechatSenderStatus,
|
||||||
getPersonalWechatRuntimeStatus,
|
|
||||||
checkPersonalWechatSenderStatus
|
checkPersonalWechatSenderStatus
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -128,7 +125,7 @@ describe('PersonalWechatSendPage on Windows', () => {
|
|||||||
expect(onNotice).toHaveBeenCalledWith('微信发送能力端口已清除')
|
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({
|
getPersonalWechatSenderStatus.mockResolvedValue({
|
||||||
...windowsStatus,
|
...windowsStatus,
|
||||||
state: 'error',
|
state: 'error',
|
||||||
@@ -156,9 +153,6 @@ describe('PersonalWechatSendPage on Windows', () => {
|
|||||||
expect(
|
expect(
|
||||||
await screen.findByText('未检测到 Windows 微信发送能力,请先启动并登录微信')
|
await screen.findByText('未检测到 Windows 微信发送能力,请先启动并登录微信')
|
||||||
).toBeVisible()
|
).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.queryByRole('button', { name: '语音发送诊断' })).not.toBeInTheDocument()
|
||||||
expect(screen.getByRole('heading', { name: '文字转语音' })).toBeVisible()
|
expect(screen.getByRole('heading', { name: '文字转语音' })).toBeVisible()
|
||||||
expect(screen.getByRole('region', { name: '文字转语音' })).toBeVisible()
|
expect(screen.getByRole('region', { name: '文字转语音' })).toBeVisible()
|
||||||
@@ -188,7 +182,6 @@ describe('PersonalWechatSendPage on Windows', () => {
|
|||||||
render(<TextToSpeechPage onNotice={vi.fn()} />)
|
render(<TextToSpeechPage onNotice={vi.fn()} />)
|
||||||
|
|
||||||
expect(await screen.findByText('API 设置')).toBeVisible()
|
expect(await screen.findByText('API 设置')).toBeVisible()
|
||||||
expect(screen.queryByText(/OneBot/i)).not.toBeInTheDocument()
|
|
||||||
expect(screen.queryByText('微信发送组件')).not.toBeInTheDocument()
|
expect(screen.queryByText('微信发送组件')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -196,7 +189,6 @@ describe('PersonalWechatSendPage on Windows', () => {
|
|||||||
render(<PersonalWechatSendPage onNotice={vi.fn()} />)
|
render(<PersonalWechatSendPage onNotice={vi.fn()} />)
|
||||||
|
|
||||||
expect(await screen.findByRole('spinbutton', { name: '微信发送能力端口' })).toBeVisible()
|
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 () => {
|
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 }))
|
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()} />)
|
render(<TextToSpeechPage onNotice={vi.fn()} />)
|
||||||
|
|
||||||
expect(await screen.findByText('API 设置')).toBeVisible()
|
expect(await screen.findByText('API 设置')).toBeVisible()
|
||||||
expect(screen.getByText('语音编码环境')).toBeVisible()
|
expect(screen.getByText('语音编码环境')).toBeVisible()
|
||||||
expect(screen.queryByText(/OneBot/i)).not.toBeInTheDocument()
|
|
||||||
expect(screen.queryByText('支持的微信版本')).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' },
|
python: { ready: true, executable: '/usr/local/bin/python3', version: '3.12.11' },
|
||||||
pilk: { ready: false, error: '未安装' },
|
pilk: { ready: false, error: '未安装' },
|
||||||
ffmpeg: { ready: true, executable: '/runtime/ffmpeg', version: '6.1' },
|
ffmpeg: { ready: true, executable: '/runtime/ffmpeg', version: '6.1' },
|
||||||
encoder: 'go-silk' as const,
|
encoder: 'silk' as const,
|
||||||
message: '语音编码环境不完整,OneBot 可能回退到 go-silk'
|
message: '语音编码环境不完整,语音将回退到内置 SILK 编码'
|
||||||
}
|
}
|
||||||
const readyEnvironment = {
|
const readyEnvironment = {
|
||||||
...incompleteEnvironment,
|
...incompleteEnvironment,
|
||||||
@@ -187,7 +186,7 @@ describe('TextToSpeechPage', () => {
|
|||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: '安装 pilk' }))
|
fireEvent.click(screen.getByRole('button', { name: '安装 pilk' }))
|
||||||
await waitFor(() => expect(installPilk).toHaveBeenCalledTimes(1))
|
await waitFor(() => expect(installPilk).toHaveBeenCalledTimes(1))
|
||||||
expect(onNotice).toHaveBeenCalledWith('语音编码环境已修复。重新启动语音发送服务后生效。')
|
expect(onNotice).toHaveBeenCalledWith('语音编码环境已修复。')
|
||||||
expect(await screen.findByText('✓ 编码环境正常')).toBeVisible()
|
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')
|
test.skip(process.platform !== 'darwin', 'The personal WeChat runtime is currently macOS-only')
|
||||||
const fixture = await launchTestApp()
|
const fixture = await launchTestApp()
|
||||||
const pageErrors: Error[] = []
|
const pageErrors: Error[] = []
|
||||||
@@ -441,22 +441,17 @@ test('SETTINGS-01 supported WeChat versions dialog is keyboard-safe and fits the
|
|||||||
.click()
|
.click()
|
||||||
await fixture.page.getByRole('button', { name: '微信发送' }).click()
|
await fixture.page.getByRole('button', { name: '微信发送' }).click()
|
||||||
|
|
||||||
const trigger = fixture.page.getByRole('button', { name: '支持版本' })
|
await expect(
|
||||||
await expect(trigger).toBeVisible()
|
fixture.page.getByText('请保持微信未登录窗口状态,点击“绑定微信”后,再点击微信窗口登录。')
|
||||||
await trigger.click()
|
).toBeVisible()
|
||||||
const dialog = fixture.page.getByRole('dialog', { name: '支持的微信版本' })
|
await expect(fixture.page.getByRole('button', { name: '支持版本' })).toHaveCount(0)
|
||||||
await expect(dialog).toBeVisible()
|
await expect(fixture.page.getByText('查看支持的微信版本')).toHaveCount(0)
|
||||||
await expect(dialog.getByText('4.1.6.12')).toBeVisible()
|
await expect(fixture.page.getByText('高级诊断')).toHaveCount(0)
|
||||||
await expect(dialog.getByText('4.1.11.53')).toBeVisible()
|
|
||||||
expect(
|
expect(
|
||||||
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
|
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
|
||||||
).toBe(true)
|
).toBe(true)
|
||||||
expect(pageErrors).toEqual([])
|
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()
|
await fixture.page.getByRole('button', { name: '文字转语音' }).click()
|
||||||
const modelSelect = fixture.page.getByRole('combobox', { name: '合成模型' })
|
const modelSelect = fixture.page.getByRole('combobox', { name: '合成模型' })
|
||||||
await modelSelect.scrollIntoViewIfNeeded()
|
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 unsignedMacUpdate = process.env.WXE_E2E_UNSIGNED_MAC_UPDATE === '1'
|
||||||
const fixtureNowMs =
|
const fixtureNowMs =
|
||||||
Number.isFinite(configuredNow) && configuredNow > 0 ? configuredNow : Date.now()
|
Number.isFinite(configuredNow) && configuredNow > 0 ? configuredNow : Date.now()
|
||||||
let keepOneBotProcess = false
|
|
||||||
|
|
||||||
const formatFixtureDateTime = (timestampSeconds) => {
|
const formatFixtureDateTime = (timestampSeconds) => {
|
||||||
const date = new Date(timestampSeconds * 1000)
|
const date = new Date(timestampSeconds * 1000)
|
||||||
@@ -400,17 +399,6 @@ handle('tts:getSettings', () => ({
|
|||||||
},
|
},
|
||||||
voices: []
|
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', () => ({
|
handle('wechat-personal:getStatus', () => ({
|
||||||
state: 'online',
|
state: 'online',
|
||||||
platform: process.platform,
|
platform: process.platform,
|
||||||
@@ -419,8 +407,7 @@ handle('wechat-personal:getStatus', () => ({
|
|||||||
wechatRunning: true,
|
wechatRunning: true,
|
||||||
wechatPid: 4668,
|
wechatPid: 4668,
|
||||||
boundWechatPid: 4668,
|
boundWechatPid: 4668,
|
||||||
oneBotPid: 5401,
|
endpoint: '127.0.0.1:4290',
|
||||||
endpoint: '127.0.0.1:58080',
|
|
||||||
endpointReady: true,
|
endpointReady: true,
|
||||||
wechatVersion: '4.1.11.53',
|
wechatVersion: '4.1.11.53',
|
||||||
runtimeReady: true,
|
runtimeReady: true,
|
||||||
@@ -454,7 +441,7 @@ handle('wechat-personal:getSendCapability', () => ({
|
|||||||
sipDisabled: true,
|
sipDisabled: true,
|
||||||
wechatRunning: true,
|
wechatRunning: true,
|
||||||
boundWechatPid: 4668,
|
boundWechatPid: 4668,
|
||||||
endpoint: '127.0.0.1:58080',
|
endpoint: '127.0.0.1:4290',
|
||||||
endpointReady: true,
|
endpointReady: true,
|
||||||
runtimeReady: true,
|
runtimeReady: true,
|
||||||
attachReady: true,
|
attachReady: true,
|
||||||
@@ -474,11 +461,6 @@ handle('wechat-personal:getSendCapability', () => ({
|
|||||||
? '个人微信已准备好发送日报'
|
? '个人微信已准备好发送日报'
|
||||||
: '微信消息发送目前仅支持 macOS 和 Windows'
|
: '微信消息发送目前仅支持 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:list', () => [...scheduledReportTasks])
|
||||||
handle('scheduled-report:listExecutions', (taskId) =>
|
handle('scheduled-report:listExecutions', (taskId) =>
|
||||||
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,
|
sipDisabled: true,
|
||||||
wechatRunning: true,
|
wechatRunning: true,
|
||||||
wechatPid: 123,
|
wechatPid: 123,
|
||||||
endpoint: '127.0.0.1:58080',
|
endpoint: '127.0.0.1:4290',
|
||||||
endpointReady: true,
|
endpointReady: true,
|
||||||
runtimeReady: true,
|
runtimeReady: true,
|
||||||
attachReady: true,
|
attachReady: true,
|
||||||
@@ -31,6 +31,27 @@ const status = (
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('PersonalWechatCapabilityService', () => {
|
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([
|
it.each([
|
||||||
['unsupported', status({ platform: 'linux' })],
|
['unsupported', status({ platform: 'linux' })],
|
||||||
['unconfigured', status({ runtimeReady: false, boundWechatPid: undefined })],
|
['unconfigured', status({ runtimeReady: false, boundWechatPid: undefined })],
|
||||||
@@ -64,7 +85,11 @@ describe('PersonalWechatCapabilityService', () => {
|
|||||||
})
|
})
|
||||||
]
|
]
|
||||||
])('maps %s', (expected, senderStatus) => {
|
])('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({
|
return expect(service.getPersonalWechatSendCapability()).resolves.toMatchObject({
|
||||||
status: expected,
|
status: expected,
|
||||||
ready: expected === 'ready',
|
ready: expected === 'ready',
|
||||||
|
|||||||
@@ -22,14 +22,10 @@ vi.mock('electron', () => ({
|
|||||||
import {
|
import {
|
||||||
buildPersonalWechatVoiceDiagnostic,
|
buildPersonalWechatVoiceDiagnostic,
|
||||||
buildWindowsWechatRequest,
|
buildWindowsWechatRequest,
|
||||||
buildPersonalWechatOneBotRequest,
|
|
||||||
findWechatImagePath,
|
findWechatImagePath,
|
||||||
findPersonalWechatRuntime,
|
|
||||||
normalizeWindowsWechatPort,
|
normalizeWindowsWechatPort,
|
||||||
parseWindowsLoginStatus,
|
parseWindowsLoginStatus,
|
||||||
parseWindowsHookResponse,
|
parseWindowsHookResponse,
|
||||||
parsePersonalWechatHookLog,
|
|
||||||
deriveMacSendCapabilities,
|
|
||||||
prepareWindowsImageFile,
|
prepareWindowsImageFile,
|
||||||
WindowsHookHttpError
|
WindowsHookHttpError
|
||||||
} from '../../src/main/services/personal-wechat-send-service'
|
} 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', () => {
|
it('keeps voice diagnostics to the safe metadata allowlist', () => {
|
||||||
const diagnostic = buildPersonalWechatVoiceDiagnostic('request-1', 'completed', {
|
const diagnostic = buildPersonalWechatVoiceDiagnostic('request-1', 'completed', {
|
||||||
input_bytes: 10,
|
input_bytes: 10,
|
||||||
@@ -79,70 +75,6 @@ describe('personal WeChat OneBot request', () => {
|
|||||||
expect(diagnostic.error).not.toContain('token-value')
|
expect(diagnostic.error).not.toContain('token-value')
|
||||||
expect(diagnostic.error).not.toContain('json-secret')
|
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', () => {
|
describe('Windows request', () => {
|
||||||
@@ -292,157 +224,4 @@ describe('personal WeChat runtime discovery', () => {
|
|||||||
`${join(tempRoot, 'ImageTemp', '2026-08')}${sep}`
|
`${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'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
const { findRuntime, buildEnvironment, writeLog } = vi.hoisted(() => ({
|
const { isRuntimePresent, buildEnvironment, writeLog } = vi.hoisted(() => ({
|
||||||
findRuntime: vi.fn(),
|
isRuntimePresent: vi.fn(),
|
||||||
buildEnvironment: vi.fn(),
|
buildEnvironment: vi.fn(),
|
||||||
writeLog: vi.fn()
|
writeLog: vi.fn()
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('../../src/main/services/personal-wechat-send-service', () => ({
|
vi.mock('../../src/main/services/personal-wechat-send-service', () => ({
|
||||||
findPersonalWechatRuntime: findRuntime,
|
|
||||||
buildPersonalWechatRuntimeEnvironment: buildEnvironment
|
buildPersonalWechatRuntimeEnvironment: buildEnvironment
|
||||||
}))
|
}))
|
||||||
|
vi.mock('../../src/main/services/mac-wechat-runtime-manager', () => ({
|
||||||
|
macWechatRuntimeManager: { isRuntimePresent: () => true }
|
||||||
|
}))
|
||||||
vi.mock('../../src/main/app-logger', () => ({ appLogger: { write: writeLog } }))
|
vi.mock('../../src/main/app-logger', () => ({ appLogger: { write: writeLog } }))
|
||||||
|
|
||||||
import { PersonalWechatVoiceEnvironmentService } from '../../src/main/services/personal-wechat-voice-environment-service'
|
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', () => {
|
describe('PersonalWechatVoiceEnvironmentService', () => {
|
||||||
let pilkAvailable = true
|
let pilkAvailable = true
|
||||||
const commands: Array<{ executable: string; args: string[]; env: NodeJS.ProcessEnv }> = []
|
const commands: Array<{ executable: string; args: string[]; env: NodeJS.ProcessEnv }> = []
|
||||||
@@ -29,11 +23,8 @@ describe('PersonalWechatVoiceEnvironmentService', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
pilkAvailable = true
|
pilkAvailable = true
|
||||||
commands.length = 0
|
commands.length = 0
|
||||||
findRuntime.mockReset().mockReturnValue(runtime)
|
isRuntimePresent.mockReset().mockReturnValue(true)
|
||||||
buildEnvironment.mockReset().mockReturnValue({
|
buildEnvironment.mockReset().mockReturnValue({ PATH: '/runtime/bin' })
|
||||||
PATH: '/runtime/bin',
|
|
||||||
PYTHONPATH: '/runtime/python'
|
|
||||||
})
|
|
||||||
writeLog.mockReset()
|
writeLog.mockReset()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -41,7 +32,7 @@ describe('PersonalWechatVoiceEnvironmentService', () => {
|
|||||||
return new PersonalWechatVoiceEnvironmentService({
|
return new PersonalWechatVoiceEnvironmentService({
|
||||||
platform: 'darwin',
|
platform: 'darwin',
|
||||||
architecture: 'arm64',
|
architecture: 'arm64',
|
||||||
findRuntime,
|
isRuntimePresent,
|
||||||
buildEnvironment,
|
buildEnvironment,
|
||||||
now: () => new Date('2026-08-31T00:00:00.000Z'),
|
now: () => new Date('2026-08-31T00:00:00.000Z'),
|
||||||
runCommand: async (executable, args, options) => {
|
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()
|
const environment = await createService().check()
|
||||||
|
|
||||||
expect(environment).toMatchObject({
|
expect(environment).toMatchObject({
|
||||||
@@ -87,8 +78,7 @@ describe('PersonalWechatVoiceEnvironmentService', () => {
|
|||||||
ffmpeg: { ready: true, executable: '/runtime/bin/ffmpeg', version: '7.0' }
|
ffmpeg: { ready: true, executable: '/runtime/bin/ffmpeg', version: '7.0' }
|
||||||
})
|
})
|
||||||
expect(commands.find((command) => command.executable === 'python3')?.env).toEqual({
|
expect(commands.find((command) => command.executable === 'python3')?.env).toEqual({
|
||||||
PATH: '/runtime/bin',
|
PATH: '/runtime/bin'
|
||||||
PYTHONPATH: '/runtime/python'
|
|
||||||
})
|
})
|
||||||
expect(commands.some((command) => command.executable === '/runtime/python3')).toBe(true)
|
expect(commands.some((command) => command.executable === '/runtime/python3')).toBe(true)
|
||||||
expect(writeLog).toHaveBeenCalledWith(
|
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
|
pilkAvailable = false
|
||||||
const service = createService()
|
const service = createService()
|
||||||
const before = await service.check()
|
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)
|
expect(before.pilk.ready).toBe(false)
|
||||||
|
|
||||||
const result = await service.installPilk()
|
const result = await service.installPilk()
|
||||||
@@ -123,7 +113,7 @@ describe('PersonalWechatVoiceEnvironmentService', () => {
|
|||||||
const service = new PersonalWechatVoiceEnvironmentService({
|
const service = new PersonalWechatVoiceEnvironmentService({
|
||||||
platform: 'darwin',
|
platform: 'darwin',
|
||||||
architecture: 'arm64',
|
architecture: 'arm64',
|
||||||
findRuntime,
|
isRuntimePresent,
|
||||||
buildEnvironment,
|
buildEnvironment,
|
||||||
runCommand: async () => {
|
runCommand: async () => {
|
||||||
throw new Error('spawn python3 ENOENT')
|
throw new Error('spawn python3 ENOENT')
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ describe('sticker HTTP failures', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('personal WeChat runtime security invariants', () => {
|
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 mainSource = readFileSync(resolve('src/main/index.ts'), 'utf8')
|
||||||
const shutdownStart = mainSource.indexOf("app.on('before-quit'")
|
const shutdownStart = mainSource.indexOf("app.on('before-quit'")
|
||||||
const shutdownEnd = mainSource.indexOf('function showMainWindow', shutdownStart)
|
const shutdownEnd = mainSource.indexOf('function showMainWindow', shutdownStart)
|
||||||
@@ -62,39 +62,16 @@ describe('personal WeChat runtime security invariants', () => {
|
|||||||
expect(shutdownStart).toBeGreaterThanOrEqual(0)
|
expect(shutdownStart).toBeGreaterThanOrEqual(0)
|
||||||
expect(shutdownEnd).toBeGreaterThan(shutdownStart)
|
expect(shutdownEnd).toBeGreaterThan(shutdownStart)
|
||||||
expect(shutdownSource).toContain('await Promise.all([')
|
expect(shutdownSource).toContain('await Promise.all([')
|
||||||
expect(shutdownSource).toContain('personalWechatSendService.terminate()')
|
expect(shutdownSource).toContain('macWechatRuntimeManager.shutdown()')
|
||||||
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')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not install Python packages while preparing the sender runtime', () => {
|
it('does not install Python packages while preparing the native runtime', () => {
|
||||||
const runtimeManagerSource = readFileSync(
|
|
||||||
resolve('src/main/services/personal-wechat-runtime-manager.ts'),
|
|
||||||
'utf8'
|
|
||||||
)
|
|
||||||
const preparationScriptSource = readFileSync(
|
const preparationScriptSource = readFileSync(
|
||||||
resolve('scripts/prepare-wechat-chatter-runtime.cjs'),
|
resolve('scripts/prepare-wechat-native-runtime.cjs'),
|
||||||
'utf8'
|
'utf8'
|
||||||
)
|
)
|
||||||
|
|
||||||
for (const source of [runtimeManagerSource, preparationScriptSource]) {
|
expect(preparationScriptSource).not.toContain('pilk==')
|
||||||
expect(source).not.toContain('pilk==')
|
expect(preparationScriptSource).not.toMatch(/['"]pip['"]/)
|
||||||
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)')
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ vi.mock('electron', () => ({ app: { getPath: () => '/tmp/tracememo-settings-test
|
|||||||
* settings-store 在模块加载时确定 settings.json 路径并缓存设置对象,
|
* settings-store 在模块加载时确定 settings.json 路径并缓存设置对象,
|
||||||
* 所以每个用例都要用独立的目录 + 重新加载模块。
|
* 所以每个用例都要用独立的目录 + 重新加载模块。
|
||||||
*/
|
*/
|
||||||
async function importStore(directory: string): Promise<
|
async function importStore(
|
||||||
typeof import('../../src/main/services/settings-store')
|
directory: string
|
||||||
> {
|
): Promise<typeof import('../../src/main/services/settings-store')> {
|
||||||
process.env.WE_SETTINGS_DIR = directory
|
process.env.WE_SETTINGS_DIR = directory
|
||||||
vi.resetModules()
|
vi.resetModules()
|
||||||
return import('../../src/main/services/settings-store')
|
return import('../../src/main/services/settings-store')
|
||||||
@@ -68,4 +68,18 @@ describe('recall protection retirement migration', () => {
|
|||||||
|
|
||||||
expect(store.loadSettings().recallProtectionEnabled).toBe(false)
|
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()
|
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 () => {
|
it('lets a user action send immediately while an automatic action is waiting', async () => {
|
||||||
vi.useFakeTimers()
|
vi.useFakeTimers()
|
||||||
vi.setSystemTime(new Date('2026-09-03T09:30:00.000Z'))
|
vi.setSystemTime(new Date('2026-09-03T09:30:00.000Z'))
|
||||||
|
|||||||
Reference in New Issue
Block a user