2026-08-08 18:42:50 -04:00
|
|
|
# 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/`.
|
|
|
|
|
|
Fix Phase 1 final-review findings: lockfile, pinned tool versions, CI verification and safety
- Commit bun.lock (removes bun.lock/bun.lockb/package-lock.json/deno.lock
from .gitignore) so CI reads a pinned revision of the git-dependency
@revenge-mod/plugin-cli, which build.gradle.kts already declared as a
buildJs input but never had a committed file to read.
- Pin wrangler as a devDependency (4.120.0, matching the version already
live) so `bunx wrangler deploy` resolves the local pinned install instead
of "latest" at every run; verified empirically that bunx follows the
local pin, not the cache/latest version.
- Pin bun-version in oven-sh/setup-bun@v2 to 1.3.14, the version already in
use, so CI's toolchain isn't a moving target either.
- Add a post-deploy CI step (.github/scripts/verify-deploy.mjs) that fetches
the live index.json, and downloads and sha256-verifies one artifact against
the hash the index claims for it, failing the workflow on any mismatch.
This promotes the manual verification done during implementation into a
permanent regression check.
- Add concurrency (cancel superseded deploys on rapid pushes),
workflow_dispatch (manual redeploy without an empty commit), and an
explicit permissions: contents: read block to deploy.yml.
- Document in README.md that next.jarviscli.dev auto-deploys on every push
to main, and that a plugin change without a manifest.json version bump
silently republishes the same version pointer with different bytes,
so already-installed clients never see the update.
2026-08-08 20:34:13 -04:00
|
|
|
## Deployment
|
|
|
|
|
|
|
|
|
|
This repository auto-deploys to **https://next.jarviscli.dev** on every push to `main`, via the
|
|
|
|
|
GitHub Actions workflow in `.github/workflows/deploy.yml`.
|
|
|
|
|
|
|
|
|
|
**Every plugin change requires bumping that plugin's `version` in its `manifest.json`.** The deploy
|
|
|
|
|
publishes whatever is on disk under whatever version each `manifest.json` currently declares — if
|
|
|
|
|
you edit a plugin without bumping its version, the deploy silently republishes the exact same
|
|
|
|
|
version pointer with different bytes at the same URL. Clients that already installed that version
|
|
|
|
|
will never see the update, since they only re-fetch an artifact when its version number changes.
|
|
|
|
|
|
2026-08-08 18:42:50 -04:00
|
|
|
## 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.
|