Import revenge-next-plugs working tree
Includes the already-applied package.json fix (build/build:dev/generate-index/serve now invoke @revenge-mod/plugin-cli via bun directly, bypassing its broken node-shebang delegation that crashed both bun run build and Gradle's packageAllPlugins).
This commit is contained in:
parent
0928974784
commit
869556245c
36 changed files with 3601 additions and 0 deletions
18
.gitignore
vendored
Normal file
18
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# Gradle / Android
|
||||
.gradle/
|
||||
build/
|
||||
plugins/*/build/
|
||||
local.properties
|
||||
.kotlin/
|
||||
|
||||
# JS
|
||||
node_modules/
|
||||
bun.lockb
|
||||
bun.lock
|
||||
package-lock.json
|
||||
deno.lock
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
.idea/
|
||||
*.iml
|
||||
8
.vscode/settings.json
vendored
Executable file
8
.vscode/settings.json
vendored
Executable file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.biome": "explicit",
|
||||
"source.fixAll.biome": "explicit",
|
||||
"source.organizeImports.biome": "explicit",
|
||||
"source.removeUnusedImports": "explicit"
|
||||
}
|
||||
}
|
||||
621
LICENSE
Executable file
621
LICENSE
Executable file
|
|
@ -0,0 +1,621 @@
|
|||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
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
|
||||
281
README.md
Normal file
281
README.md
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
# revenge-next-plugs
|
||||
|
||||
Revenge Next plugin repository, scaffolded from [revenge-plugin-template](https://github.com/revenge-mod/revenge-plugin-template).
|
||||
Each plugin becomes one ZIP file. One repository can hold any number of plugins under `plugins/`.
|
||||
|
||||
## Plugins
|
||||
|
||||
- `staff-tags` — ported from [revenge-plugins](https://github.com/everestmcarthur/revenge-plugins) (Classic).
|
||||
|
||||
A plugin has up to three parts. Its own `manifest.json` declares all of them:
|
||||
|
||||
- **`dist.android`** is native Kotlin code. The build compiles it to a DEXed JAR, and the plugin loader loads it with `DexClassLoader`.
|
||||
This code runs early, before the JS bundle.
|
||||
- **`dist.script`** is the JavaScript bundle. The Revenge JS side runs it.
|
||||
|
||||
## Layout
|
||||
|
||||
Each folder under `plugins/` is one plugin. A plugin is **native** when it has a `src/main` folder.
|
||||
A plugin is **JS-only** when it has only a JS entry file. A plugin can have both.
|
||||
|
||||
```
|
||||
├── plugins/
|
||||
│ ├── example-plugin/ # Native + JS
|
||||
│ │ ├── manifest.json # id, metadata, dist.* paths
|
||||
│ │ ├── src/main/kotlin/com/example/plugin/MyPlugin.kt # -> plugin.jar
|
||||
│ │ └── js/index.ts # -> index.js
|
||||
│ ├── example-js-plugin/ # JS-only: no src/main, no dist.android
|
||||
│ │ ├── manifest.json
|
||||
│ │ └── js/index.ts
|
||||
│ ├── example-library/ # Dependency example: the plugin others depend on
|
||||
│ └── example-dependent/ # Dependency example: depends on com.example.library
|
||||
```
|
||||
|
||||
The bundler looks for the JS entry in this order: `js/index.*`, then `src/index.*`, then `index.*` in the plugin folder.
|
||||
Each step accepts `.ts`, `.tsx`, `.js` and `.jsx`.
|
||||
|
||||
To add a plugin, create `plugins/<name>/manifest.json` and add a `src/main` folder for native code, a JS entry file, or both.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **JDK 25 or later** and the **Android SDK**, with `build-tools` and `platform 36`.
|
||||
Don't forget to set `sdk.dir` in `local.properties`, or set the `ANDROID_HOME` environment variable.
|
||||
- **A JS runtime** for the JS build: [Node](https://nodejs.org/) 22.18 or later,
|
||||
[Deno](https://deno.com/) 2, or [Bun](https://bun.com/).
|
||||
- **The Revenge plugin API in your local Maven repository.** Run this in the `revenge-xposed` repository:
|
||||
|
||||
```sh
|
||||
./gradlew :api:publishToMavenLocal
|
||||
```
|
||||
|
||||
The task publishes `io.github.revenge:api`. `gradle/libs.versions.toml` pins the version.
|
||||
|
||||
## Build
|
||||
|
||||
Build and package every plugin:
|
||||
|
||||
```sh
|
||||
./gradlew packageAllPlugins
|
||||
```
|
||||
|
||||
The task writes one `build/dist/<id>.zip` per plugin. Each ZIP holds `manifest.json`, the dexed JAR
|
||||
of a native plugin, and the JS bundle of a plugin that has one.
|
||||
|
||||
Build one plugin, or only one part of it:
|
||||
|
||||
```sh
|
||||
./gradlew packageExamplePlugin # one plugin -> build/dist/<id>.zip
|
||||
./gradlew :plugins:example-plugin:dexJar # native only -> plugins/example-plugin/build/outputs/plugin/plugin.jar
|
||||
# replace bun with npm if you use node
|
||||
bun install # install the dependencies
|
||||
bun run build # every JS bundle -> plugins/<name>/build/js/index.js
|
||||
bun run build example-plugin # the JS bundle of one plugin
|
||||
```
|
||||
|
||||
Gradle derives each package task name from the folder name, example: `plugins/example-plugin/` gives `packageExamplePlugin`.
|
||||
|
||||
## `manifest.json`
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"format": 1, // manifest format version. Required. Always 1 today.
|
||||
"id": "com.example.plugin", // also the folder name on disk
|
||||
"name": "Example Plugin",
|
||||
"description": "...",
|
||||
"author": "Your Name",
|
||||
"version": "1.0.0", // the version of this plugin. Required.
|
||||
"dependencies": { // keyed by plugin id
|
||||
"revenge.api": { "version": ">=1" },
|
||||
"discord": { "version": "*" }
|
||||
},
|
||||
"dist": {
|
||||
"script": "index.js", // relative to the plugin folder
|
||||
"android": {
|
||||
"path": "plugin.jar", // relative to the plugin folder
|
||||
"class": "com.example.plugin.MyPlugin" // the class that exposes the `plugin {}` val
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `version`
|
||||
|
||||
Revenge uses its own version scheme. A version is one or more integer segments.
|
||||
One lowercase alphanumeric prerelease label can follow. `1.0.0`, `2026.7` and `1.2.0-beta2` are all valid.
|
||||
|
||||
Two rules control the order:
|
||||
|
||||
- A short version compares as right-padded. `1.2` equals `1.2.0`.
|
||||
- A labeled version always sorts before its bare version. `1.2.0-rc` is lower than `1.2.0`.
|
||||
|
||||
This scheme looks like SemVer, but it is not SemVer. A CalVer-shaped version works equally well.
|
||||
|
||||
### `dependencies`
|
||||
|
||||
`dependencies` is a map, and each key is a plugin id:
|
||||
|
||||
```jsonc
|
||||
"dependencies": {
|
||||
"com.example.library": { "version": ">=1.0 <2", "optional": false }
|
||||
}
|
||||
```
|
||||
|
||||
Every field inside the value is optional. `{}` means `{ "version": "*" }`, which accepts any version.
|
||||
The key itself must still exist. The host never assumes a dependency that you do not declare.
|
||||
|
||||
A version range uses explicit bounds only: `<`, `<=`, `=`, `>=` and `>`, separated by spaces.
|
||||
The range syntax has no `^` and no `~`. The `"*"` wildcard accepts every version.
|
||||
|
||||
Ranges are checked at install time, at every boot, and when the user enables the plugin.
|
||||
Plugins don't load when required dependencies fail or don't satisfy the version requirements.
|
||||
|
||||
Dependencies are resolved **by ID** against the repositories that the user enabled.
|
||||
When a dependency lives in another repository, the user must add that repository before installing the plugin.
|
||||
|
||||
The `example-library` and `example-dependent` pair shows this. The dependent declares `"com.example.library": { "version": ">=1" }`.
|
||||
An install therefore also installs the library. The library always loads and starts first.
|
||||
|
||||
If the library is missing or out of range, the dependent never loads.
|
||||
|
||||
### Optional dependencies
|
||||
|
||||
`"optional": true` marks a dependency that never blocks your plugin.
|
||||
Your plugin still loads when that dependency is missing, out of range, or broken.
|
||||
|
||||
When the dependency is present, it loads before your plugin, and its code are linked and made available to your plugin.
|
||||
|
||||
To detect the dependency, probe for one of its classes:
|
||||
|
||||
```kotlin
|
||||
val themesAvailable = runCatching {
|
||||
Class.forName("com.example.themes.ThemeApi", false, javaClass.classLoader)
|
||||
}.isSuccess
|
||||
```
|
||||
|
||||
Keep all code that touches the optional API in a separate adapter class. Reference that class only after the probe succeeds.
|
||||
A reference to a missing class stays safe until a code path runs it.
|
||||
|
||||
In JS, check if your plugin API is decorated:
|
||||
|
||||
```ts
|
||||
start({ themes }) {
|
||||
const themesAvailable = !!themes
|
||||
}
|
||||
```
|
||||
|
||||
### Reserved IDs
|
||||
|
||||
Two dependency IDs are reserved.
|
||||
|
||||
- **`revenge.api`** resolves to the Revenge release version, which is the plugin API version.
|
||||
This dependency is **mandatory**. Constrain it to the API versions you tested, for example `">=1 <2"`.
|
||||
- **`discord`** resolves to the Discord app version, for example `>=355.0`.
|
||||
|
||||
## Native plugins
|
||||
|
||||
A native plugin is a **top-level `val`** that you build with the `plugin {}` DSL. You implement no
|
||||
interface, and you subclass nothing. The host reads the class that `dist.android.class` names, and
|
||||
takes the first `PluginBuilder` value it exposes. You import and use a Ktor plugin value the same
|
||||
way.
|
||||
|
||||
```kotlin
|
||||
@file:JvmName("MyPlugin") // makes dist.android.class read as com.example.plugin.MyPlugin
|
||||
|
||||
package com.example.plugin
|
||||
|
||||
import io.github.revenge.plugins.plugin
|
||||
import io.github.revenge.xposed.api.registerMethod
|
||||
|
||||
val myPlugin = plugin {
|
||||
start {
|
||||
log.i("Loaded ${manifest.id} in ${appInfo.packageName}")
|
||||
registerMethod("${manifest.id}.ping") { "pong" }
|
||||
}
|
||||
stop {
|
||||
log.i("Unloaded ${manifest.id}")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
A Kotlin top-level `val` compiles into a file-facade class. `MyPlugin.kt` becomes `MyPluginKt`.
|
||||
The `@file:JvmName("MyPlugin")` annotation renames that facade. `dist.android.class` can then use the clean name `com.example.plugin.MyPlugin`.
|
||||
|
||||
If you omit the annotation, point `dist.android.class` at `...MyPluginKt`.
|
||||
Declare exactly one `plugin {}` val in the file that the manifest names.
|
||||
|
||||
The host provides the Revenge API, the Xposed API, coroutines and the Kotlin standard library. The
|
||||
build marks them `compileOnly`. The host class loader supplies them at runtime, so the JAR must not
|
||||
contain them.
|
||||
|
||||
> **Note:** `d8` can print a `malformed kotlin.Metadata` warning. This warning is not fatal.
|
||||
> The SDK metadata library is older than the Kotlin compiler. `d8` still writes a correct DEX, and the DEX loads.
|
||||
> Only the rewrite of Kotlin reflection metadata stops.
|
||||
|
||||
## Distribution
|
||||
|
||||
This template is also a **plugin repository**. A repository is a static host that serves `index.json`
|
||||
describing every published plugin channels, versions, absolute artifact URLs, and SHA-256 digests.
|
||||
|
||||
A user can add the repository URL in Revenge. Browsing, dependency resolution and updates all run on the client.
|
||||
|
||||
### Channels
|
||||
|
||||
A **channel is a named pointer into the published versions of one plugin**. In `index.json` each plugin carries both maps:
|
||||
|
||||
```jsonc
|
||||
"channels": { "latest": "1.2.0", "testing": "1.3.0-beta" },
|
||||
"versions": { "1.2.0": { /* … */ }, "1.3.0-beta": { /* … */ } }
|
||||
```
|
||||
|
||||
`versions` holds the artifact data. `channels` only states which published version an audience gets.
|
||||
|
||||
The client picks a channel at install time, and it follows that pointer for update checks.
|
||||
A stable user never sees a beta, because the `latest` pointer never points at one.
|
||||
|
||||
**Automatic pointers**:
|
||||
|
||||
- `latest` is the newest version with **no label**. `1.2.0` qualifies. `1.3.0-beta` never does.
|
||||
- `beta` is the newest version **overall**. The generator emits it only when it differs from `latest`.
|
||||
When your newest release is stable, no `beta` pointer exists.
|
||||
|
||||
**Manual overrides**: Use the `channels` key in `repo.config.json`, keyed by plugin ID:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"name": "My Plugin Repository",
|
||||
"channels": {
|
||||
"com.example.plugin": {
|
||||
"latest": "1.1.4", // keep latest on 1.1.4, for example when 1.2.0 shipped broken
|
||||
"lts": "1.0.9" // or add a channel of your own
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
These rules apply:
|
||||
|
||||
- The generator computes `latest` and `beta` first. It then applies your overrides.
|
||||
- An override must point at a published version of that plugin. Otherwise the generator fails.
|
||||
- A channel name carries no version semantics. An `lts` version is the same artifact as its plain version.
|
||||
You only point at it for longer. To promote `beta` to `latest`, edit the pointer. No rebuilds or republishes.
|
||||
- **A dependency never references a channel.** A dependency constrains versions only, so a mixed-channel install can resolve.
|
||||
|
||||
### Serve a repository on your machine
|
||||
|
||||
You can test the full repository flow against your own builds: add the repository, browse it, install, and update.
|
||||
Build the ZIPs first, then start the dev server. The server regenerates the index and serves it beside the artifacts:
|
||||
|
||||
```sh
|
||||
./gradlew packageAllPlugins # or one package task
|
||||
bun run serve # http://<your-lan-ip>:8080
|
||||
```
|
||||
|
||||
Add the URL on the device as a repository. If the device cannot reach your IP, or if it blocks cleartext traffic, use loopback through ADB:
|
||||
|
||||
```sh
|
||||
bun run serve -- --base-url http://127.0.0.1:8080
|
||||
adb reverse tcp:8080 tcp:8080
|
||||
```
|
||||
|
||||
The server rescans the dist folder on every index request.
|
||||
Bump a manifest version, rebuild that plugin, and check for updates on the device. The new version will appear.
|
||||
113
biome.json
Normal file
113
biome.json
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.5.6/schema.json",
|
||||
"assist": {
|
||||
"actions": {
|
||||
"source": {
|
||||
"organizeImports": {
|
||||
"level": "on",
|
||||
"options": {
|
||||
"groups": [
|
||||
{
|
||||
"type": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"enabled": true
|
||||
},
|
||||
"files": {
|
||||
"includes": [
|
||||
"**/*.js",
|
||||
"**/*.json",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
"**/*.jsx",
|
||||
"**/*.mjs"
|
||||
],
|
||||
"ignoreUnknown": true
|
||||
},
|
||||
"formatter": {
|
||||
"useEditorconfig": true
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"arrowParentheses": "asNeeded",
|
||||
"enabled": true,
|
||||
"quoteProperties": "asNeeded",
|
||||
"quoteStyle": "single",
|
||||
"semicolons": "asNeeded",
|
||||
"trailingCommas": "all"
|
||||
}
|
||||
},
|
||||
"json": {
|
||||
"parser": {
|
||||
"allowComments": true,
|
||||
"allowTrailingCommas": true
|
||||
}
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"preset": "recommended",
|
||||
"a11y": {
|
||||
"useKeyWithClickEvents": {
|
||||
"level": "off"
|
||||
}
|
||||
},
|
||||
"complexity": {
|
||||
"noBannedTypes": {
|
||||
"level": "warn"
|
||||
},
|
||||
"noCommaOperator": {
|
||||
"level": "off"
|
||||
},
|
||||
"useArrowFunction": {
|
||||
"level": "warn"
|
||||
}
|
||||
},
|
||||
"performance": {
|
||||
"noDelete": {
|
||||
"level": "warn"
|
||||
}
|
||||
},
|
||||
"style": {
|
||||
"noNonNullAssertion": {
|
||||
"level": "off"
|
||||
},
|
||||
"useImportType": {
|
||||
"level": "warn",
|
||||
"options": {
|
||||
"style": "separatedType"
|
||||
}
|
||||
},
|
||||
"useNodejsImportProtocol": {
|
||||
"level": "off"
|
||||
},
|
||||
"useTemplate": {
|
||||
"level": "warn"
|
||||
}
|
||||
},
|
||||
"suspicious": {
|
||||
"noAssignInExpressions": {
|
||||
"level": "off"
|
||||
},
|
||||
"noConfusingVoidType": {
|
||||
"level": "off"
|
||||
},
|
||||
"noExplicitAny": {
|
||||
"level": "off"
|
||||
},
|
||||
"useIterableCallbackReturn": {
|
||||
"level": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"vcs": {
|
||||
"clientKind": "git",
|
||||
"enabled": true,
|
||||
"useIgnoreFile": true
|
||||
}
|
||||
}
|
||||
304
build.gradle.kts
Normal file
304
build.gradle.kts
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
import com.android.build.api.dsl.LibraryExtension
|
||||
import com.android.build.api.variant.LibraryAndroidComponentsExtension
|
||||
import groovy.json.JsonSlurper
|
||||
import java.io.File
|
||||
import java.util.concurrent.Callable
|
||||
import javax.inject.Inject
|
||||
import org.gradle.process.ExecOperations
|
||||
import org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.library) apply false
|
||||
alias(libs.plugins.kotlin.android) apply false
|
||||
}
|
||||
|
||||
val compileSdkVer = libs.versions.compileSdk.get()
|
||||
val minSdkVer = libs.versions.minSdk.get()
|
||||
val javaVer = libs.versions.javaVersion.get()
|
||||
val revengeApiDep = libs.revenge.api.get().toString()
|
||||
val xposedApiDep = libs.xposed.api.get().toString()
|
||||
val coroutinesDep = libs.kotlinx.coroutines.android.get().toString()
|
||||
val androidLibraryPluginId = libs.plugins.android.library.get().pluginId
|
||||
|
||||
// Gradle 9 removed Project.exec from task actions
|
||||
interface ExecOpsProvider {
|
||||
@get:Inject val execOps: ExecOperations
|
||||
}
|
||||
|
||||
val rootExecOps = objects.newInstance<ExecOpsProvider>().execOps
|
||||
|
||||
// `./gradlew clean packageAllPlugins` also runs every `:plugins:<name>:clean`,
|
||||
// which would delete freshly built artifacts mid-build; the tasks below `mustRunAfter` these.
|
||||
val cleanTasks = Callable { rootProject.allprojects.mapNotNull { it.tasks.findByName("clean") } }
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Plugins declared under plugins/<name>/manifest.json
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
fun parseJson(file: File) = JsonSlurper().parse(file) as Map<String, Any?>
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
fun Map<String, Any?>.obj(key: String) = this[key] as? Map<String, Any?>
|
||||
|
||||
fun Map<String, Any?>.str(key: String) = this[key] as? String
|
||||
|
||||
data class PluginDef(val dir: File, val id: String, val jarName: String?, val scriptName: String?)
|
||||
|
||||
val pluginDefs = file("plugins").listFiles().orEmpty()
|
||||
.filter(File::isDirectory)
|
||||
.sortedBy(File::getName)
|
||||
.mapNotNull { dir ->
|
||||
val manifest = File(dir, "manifest.json").takeIf(File::isFile)?.let(::parseJson)
|
||||
?: return@mapNotNull null
|
||||
val id = manifest.str("id") ?: return@mapNotNull null
|
||||
val dist = manifest.obj("dist").orEmpty()
|
||||
PluginDef(dir, id, dist.obj("android")?.str("path"), dist.str("script"))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// JS toolchain discovery
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
// Gradle's exec runs with a minimal, non-login PATH that usually omits user-level install dirs,
|
||||
// so the well-known locations are probed explicitly.
|
||||
data class JsTool(val kind: String, val exe: File) {
|
||||
val installCommand: List<String>
|
||||
get() = when (kind) {
|
||||
"bun" -> listOf(exe.absolutePath, "install", "--silent")
|
||||
"npm" -> listOf(exe.absolutePath, "install", "--no-audit", "--no-fund", "--silent")
|
||||
"deno" -> listOf(exe.absolutePath, "install", "--quiet")
|
||||
else -> error("Unknown JS tool: $kind")
|
||||
}
|
||||
|
||||
val buildCommand: List<String>
|
||||
get() = when (kind) {
|
||||
"bun", "npm" -> listOf(exe.absolutePath, "run", "build")
|
||||
// Deno creates no node_modules/.bin shims, so `deno task build` cannot resolve the CLI,
|
||||
// so we run its bin file directly.
|
||||
"deno" -> listOf(
|
||||
exe.absolutePath, "run", "-A",
|
||||
"node_modules/@revenge-mod/plugin-cli/bin/revenge-plugin.js", "build",
|
||||
)
|
||||
else -> error("Unknown JS tool: $kind")
|
||||
}
|
||||
}
|
||||
|
||||
fun findJsTool(): JsTool? {
|
||||
val kindOverride = findProperty("jsTool") as String?
|
||||
val pathOverride = (findProperty("jsToolPath") as String?) ?: System.getenv("BUN")
|
||||
if (!pathOverride.isNullOrBlank()) {
|
||||
val exe = File(pathOverride).takeIf(File::isFile) ?: return null
|
||||
return JsTool(kindOverride ?: exe.name.substringBefore('.'), exe)
|
||||
}
|
||||
|
||||
val homes = buildSet {
|
||||
System.getProperty("user.home")?.let(::add)
|
||||
System.getenv("HOME")?.let(::add)
|
||||
System.getProperty("user.name")?.let { user ->
|
||||
add("/home/$user")
|
||||
add("/var/home/$user") // atomic/ostree distros: /home -> /var/home
|
||||
}
|
||||
}
|
||||
val pathDirs = System.getenv("PATH").orEmpty().split(File.pathSeparator).filter(String::isNotEmpty)
|
||||
|
||||
fun candidatesFor(kind: String): List<File> = buildList {
|
||||
pathDirs.forEach { add(File(it, kind)) }
|
||||
when (kind) {
|
||||
"bun" -> homes.forEach { add(File(it, ".bun/bin/bun")) }
|
||||
// nvm keeps npm next to node, under versions/node/<ver>/bin.
|
||||
"npm" -> homes.forEach { home ->
|
||||
File(home, ".nvm/versions/node").listFiles()
|
||||
?.sortedDescending()
|
||||
?.forEach { add(File(it, "bin/npm")) }
|
||||
}
|
||||
"deno" -> homes.forEach { add(File(it, ".deno/bin/deno")) }
|
||||
}
|
||||
add(File("/usr/local/bin/$kind"))
|
||||
add(File("/opt/homebrew/bin/$kind"))
|
||||
}
|
||||
|
||||
val kinds = kindOverride?.let(::listOf) ?: listOf("bun", "npm", "deno")
|
||||
return kinds.firstNotNullOfOrNull { kind ->
|
||||
candidatesFor(kind).firstOrNull(File::isFile)?.let { JsTool(kind, it) }
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Native plugin modules (:plugins:<name>) are configured entirely from here,
|
||||
// so a native plugin needs nothing but a src/main folder and a manifest.json.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
configure(subprojects.filter { it.path.startsWith(":plugins:") }) {
|
||||
// AGP 9 has built-in Kotlin support, so org.jetbrains.kotlin.android isn't applied.
|
||||
pluginManager.apply(androidLibraryPluginId)
|
||||
|
||||
extensions.configure<LibraryExtension> {
|
||||
namespace = "revenge.plugin." + name.replace(Regex("[^A-Za-z0-9]"), "_")
|
||||
compileSdk = compileSdkVer.toInt()
|
||||
defaultConfig { minSdk = minSdkVer.toInt() }
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.toVersion(javaVer)
|
||||
targetCompatibility = JavaVersion.toVersion(javaVer)
|
||||
}
|
||||
sourceSets {
|
||||
named("main") { kotlin.directories += "src/main/kotlin" }
|
||||
}
|
||||
}
|
||||
|
||||
extensions.configure<KotlinAndroidProjectExtension> {
|
||||
jvmToolchain(javaVer.toInt())
|
||||
}
|
||||
|
||||
// AGP's lint tasks require the module's build.gradle as an input, which these script-less modules don't have.
|
||||
// There is nothing to lint here anyway.
|
||||
tasks.configureEach {
|
||||
if (name.contains("Lint") || name.startsWith("lint")) enabled = false
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Provided at runtime by the host, so never bundled
|
||||
add("compileOnly", revengeApiDep)
|
||||
add("compileOnly", xposedApiDep)
|
||||
add("compileOnly", coroutinesDep)
|
||||
}
|
||||
|
||||
val androidExt = extensions.getByType<LibraryExtension>()
|
||||
val sdkDirProvider = extensions.getByType<LibraryAndroidComponentsExtension>().sdkComponents.sdkDirectory
|
||||
val execOps = objects.newInstance<ExecOpsProvider>().execOps
|
||||
|
||||
tasks.register("dexJar") {
|
||||
group = "revenge"
|
||||
description = "Compiles this plugin into a DEX-containing JAR loadable by DexClassLoader."
|
||||
dependsOn("compileReleaseKotlin", "compileReleaseJavaWithJavac")
|
||||
mustRunAfter(cleanTasks)
|
||||
|
||||
// Only this module's own classes are DEXed. Dependencies compileOnly.
|
||||
val classDirs = files(
|
||||
// AGP 9 built-in Kotlin output, then the pre-AGP 9 location
|
||||
layout.buildDirectory.dir("intermediates/built_in_kotlinc/release/compileReleaseKotlin/classes"),
|
||||
layout.buildDirectory.dir("tmp/kotlin-classes/release"),
|
||||
layout.buildDirectory.dir("intermediates/javac/release/classes"),
|
||||
)
|
||||
inputs.files(classDirs).withPropertyName("classDirs").optional()
|
||||
|
||||
val outJar = layout.buildDirectory.file("outputs/plugin/plugin.jar")
|
||||
outputs.file(outJar).withPropertyName("dexJar")
|
||||
|
||||
val buildToolsVersion = androidExt.buildToolsVersion
|
||||
|
||||
doLast {
|
||||
val classFiles = classDirs.asFileTree.matching { include("**/*.class") }.files
|
||||
if (classFiles.isEmpty()) error("No compiled classes found to dex in $path.")
|
||||
|
||||
val sdkDir = sdkDirProvider.get().asFile
|
||||
// Use newest d8 as the AGP-default build-tools could ship one too old for class files from a newer toolchain
|
||||
val d8 = File(sdkDir, "build-tools").listFiles()
|
||||
?.sortedByDescending(File::getName)
|
||||
?.map { File(it, "d8") }
|
||||
?.firstOrNull(File::isFile)
|
||||
?: file("$sdkDir/build-tools/$buildToolsVersion/d8")
|
||||
val androidJar = file("$sdkDir/platforms/android-$compileSdkVer/android.jar")
|
||||
require(d8.exists()) { "d8 not found at $d8" }
|
||||
require(androidJar.exists()) { "android.jar not found at $androidJar" }
|
||||
|
||||
val out = outJar.get().asFile
|
||||
out.parentFile.mkdirs()
|
||||
execOps.exec {
|
||||
commandLine(
|
||||
d8.absolutePath,
|
||||
"--min-api", minSdkVer,
|
||||
"--lib", androidJar.absolutePath,
|
||||
"--output", out.absolutePath,
|
||||
*classFiles.map { it.absolutePath }.toTypedArray(),
|
||||
)
|
||||
}
|
||||
logger.lifecycle("Dexed $path -> ${out.relativeTo(rootProject.projectDir)}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// JS bundles (all plugins at once) and per-plugin packaging into build/dist/<id>.zip
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
val buildJs = tasks.register("buildJs") {
|
||||
group = "revenge"
|
||||
description = "Builds every plugin's JS bundle via the revenge-plugin CLI."
|
||||
mustRunAfter(cleanTasks)
|
||||
|
||||
// The bundler is @revenge-mod/plugin-cli, so the lockfiles (which pin its revision) are the inputs.
|
||||
inputs.files(
|
||||
"package.json", "tsconfig.json", "biome.json",
|
||||
"bun.lock", "bun.lockb", "package-lock.json", "deno.lock",
|
||||
).withPropertyName("config")
|
||||
// Everything a plugin's bundle can be built from.
|
||||
inputs.files(fileTree("plugins") { exclude("*/build/**", "*/src/**") }).withPropertyName("sources")
|
||||
|
||||
pluginDefs.filter { it.scriptName != null }.forEach {
|
||||
outputs.dir(File(it.dir, "build/js")).withPropertyName("bundle-${it.dir.name}")
|
||||
}
|
||||
|
||||
doLast {
|
||||
val tool = findJsTool()
|
||||
if (tool == null) {
|
||||
logger.warn(
|
||||
"No JS toolchain found (bun, npm, deno). Install Node >= 22.18, Bun or Deno 2, " +
|
||||
"or pass -PjsTool=/-PjsToolPath=. Skipping JS build.",
|
||||
)
|
||||
return@doLast
|
||||
}
|
||||
|
||||
logger.lifecycle("Building JS bundles with ${tool.kind} (${tool.exe})")
|
||||
// Put the tool's own dir on PATH so nested calls (npm -> node, script -> bin shims) resolve.
|
||||
val path = tool.exe.parentFile.absolutePath + File.pathSeparator + System.getenv("PATH").orEmpty()
|
||||
listOf(tool.installCommand, tool.buildCommand).forEach { cmd ->
|
||||
rootExecOps.exec {
|
||||
workingDir = rootDir
|
||||
environment("PATH", path)
|
||||
commandLine(cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val packageAllPlugins = tasks.register("packageAllPlugins") {
|
||||
group = "revenge"
|
||||
description = "Builds and packages every plugin into build/dist/<id>.zip."
|
||||
}
|
||||
|
||||
fun taskSuffix(dirName: String): String =
|
||||
dirName.split(Regex("[^A-Za-z0-9]")).filter(String::isNotEmpty)
|
||||
.joinToString("") { it.replaceFirstChar(Char::uppercase) }
|
||||
|
||||
pluginDefs.forEach { (dir, id, jarName, scriptName) ->
|
||||
val pkg = tasks.register<Zip>("package${taskSuffix(dir.name)}") {
|
||||
group = "revenge"
|
||||
description = "Packages '$id' into a distributable ZIP."
|
||||
dependsOn(buildJs)
|
||||
mustRunAfter(cleanTasks)
|
||||
|
||||
from(File(dir, "manifest.json"))
|
||||
|
||||
if (jarName != null && File(dir, "src/main").isDirectory) {
|
||||
val nativePath = ":plugins:${dir.name}"
|
||||
dependsOn("$nativePath:dexJar")
|
||||
from(provider { project(nativePath).tasks.named("dexJar").get().outputs.files.singleFile }) {
|
||||
rename { jarName }
|
||||
}
|
||||
}
|
||||
|
||||
if (scriptName != null) {
|
||||
from(File(dir, "build/js")) {
|
||||
include("index.js")
|
||||
rename { scriptName }
|
||||
}
|
||||
}
|
||||
|
||||
archiveFileName.set("$id.zip")
|
||||
destinationDirectory.set(layout.buildDirectory.dir("dist"))
|
||||
doLast { logger.lifecycle("Packaged $id -> ${archiveFile.get().asFile.relativeTo(rootDir)}") }
|
||||
}
|
||||
|
||||
packageAllPlugins.configure { dependsOn(pkg) }
|
||||
}
|
||||
4
gradle.properties
Normal file
4
gradle.properties
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
android.useAndroidX=true
|
||||
android.nonTransitiveRClass=true
|
||||
kotlin.code.style=official
|
||||
org.gradle.jvmargs=-Xmx2048m
|
||||
21
gradle/libs.versions.toml
Normal file
21
gradle/libs.versions.toml
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
[versions]
|
||||
agp = "9.1.1"
|
||||
kotlin = "2.4.10"
|
||||
|
||||
coroutines = "1.11.0"
|
||||
xposed = "82"
|
||||
revengeApi = "1.0.0"
|
||||
|
||||
compileSdk = "36"
|
||||
minSdk = "24"
|
||||
targetSdk = "36"
|
||||
javaVersion = "25"
|
||||
|
||||
[libraries]
|
||||
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
|
||||
xposed-api = { module = "de.robv.android.xposed:api", version.ref = "xposed" }
|
||||
revenge-api = { module = "io.github.revenge:api", version.ref = "revengeApi" }
|
||||
|
||||
[plugins]
|
||||
android-library = { id = "com.android.library", version.ref = "agp" }
|
||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Executable file
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Executable file
Binary file not shown.
7
gradle/wrapper/gradle-wrapper.properties
vendored
Executable file
7
gradle/wrapper/gradle-wrapper.properties
vendored
Executable file
|
|
@ -0,0 +1,7 @@
|
|||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
252
gradlew
vendored
Executable file
252
gradlew
vendored
Executable file
|
|
@ -0,0 +1,252 @@
|
|||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
|
||||
' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
org.gradle.wrapper.GradleWrapperMain \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
94
gradlew.bat
vendored
Executable file
94
gradlew.bat
vendored
Executable file
|
|
@ -0,0 +1,94 @@
|
|||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
42
package.json
Normal file
42
package.json
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
{
|
||||
"name": "revenge-next-plugs",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "bun node_modules/@revenge-mod/plugin-cli/src/main.ts build",
|
||||
"build:dev": "bun node_modules/@revenge-mod/plugin-cli/src/main.ts build --dev",
|
||||
"generate-index": "bun node_modules/@revenge-mod/plugin-cli/src/main.ts generate-index",
|
||||
"serve": "bun node_modules/@revenge-mod/plugin-cli/src/main.ts serve",
|
||||
"lint": "biome check",
|
||||
"lint:types": "tsc --noEmit && tsc -p cli --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@revenge-mod/devtools-client": "^1.4.0",
|
||||
"@revenge-mod/devtools-shared": "^1.3.0",
|
||||
"@revenge-mod/plugin-cli": "git+https://github.com/revenge-mod/revenge-plugin-cli.git",
|
||||
"@revenge-mod/types": "git+https://github.com/revenge-mod/revenge-types.git"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.5.6",
|
||||
"@shopify/flash-list": "^2.3.2",
|
||||
"@types/node": "^26.1.2",
|
||||
"@types/react": "^19.2.17",
|
||||
"react-native": "0.81.6",
|
||||
"typescript": "^7.0.2"
|
||||
},
|
||||
"overrides": {
|
||||
"@react-native/community-cli-plugin": "npm:dry-uninstall",
|
||||
"@react-native/codegen": "npm:dry-uninstall",
|
||||
"babel-jest": "npm:dry-uninstall",
|
||||
"babel-plugin-syntax-hermes-parser": "npm:dry-uninstall",
|
||||
"hermes-compiler": "npm:dry-uninstall",
|
||||
"jsc-android": "npm:dry-uninstall",
|
||||
"react-devtools-core": "npm:dry-uninstall",
|
||||
"recyclerlistview": "npm:dry-uninstall",
|
||||
"react-native-gesture-handler": "npm:dry-uninstall",
|
||||
"react-native-screens": "npm:dry-uninstall"
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"@swc/core"
|
||||
]
|
||||
}
|
||||
429
plugins/next-eval-checks/js/index.ts
Normal file
429
plugins/next-eval-checks/js/index.ts
Normal file
|
|
@ -0,0 +1,429 @@
|
|||
import { getModules } from '@revenge-mod/modules/finders'
|
||||
import {
|
||||
createFilterGenerator,
|
||||
FilterFlag,
|
||||
FilterScopes,
|
||||
withName,
|
||||
withProps,
|
||||
withSingleProp,
|
||||
} from '@revenge-mod/modules/finders/filters'
|
||||
import { after, before, instead as insteadPatch } from '@revenge-mod/patcher'
|
||||
import { React, ReactNative } from '@revenge-mod/react'
|
||||
import { afterJSX } from '@revenge-mod/react/jsx-runtime'
|
||||
import { findInReactFiber } from '@revenge-mod/utils/react'
|
||||
|
||||
/**
|
||||
* One-shot diagnostic plugin for staff-tags' six live-verify assumptions. Logs results via
|
||||
* Discord's own Logger (Settings > Debug Logs) as each check resolves - some resolve almost
|
||||
* immediately (module lookups), some only once you navigate to the relevant screen
|
||||
* (DisplayName/HeaderName needs a channel open, UserRow needs the member list open,
|
||||
* getTagProperties needs a message with a bot/staff tag visible).
|
||||
*
|
||||
* All of the imports above are static and generate no code by themselves - the compiled bundle
|
||||
* only touches `revenge.*` where these are actually *called*, and every call below happens
|
||||
* inside `start()` or a callback it registers, never at module top level. Every reactive
|
||||
* callback is also wrapped in `guard()`. Both are lessons from the staff-tags boot-crash history
|
||||
* (project memory: revenge-next-plugin-patch-safety) - a diagnostic plugin is not exempt from
|
||||
* either just because it's throwaway.
|
||||
*/
|
||||
|
||||
function guard<T>(fn: () => T, fallback: T): T {
|
||||
try {
|
||||
return fn()
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
function safeStringify(value: unknown): string {
|
||||
return JSON.stringify(value, (_key, v) =>
|
||||
typeof v === 'bigint' ? `${v}n` : v,
|
||||
)
|
||||
}
|
||||
|
||||
export default plugin({
|
||||
start(api) {
|
||||
const log = (label: string, data: unknown) => {
|
||||
api.logger.log(`[EvalChecks] ${label}: ${safeStringify(data)}`)
|
||||
}
|
||||
|
||||
function withTimeout(label: string, ms: number, found: { value: boolean }) {
|
||||
setTimeout(() => {
|
||||
if (!found.value)
|
||||
log(label, { found: false, note: `not found within ${ms}ms` })
|
||||
}, ms)
|
||||
}
|
||||
|
||||
// 1. Permissions module
|
||||
guard(() => {
|
||||
const permFound = { value: false }
|
||||
const permsMapFound = { value: false }
|
||||
|
||||
getModules(
|
||||
withProps<{ computePermissions: unknown; canEveryoneRole: unknown }>(
|
||||
'computePermissions',
|
||||
'canEveryoneRole',
|
||||
),
|
||||
mod => {
|
||||
guard(() => {
|
||||
permFound.value = true
|
||||
log('permissionsModule', {
|
||||
found: true,
|
||||
keys: Object.keys(mod),
|
||||
computePermissionsType: typeof mod.computePermissions,
|
||||
})
|
||||
}, undefined)
|
||||
},
|
||||
)
|
||||
withTimeout('permissionsModule', 5000, permFound)
|
||||
|
||||
getModules(
|
||||
withSingleProp<{ Permissions: Record<string, unknown> }>('Permissions'),
|
||||
mod => {
|
||||
guard(() => {
|
||||
permsMapFound.value = true
|
||||
log('permissionsMap', {
|
||||
found: true,
|
||||
sampleKeys: Object.keys(mod.Permissions).slice(0, 10),
|
||||
adminBitType: typeof mod.Permissions.ADMINISTRATOR,
|
||||
})
|
||||
}, undefined)
|
||||
},
|
||||
)
|
||||
withTimeout('permissionsMap', 5000, permsMapFound)
|
||||
}, undefined)
|
||||
|
||||
// 2. Tag module (default + getBotLabel)
|
||||
guard(() => {
|
||||
const found = { value: false }
|
||||
getModules(
|
||||
withProps<{ default: unknown; getBotLabel: unknown }>(
|
||||
'default',
|
||||
'getBotLabel',
|
||||
),
|
||||
mod => {
|
||||
guard(() => {
|
||||
found.value = true
|
||||
log('tagModule', {
|
||||
found: true,
|
||||
keys: Object.keys(mod),
|
||||
defaultType: typeof mod.default,
|
||||
defaultName: (mod.default as any)?.name,
|
||||
getBotLabelType: typeof mod.getBotLabel,
|
||||
})
|
||||
}, undefined)
|
||||
},
|
||||
{ skipDefault: true },
|
||||
)
|
||||
withTimeout('tagModule', 5000, found)
|
||||
}, undefined)
|
||||
|
||||
// 3. Gradient component
|
||||
guard(() => {
|
||||
const startEndFound = { value: false }
|
||||
const locationsFound = { value: false }
|
||||
|
||||
getModules(
|
||||
withProps<Record<string, unknown>>('colors', 'start', 'end'),
|
||||
mod => {
|
||||
guard(() => {
|
||||
startEndFound.value = true
|
||||
log('gradientComponent.startEnd', {
|
||||
found: true,
|
||||
type: typeof mod,
|
||||
name: (mod as any)?.name,
|
||||
})
|
||||
}, undefined)
|
||||
},
|
||||
)
|
||||
withTimeout('gradientComponent.startEnd', 5000, startEndFound)
|
||||
|
||||
getModules(
|
||||
withProps<Record<string, unknown>>('colors', 'locations'),
|
||||
mod => {
|
||||
guard(() => {
|
||||
locationsFound.value = true
|
||||
log('gradientComponent.locations', {
|
||||
found: true,
|
||||
type: typeof mod,
|
||||
name: (mod as any)?.name,
|
||||
})
|
||||
}, undefined)
|
||||
},
|
||||
)
|
||||
withTimeout('gradientComponent.locations', 5000, locationsFound)
|
||||
}, undefined)
|
||||
|
||||
// 4. DisplayName / HeaderName - needs a channel with messages open
|
||||
guard(() => {
|
||||
const moduleFound = { value: false }
|
||||
const renderSeen = { value: false }
|
||||
|
||||
getModules(withName<any>('DisplayName'), DisplayName => {
|
||||
guard(() => {
|
||||
moduleFound.value = true
|
||||
log('displayName.module', {
|
||||
found: true,
|
||||
fnName: (DisplayName as any)?.name,
|
||||
})
|
||||
|
||||
let unpatchJSX = () => {}
|
||||
unpatchJSX = afterJSX(DisplayName, el =>
|
||||
guard(() => {
|
||||
const unpatchType = after(el as any, 'type', ret =>
|
||||
guard(() => {
|
||||
unpatchType()
|
||||
unpatchJSX()
|
||||
renderSeen.value = true
|
||||
const row = findInReactFiber(
|
||||
ret as any,
|
||||
((c: any) =>
|
||||
c?.props?.style?.flexDirection === 'row') as any,
|
||||
)
|
||||
log('displayName.render', {
|
||||
found: true,
|
||||
rowContainerSeen: !!row,
|
||||
})
|
||||
return ret
|
||||
}, ret),
|
||||
)
|
||||
return el
|
||||
}, el),
|
||||
)
|
||||
}, undefined)
|
||||
})
|
||||
withTimeout('displayName.module', 8000, moduleFound)
|
||||
withTimeout('displayName.render', 15000, renderSeen)
|
||||
|
||||
const headerFound = { value: false }
|
||||
getModules(withName<any>('HeaderName'), HeaderName => {
|
||||
guard(() => {
|
||||
headerFound.value = true
|
||||
log('headerName', { found: true, fnName: (HeaderName as any)?.name })
|
||||
}, undefined)
|
||||
})
|
||||
withTimeout('headerName', 8000, headerFound)
|
||||
}, undefined)
|
||||
|
||||
// 5. UserRow - needs the member list open
|
||||
guard(() => {
|
||||
let count = 0
|
||||
const unsub = getModules(
|
||||
withName<any>('UserRow'),
|
||||
(UserRow, id) => {
|
||||
guard(() => {
|
||||
count++
|
||||
log('userRow', {
|
||||
found: true,
|
||||
index: count,
|
||||
id,
|
||||
fnName: (UserRow as any)?.name,
|
||||
})
|
||||
}, undefined)
|
||||
},
|
||||
{ max: 10 },
|
||||
)
|
||||
setTimeout(() => {
|
||||
unsub()
|
||||
if (count === 0)
|
||||
log('userRow', { found: false, note: 'not found within 15000ms' })
|
||||
}, 15000)
|
||||
}, undefined)
|
||||
|
||||
// 6. getTagProperties - needs a message with a bot/staff tag visible
|
||||
guard(() => {
|
||||
const found = { value: false }
|
||||
getModules(
|
||||
withName<any>('getTagProperties'),
|
||||
mod => {
|
||||
guard(() => {
|
||||
const namespace = mod as { default?: (args: any) => any }
|
||||
if (!namespace.default) return
|
||||
|
||||
const unpatch = insteadPatch(
|
||||
namespace as { default: (args: any) => any },
|
||||
'default',
|
||||
([args], original) => {
|
||||
const ret = original(args)
|
||||
return guard(() => {
|
||||
if (!found.value) {
|
||||
found.value = true
|
||||
unpatch()
|
||||
log('getTagProperties', {
|
||||
found: true,
|
||||
argKeys: Object.keys(args ?? {}),
|
||||
messageKeys: args?.message
|
||||
? Object.keys(args.message)
|
||||
: null,
|
||||
})
|
||||
}
|
||||
return ret
|
||||
}, ret)
|
||||
},
|
||||
)
|
||||
}, undefined)
|
||||
},
|
||||
{ returnNamespace: true },
|
||||
)
|
||||
withTimeout('getTagProperties', 15000, found)
|
||||
}, undefined)
|
||||
|
||||
// 7. Name sweep - DisplayName/HeaderName/UserRow are confirmed gone under those exact
|
||||
// names (Discord renamed/refactored them at some point), so instead of guessing single
|
||||
// replacement names one at a time, this sweeps every currently-initialized module for an
|
||||
// exported function whose .name matches a broad pattern, logging every match found. Real
|
||||
// candidates for the message-row name, channel header, and member-list row should show up
|
||||
// here - keep the member list and a channel with messages open for this one.
|
||||
guard(() => {
|
||||
const pattern = /Name|Row|Header|Author|Member|Username|Tag$/i
|
||||
|
||||
const withNameMatching = createFilterGenerator<[]>(
|
||||
(_args, _id, exports) => {
|
||||
const name = (exports as any)?.name
|
||||
return typeof name === 'string' && pattern.test(name)
|
||||
},
|
||||
() => 'evalChecks.nameSweep',
|
||||
FilterFlag.RequiresExports,
|
||||
FilterScopes.Initialized,
|
||||
)
|
||||
|
||||
let count = 0
|
||||
getModules(
|
||||
withNameMatching(),
|
||||
(mod, id) => {
|
||||
guard(() => {
|
||||
count++
|
||||
log('nameSweep', {
|
||||
index: count,
|
||||
id,
|
||||
name: (mod as any)?.name,
|
||||
type: typeof mod,
|
||||
})
|
||||
}, undefined)
|
||||
},
|
||||
{ max: 80 },
|
||||
)
|
||||
setTimeout(() => {
|
||||
log('nameSweep.summary', { totalMatches: count })
|
||||
}, 20000)
|
||||
}, undefined)
|
||||
|
||||
// 8. React.memo/forwardRef sweep - name-based search (checks 4/5/7) can never find a
|
||||
// React.memo()-wrapped or forwardRef()-wrapped component, because the wrapper object has
|
||||
// no .name of its own. This patches React.memo/forwardRef themselves so the inner named
|
||||
// function's real name is captured at the moment it's wrapped, before that name is lost.
|
||||
// Only catches memo()/forwardRef() calls that happen AFTER this patch is installed - if
|
||||
// the target component's module already ran before this plugin's start(), it's missed.
|
||||
// For a real answer, force-quit and reopen the app with this plugin already enabled,
|
||||
// rather than just backgrounding/foregrounding it.
|
||||
guard(() => {
|
||||
const pattern =
|
||||
/Name|Row|Header|Author|Member|Username|Tag|Display|Nameplate/i
|
||||
const seen = new Set<string>()
|
||||
|
||||
const report = (kind: string, name: string | undefined) => {
|
||||
if (name && pattern.test(name) && !seen.has(name)) {
|
||||
seen.add(name)
|
||||
log('memoSweep', { kind, name })
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof React?.memo === 'function') {
|
||||
before(React as any, 'memo', args => {
|
||||
guard(() => {
|
||||
report('memo', (args?.[0] as any)?.name)
|
||||
}, undefined)
|
||||
return args
|
||||
})
|
||||
} else {
|
||||
log('memoSweep.setup', { error: 'React.memo not available yet' })
|
||||
}
|
||||
|
||||
if (typeof React?.forwardRef === 'function') {
|
||||
before(React as any, 'forwardRef', args => {
|
||||
guard(() => {
|
||||
report('forwardRef', (args?.[0] as any)?.name)
|
||||
}, undefined)
|
||||
return args
|
||||
})
|
||||
} else {
|
||||
log('memoSweep.setup', { error: 'React.forwardRef not available yet' })
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
log('memoSweep.summary', { totalMatches: seen.size })
|
||||
}, 20000)
|
||||
}, undefined)
|
||||
|
||||
// 9. Message-author-name discovery via Text-content correlation. Neither name search nor
|
||||
// the memo/forwardRef sweep found anything for the chat message's author name row, which
|
||||
// suggests it isn't a separately-named component at all anymore (likely inlined into a
|
||||
// bigger message-row component). Instead of searching by name, this grabs a real author
|
||||
// name off a live message (piggybacking on the already-confirmed getTagProperties hook),
|
||||
// then watches every <Text> element created for one whose content matches that name. When
|
||||
// found, it captures a JS stack trace at that exact moment - since createElement calls run
|
||||
// synchronously inside the calling component's own function body, the stack should reveal
|
||||
// the enclosing component's name even though it isn't independently searchable.
|
||||
guard(() => {
|
||||
// Keeps updating to the MOST RECENT message's author rather than locking onto the
|
||||
// first one seen - a cached/already-rendered message's author is useless as a search
|
||||
// target (its Text was created before this patch installed, same issue as the memo
|
||||
// sweep), so the target has to track whatever message is *about to* freshly render.
|
||||
let authorName: string | undefined
|
||||
let matchCount = 0
|
||||
|
||||
getModules(
|
||||
withName<(args: any) => any>('getTagProperties'),
|
||||
mod => {
|
||||
guard(() => {
|
||||
const namespace = mod as { default?: (args: any) => any }
|
||||
if (!namespace.default) return
|
||||
|
||||
insteadPatch(namespace as { default: (args: any) => any }, 'default', ([args], original) => {
|
||||
const ret = original(args)
|
||||
guard(() => {
|
||||
const author = args?.message?.author
|
||||
const name = author?.globalName || author?.username || author?.nick
|
||||
if (typeof name === 'string' && name.length > 0 && name !== authorName) {
|
||||
authorName = name
|
||||
log('textSweep.authorTarget', { authorName })
|
||||
}
|
||||
}, undefined)
|
||||
return ret
|
||||
})
|
||||
}, undefined)
|
||||
},
|
||||
{ returnNamespace: true }
|
||||
)
|
||||
|
||||
if (!ReactNative?.Text) {
|
||||
log('textSweep.setup', { error: 'ReactNative.Text not available yet' })
|
||||
} else {
|
||||
afterJSX(ReactNative.Text, el =>
|
||||
guard(() => {
|
||||
if (!authorName || matchCount >= 3) return el
|
||||
|
||||
const children = (el.props as any)?.children
|
||||
const isMatch = children === authorName || (typeof children === 'string' && children.includes(authorName)) || (Array.isArray(children) && children.some((c: any) => c === authorName))
|
||||
|
||||
if (isMatch) {
|
||||
matchCount++
|
||||
const stack = new Error().stack ?? ''
|
||||
log('textSweep.match', {
|
||||
authorName,
|
||||
matchCount,
|
||||
stackLines: stack.split('\n').slice(0, 12)
|
||||
})
|
||||
}
|
||||
|
||||
return el
|
||||
}, el)
|
||||
)
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
log('textSweep.summary', { matchCount, lastAuthorName: authorName })
|
||||
}, 45000)
|
||||
}, undefined)
|
||||
},
|
||||
})
|
||||
19
plugins/next-eval-checks/manifest.json
Normal file
19
plugins/next-eval-checks/manifest.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"format": 1,
|
||||
"id": "dev.everestmcarthur.next-eval-checks",
|
||||
"name": "Next Eval Checks",
|
||||
"description": "One-shot diagnostic plugin for staff-tags' live-verify assumptions. Logs results to Settings > Debug Logs, then you can uninstall it.",
|
||||
"author": "everestmcarthur",
|
||||
"version": "1.0.4",
|
||||
"dependencies": {
|
||||
"revenge.api": {
|
||||
"version": ">=1 <2"
|
||||
},
|
||||
"discord": {
|
||||
"version": "*"
|
||||
}
|
||||
},
|
||||
"dist": {
|
||||
"script": "index.js"
|
||||
}
|
||||
}
|
||||
27
plugins/staff-tags/js/index.ts
Normal file
27
plugins/staff-tags/js/index.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { applyPatches } from './lib/applyPatches'
|
||||
import patchChat from './patches/chat'
|
||||
import patchDetails from './patches/details'
|
||||
import patchName from './patches/name'
|
||||
import patchTag from './patches/tag'
|
||||
import Settings from './ui/pages/Settings'
|
||||
import type { StaffTagsStorage } from './lib/types'
|
||||
|
||||
export default plugin<{ jsonStorage: StaffTagsStorage }>({
|
||||
jsonStorage: {
|
||||
load: true,
|
||||
default: { useRoleColor: false, tags: {} },
|
||||
},
|
||||
|
||||
start(api) {
|
||||
const unpatchAll = applyPatches('Staff Tags', api.logger, {
|
||||
tag: patchTag,
|
||||
chat: () => patchChat(api.jsonStorage),
|
||||
name: () => patchName(api.jsonStorage),
|
||||
details: () => patchDetails(api.jsonStorage),
|
||||
})
|
||||
|
||||
api.cleanup(unpatchAll)
|
||||
},
|
||||
|
||||
SettingsComponent: Settings,
|
||||
})
|
||||
18
plugins/staff-tags/js/lib/afterRendered.ts
Normal file
18
plugins/staff-tags/js/lib/afterRendered.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { after } from '@revenge-mod/patcher'
|
||||
import type { FC, ReactElement, ReactNode } from 'react'
|
||||
|
||||
/**
|
||||
* Hooks the render output of a *specific element instance* by patching its own `type` property.
|
||||
*
|
||||
* This is safe (unlike patching a component's module export) because `element.type` is a fresh
|
||||
* reference on an object we just created ourselves - nothing else has captured it yet, so there's
|
||||
* no same-chunk-destructuring staleness to worry about. See @revenge-mod/patcher's `after` docs
|
||||
* and the pattern used by Revenge's own `user-badges` plugin (`afterRendered` there).
|
||||
*/
|
||||
export const afterRendered = (
|
||||
element: ReactElement<any, FC<any>>,
|
||||
hook: (el: ReactNode) => ReactNode,
|
||||
) =>
|
||||
after(element, 'type', el =>
|
||||
el instanceof Promise ? el.then(hook) : hook(el),
|
||||
)
|
||||
35
plugins/staff-tags/js/lib/applyPatches.ts
Normal file
35
plugins/staff-tags/js/lib/applyPatches.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
export interface PluginLogger {
|
||||
error(message: string): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a patch-application function so a single broken lookup only disables that one surface
|
||||
* instead of crashing the whole plugin's `start()` and taking every other patch down with it.
|
||||
*/
|
||||
function safePatch(
|
||||
pluginName: string,
|
||||
patchName: string,
|
||||
apply: () => () => unknown,
|
||||
logger: PluginLogger,
|
||||
): () => unknown {
|
||||
try {
|
||||
return apply()
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`[${pluginName}] Failed to apply the "${patchName}" patch, that surface will be skipped: ${e}`,
|
||||
)
|
||||
return () => {}
|
||||
}
|
||||
}
|
||||
|
||||
/** Applies a named set of patches with safePatch and returns one combined unpatch function. */
|
||||
export function applyPatches(
|
||||
pluginName: string,
|
||||
logger: PluginLogger,
|
||||
patches: Record<string, () => () => unknown>,
|
||||
): () => void {
|
||||
const unpatches = Object.entries(patches).map(([name, apply]) =>
|
||||
safePatch(pluginName, name, apply, logger),
|
||||
)
|
||||
return () => unpatches.forEach(unpatch => unpatch())
|
||||
}
|
||||
64
plugins/staff-tags/js/lib/color.ts
Normal file
64
plugins/staff-tags/js/lib/color.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
export const HEX_REGEX = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i
|
||||
|
||||
export function isValidHex(value: string | undefined | null): value is string {
|
||||
return !!value && HEX_REGEX.test(value)
|
||||
}
|
||||
|
||||
/** Expands short form #abc to #aabbcc; leaves 6-digit hex untouched. */
|
||||
export function normalizeHex(hex: string): string {
|
||||
if (hex.length === 4) {
|
||||
return `#${hex
|
||||
.slice(1)
|
||||
.split('')
|
||||
.map(c => c + c)
|
||||
.join('')}`
|
||||
}
|
||||
return hex
|
||||
}
|
||||
|
||||
function hexToRgb(hex: string): number[] {
|
||||
return (normalizeHex(hex).match(/\w\w/g) ?? ['00', '00', '00']).map(x =>
|
||||
Number.parseInt(x, 16),
|
||||
)
|
||||
}
|
||||
|
||||
function rgbToHex(rgb: number[]): string {
|
||||
return `#${rgb.map(x => Math.max(0, Math.min(255, x)).toString(16).padStart(2, '0')).join('')}`
|
||||
}
|
||||
|
||||
export function interpolateColor(
|
||||
color1: string,
|
||||
color2: string,
|
||||
percentage: number,
|
||||
): string {
|
||||
const rgb1 = hexToRgb(color1)
|
||||
const rgb2 = hexToRgb(color2)
|
||||
const interpolated = rgb1.map((c1, i) =>
|
||||
Math.round(c1 + (rgb2[i] - c1) * percentage),
|
||||
)
|
||||
|
||||
return rgbToHex(interpolated)
|
||||
}
|
||||
|
||||
/** Mixes a color toward white by `amount` (0-1). Stands in for chroma's `.brighten()` without a chroma-js module lookup. */
|
||||
export function brighten(hex: string, amount: number): string {
|
||||
return interpolateColor(hex, '#FFFFFF', Math.max(0, Math.min(1, amount)))
|
||||
}
|
||||
|
||||
/**
|
||||
* WCAG relative luminance (0 = black, 1 = white). Used to pick readable text color against a
|
||||
* background - stands in for chroma's `.get('lab.l')` threshold without a chroma-js module lookup.
|
||||
*/
|
||||
export function relativeLuminance(hex: string): number {
|
||||
const [r, g, b] = hexToRgb(hex).map(c => {
|
||||
const s = c / 255
|
||||
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4
|
||||
})
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b
|
||||
}
|
||||
|
||||
/** Hardcoded Discord raw palette values (not exposed anywhere in @revenge-mod/types yet). */
|
||||
export const rawColors = {
|
||||
WHITE_500: '#FFFFFF',
|
||||
BLACK_500: '#1E1F22',
|
||||
}
|
||||
10
plugins/staff-tags/js/lib/fiber.ts
Normal file
10
plugins/staff-tags/js/lib/fiber.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
/**
|
||||
* `findInReactFiber` only narrows its result when given a type-predicate function
|
||||
* (`(node) => node is X`). These walks are over untyped, Discord-internal fiber/element shapes,
|
||||
* so there's no real `X` to narrow to - this just satisfies that signature with `any`.
|
||||
*/
|
||||
export function fiberFilter(
|
||||
predicate: (node: any) => boolean,
|
||||
): (node: any) => node is any {
|
||||
return predicate as (node: any) => node is any
|
||||
}
|
||||
245
plugins/staff-tags/js/lib/getTag.ts
Normal file
245
plugins/staff-tags/js/lib/getTag.ts
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
import { Stores } from '@revenge-mod/discord/flux'
|
||||
import { getModules } from '@revenge-mod/modules/finders'
|
||||
import { withProps, withSingleProp } from '@revenge-mod/modules/finders/filters'
|
||||
import { brighten, isValidHex, rawColors, relativeLuminance } from './color'
|
||||
import type { DiscordModules } from '@revenge-mod/discord/types'
|
||||
import type { JsonStorage } from '@revenge-mod/json-storage'
|
||||
import type { StaffTagsStorage, TagOverride } from './types'
|
||||
|
||||
interface PermissionsModule {
|
||||
computePermissions(args: {
|
||||
user: unknown
|
||||
context: unknown
|
||||
overwrites?: unknown
|
||||
}): bigint | number | undefined
|
||||
canEveryoneRole?: unknown
|
||||
}
|
||||
|
||||
interface GuildMember {
|
||||
colorString?: string
|
||||
}
|
||||
|
||||
type GuildMemberStoreType = DiscordModules.Flux.Store<{
|
||||
getMember(
|
||||
guildId: string | undefined,
|
||||
userId: string | undefined,
|
||||
): GuildMember | null
|
||||
}>
|
||||
|
||||
/**
|
||||
* Reads `Stores.GuildMemberStore` fresh on each call rather than caching it at module scope.
|
||||
*
|
||||
* External plugin bundles run via a bare `new Function('revenge', 'plugin', script)` call with
|
||||
* no Metro-style dependency ordering, at preInit, regardless of which lifecycle the plugin
|
||||
* actually declares - so touching *any* `revenge.*` API at module top level runs before
|
||||
* Revenge's own API-providing plugins are guaranteed to have set anything up. This function is
|
||||
* only ever called from inside a render patch, well after boot, where that's no longer a risk.
|
||||
*/
|
||||
function getGuildMemberStore(): GuildMemberStoreType {
|
||||
return Stores.GuildMemberStore as GuildMemberStoreType
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribed once, lazily, from `start()` rather than at plugin-module import time. Uses
|
||||
* `getModules` (reacts once Discord itself initializes the module) instead of `lookupModule`
|
||||
* (which force-initializes uninitialized modules right away) - `getTag()` only runs from inside
|
||||
* a render patch, well after boot, so this is lower-risk than the boot-time lookups elsewhere in
|
||||
* this plugin, but there's no reason to force it either. If the module isn't warmed up yet on the
|
||||
* very first call, permission-based tags just don't resolve for that call and pick up on the next
|
||||
* render once it's available.
|
||||
*
|
||||
* TODO(live-verify): computePermissions/Permissions module shapes, see eval-for-revenge.
|
||||
*/
|
||||
let permissionsModule: PermissionsModule | undefined
|
||||
let PermissionsMap: { Permissions: Record<string, bigint | number> } | undefined
|
||||
let permissionsSubscribed = false
|
||||
|
||||
function resolvePermissionsModules(): void {
|
||||
if (permissionsSubscribed) return
|
||||
permissionsSubscribed = true
|
||||
getModules(
|
||||
withProps<PermissionsModule>('computePermissions', 'canEveryoneRole'),
|
||||
mod => {
|
||||
permissionsModule = mod
|
||||
},
|
||||
)
|
||||
getModules(
|
||||
withSingleProp<{ Permissions: Record<string, bigint | number> }>(
|
||||
'Permissions',
|
||||
),
|
||||
mod => {
|
||||
PermissionsMap = mod
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export interface TagDefinition {
|
||||
id: string
|
||||
defaultText: string
|
||||
defaultColor: string
|
||||
condition?: (guild: any, channel: any, user: any) => boolean
|
||||
permissions?: string[]
|
||||
}
|
||||
|
||||
export const TAG_DEFINITIONS: TagDefinition[] = [
|
||||
{
|
||||
id: 'webhook',
|
||||
defaultText: 'WEBHOOK',
|
||||
defaultColor: '#99AAB5',
|
||||
condition: (_g, _c, user) => !!user?.isNonUserBot?.(),
|
||||
},
|
||||
{
|
||||
id: 'owner',
|
||||
defaultText: 'OWNER',
|
||||
defaultColor: '#F0B232',
|
||||
condition: (guild, _c, user) => !!guild && guild.ownerId === user?.id,
|
||||
},
|
||||
{
|
||||
id: 'admin',
|
||||
defaultText: 'ADMIN',
|
||||
defaultColor: '#F23F42',
|
||||
permissions: ['ADMINISTRATOR'],
|
||||
},
|
||||
{
|
||||
id: 'staff',
|
||||
defaultText: 'STAFF',
|
||||
defaultColor: '#23A55A',
|
||||
permissions: [
|
||||
'MANAGE_GUILD',
|
||||
'MANAGE_CHANNELS',
|
||||
'MANAGE_ROLES',
|
||||
'MANAGE_WEBHOOKS',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'mod',
|
||||
defaultText: 'MOD',
|
||||
defaultColor: '#5865F2',
|
||||
permissions: ['MANAGE_MESSAGES', 'KICK_MEMBERS', 'BAN_MEMBERS'],
|
||||
},
|
||||
{
|
||||
id: 'vc_mod',
|
||||
defaultText: 'VC Mod',
|
||||
defaultColor: '#1ABC9C',
|
||||
permissions: ['MOVE_MEMBERS', 'MUTE_MEMBERS', 'DEAFEN_MEMBERS'],
|
||||
},
|
||||
{
|
||||
id: 'chat_mod',
|
||||
defaultText: 'Chat Mod',
|
||||
defaultColor: '#9B59B6',
|
||||
permissions: ['MODERATE_MEMBERS'],
|
||||
},
|
||||
]
|
||||
|
||||
export interface ResolvedTag {
|
||||
id: string
|
||||
text: string
|
||||
textColor: string
|
||||
backgroundColor: string
|
||||
gradientColor?: string
|
||||
verified: boolean
|
||||
}
|
||||
|
||||
/** Reads the per-tag settings override from the (already-loaded) storage cache. Never mutates it - use `storage.set()` for writes. */
|
||||
export function tagSettings(
|
||||
storage: JsonStorage<StaffTagsStorage>,
|
||||
id: string,
|
||||
): TagOverride {
|
||||
return storage.cache?.tags?.[id] ?? {}
|
||||
}
|
||||
|
||||
function resolveBackgroundColor(
|
||||
def: TagDefinition,
|
||||
settings: TagOverride,
|
||||
storage: JsonStorage<StaffTagsStorage>,
|
||||
guild: any,
|
||||
user: any,
|
||||
): string {
|
||||
if (settings.useCustomColor && isValidHex(settings.color)) {
|
||||
return settings.color
|
||||
}
|
||||
|
||||
if (storage.cache?.useRoleColor) {
|
||||
try {
|
||||
const roleColor = getGuildMemberStore()?.getMember?.(
|
||||
guild?.id,
|
||||
user?.id,
|
||||
)?.colorString
|
||||
if (roleColor) return roleColor
|
||||
} catch {
|
||||
// fall through to default
|
||||
}
|
||||
}
|
||||
|
||||
return def.defaultColor
|
||||
}
|
||||
|
||||
export default function getTag(
|
||||
storage: JsonStorage<StaffTagsStorage>,
|
||||
guild: any,
|
||||
channel: any,
|
||||
user: any,
|
||||
): ResolvedTag | undefined {
|
||||
if (!user) return undefined
|
||||
|
||||
resolvePermissionsModules()
|
||||
|
||||
let permissions: string[] = []
|
||||
if (guild) {
|
||||
try {
|
||||
const permissionsInt = permissionsModule?.computePermissions?.({
|
||||
user,
|
||||
context: guild,
|
||||
overwrites: channel?.permissionOverwrites,
|
||||
})
|
||||
|
||||
if (permissionsInt != null && PermissionsMap?.Permissions) {
|
||||
permissions = Object.entries(PermissionsMap.Permissions)
|
||||
.filter(([, bit]) => (permissionsInt as bigint) & (bit as bigint))
|
||||
.map(([name]) => name)
|
||||
}
|
||||
} catch {
|
||||
// no guild permission context available, treat as none
|
||||
}
|
||||
}
|
||||
|
||||
for (const def of TAG_DEFINITIONS) {
|
||||
const settings = tagSettings(storage, def.id)
|
||||
if (settings.enabled === false) continue
|
||||
|
||||
const matchesCondition = !!def.condition?.(guild, channel, user)
|
||||
const matchesPermission =
|
||||
!user.bot && !!def.permissions?.some(p => permissions.includes(p))
|
||||
if (!matchesCondition && !matchesPermission) continue
|
||||
|
||||
const backgroundColor = resolveBackgroundColor(
|
||||
def,
|
||||
settings,
|
||||
storage,
|
||||
guild,
|
||||
user,
|
||||
)
|
||||
const textColor =
|
||||
relativeLuminance(backgroundColor) > 0.35
|
||||
? rawColors.BLACK_500
|
||||
: rawColors.WHITE_500
|
||||
|
||||
let gradientColor: string | undefined
|
||||
if (settings.useGradient) {
|
||||
gradientColor = isValidHex(settings.gradientColor)
|
||||
? settings.gradientColor
|
||||
: brighten(backgroundColor, 0.4)
|
||||
}
|
||||
|
||||
return {
|
||||
id: def.id,
|
||||
text: settings.text?.trim() || def.defaultText,
|
||||
textColor,
|
||||
backgroundColor,
|
||||
gradientColor,
|
||||
verified: false,
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
53
plugins/staff-tags/js/lib/gradient.ts
Normal file
53
plugins/staff-tags/js/lib/gradient.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { lookupModule } from '@revenge-mod/modules/finders'
|
||||
import { withProps } from '@revenge-mod/modules/finders/filters'
|
||||
import type { FC, ReactNode } from 'react'
|
||||
import type { StyleProp, ViewStyle } from 'react-native'
|
||||
|
||||
interface GradientProps {
|
||||
colors: string[]
|
||||
start?: { x: number; y: number }
|
||||
end?: { x: number; y: number }
|
||||
style?: StyleProp<ViewStyle>
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
let cached: FC<GradientProps> | null | undefined
|
||||
let attempted = false
|
||||
|
||||
/**
|
||||
* Discord ships a native gradient view (used for boost/nitro badges) but it isn't part of any
|
||||
* stable public API, so this is a best-effort lookup that quietly returns null if it can't be
|
||||
* found - gradient tags then fall back to a solid color instead of crashing anything.
|
||||
*
|
||||
* `withProps` checks the default export automatically and unwraps to it, mirroring the old
|
||||
* `findByProps(...).default` lookup from the Classic build.
|
||||
*
|
||||
* Deliberately still uses `lookupModule` (which force-initializes uninitialized modules) rather
|
||||
* than `getModules` here, unlike the other lookups in this plugin: this only ever runs from
|
||||
* inside `GradientTag`'s render, which only happens post-boot when a gradient tag is actually
|
||||
* about to be drawn on screen, and the call site needs a component reference synchronously to
|
||||
* decide what JSX to return - `getModules`' callback-based "maybe later" shape doesn't fit here.
|
||||
*
|
||||
* TODO(live-verify): unconfirmed against the current Next/Discord build, see eval-for-revenge.
|
||||
*/
|
||||
export function getGradientComponent(): FC<GradientProps> | null {
|
||||
if (attempted) return cached ?? null
|
||||
attempted = true
|
||||
|
||||
try {
|
||||
const [startEndMatch] = lookupModule(
|
||||
withProps<Record<string, unknown>>('colors', 'start', 'end'),
|
||||
)
|
||||
const [locationsMatch] = lookupModule(
|
||||
withProps<Record<string, unknown>>('colors', 'locations'),
|
||||
)
|
||||
cached =
|
||||
(startEndMatch as FC<GradientProps> | undefined) ??
|
||||
(locationsMatch as FC<GradientProps> | undefined) ??
|
||||
null
|
||||
} catch {
|
||||
cached = null
|
||||
}
|
||||
|
||||
return cached
|
||||
}
|
||||
31
plugins/staff-tags/js/lib/safe.ts
Normal file
31
plugins/staff-tags/js/lib/safe.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
/**
|
||||
* Runs `fn` and swallows any throw, returning `fallback` instead.
|
||||
*
|
||||
* `applyPatches`/`safePatch` only guard the synchronous call that *registers* a patch (e.g.
|
||||
* `getModules(filter, cb)` itself). The `cb` runs later - whenever Discord happens to initialize
|
||||
* a matching module, which can be deep inside Discord's own boot sequence - completely outside
|
||||
* that try/catch. An uncaught throw there crashes app startup instead of just skipping a tag.
|
||||
* Every render-triggered callback (getModules/afterJSX/beforeJSX/afterRendered) needs its own
|
||||
* guard for this reason.
|
||||
*/
|
||||
export function guard<T>(fn: () => T, fallback: T): T {
|
||||
try {
|
||||
return fn()
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a style object to an existing `style` prop without assuming its shape. RN style props are
|
||||
* commonly a single object (not an array) - spreading a non-array/non-iterable value with `[...x]`
|
||||
* throws, which is what crashed app boot the first time this shipped.
|
||||
*/
|
||||
export function mergeStyle(
|
||||
style: unknown,
|
||||
patch: Record<string, unknown>,
|
||||
): unknown[] {
|
||||
if (Array.isArray(style)) return [...style, patch]
|
||||
if (style) return [style, patch]
|
||||
return [patch]
|
||||
}
|
||||
13
plugins/staff-tags/js/lib/types.ts
Normal file
13
plugins/staff-tags/js/lib/types.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
export interface TagOverride {
|
||||
enabled?: boolean
|
||||
text?: string
|
||||
useCustomColor?: boolean
|
||||
color?: string
|
||||
useGradient?: boolean
|
||||
gradientColor?: string
|
||||
}
|
||||
|
||||
export interface StaffTagsStorage {
|
||||
useRoleColor: boolean
|
||||
tags: Record<string, TagOverride>
|
||||
}
|
||||
103
plugins/staff-tags/js/patches/chat.ts
Normal file
103
plugins/staff-tags/js/patches/chat.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { Stores } from '@revenge-mod/discord/flux'
|
||||
import { getModules } from '@revenge-mod/modules/finders'
|
||||
import { withName } from '@revenge-mod/modules/finders/filters'
|
||||
import { instead } from '@revenge-mod/patcher'
|
||||
import { processColor } from 'react-native'
|
||||
import getTag from '../lib/getTag'
|
||||
import { guard } from '../lib/safe'
|
||||
import type { JsonStorage } from '@revenge-mod/json-storage'
|
||||
import type { StaffTagsStorage } from '../lib/types'
|
||||
|
||||
interface GetTagPropertiesArgs {
|
||||
message: { author?: any }
|
||||
channel?: { guild_id?: string }
|
||||
}
|
||||
|
||||
interface TagProperties {
|
||||
tagType?: unknown
|
||||
tagText?: string
|
||||
tagTextColor?: unknown
|
||||
tagBackgroundColor?: unknown
|
||||
tagVerified?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat message tags are rendered from plain data (not a patchable element tree), so gradients
|
||||
* aren't possible here - only in the member list and profile, where we control the JSX directly.
|
||||
*
|
||||
* `getTagProperties` is a plain data function, not a rendered component, so `afterJSX` doesn't
|
||||
* apply - this stays a module-property patch via `patcher.instead`, same mechanism the Classic
|
||||
* build used successfully for this exact function.
|
||||
*
|
||||
* Uses `getModules` (reacts once Discord itself initializes the module) instead of
|
||||
* `lookupModule` (which force-initializes uninitialized modules right away) - forcing this
|
||||
* module to init during `start()`, before Discord is done booting, is what crashed app startup
|
||||
* the first time this shipped.
|
||||
*
|
||||
* `{ returnNamespace: true }` asks for the whole module object (not just the unwrapped default
|
||||
* export) so `instead` has a `parent`/`key` pair to patch in place.
|
||||
*
|
||||
* The `instead` hook is guarded: unlike the JSX patches, it *replaces* the original function for
|
||||
* every future call, so an uncaught throw here wouldn't just skip one tag - it would break
|
||||
* message rendering entirely from that point on.
|
||||
*
|
||||
* Confirmed live (see /root/evals-for-rn): args are `{message, isSystemDM, channel, colors}` -
|
||||
* `channel` is handed to us directly, so no `ChannelStore.getChannel()` lookup is needed.
|
||||
*/
|
||||
export default function patchChat(storage: JsonStorage<StaffTagsStorage>) {
|
||||
let unpatch = () => {}
|
||||
|
||||
const GuildStore = Stores.GuildStore as unknown as {
|
||||
getGuild(id: string | undefined): any
|
||||
}
|
||||
|
||||
const unsub = getModules(
|
||||
withName<(args: GetTagPropertiesArgs) => TagProperties>('getTagProperties'),
|
||||
TagPropertiesModule => {
|
||||
guard(() => {
|
||||
const mod = TagPropertiesModule as unknown as {
|
||||
default?: (args: GetTagPropertiesArgs) => TagProperties
|
||||
}
|
||||
if (!mod.default) return
|
||||
|
||||
unpatch = instead(
|
||||
mod as { default: (args: GetTagPropertiesArgs) => TagProperties },
|
||||
'default',
|
||||
([args], original) => {
|
||||
// Call through unguarded - if Discord's own function throws, that's not
|
||||
// something we caused or can meaningfully recover from here.
|
||||
const ret = original(args)
|
||||
|
||||
// Only our own augmentation is guarded: fall back to the real, unmodified
|
||||
// result rather than risk breaking every message render from here on.
|
||||
return guard(() => {
|
||||
if (ret?.tagType) return ret
|
||||
|
||||
const { message, channel } = args
|
||||
const guild = GuildStore?.getGuild(channel?.guild_id)
|
||||
const tag = getTag(storage, guild, channel, message?.author)
|
||||
if (!tag) return ret
|
||||
|
||||
return {
|
||||
...ret,
|
||||
tagText: tag.text,
|
||||
tagTextColor: tag.textColor
|
||||
? processColor(tag.textColor)
|
||||
: undefined,
|
||||
tagBackgroundColor: processColor(tag.backgroundColor),
|
||||
tagVerified: tag.verified,
|
||||
tagType: undefined,
|
||||
}
|
||||
}, ret)
|
||||
},
|
||||
)
|
||||
}, undefined)
|
||||
},
|
||||
{ returnNamespace: true },
|
||||
)
|
||||
|
||||
return () => {
|
||||
unsub()
|
||||
unpatch()
|
||||
}
|
||||
}
|
||||
153
plugins/staff-tags/js/patches/details.tsx
Normal file
153
plugins/staff-tags/js/patches/details.tsx
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import { Stores } from '@revenge-mod/discord/flux'
|
||||
import { getModules } from '@revenge-mod/modules/finders'
|
||||
import { withName, withProps } from '@revenge-mod/modules/finders/filters'
|
||||
import { afterJSX } from '@revenge-mod/react/jsx-runtime'
|
||||
import { findInReactFiber } from '@revenge-mod/utils/react'
|
||||
import { afterRendered } from '../lib/afterRendered'
|
||||
import { fiberFilter } from '../lib/fiber'
|
||||
import getTag from '../lib/getTag'
|
||||
import { guard } from '../lib/safe'
|
||||
import GradientTag from '../ui/GradientTag'
|
||||
import type { JsonStorage } from '@revenge-mod/json-storage'
|
||||
import type { FC } from 'react'
|
||||
import type { StaffTagsStorage } from '../lib/types'
|
||||
|
||||
interface UserRowProps {
|
||||
guildId?: string
|
||||
user: any
|
||||
}
|
||||
|
||||
interface TagComponentProps {
|
||||
type: number
|
||||
text: string
|
||||
textColor: string
|
||||
backgroundColor: string
|
||||
verified: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Covers member list rows and the profile popout, where the name and tag sit on the same line.
|
||||
*
|
||||
* Multiple distinct `UserRow` implementations can exist in the same build (member list vs.
|
||||
* profile popout, etc.), so this subscribes rather than doing a single lookup - `max` is a guess
|
||||
* and may need adjusting once verified live, see eval-for-revenge.
|
||||
*
|
||||
* Uses `getModules` (reacts once Discord itself initializes each module) instead of
|
||||
* `lookupModule` (which force-initializes uninitialized modules right away) - forcing UserRow /
|
||||
* the Tag module to init during `start()`, before Discord is done booting, is what crashed app
|
||||
* startup the first time this shipped.
|
||||
*
|
||||
* Every callback below is wrapped in `guard()`: it runs whenever Discord itself renders/
|
||||
* initializes the matched module, not inside `applyPatches`' try/catch, which only covers the
|
||||
* synchronous setup calls - an uncaught throw here crashes app startup instead of just skipping
|
||||
* one row.
|
||||
*
|
||||
* TODO(live-verify): UserRow component name/count, rendered-tree shape (`c?.type?.Types`), see
|
||||
* eval-for-revenge.
|
||||
*/
|
||||
export default function patchDetails(storage: JsonStorage<StaffTagsStorage>) {
|
||||
const patches: (() => void)[] = []
|
||||
|
||||
let TagModule: { default?: FC<TagComponentProps> } | undefined
|
||||
const unsubTagModule = getModules(
|
||||
withProps('default', 'getBotLabel'),
|
||||
mod => {
|
||||
TagModule = mod as { default?: FC<TagComponentProps> }
|
||||
},
|
||||
{ skipDefault: true },
|
||||
)
|
||||
patches.push(unsubTagModule)
|
||||
|
||||
const GuildStore = Stores.GuildStore as unknown as {
|
||||
getGuild(id: string | undefined): any
|
||||
}
|
||||
|
||||
const unsubUserRow = getModules(
|
||||
withName<FC<UserRowProps>>('UserRow'),
|
||||
UserRow => {
|
||||
guard(() => {
|
||||
patches.push(
|
||||
afterJSX(UserRow, el =>
|
||||
guard(() => {
|
||||
const { guildId, user } = el.props
|
||||
const unpatch = afterRendered(el, ret =>
|
||||
guard(() => {
|
||||
unpatch()
|
||||
|
||||
const label = findInReactFiber(
|
||||
ret as any,
|
||||
fiberFilter(
|
||||
c =>
|
||||
Array.isArray(c?.props?.children) &&
|
||||
c.props.children.some(
|
||||
(ch: any) =>
|
||||
typeof ch === 'string' ||
|
||||
typeof ch?.props?.children === 'string',
|
||||
),
|
||||
),
|
||||
)
|
||||
if (!label) return ret
|
||||
|
||||
const existingTag = findInReactFiber(
|
||||
label as any,
|
||||
fiberFilter(c => c?.type?.Types),
|
||||
)
|
||||
if (existingTag && existingTag.props?.type !== 0) return ret
|
||||
|
||||
const guild = GuildStore?.getGuild(guildId)
|
||||
const tag = getTag(storage, guild, undefined, user)
|
||||
if (!tag) return ret
|
||||
|
||||
if (existingTag) {
|
||||
Object.assign(existingTag.props, {
|
||||
type: 0,
|
||||
text: tag.text,
|
||||
textColor: tag.textColor,
|
||||
backgroundColor: tag.backgroundColor,
|
||||
verified: tag.verified,
|
||||
})
|
||||
return ret
|
||||
}
|
||||
|
||||
const container = label as any
|
||||
if (!Array.isArray(container.props.children)) {
|
||||
container.props.children = [container.props.children]
|
||||
}
|
||||
|
||||
if (tag.gradientColor) {
|
||||
container.props.children.push(
|
||||
<GradientTag
|
||||
text={tag.text}
|
||||
textColor={tag.textColor}
|
||||
backgroundColor={tag.backgroundColor}
|
||||
gradientColor={tag.gradientColor}
|
||||
/>,
|
||||
)
|
||||
} else if (TagModule?.default) {
|
||||
const Component = TagModule.default
|
||||
container.props.children.push(
|
||||
<Component
|
||||
type={0}
|
||||
text={tag.text}
|
||||
textColor={tag.textColor}
|
||||
backgroundColor={tag.backgroundColor}
|
||||
verified={tag.verified}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
return ret
|
||||
}, ret),
|
||||
)
|
||||
return el
|
||||
}, el),
|
||||
),
|
||||
)
|
||||
}, undefined)
|
||||
},
|
||||
{ max: 4 },
|
||||
)
|
||||
patches.push(unsubUserRow)
|
||||
|
||||
return () => patches.forEach(unpatch => unpatch())
|
||||
}
|
||||
175
plugins/staff-tags/js/patches/name.tsx
Normal file
175
plugins/staff-tags/js/patches/name.tsx
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
import { Stores } from '@revenge-mod/discord/flux'
|
||||
import { getModules } from '@revenge-mod/modules/finders'
|
||||
import { withName, withProps } from '@revenge-mod/modules/finders/filters'
|
||||
import { afterJSX } from '@revenge-mod/react/jsx-runtime'
|
||||
import { findInReactFiber } from '@revenge-mod/utils/react'
|
||||
import { afterRendered } from '../lib/afterRendered'
|
||||
import { fiberFilter } from '../lib/fiber'
|
||||
import getTag from '../lib/getTag'
|
||||
import { guard } from '../lib/safe'
|
||||
import GradientTag from '../ui/GradientTag'
|
||||
import type { JsonStorage } from '@revenge-mod/json-storage'
|
||||
import type { FC } from 'react'
|
||||
import type { StaffTagsStorage } from '../lib/types'
|
||||
|
||||
interface DisplayNameProps {
|
||||
guildId?: string
|
||||
channelId?: string
|
||||
user: any
|
||||
}
|
||||
|
||||
interface HeaderNameProps {
|
||||
channelId?: string
|
||||
}
|
||||
|
||||
interface TagComponentProps {
|
||||
type: number
|
||||
text: string
|
||||
textColor: string
|
||||
backgroundColor: string
|
||||
verified: boolean
|
||||
style?: any
|
||||
}
|
||||
|
||||
/**
|
||||
* Covers the name row shown in chat messages (next to the display name + timestamp) and channel
|
||||
* headers.
|
||||
*
|
||||
* Uses `getModules` (reacts once Discord itself initializes each module) instead of
|
||||
* `lookupModule` (which force-initializes uninitialized modules right away) - forcing
|
||||
* DisplayName/HeaderName/the Tag module to init during `start()`, before Discord is done
|
||||
* booting, is what crashed app startup the first time this shipped.
|
||||
*
|
||||
* Every callback below is wrapped in `guard()`: it runs whenever Discord itself renders/
|
||||
* initializes the matched module, not inside `applyPatches`' try/catch, which only covers the
|
||||
* synchronous setup calls - an uncaught throw here crashes app startup instead of just skipping
|
||||
* a name row.
|
||||
*
|
||||
* TODO(live-verify): DisplayName/HeaderName component names and rendered-tree shapes
|
||||
* (`c?.type?.Types`, the flexDirection: "row" container), see eval-for-revenge.
|
||||
*/
|
||||
export default function patchName(storage: JsonStorage<StaffTagsStorage>) {
|
||||
const cleanups: (() => void)[] = []
|
||||
|
||||
let TagModule: { default?: FC<TagComponentProps> } | undefined
|
||||
const unsubTagModule = getModules(
|
||||
withProps('default', 'getBotLabel'),
|
||||
mod => {
|
||||
TagModule = mod as { default?: FC<TagComponentProps> }
|
||||
},
|
||||
{ skipDefault: true },
|
||||
)
|
||||
cleanups.push(unsubTagModule)
|
||||
|
||||
const GuildStore = Stores.GuildStore as unknown as {
|
||||
getGuild(id: string | undefined): any
|
||||
}
|
||||
const ChannelStore = Stores.ChannelStore as unknown as {
|
||||
getChannel(id: string | undefined): any
|
||||
}
|
||||
|
||||
const unsubHeaderName = getModules(
|
||||
withName<FC<HeaderNameProps>>('HeaderName'),
|
||||
HeaderName => {
|
||||
guard(() => {
|
||||
cleanups.push(
|
||||
afterJSX(HeaderName, el =>
|
||||
guard(() => {
|
||||
const { channelId } = el.props
|
||||
const unpatch = afterRendered(el, ret =>
|
||||
guard(() => {
|
||||
unpatch()
|
||||
const node = ret as any
|
||||
if (node?.props) node.props.channelId = channelId
|
||||
return ret
|
||||
}, ret),
|
||||
)
|
||||
return el
|
||||
}, el),
|
||||
),
|
||||
)
|
||||
}, undefined)
|
||||
},
|
||||
)
|
||||
cleanups.push(unsubHeaderName)
|
||||
|
||||
const unsubDisplayName = getModules(
|
||||
withName<FC<DisplayNameProps>>('DisplayName'),
|
||||
DisplayName => {
|
||||
guard(() => {
|
||||
cleanups.push(
|
||||
afterJSX(DisplayName, el =>
|
||||
guard(() => {
|
||||
const { guildId, channelId, user } = el.props
|
||||
const unpatch = afterRendered(el, ret =>
|
||||
guard(() => {
|
||||
unpatch()
|
||||
|
||||
const tagComponent = findInReactFiber(
|
||||
ret as any,
|
||||
fiberFilter(c => c?.type?.Types),
|
||||
)
|
||||
|
||||
// A real built-in tag (bot/system/etc.) is already present - don't touch it.
|
||||
if (tagComponent && tagComponent.props?.type !== 0) return ret
|
||||
|
||||
const guild = GuildStore?.getGuild(guildId)
|
||||
const channel = ChannelStore?.getChannel(channelId)
|
||||
const tag = getTag(storage, guild, channel, user)
|
||||
if (!tag) return ret
|
||||
|
||||
if (tagComponent) {
|
||||
tagComponent.props = {
|
||||
type: 0,
|
||||
text: tag.text,
|
||||
textColor: tag.textColor,
|
||||
backgroundColor: tag.backgroundColor,
|
||||
verified: tag.verified,
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
const row = findInReactFiber(
|
||||
ret as any,
|
||||
fiberFilter(c => c?.props?.style?.flexDirection === 'row'),
|
||||
)
|
||||
if (!Array.isArray(row?.props?.children)) return ret
|
||||
|
||||
if (tag.gradientColor) {
|
||||
row.props.children.push(
|
||||
<GradientTag
|
||||
style={{ marginLeft: 0 }}
|
||||
text={tag.text}
|
||||
textColor={tag.textColor}
|
||||
backgroundColor={tag.backgroundColor}
|
||||
gradientColor={tag.gradientColor}
|
||||
/>,
|
||||
)
|
||||
} else if (TagModule?.default) {
|
||||
const Component = TagModule.default
|
||||
row.props.children.push(
|
||||
<Component
|
||||
style={{ marginLeft: 0 }}
|
||||
type={0}
|
||||
text={tag.text}
|
||||
textColor={tag.textColor}
|
||||
backgroundColor={tag.backgroundColor}
|
||||
verified={tag.verified}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
return ret
|
||||
}, ret),
|
||||
)
|
||||
return el
|
||||
}, el),
|
||||
),
|
||||
)
|
||||
}, undefined)
|
||||
},
|
||||
)
|
||||
cleanups.push(unsubDisplayName)
|
||||
|
||||
return () => cleanups.forEach(fn => fn())
|
||||
}
|
||||
94
plugins/staff-tags/js/patches/tag.tsx
Normal file
94
plugins/staff-tags/js/patches/tag.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { getModules } from '@revenge-mod/modules/finders'
|
||||
import { withProps } from '@revenge-mod/modules/finders/filters'
|
||||
import { afterJSX } from '@revenge-mod/react/jsx-runtime'
|
||||
import { findInReactFiber } from '@revenge-mod/utils/react'
|
||||
import { afterRendered } from '../lib/afterRendered'
|
||||
import { fiberFilter } from '../lib/fiber'
|
||||
import { guard, mergeStyle } from '../lib/safe'
|
||||
import type { FC } from 'react'
|
||||
|
||||
interface TagProps {
|
||||
text?: string
|
||||
textColor?: string
|
||||
backgroundColor?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Discord's own Tag component ignores custom text/color props unless we reach into its
|
||||
* already-rendered output and push them in ourselves - this is what actually makes the
|
||||
* text/textColor/backgroundColor props (that `name.tsx`/`details.tsx` inject) visible.
|
||||
*
|
||||
* `afterJSX` intercepts every `createElement(Tag, ...)` call globally, regardless of who calls
|
||||
* it (Discord's own code or ours) and regardless of which chunk captured the reference -
|
||||
* patching the component's module export directly wouldn't reliably reach Discord's own
|
||||
* same-chunk render calls.
|
||||
*
|
||||
* Uses `getModules` (reacts once Discord itself initializes the module) instead of
|
||||
* `lookupModule` (which force-initializes uninitialized modules right away to check their
|
||||
* exports) - forcing this module to init during `start()`, before Discord is done booting, is
|
||||
* what crashed app startup the first time this shipped.
|
||||
*
|
||||
* Every callback below is wrapped in `guard()`: it runs whenever Discord itself renders/
|
||||
* initializes the matched module, not inside `applyPatches`' try/catch, which only covers the
|
||||
* synchronous setup call above - an uncaught throw here crashes app startup instead of just
|
||||
* skipping this one tag.
|
||||
*
|
||||
* TODO(live-verify): Tag module shape (`default` + `getBotLabel`), see eval-for-revenge.
|
||||
*/
|
||||
export default function patchTag() {
|
||||
let unpatchTag = () => {}
|
||||
|
||||
const unsub = getModules(
|
||||
withProps<{ default: FC<TagProps>; getBotLabel: unknown }>(
|
||||
'default',
|
||||
'getBotLabel',
|
||||
),
|
||||
TagModule => {
|
||||
guard(() => {
|
||||
const Tag = TagModule.default
|
||||
if (!Tag) return
|
||||
|
||||
unpatchTag = afterJSX(Tag, el =>
|
||||
guard(() => {
|
||||
const { text, textColor, backgroundColor } = el.props
|
||||
if (!text && !textColor && !backgroundColor) return el
|
||||
|
||||
const unpatch = afterRendered(el, ret =>
|
||||
guard(() => {
|
||||
unpatch()
|
||||
|
||||
const label = findInReactFiber(
|
||||
ret as any,
|
||||
fiberFilter(c => typeof c?.props?.children === 'string'),
|
||||
)
|
||||
if (!label) return ret
|
||||
|
||||
if (text) label.props.children = text
|
||||
if (textColor)
|
||||
label.props.style = mergeStyle(label.props.style, {
|
||||
color: textColor,
|
||||
})
|
||||
if (backgroundColor && (ret as any)?.props) {
|
||||
;(ret as any).props.style = mergeStyle(
|
||||
(ret as any).props.style,
|
||||
{ backgroundColor },
|
||||
)
|
||||
}
|
||||
|
||||
return ret
|
||||
}, ret),
|
||||
)
|
||||
|
||||
return el
|
||||
}, el),
|
||||
)
|
||||
}, undefined)
|
||||
},
|
||||
{ skipDefault: true },
|
||||
)
|
||||
|
||||
return () => {
|
||||
unsub()
|
||||
unpatchTag()
|
||||
}
|
||||
}
|
||||
104
plugins/staff-tags/js/ui/ColorInput.tsx
Normal file
104
plugins/staff-tags/js/ui/ColorInput.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { Text, TextInput, TouchableOpacity, View } from 'react-native'
|
||||
import { isValidHex } from '../lib/color'
|
||||
|
||||
const PRESETS = [
|
||||
'#F23F42',
|
||||
'#F0B232',
|
||||
'#FEE75C',
|
||||
'#23A55A',
|
||||
'#1ABC9C',
|
||||
'#5865F2',
|
||||
'#9B59B6',
|
||||
'#EB459E',
|
||||
'#99AAB5',
|
||||
'#FFFFFF',
|
||||
]
|
||||
|
||||
interface ColorInputProps {
|
||||
title: string
|
||||
value?: string
|
||||
placeholder?: string
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Hex color field with a live swatch preview and tap-to-pick presets. Built entirely from RN
|
||||
* primitives (no Discord-internal component reuse), so it doesn't carry the same "might silently
|
||||
* stop rendering after a Discord update" risk as the plugin's UI patches do.
|
||||
*/
|
||||
export default function ColorInput({
|
||||
title,
|
||||
value,
|
||||
placeholder,
|
||||
onChange,
|
||||
}: ColorInputProps) {
|
||||
const valid = !value || isValidHex(value)
|
||||
const swatchColor = isValidHex(value)
|
||||
? value
|
||||
: isValidHex(placeholder)
|
||||
? placeholder
|
||||
: '#5865F2'
|
||||
|
||||
return (
|
||||
<View style={{ paddingHorizontal: 16, paddingVertical: 8 }}>
|
||||
<View
|
||||
style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 8 }}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: 6,
|
||||
backgroundColor: swatchColor,
|
||||
marginRight: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(128,128,128,0.35)',
|
||||
}}
|
||||
/>
|
||||
<Text style={{ fontSize: 15, fontWeight: '600' }}>{title}</Text>
|
||||
</View>
|
||||
<TextInput
|
||||
value={value ?? ''}
|
||||
placeholder={placeholder ?? '#5865F2'}
|
||||
onChangeText={onChange}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
style={{
|
||||
borderWidth: 1,
|
||||
borderColor: valid ? 'rgba(128,128,128,0.35)' : '#F23F42',
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 8,
|
||||
fontSize: 14,
|
||||
marginBottom: 8,
|
||||
}}
|
||||
/>
|
||||
<View style={{ flexDirection: 'row', flexWrap: 'wrap' }}>
|
||||
{PRESETS.map(preset => {
|
||||
const active = value?.toLowerCase() === preset.toLowerCase()
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={preset}
|
||||
onPress={() => onChange(preset)}
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 6,
|
||||
backgroundColor: preset,
|
||||
marginRight: 8,
|
||||
marginBottom: 8,
|
||||
borderWidth: active ? 2 : 1,
|
||||
borderColor: active ? '#ffffff' : 'rgba(128,128,128,0.35)',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
{!valid && (
|
||||
<Text style={{ color: '#F23F42', marginTop: 2, fontSize: 12 }}>
|
||||
Invalid hex color, e.g. #5865F2
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
54
plugins/staff-tags/js/ui/GradientTag.tsx
Normal file
54
plugins/staff-tags/js/ui/GradientTag.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { Text, View } from 'react-native'
|
||||
import { getGradientComponent } from '../lib/gradient'
|
||||
import type { StyleProp, ViewStyle } from 'react-native'
|
||||
|
||||
const baseStyle: ViewStyle = {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderRadius: 4,
|
||||
paddingHorizontal: 4,
|
||||
marginLeft: 4,
|
||||
overflow: 'hidden',
|
||||
}
|
||||
|
||||
interface GradientTagProps {
|
||||
text: string
|
||||
textColor: string
|
||||
backgroundColor: string
|
||||
gradientColor?: string
|
||||
style?: StyleProp<ViewStyle>
|
||||
}
|
||||
|
||||
export default function GradientTag({
|
||||
text,
|
||||
textColor,
|
||||
backgroundColor,
|
||||
gradientColor,
|
||||
style,
|
||||
}: GradientTagProps) {
|
||||
const textStyle = {
|
||||
color: textColor,
|
||||
fontSize: 11,
|
||||
fontWeight: '700' as const,
|
||||
}
|
||||
const Gradient = gradientColor ? getGradientComponent() : null
|
||||
|
||||
if (Gradient) {
|
||||
return (
|
||||
<Gradient
|
||||
colors={[backgroundColor, gradientColor as string]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={[baseStyle, style]}
|
||||
>
|
||||
<Text style={textStyle}>{text}</Text>
|
||||
</Gradient>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[baseStyle, { backgroundColor }, style]}>
|
||||
<Text style={textStyle}>{text}</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
116
plugins/staff-tags/js/ui/pages/Settings.tsx
Normal file
116
plugins/staff-tags/js/ui/pages/Settings.tsx
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { Design } from '@revenge-mod/discord/design'
|
||||
import { ScrollView, View } from 'react-native'
|
||||
import { TAG_DEFINITIONS } from '../../lib/getTag'
|
||||
import ColorInput from '../ColorInput'
|
||||
import type { PluginApi } from '@revenge-mod/plugins/types'
|
||||
import type { StaffTagsStorage, TagOverride } from '../../lib/types'
|
||||
|
||||
interface TagSettingsSectionProps {
|
||||
api: PluginApi<{ jsonStorage: StaffTagsStorage }>
|
||||
id: string
|
||||
defaultText: string
|
||||
defaultColor: string
|
||||
}
|
||||
|
||||
/**
|
||||
* `Design` is read inside each component (not destructured at module top level): external
|
||||
* plugin bundles run via a bare `new Function('revenge', 'plugin', script)` call at preInit with
|
||||
* no Metro-style dependency ordering, so `revenge.discord.design.Design`'s fields aren't
|
||||
* guaranteed populated yet at that point. By the time these components actually render (the user
|
||||
* opened the plugin's settings page), Discord's design system is long since ready.
|
||||
*/
|
||||
function TagSettingsSection({
|
||||
api,
|
||||
id,
|
||||
defaultText,
|
||||
defaultColor,
|
||||
}: TagSettingsSectionProps) {
|
||||
const { TableRowGroup, TableSwitchRow, TextInput } = Design
|
||||
const storage = api.jsonStorage.use()
|
||||
const settings: TagOverride = storage?.tags?.[id] ?? {}
|
||||
const enabled = settings.enabled !== false
|
||||
|
||||
const update = (patch: TagOverride) => {
|
||||
api.jsonStorage.set({ tags: { [id]: patch } })
|
||||
}
|
||||
|
||||
return (
|
||||
<TableRowGroup title={defaultText}>
|
||||
<TableSwitchRow
|
||||
label="Show this tag"
|
||||
value={enabled}
|
||||
onValueChange={(v: boolean) => update({ enabled: v })}
|
||||
/>
|
||||
<TextInput
|
||||
label="Tag text"
|
||||
placeholder={defaultText}
|
||||
value={settings.text ?? ''}
|
||||
editable={enabled}
|
||||
onChange={(v: string) => update({ text: v })}
|
||||
/>
|
||||
<TableSwitchRow
|
||||
label="Custom color"
|
||||
subLabel={`Default: ${defaultColor}`}
|
||||
value={!!settings.useCustomColor}
|
||||
disabled={!enabled}
|
||||
onValueChange={(v: boolean) => update({ useCustomColor: v })}
|
||||
/>
|
||||
{settings.useCustomColor && (
|
||||
<ColorInput
|
||||
title="Color"
|
||||
value={settings.color}
|
||||
placeholder={defaultColor}
|
||||
onChange={(v: string) => update({ color: v })}
|
||||
/>
|
||||
)}
|
||||
<TableSwitchRow
|
||||
label="Gradient"
|
||||
subLabel="Member list & profile only, chat tags stay solid"
|
||||
value={!!settings.useGradient}
|
||||
disabled={!enabled}
|
||||
onValueChange={(v: boolean) => update({ useGradient: v })}
|
||||
/>
|
||||
{settings.useGradient && (
|
||||
<ColorInput
|
||||
title="Gradient color"
|
||||
value={settings.gradientColor}
|
||||
onChange={(v: string) => update({ gradientColor: v })}
|
||||
/>
|
||||
)}
|
||||
</TableRowGroup>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Settings({
|
||||
api,
|
||||
}: {
|
||||
api: PluginApi<{ jsonStorage: StaffTagsStorage }>
|
||||
}) {
|
||||
const { TableRowGroup, TableSwitchRow } = Design
|
||||
const storage = api.jsonStorage.use()
|
||||
|
||||
return (
|
||||
<ScrollView style={{ flex: 1 }}>
|
||||
<TableRowGroup title="General">
|
||||
<TableSwitchRow
|
||||
label="Use top role color"
|
||||
subLabel="Used when a tag has no custom color set"
|
||||
value={!!storage?.useRoleColor}
|
||||
onValueChange={(v: boolean) =>
|
||||
api.jsonStorage.set({ useRoleColor: v })
|
||||
}
|
||||
/>
|
||||
</TableRowGroup>
|
||||
{TAG_DEFINITIONS.map(def => (
|
||||
<TagSettingsSection
|
||||
key={def.id}
|
||||
api={api}
|
||||
id={def.id}
|
||||
defaultText={def.defaultText}
|
||||
defaultColor={def.defaultColor}
|
||||
/>
|
||||
))}
|
||||
<View style={{ height: 24 }} />
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
19
plugins/staff-tags/manifest.json
Normal file
19
plugins/staff-tags/manifest.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"format": 1,
|
||||
"id": "dev.everestmcarthur.staff-tags",
|
||||
"name": "Staff Tags",
|
||||
"description": "Adds customizable tags (OWNER, ADMIN, STAFF, MOD, and more) next to members based on their server permissions. Supports per-tag colors, gradients, custom text, and visibility toggles.",
|
||||
"author": "everestmcarthur",
|
||||
"version": "1.0.4",
|
||||
"dependencies": {
|
||||
"revenge.api": {
|
||||
"version": ">=1 <2"
|
||||
},
|
||||
"discord": {
|
||||
"version": "*"
|
||||
}
|
||||
},
|
||||
"dist": {
|
||||
"script": "index.js"
|
||||
}
|
||||
}
|
||||
5
repo.config.json
Normal file
5
repo.config.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"name": "revenge-next-plugs",
|
||||
"description": "Everest's Revenge Next plugin repository.",
|
||||
"channels": {}
|
||||
}
|
||||
30
settings.gradle.kts
Normal file
30
settings.gradle.kts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import java.io.File
|
||||
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
// Xposed API (provided by the framework at runtime).
|
||||
maven(url = "https://api.xposed.info/")
|
||||
// The Revenge plugin API, published locally from the revenge-xposed repo via
|
||||
// `./gradlew :api:publishToMavenLocal`.
|
||||
mavenLocal()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "revenge-plugin-template"
|
||||
|
||||
// Each `plugins/<name>/` folder with Kotlin sources (`src/main`) is a native plugin module.
|
||||
// Plugins without `src/main` are JS-only and are packaged without a Gradle project.
|
||||
file("plugins").listFiles()
|
||||
?.filter { it.isDirectory && File(it, "src/main").isDirectory }
|
||||
?.sortedBy { it.name }
|
||||
?.forEach { dir -> include(":plugins:${dir.name}") }
|
||||
15
tsconfig.json
Normal file
15
tsconfig.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"types": ["@revenge-mod/types"]
|
||||
},
|
||||
"include": ["plugins", "types"],
|
||||
"exclude": ["plugins/*/build"]
|
||||
}
|
||||
24
types/assets.d.ts
vendored
Normal file
24
types/assets.d.ts
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
declare module '*.webp' {
|
||||
const content: string
|
||||
export default content
|
||||
}
|
||||
|
||||
declare module '*.png' {
|
||||
const content: string
|
||||
export default content
|
||||
}
|
||||
|
||||
declare module '*.jpg' {
|
||||
const content: string
|
||||
export default content
|
||||
}
|
||||
|
||||
declare module '*.jpeg' {
|
||||
const content: string
|
||||
export default content
|
||||
}
|
||||
|
||||
declare module '*.svg' {
|
||||
const content: string
|
||||
export default content
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue