feat!: new repo system + versioning system support + bug fixes

BREAKING CHANGES:
- New versioning system: Allowing any amount of number segments, with prerelease support.
  - Any number of segments is supported: 1, 1.0, 1.0.0, 1.0.0.0, ...
  - Prerelease labels are compared "digit-run", similar to Debian.
  - Plugins must specify a version.
  - User-facing plugins should use CalVer. API plugins should use SemVer.
    Revenge APIs use SemVer while the new `discord` dependency parses the Discord version string directly.
- Dependency is now a Map, with support for version ranges and optional. Suggested URL is removed. (See new repo system)
- Plugins will now have to declare a dependency on `revenge.api` (Revenge API version) and `discord` (Discord version).
- Manifest now requires a format version (currently `1`)

New APIs:
- lib/plugins/utils: Utilities to format versioning

Features:
- New repo system: Repository design is inspired by Linux package repositories.
  - Repos host manifest, versions, channels, download URL/size, and hash for multiple plugins.
  - New dependency system: Plugins are versioned, and shouldn't break when updates come.
    Plugins can now declare dependency on supported Discord/Revenge API versions, so they won't break with auto-updates.
    Dependencies can be optional, but dependency on `revenge.api` and `discord` must be required.
    Dependents are soft-disabled when dependencies don't satisfy. Once satisfied, the plugin can start again.
  - All updates for running plugins REQUIRE a reload to prevent stale linking issues. Both native and JS-side.
  - Update checking is handled entirely by JS. But the native-side still handles installation, indexing, and verification.
  - Local indexes are updated against remote indexes. Dependency checking always uses local indexes.
  - Users can add custom repositories and custom their priority order. URL is the identity.
- Plugins can now have custom data URI icons instead of being restricted to React Native app assets.
- External plugin installs require UI confirmation before installing.
- Pending Update and Pending Reload filters in Plugins page.
- Ability to browse external plugins in repositories. Official repository will be added soon.

Fixes/Refactor:
- Refactored Plugin-related settings to a separate "Plugin Settings" plugin.
- Masonry layout fixes in landscape.
- Updated plugins now show "reload to apply" toast instead.
- Removed dead code.
This commit is contained in:
PalmDevs 2026-07-25 23:35:23 +07:00
parent fb35d7af95
commit 9b412a9404
No known key found for this signature in database
GPG key ID: 6FAE8F8677ACA8BA
63 changed files with 2746 additions and 501 deletions

View file

@ -6,6 +6,9 @@
"./constants": {
"default": "./src/constants.ts"
},
"./utils": {
"default": "./src/utils.ts"
},
"./preinit": {
"default": "./src/preinit.ts"
},
@ -20,6 +23,9 @@
},
"./_": {
"default": "./src/_internal/index.ts"
},
"./_/repositories": {
"default": "./src/_internal/repositories.ts"
}
}
}

View file

@ -1,4 +1,4 @@
import { getInternalPluginMeta, getPluginDependencies } from '../_internal'
import { getPluginDependencies, isPluginStartable } from '../_internal'
import { pApis } from './decorators'
import type { AnyPlugin } from '../_internal'
@ -23,8 +23,27 @@ export const pListOrdered: AnyPlugin[] = []
// Pending plugins to be computed
export const pPending = new Set<AnyPlugin>()
// Reserved dependencies verified by native
export const ApiDependencyId = 'revenge.api'
export const DiscordDependencyId = 'discord'
export function isReservedDependency(id: string) {
return id === ApiDependencyId || id === DiscordDependencyId
}
/**
* Whether the plugin has start-order dependencies. Reserved dependencies don't count.
*/
function hasGraphableDependencies(plugin: AnyPlugin): boolean {
const deps = plugin.manifest.dependencies
if (!deps) return false
for (const id in deps) if (!isReservedDependency(id)) return true
return false
}
export function computePendingNodes() {
for (const plugin of pPending) resolvePluginGraph(plugin)
for (const plugin of pPending)
if (isPluginStartable(plugin)) resolvePluginGraph(plugin)
const apis: AnyPlugin[] = []
@ -44,8 +63,8 @@ export function computePendingNodes() {
continue
}
if (plugin.manifest.dependencies?.length) {
for (const dep of getPluginDependencies(plugin))
if (hasGraphableDependencies(plugin)) {
for (const dep of getPluginDependencies(plugin, false))
if (!pLeafOrSingleNodes.has(dep)) stack.push(dep)
stack.push(plugin)
@ -60,18 +79,12 @@ export function computePendingNodes() {
}
export function resolvePluginGraph(plugin: AnyPlugin) {
const { manifest } = plugin
if (manifest.dependencies?.length) {
if (hasGraphableDependencies(plugin)) {
// Optimisitically add to root nodes (if there are dependents, it will be removed later)
pRootNodes.add(plugin)
for (const dep of getPluginDependencies(plugin)) {
const depMeta = getInternalPluginMeta(dep)
depMeta.dependents.push(plugin)
// Not a root node if it has dependencies
if (dep.manifest.dependencies?.length) pRootNodes.delete(dep)
}
// Not a root node if it has dependencies
for (const dep of getPluginDependencies(plugin, false))
if (hasGraphableDependencies(dep)) pRootNodes.delete(dep)
} else pLeafOrSingleNodes.add(plugin)
}

View file

@ -11,16 +11,22 @@ import {
handlePluginError,
InternalPluginFlags,
isPluginEnabled,
isPluginPendingReload,
PluginFlags,
pEmitter,
pList,
registerInternalPlugin,
registerPlugin,
runPluginLate,
stopPlugin,
toPluginError,
} from '.'
import { pPending } from './dependency-graph'
import { registerRepositoryEvents } from './repositories'
import type { PluginManifest, PluginOptionsFactory } from '../types'
import type { AnyPlugin } from '.'
import type {
AnyPlugin,
PluginError,
PluginInstallReadyEvent,
PluginSource,
} from '.'
interface ExternalPlugin {
manifest: PluginManifest
@ -28,68 +34,89 @@ interface ExternalPlugin {
internal?: boolean
essential?: boolean
enabledByDefault?: boolean
api?: boolean
/**
* The plugin failed to load at native boot (session-skip).
* It is registered so the user sees it and the reasons, but it never runs this session.
* - Dependency failures keep the enabled flag (auto-recovers next boot once resolved).
* - Own-fault failures (bad code, bad manifest) arrive already disabled by native.
*/
failed?: boolean
/** Where the plugin came from. Missing or `repo: null` means sideloaded. */
source?: PluginSource | null
unsatisfiedOptionalDependencies?: string[]
/** Errors the native side already hit (eg. at boot before JS was up, after faulty update). */
errors?: string[]
errors?: PluginError[]
}
type PluginInstallResult =
| { error: false; plugin: ExternalPlugin }
| { error: string }
| { error: PluginError }
export function registerExternalPlugins() {
registerJSMethod(
'revenge.plugins.stop',
async function stopHandler(id: string) {
const plugin = pList.get(id)
if (plugin?.status) await stopPlugin(plugin)
},
)
registerJSMethod(
'revenge.plugins.events.pluginInstalled',
'revenge.plugins.events.pluginInstallResult',
async function installedHandler(result: PluginInstallResult) {
if (result.error !== false) {
pEmitter.emit('install', { error: result.error })
return
}
// Native only fires this for fresh installs of new IDs
// Updates go through `pluginUpdated` instead and apply at reload
const { plugin } = result
const existing = pList.get(plugin.manifest.id)
try {
if (existing?.status) {
// Should already be stopped by this point, but just in case the native side didn't await
await stopPlugin(existing)
}
const pendingReload =
existing !== undefined && isPluginPendingReload(existing)
if (existing) pList.delete(plugin.manifest.id)
if (pendingReload) {
// Since reload is required to (un)apply changes, we don't load the new plugin yet
// We'll do the next time the app is started
const registered = pList.get(
registerExternalPlugin(plugin).id,
)!
const meta = getInternalPluginMeta(registered)
meta.flags |= PluginFlags.PendingUpdate
} else await loadExternalPlugin(plugin)
registerExternalPlugin(plugin)
} catch (e) {
pEmitter.emit('install', { error: getErrorStack(e) })
pEmitter.emit('install', { error: toPluginError(e) })
return
}
pEmitter.emit('install', {
error: false,
manifest: plugin.manifest,
updated: existing !== undefined,
updated: false,
pending: false,
})
},
)
registerJSMethod(
'revenge.plugins.events.pluginUpdated',
async function updatePendingHandler({
id,
version,
}: {
id: string
version: string
}) {
// The new version is on disk only, the running plugin keeps its old code until reload
// PendingUpdate causes the reload alert to show up
const plugin = pList.get(id)
if (plugin) {
const meta = getInternalPluginMeta(plugin)
meta.flags |= PluginFlags.PendingUpdate
}
pEmitter.emit('install', {
error: false,
pending: true,
id,
version,
})
},
)
registerJSMethod(
'revenge.plugins.events.pluginInstallReady',
(event: PluginInstallReadyEvent) => {
pEmitter.emit('installReady', event)
},
)
registerRepositoryEvents()
const externals = callNativeMethodSync('revenge.plugins.list', [])
if (!externals) return
@ -107,31 +134,54 @@ export function registerExternalPlugins() {
}
export function registerExternalPlugin(external: ExternalPlugin) {
const { manifest, script, internal, essential, enabledByDefault, errors } =
external
const dep = registerPlugin(
const {
manifest,
createOptionsFactory(script),
essential || enabledByDefault ? PluginFlags.Enabled : 0,
(internal ? InternalPluginFlags.Internal : 0) |
(essential ? InternalPluginFlags.Essential : 0),
)
script,
internal,
essential,
enabledByDefault,
api,
failed,
source,
unsatisfiedOptionalDependencies,
errors,
} = external
// Sync errors the native side already caught
if (errors?.length) {
const plugin = pList.get(dep.id)!
for (const error of errors) handlePluginError(error, plugin)
const id = internal
? registerInternalPlugin(
manifest,
createOptionsFactory(script),
essential || enabledByDefault ? PluginFlags.Enabled : 0,
InternalPluginFlags.Internal |
(essential ? InternalPluginFlags.Essential : 0) |
(api ? InternalPluginFlags.API : 0),
)
: registerPlugin(
manifest,
createOptionsFactory(script),
enabledByDefault ? PluginFlags.Enabled : 0,
)
const plugin = pList.get(id)!
const meta = getInternalPluginMeta(plugin)
meta.source = source
if (unsatisfiedOptionalDependencies?.length)
meta.unsatisfiedOptionalDependencies = Object.freeze(
unsatisfiedOptionalDependencies,
)
/** @see {@link PluginFlags.Failed} */
if (failed) {
meta.flags |= PluginFlags.Failed
pPending.delete(plugin)
}
return dep
}
// Sync errors the native side already caught
if (errors?.length)
for (const error of errors) handlePluginError(error, plugin)
export async function loadExternalPlugin(external: ExternalPlugin) {
const { id } = registerExternalPlugin(external)
const plugin = pList.get(id)
if (plugin && isPluginEnabled(plugin)) await runPluginLate(plugin)
return id
}
export async function uninstallExternalPlugin(plugin: AnyPlugin) {
@ -143,6 +193,27 @@ export async function uninstallExternalPlugin(plugin: AnyPlugin) {
pEmitter.emit('unregister', plugin)
}
export async function resyncPluginSources() {
const externals = await callNativeMethod('revenge.plugins.list', [])
if (!externals) return
for (const external of externals) {
const plugin = pList.get(external.manifest.id)
if (!plugin) continue
const meta = getInternalPluginMeta(plugin)
meta.source = external.source
pEmitter.emit('flagUpdate', plugin)
}
}
export function confirmInstall(
token: string,
accepted: boolean,
): Promise<{ result: 'installed' | 'pending' | 'cancelled' }> {
return callNativeMethod('revenge.plugins.confirmInstall', [token, accepted])
}
function createOptionsFactory(script?: string): PluginOptionsFactory {
if (!script) return () => ({})
return () => new Function('revenge', `return ${script}`)(pUnscopedApi)
@ -151,7 +222,11 @@ function createOptionsFactory(script?: string): PluginOptionsFactory {
declare module '@revenge-mod/modules/native' {
interface NativeMethods {
'revenge.plugins.list': [[], ExternalPlugin[] | null]
'revenge.plugins.install': [[], null]
'revenge.plugins.installFile': [[], null]
'revenge.plugins.uninstall': [[string], null]
'revenge.plugins.confirmInstall': [
[token: string, accepted: boolean],
{ result: 'installed' | 'pending' | 'cancelled' },
]
}
}

View file

@ -18,7 +18,9 @@ import {
pDecoratorsStart,
} from './decorators'
import {
ApiDependencyId,
computePendingNodes,
DiscordDependencyId,
pLeafOrSingleNodes,
pListOrdered,
pPending,
@ -29,28 +31,45 @@ import type {
PluginApi,
PluginApiExtensionsOptions,
PluginCleanup,
PluginDependency,
PluginManifest,
PluginOptions,
PluginOptionsFactory,
PluginVersion,
PreInitPluginApi,
} from '../types'
export type AnyPlugin = Plugin<any, any>
const InternalPluginVersion: PluginVersion = (() => {
const [segments, label] = __BUILD_VERSION__.split('-')
const nums = segments.split('.').map(Number)
if (!label) return { nums }
return { nums, label }
})()
const MaxWaitTime = 5000
// TODO: Maybe remove this later?
// This is never used in bridge, but still used internally by JS.
export const PluginFlags = {
Enabled: 1 << 0,
PendingReload: 1 << 1,
EnabledLate: 1 << 2,
/**
* Marks the plugin as pending an update. Set when {@link PluginFlags.PendingReload} is set when stopping the plugin to update.
* This means for the update to be applied safely, the updated plugin can only be started after a reload.
* A newer version of the plugin is on disk, but the running version is still active.
* The new plugin version will be reloaded on next reload.
*
* This flag is JS-side.
*/
PendingUpdate: 1 << 3,
/**
* The plugin failed to load this session (aka. session-skip), but is registered so the user sees it and the reason.
* It can never run in this session, and it isn't disabled however.
*
* This is usually because some dependencies failed to start, but could be caused by other misc errors too,
* such as missing dependencies, bad code, etc.
*
* This flag is JS-side.
*/
Failed: 1 << 4,
}
const Flag = PluginFlags
@ -84,12 +103,22 @@ export interface InternalPluginMeta {
cleanups: PluginCleanup[]
iflags: number
apiLevel: number
dependents: AnyPlugin[]
dependencies?: AnyPlugin[]
unsatisfiedOptionalDependencies: readonly string[]
options: PluginOptions<any>
optionsFactory?: PluginOptionsFactory<any>
flags: number
nativeErrors: readonly string[]
nativeErrors: readonly PluginError[]
/**
* Where the plugin came from. `repo: null` or missing means sideloaded.
* Internal plugins never have one.
*/
source?: PluginSource | null
}
export interface PluginSource {
// url
repo: string | null
channel: string
}
export const pUnscopedApi = uapi
@ -105,11 +134,99 @@ export const pEmitter = new TypedEventEmitter<{
errored: [AnyPlugin, unknown]
flagUpdate: [AnyPlugin]
install: [PluginInstallEvent]
installReady: [PluginInstallReadyEvent]
allReposRefreshed: []
}>()
/**
* A sideloaded plugin was staged and validated.
* The user can confirm (or declines) through `confirmInstall(token, accepted)`.
*/
export interface PluginInstallReadyEvent {
/** Single-use confirmation token. */
token: string
manifest: {
id: string
name: string
description: string
author: string
version: string
icon?: string | null
}
/** The installed version this replaces, or null for a fresh install. */
replaces: string | null
}
export type PluginInstallEvent =
| { error: false; manifest: PluginManifest; updated: boolean }
| { error: string }
| {
error: false
manifest: PluginManifest
updated: boolean
pending: false
}
| {
/**
* The plugin was applied on disk only. The running version, if any, is
* untouched and the new one loads at next reload.
*/
error: false
pending: true
id: string
version: string
}
| { error: PluginError }
/// PLUGIN ERRORS
export interface PluginError {
/** One of {@link PluginErrorCodes}, or something unknown. */
code: string
message: string
stack?: string | null
}
export const PluginErrorCodes = {
// Discovery / boot
ManifestInvalid: 'MANIFEST_INVALID',
DependencyMissing: 'DEPENDENCY_MISSING',
DependencyUnsatisfied: 'DEPENDENCY_UNSATISFIED',
DependencyFailed: 'DEPENDENCY_FAILED',
DependencyCycle: 'DEPENDENCY_CYCLE',
LoadFailed: 'LOAD_FAILED',
/* The plugin's own code threw */
PluginError: 'PLUGIN_ERROR',
InstallInvalidZip: 'INSTALL_INVALID_ZIP',
InstallVerifyFailed: 'INSTALL_VERIFY_FAILED',
InstallMismatch: 'INSTALL_MISMATCH',
InstallFailed: 'INSTALL_FAILED',
} as const
export function isPluginError(e: unknown): e is PluginError {
return (
typeof e === 'object' &&
e !== null &&
typeof (e as PluginError).code === 'string' &&
typeof (e as PluginError).message === 'string'
)
}
export function toPluginError(e: unknown): PluginError {
if (isPluginError(e)) return e
if (e instanceof Error)
return {
code: PluginErrorCodes.PluginError,
message: e.message,
stack: e.stack,
}
return { code: PluginErrorCodes.PluginError, message: String(e) }
}
export function formatPluginError(e: unknown): string {
const err = toPluginError(e)
return `[${err.code}] ${err.message}${err.stack ? `\n${err.stack}` : ''}`
}
export const pList = new Map<PluginManifest['id'], AnyPlugin>()
const pMetadata = new WeakMap<AnyPlugin, InternalPluginMeta>()
@ -142,6 +259,8 @@ async function applyPluginFlags(id: PluginManifest['id'], flags: number) {
if (!plugin) return
const meta = getInternalPluginMeta(plugin)
// Repersist JS-side flags
flags |= meta.flags & (Flag.PendingUpdate | Flag.Failed)
if (meta.flags === flags) return
const wasEnabled = meta.flags & Flag.Enabled
@ -162,7 +281,7 @@ async function applyPluginFlags(id: PluginManifest['id'], flags: number) {
registerJSMethod(
'revenge.plugins.events.pluginErrored',
(id: string, errors: string[]) => {
(id: string, errors: PluginError[]) => {
const plugin = pList.get(id)
if (!plugin) return
@ -181,15 +300,42 @@ registerJSMethod(
* @param manifest The manifest of the plugin.
* @param options The options for the plugin.
* @param defflags The default flags for the plugin.
* @param iflags The internal flags for the plugin.
*/
export function registerPlugin<O extends PluginApiExtensionsOptions>(
manifest: PluginManifest,
options: PluginOptions<O> | PluginOptionsFactory<O>,
defflags: number,
) {
return register(manifest, options, defflags, 0)
}
export type InternalPluginManifest = Omit<
PluginManifest,
'version' | 'format' | 'dependencies'
> &
Partial<Pick<PluginManifest, 'version' | 'format' | 'dependencies'>>
export function registerInternalPlugin<O extends PluginApiExtensionsOptions>(
manifest: InternalPluginManifest,
options: PluginOptions<O> | PluginOptionsFactory<O>,
defflags: number,
iflags = 0,
) {
manifest.version ??= InternalPluginVersion
manifest.format ??= 1
manifest.dependencies ??= {}
manifest.dependencies[ApiDependencyId] ??= { version: '*' }
manifest.dependencies[DiscordDependencyId] ??= { version: '*' }
return register(manifest as PluginManifest, options, defflags, iflags)
}
function register<O extends PluginApiExtensionsOptions>(
manifest: PluginManifest,
options: PluginOptions<O> | PluginOptionsFactory<O>,
defflags: number,
iflags: number,
) {
// TODO(plugins): verify plugin manifest
if (pList.has(manifest.id))
throw new Error(`Plugin with ID "${manifest.id}" already registered`)
@ -226,7 +372,7 @@ export function registerPlugin<O extends PluginApiExtensionsOptions>(
promises: [],
iflags,
apiLevel: PluginApiLevel.None,
dependents: [],
unsatisfiedOptionalDependencies: Object.freeze([]),
handleError: e => handlePluginError(e, plugin),
options: resolved ?? {},
optionsFactory: factory,
@ -252,41 +398,75 @@ export function registerPlugin<O extends PluginApiExtensionsOptions>(
pEmitter.emit('register', plugin, meta.options)
return { id: manifest.id } satisfies PluginDependency
return manifest.id
}
/**
* Gets dependencies for a plugin.
*/
export function getPluginDependencies(plugin: AnyPlugin): AnyPlugin[] {
const meta = getInternalPluginMeta(plugin)!
if (meta.dependencies) return meta.dependencies
export function getPluginDependencies(
plugin: AnyPlugin,
throwOnMissing = true,
): AnyPlugin[] {
const { dependencies, id } = plugin.manifest
const deps: AnyPlugin[] = []
const { unsatisfiedOptionalDependencies } = getInternalPluginMeta(plugin)
if (dependencies?.length)
for (const { id: depId } of dependencies) {
if (dependencies)
for (const [depId, spec] of Object.entries(dependencies)) {
const dep = pList.get(depId)
if (dep) {
if (isPluginEnabled(dep)) deps.push(dep)
else
throw new Error(
`Plugin "${id}" depends on disabled plugin "${depId}"`,
)
} else {
// TODO: Once external plugins are implemented, we will have to check the external plugin registry here as well
// External plugin registry should ideally be Record<PluginManifest['id'], [PluginManifest, Flags: number, PluginCode: string]>
// Then we register the plugin here and do dep = pList.get(id) again
throw new Error(
`Plugin "${id}" depends on unregistered plugin "${depId}"`,
if (
!spec.optional ||
(isPluginEnabled(dep) &&
!unsatisfiedOptionalDependencies.includes(depId))
)
deps.push(dep)
} else if (!spec.optional) {
if (throwOnMissing) {
throw new Error(
`Plugin "${id}" depends on unregistered plugin "${depId}"`,
)
}
}
}
return (meta.dependencies = deps)
return deps
}
export function getMissingPluginDependencies(plugin: AnyPlugin): string[] {
const { dependencies } = plugin.manifest
if (!dependencies) return []
const missing: string[] = []
for (const [depId, spec] of Object.entries(dependencies)) {
if (!spec.optional && !pList.has(depId)) missing.push(depId)
}
return missing
}
export function getPluginDependents(
plugin: AnyPlugin,
includeLinkedOptionals = false,
): AnyPlugin[] {
const { id } = plugin.manifest
const dependents: AnyPlugin[] = []
const enabled = isPluginEnabled(plugin)
for (const p of pList.values()) {
const spec = p.manifest.dependencies?.[id]
if (!spec) continue
if (!spec.optional) dependents.push(p)
else if (
includeLinkedOptionals &&
enabled &&
!getInternalPluginMeta(p).unsatisfiedOptionalDependencies.includes(
id,
)
)
dependents.push(p)
}
return dependents
}
export function isPluginEnabled(plugin: AnyPlugin): boolean {
@ -321,18 +501,28 @@ export function isPluginPendingUpdate(plugin: AnyPlugin): boolean {
return Boolean(meta && meta.flags & Flag.PendingUpdate)
}
/** @see {@link Flag.Failed} */
export function isPluginFailed(plugin: AnyPlugin): boolean {
const meta = getInternalPluginMeta(plugin)
return Boolean(meta && meta.flags & Flag.Failed)
}
/**
* Requires that the plugin is in a state where it can be started, all of:
* - Enabled
* - Not pending an update
* Requires that the plugin can be started: enabled, not pending a reload, and not failed at discovery.
* PendingUpdate doesn't block, it only means a newer version sits on disk for next reload.
*/
function requirePluginStartableState(plugin: AnyPlugin) {
if (!isPluginEnabled(plugin))
throw new Error(`Plugin "${plugin.manifest.id}" is not enabled`)
if (isPluginPendingUpdate(plugin))
if (isPluginPendingReload(plugin))
throw new Error(
`Plugin "${plugin.manifest.id}" is pending an update and cannot be started`,
`Plugin "${plugin.manifest.id}" requires a reload before it can be started again`,
)
if (isPluginFailed(plugin))
throw new Error(
`Plugin "${plugin.manifest.id}" failed to load this session (reload to retry)`,
)
}
@ -357,9 +547,12 @@ export async function handlePluginError(e: unknown, plugin: AnyPlugin) {
;(plugin.errors as unknown[]).push(e)
// TODO: Emit errored event so UI can update?
// Update: errored event removed (but status changes are still emitted), so UI should update fine
nativeLoggingHook(
`\u001b[31mPlugin "${plugin.manifest.id}" encountered an error: ${getErrorStack(e)}\u001b[0m`,
`\u001b[31mPlugin "${plugin.manifest.id}" encountered an error: ${
isPluginError(e) ? formatPluginError(e) : getErrorStack(e)
}\u001b[0m`,
2,
)
@ -367,6 +560,8 @@ export async function handlePluginError(e: unknown, plugin: AnyPlugin) {
if (
!isPluginEssential(getInternalPluginMeta(plugin)) &&
// Failed plugins keep their persisted enabled state untouched, recovering on a later boot
!isPluginFailed(plugin) &&
// Multiple errors may surface, but we only want to disable the plugin once
isPluginEnabled(plugin)
)
@ -461,7 +656,9 @@ function tryPreparePluginStart(plugin: AnyPlugin) {
* Disables a plugin, as well as all its dependents.
*/
export async function disablePlugin(plugin: AnyPlugin) {
requirePluginStartableState(plugin)
// PendingReload/PendingUpdate never block disabling
if (!isPluginEnabled(plugin))
throw new Error(`Plugin "${plugin.manifest.id}" is not enabled`)
const meta = getInternalPluginMeta(plugin)
@ -470,10 +667,9 @@ export async function disablePlugin(plugin: AnyPlugin) {
`Plugin "${plugin.manifest.id}" is essential and cannot be disabled`,
)
const { dependents } = meta
// Disable dependents
await Promise.all(
dependents.map(dep => {
getPluginDependents(plugin).map(dep => {
if (getInternalPluginMeta(dep)!.flags & Flag.Enabled)
return disablePlugin(dep)
}),
@ -505,11 +701,24 @@ export async function enablePlugin(plugin: AnyPlugin) {
}),
)
await callNativeMethod('revenge.plugins.setEnabled', [
const result = await callNativeMethod('revenge.plugins.setEnabled', [
plugin.manifest.id,
true,
])
if (result?.code === 'DEPENDENCIES_UNSATISFIED') {
const details = result.problems
.map(
p =>
`"${p.id}" (requires ${p.required}, installed: ${p.installed ?? 'none'}${p.installed && !p.enabled ? ', disabled' : ''})`,
)
.join(', ')
throw new Error(
`Cannot enable plugin "${plugin.manifest.id}": unsatisfied dependencies: ${details}`,
)
}
const meta = getInternalPluginMeta(plugin)
meta.flags |= Flag.Enabled
@ -686,7 +895,9 @@ export async function startPlugin(plugin: AnyPlugin) {
* Stops a plugin by running its stop lifecycle and cleanup functions.
*/
export async function stopPlugin(plugin: AnyPlugin) {
requirePluginStartableState(plugin)
// PendingReload/PendingUpdate shouldn't block stopping
if (!isPluginEnabled(plugin))
throw new Error(`Plugin "${plugin.manifest.id}" is not enabled`)
const {
manifest: { id },
@ -720,6 +931,15 @@ export async function stopPlugin(plugin: AnyPlugin) {
)
throw new Error(`Plugin "${id}" is not running`)
// Stop dependents
await Promise.all(
getPluginDependents(plugin, true).map(dep =>
dep.status && !(dep.status & Status.Stopping)
? stopPlugin(dep)
: undefined,
),
)
plugin.status |= Status.Stopping
try {
@ -786,7 +1006,11 @@ export async function deleteStorageForPlugin(plugin: Plugin<any, any>) {
if (await exists(dir)) await rm(dir)
}
export { uninstallExternalPlugin } from './external-plugins'
export {
confirmInstall,
resyncPluginSources,
uninstallExternalPlugin,
} from './external-plugins'
declare module '@revenge-mod/modules/native' {
interface NativeMethods {
@ -794,11 +1018,23 @@ declare module '@revenge-mod/modules/native' {
'revenge.plugins.states.read': [[], PersistedPluginStates | null]
'revenge.plugins.setEnabled': [
[id: PluginManifest['id'], enabled: boolean],
null,
SetEnabledError | null,
]
}
}
type SetEnabledError = {
code: 'DEPENDENCIES_UNSATISFIED'
problems: Array<{
id: PluginManifest['id']
/** The declared range (`*` for any). */
required: string
/** Installed version, or `null` when missing entirely. */
installed: string | null
enabled: boolean
}>
}
interface PluginStateObject {
enabled?: boolean
pendingReload?: boolean

View file

@ -0,0 +1,296 @@
import { TypedEventEmitter } from '@revenge-mod/discord/common/utils'
import { callNativeMethod, registerJSMethod } from '@revenge-mod/modules/native'
import { pEmitter } from '@revenge-mod/plugins/_'
export interface DownloadProgressEvent {
id: string
version: string
/** The repository the artifact downloads from. */
repo: string
/** Bytes received so far. */
received: number
/** Total bytes, from the plan's size field. */
total: number
/** 1-based position of this artifact in the plan. */
index: number
/** Number of artifacts in the plan. */
count: number
}
export interface RepoStateEvent {
url: string
state: 'refreshing' | 'ready' | 'error'
error?: string
}
export const repoEvents = new TypedEventEmitter<{
downloadProgress: [DownloadProgressEvent]
repoState: [RepoStateEvent]
}>()
export function registerRepositoryEvents() {
registerJSMethod(
'revenge.plugins.events.downloadProgress',
(event: DownloadProgressEvent) => {
repoEvents.emit('downloadProgress', event)
},
)
registerJSMethod(
'revenge.plugins.events.repoStateUpdate',
(event: RepoStateEvent) => {
repoEvents.emit('repoState', event)
},
)
}
/**
* A plugin repository as reported by native.
*
* The repository's URL is its identity.
* The hidden internal repository (serving internal plugins) is always first and cannot be modified or removed.
*/
export interface Repo {
/** Absolute URL of the repository root; also its identity. */
url: string
enabled: boolean
internal: boolean
/** Display metadata from the cached index, if any. */
name: string | null
description: string | null
/** A Discord-packaged asset name or a `data:` URL. Never a remote URL. */
icon: string | null
}
export interface RepoConfigEntry {
url: string
enabled?: boolean
}
export function listRepos(): Promise<Repo[]> {
return callNativeMethod('revenge.plugins.repos.list', [])
}
export function setRepos(config: RepoConfigEntry[]): Promise<null> {
return callNativeMethod('revenge.plugins.repos.set', [config])
}
export function refreshRepo(url: string): Promise<Repo> {
return callNativeMethod('revenge.plugins.repos.refresh', [url])
}
/**
* Refreshes every enabled user repository in parallel.
* Per-repo failures are collected instead.
*/
export async function refreshAllRepos(): Promise<{
refreshed: Repo[]
errors: { url: string; error: unknown }[]
}> {
const repos = await listRepos()
const refreshed: Repo[] = []
const errors: { url: string; error: unknown }[] = []
await Promise.all(
repos
.filter(repo => !repo.internal && repo.enabled)
.map(repo =>
refreshRepo(repo.url).then(
result => {
refreshed.push(result)
},
error => {
errors.push({ url: repo.url, error })
},
),
),
)
pEmitter.emit('allReposRefreshed')
return { refreshed, errors }
}
export interface RepoPluginListing {
id: string
name: string
description: string
author: string
/** A Discord-packaged asset name or a `data:` URL. Never a remote URL. */
icon: string | null
/** Channel pointers (eg. `latest`), each naming a key of {@link versions}. */
channels: Record<string, string>
versions: Record<
string,
{
/** Absolute artifact URL. `null` for the internal repository (nothing downloadable). */
url: string | null
sha256: string | null
size: number
dependencies: Record<
string,
{
version: string
optional: boolean
}
>
}
>
}
export interface InstallPlanAction {
id: string
version: string
url: string
sha256: string
size: number
/** The repository this action installs from (recorded as provenance). */
repo: string
/** The channel followed for future update checks. */
channel: string
/** The installed version being replaced, or `null` for a fresh install. */
replaces: string | null
}
export interface InstallPlan {
actions: InstallPlanAction[]
/** Non-blocking problems (skipped optionals, dependent-range conflicts). */
warnings: string[]
}
export interface RepoUpdate {
id: string
installed: string
available: string
channel: string
}
/**
* Lists one repository's plugins from its cached index.
*/
export function listRepoPlugins(url: string): Promise<RepoPluginListing[]> {
return callNativeMethod('revenge.plugins.repos.listPlugins', [url])
}
/**
* Resolves an install of one plugin (+ unsatisfied dependencies) against cached indexes.
*/
export function planInstall(
id: string,
version?: string,
channel?: string,
): Promise<InstallPlan> {
return callNativeMethod('revenge.plugins.planInstall', [
id,
version ?? null,
channel ?? null,
])
}
/**
* Executes one confirmed install plan: download all, verify all, then apply on disk.
*
* - Fresh plugins (new IDs with live dependencies) load immediately (`installed`).
* - Updates, and fresh IDs depending on them, only land on disk and load at next reload (`pending`).
* - `skipped` lists actions an overlapping plan already satisfied.
*/
export function installFromRepo(plan: InstallPlan): Promise<{
installed: string[]
pending: string[]
skipped: string[]
}> {
return callNativeMethod('revenge.plugins.install', [plan])
}
/**
* Lists available updates for plugins pinned to one repository, from its **cached index**.
* Call {@link refreshRepo} first to ensure the index is up-to-date.
*/
export function listUpdates(url: string): Promise<RepoUpdate[]> {
return callNativeMethod('revenge.plugins.repos.listUpdates', [url])
}
/**
* Lists updates across every enabled user repository in parallel.
* Per-repo failures (eg. no cached index yet) are returned.
*/
export async function listAllUpdates(): Promise<{
updates: RepoUpdate[]
errors: { url: string; error: unknown }[]
}> {
const repos = await listRepos()
const updates: RepoUpdate[] = []
const errors: { url: string; error: unknown }[] = []
await Promise.all(
repos
.filter(repo => !repo.internal && repo.enabled)
.map(repo =>
listUpdates(repo.url).then(
result => {
updates.push(...result)
},
error => {
errors.push({ url: repo.url, error })
},
),
),
)
return { updates, errors }
}
/**
* Resolves and installs updates for every entry of {@link listAllUpdates}.
* Failures are collected per plugin. Updates land on disk only (`pending`), a reload applies them.
*/
export async function updateAllPlugins(): Promise<{
installed: string[]
pending: string[]
errors: { id: string; error: unknown }[]
}> {
const { updates } = await listAllUpdates()
const installed: string[] = []
const pending: string[] = []
const errors: { id: string; error: unknown }[] = []
await Promise.all(
updates.map(async update => {
try {
const plan = await planInstall(
update.id,
undefined,
update.channel,
)
const result = await installFromRepo(plan)
installed.push(...result.installed)
pending.push(...result.pending)
} catch (error) {
errors.push({ id: update.id, error })
}
}),
)
return { installed, pending, errors }
}
declare module '@revenge-mod/modules/native' {
interface NativeMethods {
'revenge.plugins.repos.list': [[], Repo[]]
'revenge.plugins.repos.set': [[config: RepoConfigEntry[]], null]
'revenge.plugins.repos.refresh': [[url: string], Repo]
'revenge.plugins.repos.listPlugins': [
[url: string],
RepoPluginListing[],
]
'revenge.plugins.repos.listUpdates': [[url: string], RepoUpdate[]]
'revenge.plugins.planInstall': [
[id: string, version: string | null, channel: string | null],
InstallPlan,
]
'revenge.plugins.install': [
[plan: InstallPlan],
{ installed: string[]; pending: string[]; skipped: string[] },
]
}
}

View file

@ -1,9 +1,12 @@
import * as PluginsApiConstants from '@revenge-mod/plugins/constants'
import * as PluginsApiUtils from '@revenge-mod/plugins/utils'
export interface PluginApiPlugins {
utils: typeof import('@revenge-mod/plugins/utils')
constants: typeof import('@revenge-mod/plugins/constants')
}
export const plugins: PluginApiPlugins = {
constants: PluginsApiConstants,
utils: PluginsApiUtils,
}

View file

@ -21,5 +21,5 @@ const { storageRootPath } = callNativeMethodSync(
/**
* Per-plugin storage directory, relative to app data directory.
*/
// TODO: is it best to share this with the native side, or should we let native side be the sole source of truth?
// TODO: is it best to dupe this logic with the native side???
export const pluginStorageDirFor = (id: string) => `${storageRootPath}/${id}`

View file

@ -1,7 +1,7 @@
import { initPlugin, isPluginEnabled } from './_internal'
import { initPlugin, isPluginStartable } from './_internal'
import { computePendingNodes, pListOrdered } from './_internal/dependency-graph'
computePendingNodes()
for (const plugin of pListOrdered)
if (isPluginEnabled(plugin)) initPlugin(plugin)
if (isPluginStartable(plugin)) initPlugin(plugin)

View file

@ -1,4 +1,4 @@
import { isPluginEnabled, preInitPlugin } from './_internal'
import { isPluginStartable, preInitPlugin } from './_internal'
import { computePendingNodes, pListOrdered } from './_internal/dependency-graph'
import { registerExternalPlugins } from './_internal/external-plugins'
@ -6,4 +6,4 @@ registerExternalPlugins()
computePendingNodes()
for (const plugin of pListOrdered)
if (isPluginEnabled(plugin)) preInitPlugin(plugin)
if (isPluginStartable(plugin)) preInitPlugin(plugin)

View file

@ -1,4 +1,4 @@
import { isPluginEnabled, startPlugin } from './_internal'
import { isPluginStartable, startPlugin } from './_internal'
import { computePendingNodes, pListOrdered } from './_internal/dependency-graph'
computePendingNodes()
@ -10,4 +10,4 @@ if (__DEV__)
)
for (const plugin of pListOrdered)
if (isPluginEnabled(plugin)) startPlugin(plugin)
if (isPluginStartable(plugin)) startPlugin(plugin)

View file

@ -136,26 +136,14 @@ export interface PluginApi<
plugin: Plugin<O, 'Start'>
}
// TODO(plugins): support plugin bundles
// export interface PluginBundle {
// /**
// * The unique identifier for the plugin bundle.
// */
// id: string
// /**
// * The author of the plugin bundle.
// */
// author: string
// /**
// * The URL of the plugin bundle.
// */
// url: string
// }
/**
* The plugin manifest.
*/
export interface PluginManifest {
/**
* The manifest format version.
*/
format: number
/**
* The unique identifier for the plugin.
*/
@ -173,25 +161,36 @@ export interface PluginManifest {
*/
description: string
/**
* The icon of the plugin.
* The icon of the plugin. An asset name, or a `data:` URL.
*/
icon?: string
/**
* The dependencies of the plugin.
* The dependencies of the plugin, keyed by plugin ID.
*/
dependencies?: PluginDependency[]
dependencies?: Record<string, PluginDependency>
/**
* The plugin's version.
*/
version: PluginVersion
}
export interface PluginVersion {
nums: number[]
label?: string
}
/**
* A dependency specification. The dependency's plugin ID is the key in {@link PluginManifest.dependencies}.
*/
export interface PluginDependency {
/**
* The ID of this dependency.
* Version range the dependency must satisfy.
*/
id: string
// TODO(plugins): support plugin bundles
// /**
// * The bundle of this dependency.
// */
// bundle: PluginBundle
version?: string
/**
* Whether this dependency can be optionally linked.
*/
optional?: boolean
}
export interface PluginOptions<
@ -249,11 +248,6 @@ export interface Plugin<
S extends
keyof PluginApiInLifecycleMap<O> = keyof PluginApiInLifecycleMap<O>,
> {
// TODO(plugins): support plugin bundles
// /**
// * The plugin bundle this plugin belongs to.
// */
// bundle: PluginBundle
manifest: PluginManifest
lifecycles: PluginLifecycles<O>

7
lib/plugins/src/utils.ts Normal file
View file

@ -0,0 +1,7 @@
import type { PluginVersion } from '@revenge-mod/plugins/types'
/**
* Formats a plugin version for display.
*/
export const formatVersion = (version: PluginVersion) =>
version.nums.join('.') + (version.label ? `-${version.label}` : '')

View file

@ -1,11 +1,11 @@
import {
InternalPluginFlags,
PluginFlags,
registerPlugin,
registerInternalPlugin,
} from '@revenge-mod/plugins/_'
import { defineLazyProperty } from '@revenge-mod/utils/object'
registerPlugin(
registerInternalPlugin(
{
id: 'revenge.api.discord',
name: 'Discord API',

View file

@ -4,7 +4,7 @@ import { exists, rm } from '@revenge-mod/modules/native/fs'
import {
InternalPluginFlags,
PluginFlags,
registerPlugin,
registerInternalPlugin,
} from '@revenge-mod/plugins/_'
import { pluginStorageDirFor } from '@revenge-mod/plugins/constants'
import { defineLazyProperty } from '@revenge-mod/utils/object'
@ -18,7 +18,7 @@ type PluginJsonStorageOptions = JsonStorageOptions & {
const storageOptions = new WeakMap<Plugin<any, any>, PluginJsonStorageOptions>()
registerPlugin(
registerInternalPlugin(
{
id: 'revenge.api.json-storage',
name: 'JSON Storage API',

View file

@ -1,7 +1,7 @@
import {
InternalPluginFlags,
PluginFlags,
registerPlugin,
registerInternalPlugin,
} from '@revenge-mod/plugins/_'
import defer * as UtilsCallback from '@revenge-mod/utils/callback'
import defer * as UtilsDiscord from '@revenge-mod/utils/discord'
@ -12,7 +12,7 @@ import defer * as UtilsProxy from '@revenge-mod/utils/proxy'
import defer * as UtilsReact from '@revenge-mod/utils/react'
import defer * as UtilsTree from '@revenge-mod/utils/tree'
registerPlugin(
registerInternalPlugin(
{
id: 'revenge.api.utils',
name: 'Utils API',

View file

@ -2,7 +2,7 @@ import { AppStartPerformance } from '@revenge-mod/discord/preinit'
import {
InternalPluginFlags,
PluginFlags,
registerPlugin,
registerInternalPlugin,
} from '@revenge-mod/plugins/_'
import { BuildEnvironment, FullVersion } from '~/constants'
@ -13,7 +13,7 @@ if (__DEV__) {
let tsPreInit: number
let tsInit: number
registerPlugin(
registerInternalPlugin(
{
id: 'revenge.logging',
name: 'Logging',

View file

@ -6,7 +6,7 @@ import {
InternalPluginFlags,
isPluginEnabledLate,
PluginFlags,
registerPlugin,
registerInternalPlugin,
} from '@revenge-mod/plugins/_'
import { noop } from '@revenge-mod/utils/callback'
import { getCurrentStack } from '@revenge-mod/utils/error'
@ -39,7 +39,7 @@ const fakeSentryCarrier = new Proxy(
const getFakeCarrier = () => fakeSentryCarrier
registerPlugin(
registerInternalPlugin(
{
id: 'revenge.no-track',
name: 'No Track',

View file

@ -7,7 +7,7 @@ import {
InternalPluginFlags,
isPluginEnabledLate,
PluginFlags,
registerPlugin,
registerInternalPlugin,
} from '@revenge-mod/plugins/_'
import pluginSettings from '../settings'
import * as dt from './devtools'
@ -37,14 +37,14 @@ const defaultStorage: Storage = {
}
// TODO(PalmDevs): only register in development builds once updates can be made automatic
registerPlugin<{ jsonStorage: Storage }>(
registerInternalPlugin<{ jsonStorage: Storage }>(
{
id: 'revenge.developer-kit',
name: 'Developer Kit',
description: 'Tools assisting Revenge developers.',
author: 'Revenge',
icon: 'WrenchIcon',
dependencies: [pluginSettings],
dependencies: { [pluginSettings]: {} },
},
{
jsonStorage: {

View file

@ -29,6 +29,6 @@ export function register() {
[Setting.AssetBrowser]: AssetBrowserSetting,
[Setting.TestErrorBoundary]: TestErrorBoundarySetting,
}),
addSettingsItemToSection('REVENGE', Setting.RevengeDeveloper),
addSettingsItemToSection('REVENGE', Setting.RevengeDeveloper, -1),
)
}

View file

@ -4,12 +4,12 @@ import { instead } from '@revenge-mod/patcher'
import {
InternalPluginFlags,
PluginFlags,
registerPlugin,
registerInternalPlugin,
} from '@revenge-mod/plugins/_'
import ErrorBoundaryScreen from './components/ErrorBoundaryScreen'
import type { Component, ReactNode } from 'react'
registerPlugin(
registerInternalPlugin(
{
id: 'revenge.error-boundary',
name: 'Error Boundary',

View file

@ -0,0 +1,109 @@
import { getAssetIdByName } from '@revenge-mod/assets'
import TableRowAssetIcon from '@revenge-mod/components/TableRowAssetIcon'
import { Design } from '@revenge-mod/discord/design'
import { useState } from 'react'
import type { AssetId } from '@revenge-mod/assets/types'
const {
ActionSheet,
BottomSheetTitleHeader,
TableCheckboxRow,
TableRadioGroup,
TableRadioRow,
TableRowGroup,
TableSwitchRow,
} = Design
export const BrowseSortLabels = {
name: 'Name (A-Z)',
size: 'Size (smallest first)',
} as const
export type BrowseSortKey = keyof typeof BrowseSortLabels
const SortIcons: Record<BrowseSortKey, AssetId> = {
name: getAssetIdByName('IdIcon')!,
size: getAssetIdByName('DownloadIcon')!,
}
export interface BrowseFilterAndSortActionSheetProps {
/** Repositories that currently have entries to browse. */
repos: Array<{ url: string; name: string | null }>
/** Checked repository URLs. */
checked: string[]
setChecked: (urls: string[]) => void
sort: BrowseSortKey
setSort: (sort: BrowseSortKey) => void
reverse: boolean
setReverse: (reverse: boolean) => void
}
export default function BrowseFilterAndSortActionSheet({
repos,
checked,
setChecked,
sort,
setSort,
reverse,
setReverse,
}: BrowseFilterAndSortActionSheetProps) {
const [checked_, setChecked_] = useState(checked)
const [reverse_, setReverse_] = useState(reverse)
return (
<ActionSheet>
<BottomSheetTitleHeader title="Filter & Sort" />
<TableRowGroup title="Filter by Repositories">
{repos.map(repo => {
const isChecked = checked_.includes(repo.url)
return (
<TableCheckboxRow
key={repo.url}
label={repo.name ?? repo.url}
subLabel={repo.name ? repo.url : undefined}
value={repo.url}
checked={isChecked}
onPress={() => {
const v = isChecked
? checked_.filter(url => url !== repo.url)
: [...checked_, repo.url]
setChecked(v)
setChecked_(v)
}}
/>
)
})}
</TableRowGroup>
<TableRadioGroup
title="Sort by"
defaultValue={sort}
onChange={v => setSort(v as BrowseSortKey)}
>
{Object.entries(BrowseSortLabels).map(([key, label]) => (
<TableRadioRow
key={key}
icon={
<TableRowAssetIcon
id={SortIcons[key as BrowseSortKey]}
/>
}
label={label}
value={key}
/>
))}
</TableRadioGroup>
<TableRowGroup>
<TableSwitchRow
label="Reverse results"
value={reverse_}
onValueChange={v => {
setReverse(v)
setReverse_(v)
}}
/>
</TableRowGroup>
</ActionSheet>
)
}

View file

@ -0,0 +1,68 @@
import { getAssetIdByName } from '@revenge-mod/assets'
import { ActionSheetActionCreators } from '@revenge-mod/discord/actions'
import { Design } from '@revenge-mod/discord/design'
import { PluginInfo } from './PluginCard'
import { IdRow, RepositoryRow } from './PluginOptionsActionSheet'
const { ActionSheet, Button, Stack, TableRowGroup } = Design
const DownloadIcon = getAssetIdByName('DownloadIcon', 'png')!
export interface BrowsePluginActionSheetProps {
name: string
author: string
description: string
version: string
icon?: string
id: string
/** Display text for the Repository row, eg. `Name (url)`. */
repositoryText: string
onInstall: () => void
sheetKey: string
}
/**
* Sheet for a plugin that isn't installed yet, opened from the Browse screen.
*/
export default function BrowsePluginActionSheet({
name,
author,
description,
version,
icon,
id,
repositoryText,
onInstall,
sheetKey,
}: BrowsePluginActionSheetProps) {
return (
<ActionSheet>
<Stack spacing={24} style={{ paddingTop: 8 }}>
<PluginInfo
name={name}
author={author}
version={version}
description={description}
icon={icon}
actions={
<Button
size="sm"
text="Install"
icon={DownloadIcon}
onPress={() => {
ActionSheetActionCreators.hideActionSheet(
sheetKey,
)
onInstall()
}}
/>
}
/>
<TableRowGroup title="Advanced">
<IdRow id={id} />
<RepositoryRow text={repositoryText} copyable />
</TableRowGroup>
</Stack>
</ActionSheet>
)
}

View file

@ -1,18 +1,22 @@
import { getAssetIdByName } from '@revenge-mod/assets'
import { styles } from '@revenge-mod/components/_'
import FormSwitch from '@revenge-mod/components/FormSwitch'
import { Tokens } from '@revenge-mod/discord/common'
import { Design } from '@revenge-mod/discord/design'
import {
isPluginEssential,
isPluginPendingUpdate,
isPluginStartable,
} from '@revenge-mod/plugins/_'
import { formatVersion } from '@revenge-mod/plugins/utils'
import { memo } from 'react'
import { Image, Pressable } from 'react-native'
import { Pressable } from 'react-native'
import { handleDisablePlugin, handleEnablePlugin } from '../utils/actions'
import { openPluginSettings } from '../utils/alerts'
import { showPluginOptionsActionSheet } from '../utils/sheets'
import {
showBrowsePluginActionSheet,
showPluginOptionsActionSheet,
} from '../utils/sheets'
import { PluginIcon } from './PluginIcon'
import { usePluginEnabled } from './PluginStateProvider'
import {
useClickOutsideTooltip,
@ -21,48 +25,42 @@ import {
} from './TooltipProvider'
import type { AnyPlugin, InternalPluginMeta } from '@revenge-mod/plugins/_'
const { Card, Text, Stack, IconButton, createStyles } = Design
const { Card, Text, Stack, IconButton, Button, createStyles } = Design
const PuzzlePieceIcon = getAssetIdByName('PuzzlePieceIcon', 'png')!
const SettingsIcon = getAssetIdByName('SettingsIcon', 'png')!
const MoreVerticalIcon = getAssetIdByName('MoreVerticalIcon', 'png')!
const DownloadIcon = getAssetIdByName('DownloadIcon', 'png')!
export const PLUGIN_CARD_ESTIMATED_SIZE = 116
export const PLUGIN_CARD_HALF_GUTTER = 6
export const PluginCard = memo(function PluginCard({
name,
description,
version,
author,
icon,
actions,
rowEnd,
columnEnd,
}: {
name: string
description: string
version: string
author: string
icon?: string
actions?: React.ReactNode
rowEnd?: boolean
columnEnd?: boolean
}) {
const styles_ = usePluginCardStyles()
return (
<Card
style={[
styles_.card,
styles.grow,
columnEnd && styles_.columnEnd,
rowEnd && styles_.rowEnd,
]}
>
<Card style={[styles_.card, styles.grow]}>
<PluginInfo
name={name}
description={description}
author={author}
icon={icon}
actions={actions}
version={version}
aligned
/>
</Card>
@ -73,6 +71,7 @@ export const PluginInfo = memo(function PluginInfo({
name,
description,
author,
version,
icon,
actions,
aligned,
@ -80,6 +79,7 @@ export const PluginInfo = memo(function PluginInfo({
name: string
description: string
author: string
version: string
icon?: string
actions?: React.ReactNode
aligned?: boolean
@ -97,10 +97,7 @@ export const PluginInfo = memo(function PluginInfo({
spacing={8}
style={[styles_.topContainer, styles.flex]}
>
<Image
source={icon ? getAssetIdByName(icon) : PuzzlePieceIcon}
style={styles_.icon}
/>
<PluginIcon icon={icon} />
<Text
variant="heading-lg/semibold"
textBreakStrategy="balanced"
@ -121,6 +118,7 @@ export const PluginInfo = memo(function PluginInfo({
variant="heading-md/medium"
>
by {author}
{version ? ` \u2022 ${version}` : ''}
</Text>
<Text style={styles.grow} variant="text-md/medium">
{description}
@ -133,18 +131,14 @@ export const PluginInfo = memo(function PluginInfo({
export const InstalledPluginCard = memo(function InstalledPluginCard({
plugin,
meta,
rowEnd,
columnEnd,
}: {
plugin: AnyPlugin
meta: InternalPluginMeta
rowEnd?: boolean
columnEnd?: boolean
}) {
const enabled = usePluginEnabled(plugin)
const {
manifest: { name, description, author, icon },
manifest: { name, description, version, author, icon },
} = plugin
const essential = isPluginEssential(meta)
@ -167,10 +161,9 @@ export const InstalledPluginCard = memo(function InstalledPluginCard({
<PluginCard
name={name}
description={description}
version={formatVersion(version)}
author={author}
icon={icon}
rowEnd={rowEnd}
columnEnd={columnEnd}
actions={
<>
<IconButton
@ -231,24 +224,73 @@ export const InstalledPluginCard = memo(function InstalledPluginCard({
)
})
/**
* Card for a plugin that isn't installed yet, shown on the Browse screen.
* No switch or settings button, just a more menu and a small Install button.
*/
export const BrowsePluginCard = memo(function BrowsePluginCard({
name,
description,
version,
author,
icon,
id,
repositoryText,
onInstall,
}: {
name: string
description: string
version: string
author: string
icon?: string
id: string
repositoryText: string
onInstall: () => void
}) {
return (
<PluginCard
name={name}
description={description}
version={version}
author={author}
icon={icon}
actions={
<>
<IconButton
size="sm"
variant="secondary"
icon={MoreVerticalIcon}
onPress={() => {
showBrowsePluginActionSheet({
name,
author,
description,
version,
icon,
id,
repositoryText,
onInstall,
})
}}
/>
<Button
size="sm"
text="Install"
icon={DownloadIcon}
onPress={onInstall}
/>
</>
}
/>
)
})
const usePluginCardStyles = createStyles({
icon: {
width: 20,
height: 20,
tintColor: Tokens.default.colors.TEXT_DEFAULT,
},
card: {
paddingVertical: 12,
paddingHorizontal: 12,
gap: 4,
marginBottom: 12,
marginRight: 12,
},
rowEnd: {
marginBottom: 0,
},
columnEnd: {
marginRight: 0,
margin: PLUGIN_CARD_HALF_GUTTER,
},
topContainer: {
alignItems: 'center',

View file

@ -1,4 +1,5 @@
import { Design } from '@revenge-mod/discord/design'
import { useState } from 'react'
import { PixelRatio, useWindowDimensions, View } from 'react-native'
import { PLUGIN_CARD_ESTIMATED_SIZE } from './PluginCard'
import { PluginFlashList } from './PluginList'
@ -17,6 +18,9 @@ export default function PluginHasDependenciesAlert({
}) {
const { height: windowHeight } = useWindowDimensions()
const maxHeight = PixelRatio.get() * windowHeight * 0.35 - 64
const [height, setHeight] = useState(
PLUGIN_CARD_ESTIMATED_SIZE * dependencies.length,
)
return (
<AlertModal
@ -35,14 +39,14 @@ export default function PluginHasDependenciesAlert({
extraContent={
<View
style={{
height: Math.min(
PLUGIN_CARD_ESTIMATED_SIZE * dependencies.length,
maxHeight,
),
height,
maxHeight,
}}
>
<PluginFlashList plugins={dependencies} />
<PluginFlashList
plugins={dependencies}
onContentSizeChange={(_, h) => setHeight(h)}
/>
</View>
}
actions={

View file

@ -1,4 +1,5 @@
import { Design } from '@revenge-mod/discord/design'
import { useState } from 'react'
import { PixelRatio, useWindowDimensions, View } from 'react-native'
import { PLUGIN_CARD_ESTIMATED_SIZE } from './PluginCard'
import { PluginFlashList } from './PluginList'
@ -17,6 +18,9 @@ export default function PluginHasDependentsAlert({
}) {
const { height: windowHeight } = useWindowDimensions()
const maxHeight = PixelRatio.get() * windowHeight * 0.35 - 64
const [height, setHeight] = useState(
PLUGIN_CARD_ESTIMATED_SIZE * dependents.length,
)
return (
<AlertModal
@ -35,14 +39,14 @@ export default function PluginHasDependentsAlert({
extraContent={
<View
style={{
height: Math.min(
PLUGIN_CARD_ESTIMATED_SIZE * dependents.length,
maxHeight,
),
height,
maxHeight,
}}
>
<PluginFlashList plugins={dependents} />
<PluginFlashList
plugins={dependents}
onContentSizeChange={(_, h) => setHeight(h)}
/>
</View>
}
actions={

View file

@ -0,0 +1,39 @@
import { getAssetIdByName } from '@revenge-mod/assets'
import { Tokens } from '@revenge-mod/discord/common'
import { Design } from '@revenge-mod/discord/design'
import { Image } from 'react-native'
const PuzzlePieceIcon = getAssetIdByName('PuzzlePieceIcon', 'png')!
export function PluginIcon({
icon,
size = 20,
}: {
icon?: string
dataUrl?: boolean
size?: number
}) {
const styles = usePluginIconStyles()
// Icons are either a packaged asset name or an inline data: URL, never fetched.
// Asset icons are monochrome and get tinted, data: URLs render as-is.
const dataUrl = icon?.startsWith('data:')
return (
<Image
source={
dataUrl
? { uri: icon }
: icon
? (getAssetIdByName(icon) ?? PuzzlePieceIcon)
: PuzzlePieceIcon
}
style={[!dataUrl && styles.icon, { width: size, height: size }]}
/>
)
}
const usePluginIconStyles = Design.createStyles({
icon: {
tintColor: Tokens.default.colors.TEXT_DEFAULT,
},
})

View file

@ -0,0 +1,49 @@
import { Design } from '@revenge-mod/discord/design'
import { confirmInstall } from '@revenge-mod/plugins/_'
import type { PluginInstallReadyEvent } from '@revenge-mod/plugins/_'
const { AlertModal, AlertActionButton, Text } = Design
export default function PluginInstallConfirmAlert({
prompt,
}: {
prompt: PluginInstallReadyEvent
}) {
const { manifest } = prompt
return (
<AlertModal
title={`Install ${manifest.name}?`}
content={`${manifest.id} v${manifest.version}${manifest.author ? ` by ${manifest.author}` : ''}`}
extraContent={
prompt.replaces != null ? (
<Text
variant="text-md/medium"
color="text-feedback-critical"
>
This replaces the installed version {prompt.replaces}.
The update applies after a reload.
</Text>
) : undefined
}
actions={
<>
<AlertActionButton
text="Install"
variant="primary"
onPress={() => {
confirmInstall(prompt.token, true)
}}
/>
<AlertActionButton
text="Cancel"
variant="secondary"
onPress={() => {
confirmInstall(prompt.token, false)
}}
/>
</>
}
/>
)
}

View file

@ -0,0 +1,35 @@
import { Design } from '@revenge-mod/discord/design'
import { Clipboard } from '@revenge-mod/externals/react-native-clipboard'
import { formatPluginError } from '@revenge-mod/plugins/_'
import type { PluginError } from '@revenge-mod/plugins/_'
const { AlertModal, AlertActionButton, Text } = Design
export default function PluginInstallFailedAlert({
error,
}: {
error: PluginError
}) {
return (
<AlertModal
title="Plugin install failed"
content={
<Text variant="text-md/medium" color="text-feedback-critical">
{error.message}
</Text>
}
actions={
<>
<AlertActionButton
text="Copy details"
variant="secondary"
onPress={() => {
Clipboard.setString(formatPluginError(error))
}}
/>
<AlertActionButton text="Got it" />
</>
}
/>
)
}

View file

@ -0,0 +1,165 @@
import { styles } from '@revenge-mod/components/_'
import { Design } from '@revenge-mod/discord/design'
import { formatVersion } from '@revenge-mod/plugins/utils'
import { FlashList } from '@shopify/flash-list'
import { useWindowDimensions } from 'react-native'
import {
BrowsePluginCard,
InstalledPluginCard,
PLUGIN_CARD_HALF_GUTTER,
PluginCard,
} from './PluginCard'
import { useResetTooltips } from './TooltipProvider'
import type { AnyPlugin, InternalPluginMeta } from '@revenge-mod/plugins/_'
import type { RepoPluginListing } from '@revenge-mod/plugins/_/repositories'
import type { FlashListProps } from '@shopify/flash-list'
const { Text } = Design
const gutterCompensation = { margin: -PLUGIN_CARD_HALF_GUTTER }
export function PluginFlashList({
plugins,
onContentSizeChange,
}: { plugins: AnyPlugin[] } & Pick<
FlashListProps<AnyPlugin>,
'onContentSizeChange'
>) {
const resetTooltips = useResetTooltips()
return (
<FlashList
style={gutterCompensation}
onContentSizeChange={onContentSizeChange}
data={plugins}
onScrollBeginDrag={resetTooltips}
fadingEdgeLength={plugins.length === 1 ? 0 : 16}
keyExtractor={plugin => plugin.manifest.id}
renderItem={({
item: {
manifest: { name, description, version, author, icon },
},
}) => (
<PluginCard
name={name}
description={description}
version={formatVersion(version)}
author={author}
icon={icon}
/>
)}
/>
)
}
export function InstalledPluginMasonryFlashList({
plugins,
}: {
plugins: (readonly [AnyPlugin, InternalPluginMeta])[]
}) {
const numColumns = useNumColumns()
const resetTooltips = useResetTooltips()
return (
<FlashList
masonry
style={gutterCompensation}
// FAB is 56px tall, plus 16px spacing on top and bottom
contentContainerStyle={{ paddingBottom: 56 + 2 * 16 }}
data={plugins}
onScrollBeginDrag={resetTooltips}
fadingEdgeLength={16}
keyExtractor={([plugin]) => plugin.manifest.id}
numColumns={numColumns}
ListEmptyComponent={NoPlugins}
renderItem={({ item: [plugin, meta] }) => (
<InstalledPluginCard plugin={plugin} meta={meta} />
)}
/>
)
}
function NoPlugins() {
return (
<Text variant="heading-md/medium" style={{ textAlign: 'center' }}>
No plugins found. Try changing your query or filters.
</Text>
)
}
function useNumColumns() {
const { width } = useWindowDimensions()
const actualWidth = width - styles.pagePadding.paddingHorizontal * 2
return Math.floor(actualWidth / 448) || 1
}
/**
* One plugin offered by one repository on the Browse screen.
* The same ID can appear as separate entries from different repositories.
*/
export interface BrowseEntry {
/** `repoUrl#id` */
key: string
listing: RepoPluginListing
/** For repository filtering. */
repoUrl: string
repoName: string | null
/** Display text for the Repository row/sheet, eg. `Name (url)`. */
repositoryText: string
version: string
size: number
installed?: readonly [AnyPlugin, InternalPluginMeta]
}
export function BrowsePluginMasonryFlashList({
entries,
onInstall,
}: {
entries: BrowseEntry[]
onInstall: (entry: BrowseEntry) => void
}) {
const numColumns = useNumColumns()
const resetTooltips = useResetTooltips()
return (
<FlashList
masonry
style={gutterCompensation}
contentContainerStyle={{ paddingBottom: 16 }}
data={entries}
onScrollBeginDrag={resetTooltips}
fadingEdgeLength={16}
keyExtractor={entry => entry.key}
numColumns={numColumns}
ListEmptyComponent={NoBrowsePlugins}
renderItem={({ item: entry }) => {
if (entry.installed) {
const [plugin, meta] = entry.installed
return <InstalledPluginCard plugin={plugin} meta={meta} />
}
const { listing } = entry
return (
<BrowsePluginCard
name={listing.name}
description={listing.description}
version={entry.version}
author={listing.author}
icon={listing.icon ?? undefined}
id={listing.id}
repositoryText={entry.repositoryText}
onInstall={() => onInstall(entry)}
/>
)
}}
/>
)
}
function NoBrowsePlugins() {
return (
<Text variant="heading-md/medium" style={{ textAlign: 'center' }}>
No plugins available. Add repositories to browse plugins.
</Text>
)
}

View file

@ -0,0 +1,52 @@
import { Design } from '@revenge-mod/discord/design'
import type { AnyPlugin } from '@revenge-mod/plugins/_'
const { AlertModal, AlertActionButton, Text, TableRowGroup, TableRow } = Design
export default function PluginMissingDependenciesAlert({
plugin,
dependencies,
action,
}: {
plugin: AnyPlugin
/** The missing required dependencies: id + the declared version range. */
dependencies: { id: string; range: string }[]
action: () => unknown
}) {
return (
<AlertModal
title="Plugin has missing dependencies"
content={
<Text color="text-default">
Plugin{' '}
<Text variant="text-md/semibold" color="text-default">
{plugin.manifest.name}
</Text>{' '}
requires plugins that are not installed. Install them from
your repositories to enable it.
</Text>
}
extraContent={
<TableRowGroup>
{dependencies.map(dep => (
<TableRow
key={dep.id}
label={dep.id}
subLabel={`Requires version ${dep.range}`}
/>
))}
</TableRowGroup>
}
actions={
<>
<AlertActionButton
text="Install"
variant="primary"
onPress={action}
/>
<AlertActionButton text="Cancel" variant="secondary" />
</>
}
/>
)
}

View file

@ -7,17 +7,24 @@ import {
import { Design } from '@revenge-mod/discord/design'
import { Clipboard } from '@revenge-mod/externals/react-native-clipboard'
import {
formatPluginError,
getInternalPluginMeta,
getPluginDependencies,
getPluginDependents,
InternalPluginFlags,
isPluginEssential,
isPluginInternal,
isPluginPendingUpdate,
isPluginStartable,
PluginFlags,
runPluginLate,
stopPlugin,
} from '@revenge-mod/plugins/_'
import { listRepos } from '@revenge-mod/plugins/_/repositories'
import { PluginStatus } from '@revenge-mod/plugins/constants'
import { formatVersion } from '@revenge-mod/plugins/utils'
import { lookupGeneratedIconComponent } from '@revenge-mod/utils/discord'
import { getErrorStack } from '@revenge-mod/utils/error'
import { useEffect, useState } from 'react'
import { Pressable } from 'react-native'
import { ClickOutsideProvider } from 'react-native-click-outside'
import { handleDisablePlugin, handleEnablePlugin } from '../utils/actions'
@ -26,6 +33,7 @@ import {
showPluginClearDataConfirmation,
showPluginUninstallConfirmation,
} from '../utils/alerts'
import { messageOf, showErrorToast } from '../utils/repos'
import { PluginInfo } from './PluginCard'
import { usePluginEnabled, usePluginStatus } from './PluginStateProvider'
import {
@ -43,23 +51,20 @@ export interface PluginOptionsActionSheetProps {
const { ActionSheet, IconButton, TableRowGroup, TableRow, Stack } = Design
const FileWarningIcon = getAssetIdByName('FileWarningIcon', 'png')!
const PlayIcon = getAssetIdByName('PlayIcon', 'png')!
const SettingsIcon = getAssetIdByName('SettingsIcon', 'png')!
const StopIcon = getAssetIdByName('StopIcon', 'png')!
const TrashIcon = getAssetIdByName('TrashIcon', 'png')!
export default function PluginOptionsActionSheet({
plugin,
sheetKey,
}: PluginOptionsActionSheetProps) {
const status = usePluginStatus(plugin)
const enabled = usePluginEnabled(plugin)
const meta = getInternalPluginMeta(plugin)
const { dependencies, dependents } = meta!
const essential = isPluginEssential(meta)
const pendingUpdate = isPluginPendingUpdate(plugin)
const {
manifest: { id, name, author, description, icon },
} = plugin
const errors = [...plugin.errors, ...meta.nativeErrors]
const { name, author, description, icon, version } = plugin.manifest
return (
<ActionSheet>
@ -67,6 +72,7 @@ export default function PluginOptionsActionSheet({
<PluginInfo
name={name}
author={author}
version={formatVersion(version)}
description={description}
icon={icon}
actions={
@ -94,79 +100,95 @@ export default function PluginOptionsActionSheet({
/>
</EnablePluginTooltipProvider>
</ClickOutsideProvider>
<TableRowGroup title="Status">
<TableRow
icon={
<TableRowAssetIcon name="CircleInformationIcon" />
}
label="Status (TODO)"
subLabel={bitFieldToString(PluginStatus, status)}
/>
{errors.length && (
<TableRow
variant="danger"
label="Errors"
icon={
<TableRowAssetIcon
variant="danger"
name="CircleErrorIcon"
/>
}
subLabel={`${errors.length} errors. Tap to copy.`}
onPress={() => {
Clipboard.setString(
errors.map(getErrorStack).join('\n'),
)
showCopiedToClipboardToast()
}}
/>
)}
</TableRowGroup>
<TableRowGroup title="Advanced">
<TableRow
icon={<TableRowAssetIcon name="IdIcon" />}
label="ID"
subLabel={id}
onPress={() => {
Clipboard.setString(id)
showCopiedToClipboardToast()
}}
/>
<TableRow
icon={<TableRowAssetIcon name="FlagIcon" />}
label="Flags"
subLabel={bitFieldToString(PluginFlags, meta.flags)}
/>
{meta.iflags && (
<TableRow
icon={<TableRowAssetIcon name="FlagIcon" />}
label="Internal Flags"
subLabel={bitFieldToString(
InternalPluginFlags,
meta.iflags,
)}
/>
)}
{dependencies?.length && (
<TableRow
icon={<TableRowAssetIcon name="ListBulletsIcon" />}
label="Dependencies (TODO)"
subLabel={`${name} depends on ${dependencies.length} other plugins`}
/>
)}
{dependents?.length && (
<TableRow
icon={<TableRowAssetIcon name="ListBulletsIcon" />}
label="Dependents (TODO)"
subLabel={`${dependents.length} other plugins depend on ${name}`}
/>
)}
</TableRowGroup>
<StatusSection plugin={plugin} />
<AdvancedSection plugin={plugin} />
</Stack>
</ActionSheet>
)
}
function StatusSection({ plugin }: { plugin: AnyPlugin }) {
const status = usePluginStatus(plugin)
const meta = getInternalPluginMeta(plugin)
const errors = [...plugin.errors, ...meta.nativeErrors]
return (
<TableRowGroup title="Status">
<TableRow
icon={<TableRowAssetIcon name="CircleInformationIcon" />}
label="Status (TODO)"
subLabel={bitFieldToString(PluginStatus, status)}
/>
{errors.length > 0 && (
<TableRow
variant="danger"
label="Errors"
icon={
<TableRowAssetIcon
variant="danger"
name="CircleErrorIcon"
/>
}
subLabel={`${errors.length} errors. Tap to copy.`}
onPress={() => {
Clipboard.setString(
errors.map(formatPluginError).join('\n\n'),
)
showCopiedToClipboardToast()
}}
/>
)}
</TableRowGroup>
)
}
function AdvancedSection({ plugin }: { plugin: AnyPlugin }) {
const meta = getInternalPluginMeta(plugin)
const dependents = getPluginDependents(plugin, true)
const dependencies = getPluginDependencies(plugin, false)
const repositoryText = usePluginRepositoryText(plugin)
const { id, name } = plugin.manifest
return (
<TableRowGroup title="Advanced">
<IdRow id={id} />
<RepositoryRow
text={repositoryText}
copyable={!!meta.source?.repo}
/>
<TableRow
icon={<TableRowAssetIcon name="FlagIcon" />}
label="Flags"
subLabel={bitFieldToString(PluginFlags, meta.flags)}
/>
{meta.iflags > 0 && (
<TableRow
icon={<TableRowAssetIcon name="FlagIcon" />}
label="Internal Flags"
subLabel={bitFieldToString(
InternalPluginFlags,
meta.iflags,
)}
/>
)}
{dependencies.length > 0 && (
<TableRow
icon={<TableRowAssetIcon name="ListBulletsIcon" />}
label="Dependencies (TODO)"
subLabel={`${name} depends on ${dependencies.length} other plugins`}
/>
)}
{dependents.length > 0 && (
<TableRow
icon={<TableRowAssetIcon name="ListBulletsIcon" />}
label="Dependents (TODO)"
subLabel={`${dependents.length} other plugins depend on ${name}`}
/>
)}
</TableRowGroup>
)
}
function bitFieldToString(map: Record<string, number>, bitField: number) {
return (
Object.entries(map)
@ -176,6 +198,72 @@ function bitFieldToString(map: Record<string, number>, bitField: number) {
)
}
export function IdRow({ id }: { id: string }) {
return (
<TableRow
icon={<TableRowAssetIcon name="IdIcon" />}
label="ID"
subLabel={id}
onPress={() => {
Clipboard.setString(id)
showCopiedToClipboardToast()
}}
/>
)
}
export function RepositoryRow({
text,
copyable,
}: {
text: string
copyable?: boolean
}) {
return (
<TableRow
icon={<TableRowAssetIcon name="GlobeEarthIcon" />}
label="Repository"
subLabel={text}
onPress={
copyable
? () => {
Clipboard.setString(text)
showCopiedToClipboardToast()
}
: undefined
}
/>
)
}
function usePluginRepositoryText(plugin: AnyPlugin) {
const meta = getInternalPluginMeta(plugin)
const internal = isPluginInternal(meta)
const repoUrl = meta.source?.repo ?? null
const hasUrl = !internal && repoUrl
const [repoName, setRepoName] = useState<string | null>(null)
useEffect(() => {
if (!hasUrl) return
listRepos().then(
repos => {
const repo = repos.find(r => r.url === repoUrl)
if (repo?.name) setRepoName(repo.name)
},
() => {},
)
}, [repoUrl, hasUrl])
return hasUrl
? repoName
? `${repoName} (${repoUrl})`
: repoUrl
: internal
? 'Built-in'
: 'Sideloaded'
}
function PluginActions({
plugin,
closeSheet,
@ -187,6 +275,9 @@ function PluginActions({
const settingsRef = useClickOutsideTooltip(useEnablePluginTooltip, () => {})
const meta = getInternalPluginMeta(plugin)
const startable = isPluginStartable(plugin)
const enabled = usePluginEnabled(plugin)
// Any lifecycle progress counts as running, stop waits for in-flight lifecycles
const running = Boolean(usePluginStatus(plugin))
return (
<Stack
@ -194,6 +285,23 @@ function PluginActions({
justify="space-around"
style={{ paddingHorizontal: 8, paddingVertical: 16 }}
>
{enabled && !isPluginEssential(meta) && (
<IconButton
variant="secondary"
size="lg"
icon={running ? StopIcon : PlayIcon}
label={running ? 'Stop' : 'Start'}
disabled={!running && !startable}
onPress={async () => {
try {
if (running) await stopPlugin(plugin)
else await runPluginLate(plugin)
} catch (e) {
showErrorToast(messageOf(e))
}
}}
/>
)}
<IconButton
variant="secondary"
size="lg"
@ -245,7 +353,7 @@ function PluginActions({
const CopyIcon = lookupGeneratedIconComponent('CopyIcon')!
function showCopiedToClipboardToast() {
export function showCopiedToClipboardToast() {
ToastActionCreators.open({
key: 'REVENGE_PLUGIN_SETTINGS_COPIED',
content: 'Copied to clipboard',

View file

@ -64,6 +64,11 @@ export default function PluginStatesProvider({
pEmitter.on('disabled', handleDisabled)
return () => {
pEmitter.off('preInited', handleStatusChange)
pEmitter.off('inited', handleStatusChange)
pEmitter.off('started', handleStatusChange)
pEmitter.off('stopped', handleStatusChange)
pEmitter.off('enabled', handleEnabled)
pEmitter.off('disabled', handleDisabled)
}

View file

@ -0,0 +1,11 @@
export const Setting = {
RevengePlugins: 'RevengePlugins',
RevengePluginsAdvanced: 'RevengePluginsAdvanced',
RevengePluginsBrowse: 'RevengePluginsBrowse',
} as const
export const RouteNames = {
[Setting.RevengePlugins]: 'Revenge Plugins',
[Setting.RevengePluginsAdvanced]: 'Revenge Plugins Advanced',
[Setting.RevengePluginsBrowse]: 'Browse Plugins',
} as const

View file

@ -0,0 +1,17 @@
import TableRowAssetIcon from '@revenge-mod/components/TableRowAssetIcon'
import { RouteNames, Setting } from '../constants'
import defer * as RevengePluginsAdvancedSettingScreen from '../screens/RevengePluginsAdvancedSettingScreen'
import type { SettingsItem } from '@revenge-mod/discord/modules/settings'
const RevengePluginsAdvancedSetting: SettingsItem = {
parent: null,
type: 'route',
IconComponent: () => <TableRowAssetIcon name="SettingsIcon" />,
useTitle: () => 'Advanced',
screen: {
route: RouteNames[Setting.RevengePluginsAdvanced],
getComponent: () => RevengePluginsAdvancedSettingScreen.default,
},
}
export default RevengePluginsAdvancedSetting

View file

@ -0,0 +1,17 @@
import TableRowAssetIcon from '@revenge-mod/components/TableRowAssetIcon'
import { RouteNames, Setting } from '../constants'
import defer * as RevengePluginsBrowseSettingScreen from '../screens/RevengePluginsBrowseSettingScreen'
import type { SettingsItem } from '@revenge-mod/discord/modules/settings'
const RevengePluginsBrowseSetting: SettingsItem = {
parent: null,
type: 'route',
IconComponent: () => <TableRowAssetIcon name="PlusLargeIcon" />,
useTitle: () => 'Browse Plugins',
screen: {
route: RouteNames[Setting.RevengePluginsBrowse],
getComponent: () => RevengePluginsBrowseSettingScreen.default,
},
}
export default RevengePluginsBrowseSetting

View file

@ -0,0 +1,104 @@
import { ToastActionCreators } from '@revenge-mod/discord/actions'
import {
onSettingsModulesLoaded,
refreshSettingsNavigator,
refreshSettingsOverviewScreen,
} from '@revenge-mod/discord/modules/settings'
import {
InternalPluginFlags,
isPluginEnabledLate,
PluginFlags,
pEmitter,
registerInternalPlugin,
} from '@revenge-mod/plugins/_'
import {
refreshAllRepos,
updateAllPlugins,
} from '@revenge-mod/plugins/_/repositories'
import { lookupGeneratedIconComponent } from '@revenge-mod/utils/discord'
import pluginSettings from '../settings'
import type { PluginApi } from '@revenge-mod/plugins/types'
export interface Storage {
autoUpdate: boolean
lastUpdateCheck?: number
}
const OUTDATED_THRESHOLD = 24 * 60 * 60 * 1000 // 1 day
const AUTO_UPDATE_CHECK_DELAY = 10_000
const CircleXIconComponent = lookupGeneratedIconComponent(
'CircleXIcon',
'CircleXIcon-primary',
'CircleXIcon-secondary',
)!
registerInternalPlugin<{ jsonStorage: Storage }>(
{
id: 'revenge.settings.plugins',
name: 'Plugin Settings',
description: 'Plugin management UI for Revenge.',
author: 'Revenge',
icon: 'PuzzlePieceIcon',
dependencies: { [pluginSettings]: {} },
},
{
jsonStorage: {
load: true,
default: {
autoUpdate: true,
},
},
async start(api_) {
api = api_
// @as-require
import('./plugins')
onSettingsModulesLoaded(() => {
// @as-require
import('./register')
})
if (isPluginEnabledLate(api_.plugin)) {
refreshSettingsOverviewScreen()
refreshSettingsNavigator()
}
pEmitter.addListener('allReposRefreshed', () => {
api.jsonStorage.set({ lastUpdateCheck: Date.now() })
})
const settings = await api.jsonStorage.get()
if (!settings.autoUpdate) return
if (
settings.lastUpdateCheck !== undefined &&
Date.now() - settings.lastUpdateCheck <= OUTDATED_THRESHOLD
)
return
setTimeout(async () => {
try {
await refreshAllRepos()
await updateAllPlugins()
} catch (e) {
ToastActionCreators.open({
key: 'PLUGIN_UPDATE_CHECK_FAILED',
content: 'Failed to check for plugin updates',
IconComponent: CircleXIconComponent,
})
api.logger.warn('Failed to check for plugin updates', e)
}
}, AUTO_UPDATE_CHECK_DELAY)
},
stop({ cleanup }) {
cleanup(refreshSettingsOverviewScreen, refreshSettingsNavigator)
},
},
PluginFlags.Enabled,
InternalPluginFlags.Internal | InternalPluginFlags.Essential,
)
// Exposed for the Advanced settings screen (auto-update toggle)
export let api: PluginApi<{ jsonStorage: Storage }>

View file

@ -16,11 +16,12 @@ import {
} from '@revenge-mod/plugins/_'
import { lookupGeneratedIconComponent } from '@revenge-mod/utils/discord'
import { useLayoutEffect } from 'react'
import { Setting } from '../settings/constants'
import defer * as NavigatorHeaderWithIcon from './components/NavigatorHeaderWithIcon'
import PluginInstallConfirmAlert from './components/PluginInstallConfirmAlert'
import PluginInstallFailedAlert from './components/PluginInstallFailedAlert'
import PluginsFailedToStartAlert from './components/PluginsFailedToStartAlert'
import PluginsRequireReloadAlert from './components/PluginsRequireReloadAlert'
import { Setting } from './constants'
import type { StackScreenProps } from '@react-navigation/stack'
import type { ReactNavigationParamList } from '@revenge-mod/externals/react-navigation'
import type { PluginApi } from '@revenge-mod/plugins/types'
@ -124,6 +125,15 @@ pEmitter.on('stopped', plugin => {
const pluginInstallToastKeyFor = (id: string) => `REVENGE_PLUGIN_INSTALL:${id}`
const PluginInstallFailedAlertKey = 'plugin-install-failed'
const PluginInstallConfirmAlertKey = 'plugin-install-confirm'
pEmitter.on('installReady', prompt => {
AlertActionCreators.dismissAlert(PluginInstallConfirmAlertKey)
AlertActionCreators.openAlert(
PluginInstallConfirmAlertKey,
<PluginInstallConfirmAlert prompt={prompt} />,
)
})
pEmitter.on('install', result => {
if (result.error !== false) {
@ -135,6 +145,15 @@ pEmitter.on('install', result => {
return
}
if (result.pending) {
// Applied on disk only, the new version loads at next reload
showInstallToast(
`Downloaded ${pList.get(result.id)?.manifest.name || result.id} ${result.version}, reload to apply`,
result.id,
)
return
}
showInstallToast(
result.updated
? `Updated ${result.manifest.name}`

View file

@ -0,0 +1,17 @@
import {
addSettingsItemToSection,
registerSettingsItems,
} from '@revenge-mod/discord/modules/settings'
import { Setting } from './constants'
import RevengePluginsAdvancedSetting from './definitions/RevengePluginsAdvancedSetting'
import RevengePluginsBrowseSetting from './definitions/RevengePluginsBrowseSetting'
import RevengePluginsSetting from './definitions/RevengePluginsSetting'
registerSettingsItems({
[Setting.RevengePlugins]: RevengePluginsSetting,
[Setting.RevengePluginsAdvanced]: RevengePluginsAdvancedSetting,
[Setting.RevengePluginsBrowse]: RevengePluginsBrowseSetting,
})
// The settings plugin registers this section, and it always starts before us (we depend on it).
addSettingsItemToSection('REVENGE', Setting.RevengePlugins, 1)

View file

@ -0,0 +1,368 @@
import { getAssetIdByName } from '@revenge-mod/assets'
import { FormSwitch } from '@revenge-mod/components'
import { styles } from '@revenge-mod/components/_'
import Page from '@revenge-mod/components/Page'
import TableRowAssetIcon from '@revenge-mod/components/TableRowAssetIcon'
import { Design } from '@revenge-mod/discord/design'
import { callNativeMethod } from '@revenge-mod/modules/native'
import { resyncPluginSources } from '@revenge-mod/plugins/_'
import {
listAllUpdates,
listRepos,
refreshAllRepos,
refreshRepo,
repoEvents,
setRepos,
updateAllPlugins,
} from '@revenge-mod/plugins/_/repositories'
import { lookupGeneratedIconComponent } from '@revenge-mod/utils/discord'
import { useCallback, useEffect, useState } from 'react'
import { ScrollView, View } from 'react-native'
import { api } from '..'
import { formatBytes, messageOf, showErrorToast } from '../utils/repos'
import type {
DownloadProgressEvent,
Repo,
RepoConfigEntry,
RepoStateEvent,
RepoUpdate,
} from '@revenge-mod/plugins/_/repositories'
const {
Button,
ContextMenu,
IconButton,
Stack,
TableRow,
TableRowGroup,
TableSwitchRow,
TextInput,
} = Design
const MoreIcon = getAssetIdByName('MoreVerticalIcon')!
const UpIconComponent = lookupGeneratedIconComponent('ArrowSmallUpIcon')!
const DownIconComponent = lookupGeneratedIconComponent('ArrowSmallDownIcon')!
const TrashIconComponent = lookupGeneratedIconComponent('TrashIcon')!
function toConfig(list: Repo[]): RepoConfigEntry[] {
return list.map(({ url, enabled }) => ({ url, enabled }))
}
function repoSubLabel(repo: Repo, state?: RepoStateEvent['state']) {
if (state === 'refreshing') return `${repo.url} (refreshing...)`
if (state === 'error') return `${repo.url} (refresh failed)`
return repo.url
}
interface UserRepoRowProps {
repo: Repo
state?: RepoStateEvent['state']
onMove: (repo: Repo, delta: number) => void
onRemove: (repo: Repo) => void
onToggle: (repo: Repo, enabled: boolean) => void
}
function UserRepoRow({
repo,
state,
onMove,
onRemove,
onToggle,
}: UserRepoRowProps) {
const menuItems = [
[
{
label: 'Move up',
IconComponent: UpIconComponent,
action: () => onMove(repo, -1),
},
{
label: 'Move down',
IconComponent: DownIconComponent,
action: () => onMove(repo, 1),
},
],
[
{
label: 'Refresh',
IconComponent: lookupGeneratedIconComponent('RefreshIcon')!,
action: () =>
refreshRepo(repo.url).catch(e => {
showErrorToast(messageOf(e))
}),
},
{
label: 'Delete',
IconComponent: TrashIconComponent,
variant: 'destructive' as const,
action: () => onRemove(repo),
},
],
]
return (
<TableRow
label={repo.name ?? repo.url}
subLabel={repoSubLabel(repo, state)}
trailing={
<Stack direction="horizontal" spacing={8}>
<ContextMenu
items={menuItems}
title={repo.name ?? repo.url}
>
{props => (
<IconButton
{...props}
icon={MoreIcon}
size="sm"
variant="secondary"
/>
)}
</ContextMenu>
<FormSwitch
value={repo.enabled}
onValueChange={enabled => onToggle(repo, enabled)}
/>
</Stack>
}
/>
)
}
export default function RevengePluginsAdvancedSettingScreen() {
// Internal repositories are native-managed and not part of the config.
const [repos, setReposState] = useState<Repo[]>([])
const [url, setUrl] = useState('')
const [busy, setBusy] = useState(false)
const [updates, setUpdates] = useState<RepoUpdate[] | null>(null)
const [repoStates, setRepoStates] = useState<
Record<string, RepoStateEvent['state']>
>({})
const [progress, setProgress] = useState<DownloadProgressEvent | null>(null)
useEffect(() => {
const onRepoState = (event: RepoStateEvent) => {
setRepoStates(states => ({ ...states, [event.url]: event.state }))
}
const onProgress = (event: DownloadProgressEvent) => {
setProgress(event.received >= event.total ? null : event)
}
repoEvents.on('repoState', onRepoState)
repoEvents.on('downloadProgress', onProgress)
return () => {
repoEvents.off('repoState', onRepoState)
repoEvents.off('downloadProgress', onProgress)
}
}, [])
const settings = api.jsonStorage.use()
const refresh = useCallback(() => {
listRepos().then(setReposState, e => showErrorToast(messageOf(e)))
}, [])
useEffect(refresh, [refresh])
const commit = useCallback(
async (config: RepoConfigEntry[]) => {
try {
await setRepos(config)
} catch (e) {
showErrorToast(messageOf(e))
}
refresh()
},
[refresh],
)
const userRepos = repos.filter(repo => !repo.internal)
const move = useCallback(
(repo: Repo, delta: number) => {
const config = toConfig(userRepos)
const from = config.findIndex(entry => entry.url === repo.url)
const to = from + delta
if (from < 0 || to < 0 || to >= config.length) return
;[config[from], config[to]] = [config[to], config[from]]
commit(config)
},
[commit, userRepos],
)
const addRepo = useCallback(async () => {
const added = url.trim()
await commit([...toConfig(userRepos), { url: added, enabled: true }])
setUrl('')
try {
// Refresh the new repository right away so its plugins show up
await refreshRepo(added)
} catch (e) {
showErrorToast(messageOf(e))
}
refresh()
}, [commit, refresh, url, userRepos])
const removeRepo = useCallback(
async (repo: Repo) => {
await commit(toConfig(userRepos.filter(r => r.url !== repo.url)))
// Plugins installed from the removed repository turn Sideloaded
resyncPluginSources().catch(() => {})
},
[commit, userRepos],
)
const toggleRepo = useCallback(
(repo: Repo, enabled: boolean) => {
commit(
toConfig(userRepos).map(entry =>
entry.url === repo.url ? { ...entry, enabled } : entry,
),
)
},
[commit, userRepos],
)
const checkForUpdates = useCallback(async () => {
setBusy(true)
try {
const { errors } = await refreshAllRepos()
if (errors.length)
showErrorToast(
errors.map(e => `${e.url}: ${e.error}`).join('\n'),
)
const result = await listAllUpdates()
setUpdates(result.updates)
} finally {
setBusy(false)
refresh()
}
}, [refresh])
const updateAll = useCallback(async () => {
setBusy(true)
try {
const { errors } = await updateAllPlugins()
if (errors.length)
showErrorToast(
errors.map(e => `${e.id}: ${e.error}`).join('\n'),
)
setUpdates(null)
} finally {
setBusy(false)
refresh()
}
}, [refresh])
const lastCheckedSubLabel =
updates !== null && !updates.length
? 'All plugins up to date!'
: settings?.lastUpdateCheck &&
`Last checked: ${new Date(settings.lastUpdateCheck).toLocaleString()}`
return (
<Page spacing={0}>
<ScrollView keyboardShouldPersistTaps="handled" style={styles.flex}>
<Stack spacing={16} style={{ paddingBottom: 32 }}>
<Stack direction="horizontal" spacing={8}>
<View style={styles.grow}>
<TextInput
label="Repository URL"
onChange={setUrl}
placeholder="https://example.com/plugins"
size="md"
value={url}
/>
</View>
</Stack>
<Button
disabled={!url.trim()}
onPress={addRepo}
text="Add repository"
variant="primary"
/>
<TableRowGroup title="Repositories">
{repos.map(repo =>
repo.internal ? (
<TableRow
icon={<TableRowAssetIcon name="LockIcon" />}
key={repo.url}
label={repo.name ?? repo.url}
subLabel={repo.description ?? repo.url}
/>
) : (
<UserRepoRow
key={repo.url}
repo={repo}
state={repoStates[repo.url]}
onMove={move}
onRemove={removeRepo}
onToggle={toggleRepo}
/>
),
)}
</TableRowGroup>
<TableRowGroup title="Updates">
<TableSwitchRow
label="Update plugins automatically"
subLabel="Check repositories and apply plugin updates after startup."
onValueChange={autoUpdate => {
api.jsonStorage.set({ autoUpdate })
}}
value={settings?.autoUpdate ?? true}
/>
<TableRow
icon={<TableRowAssetIcon name="RefreshIcon" />}
label="Check for updates"
subLabel={lastCheckedSubLabel}
disabled={busy || !userRepos.length}
onPress={checkForUpdates}
/>
{progress ? (
<TableRow
icon={<TableRowAssetIcon name="<EMPTY>" />}
label={`Downloading ${progress.id} ${progress.version}`}
subLabel={`${formatBytes(progress.received)} / ${formatBytes(progress.total)} (${progress.index} of ${progress.count})`}
/>
) : null}
{updates?.length ? (
<>
{updates.map(update => (
<TableRow
icon={
<TableRowAssetIcon name="DownloadIcon" />
}
key={update.id}
label={update.id}
subLabel={`${update.installed} → ${update.available} (${update.channel})`}
/>
))}
<TableRow
icon={
<TableRowAssetIcon name="DownloadIcon" />
}
label="Update all"
onPress={updateAll}
/>
</>
) : null}
</TableRowGroup>
<TableRowGroup title="Advanced">
<TableRow
icon={<TableRowAssetIcon name="DownloadIcon" />}
label="Install from file"
onPress={() =>
callNativeMethod(
'revenge.plugins.installFile',
[],
)
}
/>
</TableRowGroup>
</Stack>
</ScrollView>
</Page>
)
}

View file

@ -0,0 +1,229 @@
import { getAssetIdByName } from '@revenge-mod/assets'
import { styles } from '@revenge-mod/components/_'
import Page from '@revenge-mod/components/Page'
import SearchInput from '@revenge-mod/components/SearchInput'
import { ActionSheetActionCreators } from '@revenge-mod/discord/actions'
import { Design } from '@revenge-mod/discord/design'
import { getInternalPluginMeta, pEmitter, pList } from '@revenge-mod/plugins/_'
import {
listRepoPlugins,
listRepos,
refreshAllRepos,
} from '@revenge-mod/plugins/_/repositories'
import { debounce } from '@revenge-mod/utils/callback'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { View } from 'react-native'
import { ClickOutsideProvider } from 'react-native-click-outside'
import { BrowsePluginMasonryFlashList } from '../components/PluginList'
import PluginStatesProvider from '../components/PluginStateProvider'
import {
EnablePluginTooltipProvider,
EssentialPluginTooltipProvider,
} from '../components/TooltipProvider'
import { runInstallFlow } from '../utils/repos'
import type { RepoPluginListing } from '@revenge-mod/plugins/_/repositories'
import type { BrowseSortKey } from '../components/BrowseFilterAndSortActionSheet'
import type { BrowseEntry } from '../components/PluginList'
const { Stack, IconButton, LayerScope } = Design
const FiltersHorizontalIcon = getAssetIdByName('FiltersHorizontalIcon', 'png')!
const SearchDebounceTime = 100
export default function RevengePluginsBrowseSettingScreen() {
return (
<LayerScope>
<ClickOutsideProvider>
<PluginStatesProvider>
<Page spacing={16}>
<EssentialPluginTooltipProvider>
<EnablePluginTooltipProvider>
<Screen />
</EnablePluginTooltipProvider>
</EssentialPluginTooltipProvider>
</Page>
</PluginStatesProvider>
</ClickOutsideProvider>
</LayerScope>
)
}
// TODO: Let the user pick a release channel
/** The version a listing displays: its `latest` channel, else the first channel. */
function displayVersionOf(listing: RepoPluginListing) {
return listing.channels.latest ?? Object.values(listing.channels)[0] ?? ''
}
function compareNames(a: BrowseEntry, b: BrowseEntry) {
return a.listing.name.localeCompare(b.listing.name, undefined, {
sensitivity: 'base',
})
}
const Sorts: Record<BrowseSortKey, (a: BrowseEntry, b: BrowseEntry) => number> =
{
name: compareNames,
size: (a, b) => a.size - b.size || compareNames(a, b),
}
function Screen() {
const [entries, setEntries] = useState<BrowseEntry[]>([])
const [search, setSearch] = useState('')
const debouncedSetSearch = useCallback(
debounce(setSearch, SearchDebounceTime),
[],
)
// Unchecked repository URLs, so repos that appear later default to checked
const [excluded, setExcluded] = useState<string[]>([])
const [sort, setSort] = useState<BrowseSortKey>('name')
const [reverse, setReverse] = useState(false)
const load = useCallback(async () => {
const repos = await listRepos()
const all: BrowseEntry[] = []
await Promise.all(
repos
.filter(repo => !repo.internal && repo.enabled)
.map(repo =>
listRepoPlugins(repo.url).then(
listings => {
const repositoryText = repo.name
? `${repo.name} (${repo.url})`
: repo.url
for (const listing of listings) {
const plugin = pList.get(listing.id)
const displayVersion = displayVersionOf(listing)
all.push({
key: `${repo.url}#${listing.id}`,
listing,
repoUrl: repo.url,
repoName: repo.name ?? null,
repositoryText,
version: displayVersion,
size:
listing.versions[displayVersion]
?.size ?? 0,
installed: plugin
? ([
plugin,
getInternalPluginMeta(plugin),
] as const)
: undefined,
})
}
},
// No cached index yet is normal, ignore
() => {},
),
),
)
setEntries(all)
}, [])
useEffect(() => {
// Show cached indexes right away, then refresh everything
load()
refreshAllRepos().then(load, () => {})
}, [load])
// Fresh installs register live, re-mark entries as installed when they do
useEffect(() => {
const handleUpdate = () => load()
pEmitter.on('register', handleUpdate)
pEmitter.on('unregister', handleUpdate)
return () => {
pEmitter.off('register', handleUpdate)
pEmitter.off('unregister', handleUpdate)
}
}, [load])
const install = useCallback(
async (entry: BrowseEntry) => {
await runInstallFlow(entry.listing.id)
load()
},
[load],
)
// Repositories that currently have entries, for the filter sheet
const repos = useMemo(() => {
const seen = new Map<string, string | null>()
for (const entry of entries)
if (!seen.has(entry.repoUrl))
seen.set(entry.repoUrl, entry.repoName)
return [...seen].map(([url, name]) => ({ url, name }))
}, [entries])
const visible = useMemo(() => {
const query = search.toLowerCase()
const result = entries.filter(entry => {
if (excluded.includes(entry.repoUrl)) return false
if (!query) return true
const { name, description, author, id } = entry.listing
return (
name.toLowerCase().includes(query) ||
description.toLowerCase().includes(query) ||
author.toLowerCase().includes(query) ||
id.toLowerCase().includes(query)
)
})
result.sort(Sorts[sort])
if (reverse) result.reverse()
return result
}, [entries, excluded, search, sort, reverse])
return (
<>
<Stack direction="horizontal">
<View style={styles.grow}>
<SearchInput onChange={debouncedSetSearch} size="md" />
</View>
<IconButton
icon={FiltersHorizontalIcon}
variant="tertiary"
onPress={() =>
ActionSheetActionCreators.openLazy(
import(
'../components/BrowseFilterAndSortActionSheet'
),
'browse-filter-and-sort',
{
repos,
checked: repos
.map(repo => repo.url)
.filter(url => !excluded.includes(url)),
setChecked: (urls: string[]) =>
setExcluded(
repos
.map(repo => repo.url)
.filter(url => !urls.includes(url)),
),
sort,
setSort,
reverse,
setReverse,
},
)
}
/>
</Stack>
<BrowsePluginMasonryFlashList
entries={visible}
onInstall={install}
/>
</>
)
}

View file

@ -5,13 +5,14 @@ import Page from '@revenge-mod/components/Page'
import SearchInput from '@revenge-mod/components/SearchInput'
import { ActionSheetActionCreators } from '@revenge-mod/discord/actions'
import { Design } from '@revenge-mod/discord/design'
import { callNativeMethod } from '@revenge-mod/modules/native'
import {
getInternalPluginMeta,
InternalPluginFlags,
isPluginEnabled,
isPluginEssential,
isPluginInternal,
isPluginPendingReload,
isPluginPendingUpdate,
pEmitter,
pList,
} from '@revenge-mod/plugins/_'
@ -32,15 +33,16 @@ import {
EnablePluginTooltipProvider,
EssentialPluginTooltipProvider,
} from '../components/TooltipProvider'
import type { RouteProp } from '@react-navigation/core'
import { RouteNames, Setting } from '../constants'
import type { NavigationProp, RouteProp } from '@react-navigation/core'
import type { ReactNavigationParamList } from '@revenge-mod/externals/react-navigation'
import type { FilterAndSortActionSheetProps } from '../components/FilterAndSortActionSheet'
import type { RouteNames, Setting } from '../constants'
const { Stack, IconButton, LayerScope, ContextMenu } = Design
const { Stack, IconButton, FloatingActionButton, LayerScope } = Design
const FiltersHorizontalIcon = getAssetIdByName('FiltersHorizontalIcon', 'png')!
const MoreHorizontalIcon = getAssetIdByName('MoreHorizontalIcon')!
const SettingsIcon = getAssetIdByName('SettingsIcon')!
const PlusLargeIcon = getAssetIdByName('PlusLargeIcon')!
export default function RevengePluginsSettingScreen() {
return (
@ -75,6 +77,14 @@ const Filters: FilterAndSortActionSheetProps['filters'] = {
icon: getAssetIdByName('CircleErrorIcon')!,
filter: plugin => plugin.errors.length > 0,
},
'Pending Reload': {
icon: getAssetIdByName('RetryIcon')!,
filter: plugin => isPluginPendingReload(plugin),
},
'Pending Update': {
icon: getAssetIdByName('RefreshIcon')!,
filter: plugin => isPluginPendingUpdate(plugin),
},
Internal: {
icon: RevengeIcon,
desc: 'Included with Revenge.',
@ -110,27 +120,31 @@ const Sorts = {
],
} satisfies FilterAndSortActionSheetProps['sorts']
function HeaderContextMenu() {
function HeaderButton() {
const navigation = useNavigation<NavigationProp<any>>()
return (
<ContextMenu
title="More options"
align="below"
items={[
{
label: 'Install from file',
action: () =>
callNativeMethod('revenge.plugins.install', []),
},
]}
>
{props => (
<IconButton
{...props}
icon={MoreHorizontalIcon}
variant="tertiary"
/>
)}
</ContextMenu>
<IconButton
icon={SettingsIcon}
onPress={() =>
navigation.navigate(RouteNames[Setting.RevengePluginsAdvanced])
}
variant="tertiary"
/>
)
}
function BrowseFloatingActionButton() {
const navigation = useNavigation<NavigationProp<any>>()
return (
<FloatingActionButton
icon={PlusLargeIcon}
accessibilityLabel="Browse plugins"
onPress={() =>
navigation.navigate(RouteNames[Setting.RevengePluginsBrowse])
}
/>
)
}
@ -158,7 +172,7 @@ function Screen() {
useLayoutEffect(() => {
navigation.setOptions({
headerRight: () => <HeaderContextMenu />,
headerRight: () => <HeaderButton />,
})
}, [navigation])
@ -242,6 +256,7 @@ function Screen() {
/>
</Stack>
<InstalledPluginMasonryFlashList plugins={plugins} />
<BrowseFloatingActionButton />
</>
)
}

View file

@ -0,0 +1,16 @@
import type { RouteNames, Setting } from './constants'
declare module '@revenge-mod/externals/react-navigation' {
interface ReactNavigationParamList extends PluginsSettingsParamList {}
}
type PluginsSettingsParamList = {
[K in (typeof RouteNames)[keyof typeof RouteNames]]: object
} & {
[K in (typeof RouteNames)[(typeof Setting)['RevengePlugins']]]: {
sort?: string
filter?: string[]
matchAll?: boolean
reverse?: boolean
}
}

View file

@ -0,0 +1,87 @@
import {
disablePlugin,
enablePlugin,
getMissingPluginDependencies,
getPluginDependencies,
getPluginDependents,
isPluginEnabled,
runPluginLate,
} from '@revenge-mod/plugins/_'
import {
installFromRepo,
planInstall,
} from '@revenge-mod/plugins/_/repositories'
import { noop } from '@revenge-mod/utils/callback'
import {
showPluginHasDependenciesAlert,
showPluginHasDependentsAlert,
showPluginMissingDependenciesAlert,
} from './alerts'
import { confirmPlan, messageOf, showErrorToast } from './repos'
import type { AnyPlugin } from '@revenge-mod/plugins/_'
import type { InstallPlan } from '@revenge-mod/plugins/_/repositories'
export async function handleEnablePlugin(plugin: AnyPlugin) {
const missing = getMissingPluginDependencies(plugin)
if (missing.length) {
const deps = missing.map(id => ({
id,
range: plugin.manifest.dependencies?.[id]?.version ?? '*',
}))
// Required deps can be uninstalled. Offer installing them back.
showPluginMissingDependenciesAlert(plugin, deps, () => {
// Non-async because we can't show 2 dialogs at the same time, so we need for the PluginMissing to close first
// Then the ConfirmPlan dialog will open, and then the install will run after that.
Promise.all(missing.map(id => planInstall(id)))
.then(
it =>
it.reduce(
(acc, cur) => ({
actions: [...acc.actions, ...cur.actions],
warnings: [...acc.warnings, ...cur.warnings],
}),
{ actions: [], warnings: [] },
) satisfies InstallPlan,
)
.then(async plan => {
const accepted = await confirmPlan(plan)
if (!accepted) return false
await installFromRepo(plan)
await handleEnablePlugin(plugin)
})
})
return
}
const dependencies = getPluginDependencies(plugin)
const disabledDeps = dependencies.filter(dep => !isPluginEnabled(dep))
async function action() {
try {
await enablePlugin(plugin)
} catch (e) {
// Requirements not satisfied by native
showErrorToast(messageOf(e))
}
await runPluginLate(plugin).catch(noop)
}
if (disabledDeps.length)
showPluginHasDependenciesAlert(plugin, disabledDeps, action)
else await action()
}
export async function handleDisablePlugin(plugin: AnyPlugin) {
const dependents = getPluginDependents(plugin, true)
const action = () => disablePlugin(plugin)
const enabledDeps = dependents.filter(isPluginEnabled)
if (enabledDeps.length)
showPluginHasDependentsAlert(plugin, enabledDeps, action)
else await action()
}

View file

@ -5,10 +5,12 @@ import {
isPluginStartable,
uninstallExternalPlugin,
} from '@revenge-mod/plugins/_'
import { getErrorStack } from '@revenge-mod/utils/error'
import { deleteJsonStorageForPlugin } from '~plugins/preinit/api.json-storage'
import PluginClearDataConfirmationAlert from '../components/PluginClearDataConfirmationAlert'
import PluginHasDependenciesAlert from '../components/PluginHasDependenciesAlert'
import PluginHasDependentsAlert from '../components/PluginHasDependentsAlert'
import PluginMissingDependenciesAlert from '../components/PluginMissingDependenciesAlert'
import PluginStatesProvider from '../components/PluginStateProvider'
import PluginUninstallConfirmationAlert from '../components/PluginUninstallConfirmationAlert'
import type { AnyPlugin } from '@revenge-mod/plugins/_'
@ -20,9 +22,13 @@ export function showPluginClearDataConfirmation(
const KEY = 'plugin-clear-data-confirmation'
async function action() {
await deleteStorageForPlugin(plugin)
// Trigger update for JSON storage
await deleteJsonStorageForPlugin(plugin)
try {
await deleteStorageForPlugin(plugin)
// Trigger update for JSON storage
await deleteJsonStorageForPlugin(plugin)
} catch (e) {
alert(getErrorStack(e))
}
callback()
}
@ -79,6 +85,21 @@ export function showPluginHasDependenciesAlert(
)
}
export function showPluginMissingDependenciesAlert(
plugin: AnyPlugin,
dependencies: { id: string; range: string }[],
action: () => unknown,
) {
AlertActionCreators.openAlert(
'plugin-missing-dependencies',
<PluginMissingDependenciesAlert
plugin={plugin}
dependencies={dependencies}
action={action}
/>,
)
}
export function showPluginHasDependentsAlert(
plugin: AnyPlugin,
dependents: AnyPlugin[],

View file

@ -0,0 +1,159 @@
import {
AlertActionCreators,
ToastActionCreators,
} from '@revenge-mod/discord/actions'
import { Design } from '@revenge-mod/discord/design'
import { Clipboard } from '@revenge-mod/externals/react-native-clipboard'
import { isPluginError } from '@revenge-mod/plugins/_'
import {
installFromRepo,
listRepoPlugins,
listRepos,
planInstall,
} from '@revenge-mod/plugins/_/repositories'
import { lookupGeneratedIconComponent } from '@revenge-mod/utils/discord'
import { PluginIcon } from '../components/PluginIcon'
import type { InstallPlan } from '@revenge-mod/plugins/_/repositories'
const { AlertActionButton, AlertModal } = Design
const CircleXIconComponent = lookupGeneratedIconComponent(
'CircleXIcon',
'CircleXIcon-primary',
'CircleXIcon-secondary',
)!
export function showErrorToast(message: string) {
ToastActionCreators.open({
key: 'REVENGE_REPOSITORIES_ERROR',
content: message,
IconComponent: CircleXIconComponent,
})
}
export function messageOf(e: unknown) {
if (e instanceof Error) return e.message
if (isPluginError(e)) return e.message
return String(e)
}
export function formatBytes(bytes: number) {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
const PlanConfirmAlertKey = 'repo-install-plan-confirm'
/**
* Shows the resolved plan and asks before anything downloads.
* Resolves true only when the user presses Install.
*/
export async function confirmPlan(plan: InstallPlan): Promise<boolean> {
const repos = await listRepos()
let resolve: (value: boolean) => void
const promise = new Promise<boolean>(r => (resolve = r))
const summary = await Promise.all(
plan.actions.map(async action => {
const row = (
label: string,
repo: string,
author?: string,
icon?: string | null,
) => (
<Design.TableRow
icon={<PluginIcon icon={icon ?? undefined} size={24} />}
onPress={() => {
Clipboard.setString(
`${label} (${action.id}) v${action.version} (${action.channel})${author ? ` by ${author}` : ''} from ${repo} (${action.repo})\n\n` +
`Replaces: ${action.replaces ?? '(None)'}\n` +
`Download: ${action.url} (size: ${action.size})\n` +
`SHA256: ${action.sha256}`,
)
}}
label={
action.replaces
? `${label} • ${action.replaces} → ${action.version}`
: `${label} • ${action.version}`
}
subLabel={`${author ? `${author} • ` : ''}${repo} • ${formatBytes(action.size)}`}
/>
)
const repo = repos.find(r => r.url === action.repo)
if (!repo) return row(action.id, action.repo)
const plugins = await listRepoPlugins(repo.url)
const plugin = plugins.find(p => p.id === action.id)
if (!plugin) return row(action.id, repo.name || action.repo)
return row(
plugin.name || action.id,
repo.name || action.repo,
plugin.author,
plugin.icon,
)
}),
)
AlertActionCreators.openAlert(
PlanConfirmAlertKey,
<AlertModal
title={
plan.actions.length === 1
? 'Install plugin?'
: `Install ${plan.actions.length} plugins?`
}
content={
plan.actions.length > 1
? 'Some plugins require other plugins to be installed first. The following will be installed:'
: undefined
}
extraContent={
<Design.TableRowGroup>{summary}</Design.TableRowGroup>
}
actions={
<>
<AlertActionButton
text="Install"
variant="primary"
onPress={() => resolve(true)}
/>
<AlertActionButton
text="Cancel"
variant="secondary"
onPress={() => resolve(false)}
/>
</>
}
/>,
)
return await promise
}
/**
* Runs the install flow for one plugin: resolve, show warnings, confirm the plan, and execute.
* Errors show as toasts.
*
* Resolves true only when the plan was confirmed and installed.
*/
// TODO: Let the user pick a release channel
export async function runInstallFlow(id: string): Promise<boolean> {
try {
const plan = await planInstall(id)
if (plan.warnings.length) showErrorToast(plan.warnings.join('\n'))
const accepted = await confirmPlan(plan)
AlertActionCreators.dismissAlert(PlanConfirmAlertKey)
if (!accepted) return false
await installFromRepo(plan)
return true
} catch (e) {
showErrorToast(messageOf(e))
return false
}
}

View file

@ -1,6 +1,7 @@
import { ActionSheetActionCreators } from '@revenge-mod/discord/actions'
import PluginStatesProvider from '../components/PluginStateProvider'
import type { AnyPlugin } from '@revenge-mod/plugins/_'
import type { BrowsePluginActionSheetProps } from '../components/BrowsePluginActionSheet'
import type { PluginOptionsActionSheetProps } from '../components/PluginOptionsActionSheet'
export function showPluginOptionsActionSheet(plugin: AnyPlugin) {
@ -18,3 +19,15 @@ export function showPluginOptionsActionSheet(plugin: AnyPlugin) {
{ plugin, sheetKey: KEY },
)
}
export function showBrowsePluginActionSheet(
props: Omit<BrowsePluginActionSheetProps, 'sheetKey'>,
) {
const KEY = 'browse-plugin-action-sheet'
ActionSheetActionCreators.openLazy(
import('../components/BrowsePluginActionSheet'),
KEY,
{ ...props, sheetKey: KEY },
)
}

View file

@ -1,17 +0,0 @@
import { Design } from '@revenge-mod/discord/design'
const { AlertModal, AlertActionButton, Text } = Design
export default function PluginInstallFailedAlert({ error }: { error: string }) {
return (
<AlertModal
title="Plugin install failed"
content={
<Text variant="text-md/medium" color="text-feedback-critical">
{error}
</Text>
}
actions={<AlertActionButton text="Got it" />}
/>
)
}

View file

@ -1,89 +0,0 @@
import { styles } from '@revenge-mod/components/_'
import { Design } from '@revenge-mod/discord/design'
import { FlashList, MasonryFlashList } from '@shopify/flash-list'
import { useWindowDimensions } from 'react-native'
import {
InstalledPluginCard,
PLUGIN_CARD_ESTIMATED_SIZE,
PluginCard,
} from './PluginCard'
import { useResetTooltips } from './TooltipProvider'
import type { AnyPlugin, InternalPluginMeta } from '@revenge-mod/plugins/_'
const { Text } = Design
export function PluginFlashList({ plugins }: { plugins: AnyPlugin[] }) {
const { width, height } = useWindowDimensions()
const actualWidth = width - styles.pagePadding.paddingHorizontal * 2
const resetTooltips = useResetTooltips()
return (
<FlashList
data={plugins}
onScrollBeginDrag={resetTooltips}
fadingEdgeLength={plugins.length === 1 ? 0 : 16}
keyExtractor={plugin => plugin.manifest.id}
estimatedItemSize={PLUGIN_CARD_ESTIMATED_SIZE}
estimatedListSize={{ width: actualWidth, height }}
renderItem={({
item: {
manifest: { name, description, author, icon },
},
index,
}) => (
<PluginCard
name={name}
description={description}
author={author}
icon={icon}
rowEnd={index === plugins.length - 1}
columnEnd
/>
)}
/>
)
}
export function InstalledPluginMasonryFlashList({
plugins,
}: {
plugins: (readonly [AnyPlugin, InternalPluginMeta])[]
}) {
const { width, height } = useWindowDimensions()
const actualWidth = width - styles.pagePadding.paddingHorizontal * 2
const numColumns = Math.floor(actualWidth / 448) || 1
const resetTooltips = useResetTooltips()
return (
<MasonryFlashList
contentContainerStyle={{ paddingBottom: 16 }}
data={plugins}
onScrollBeginDrag={resetTooltips}
fadingEdgeLength={16}
keyExtractor={([plugin]) => plugin.manifest.id}
estimatedListSize={{ width: actualWidth, height }}
estimatedItemSize={PLUGIN_CARD_ESTIMATED_SIZE}
numColumns={numColumns}
ListEmptyComponent={NoPlugins}
renderItem={({ item: [plugin, meta], columnIndex, index }) => (
<InstalledPluginCard
plugin={plugin}
meta={meta}
rowEnd={
index > plugins.length - numColumns &&
columnIndex <= plugins.length % numColumns
}
columnEnd={columnIndex === numColumns - 1}
/>
)}
/>
)
}
function NoPlugins() {
return (
<Text variant="heading-md/medium" style={{ textAlign: 'center' }}>
No plugins found. Try changing your query or filters.
</Text>
)
}

View file

@ -2,7 +2,6 @@ export const Setting = {
// MAIN SETTINGS
Revenge: 'Revenge',
RevengePlugins: 'RevengePlugins',
// SUBSETTINGS
@ -20,5 +19,4 @@ export const Setting = {
export const RouteNames = {
[Setting.Revenge]: 'Revenge',
[Setting.RevengePlugins]: 'Revenge Plugins',
} as const

View file

@ -7,7 +7,7 @@ import { instead } from '@revenge-mod/patcher'
import {
InternalPluginFlags,
PluginFlags,
registerPlugin,
registerInternalPlugin,
} from '@revenge-mod/plugins/_'
import { React } from '@revenge-mod/react'
import { asap, noop } from '@revenge-mod/utils/callback'
@ -18,7 +18,7 @@ import type { FC } from 'react'
let DEBUG_patchedNavigator = false
const pluginSettings = registerPlugin(
const pluginSettings = registerInternalPlugin(
{
id: 'revenge.settings',
name: 'Settings',
@ -28,9 +28,6 @@ const pluginSettings = registerPlugin(
},
{
start() {
// @as-require
import('./plugins')
onSettingsModulesLoaded(() => {
// @as-require
import('./register')

View file

@ -10,14 +10,12 @@ import ReactVersionSetting from './definitions/ReactVersionSetting'
import ReloadSetting from './definitions/ReloadSetting'
import RevengeDiscordSetting from './definitions/RevengeDiscordSetting'
import RevengeLicenseSetting from './definitions/RevengeLicenseSetting'
import RevengePluginsSetting from './definitions/RevengePluginsSetting'
import RevengeSetting from './definitions/RevengeSetting'
import RevengeSourceRepositorySetting from './definitions/RevengeSourceRepositorySetting'
import RevengeVersionSetting from './definitions/RevengeVersionSetting'
registerSettingsItems({
[Setting.Revenge]: RevengeSetting,
[Setting.RevengePlugins]: RevengePluginsSetting,
[Setting.RevengeSourceRepository]: RevengeSourceRepositorySetting,
[Setting.RevengeLicense]: RevengeLicenseSetting,
[Setting.RevengeDiscord]: RevengeDiscordSetting,
@ -31,5 +29,5 @@ registerSettingsItems({
registerSettingsSection('REVENGE', {
label: 'Revenge',
settings: [Setting.Revenge, Setting.RevengePlugins],
settings: [Setting.Revenge],
})

View file

@ -1,4 +1,4 @@
import type { RouteNames, Setting } from './constants'
import type { RouteNames } from './constants'
declare module '@revenge-mod/externals/react-navigation' {
interface ReactNavigationParamList extends RevengeSettingsParamList {}
@ -6,11 +6,4 @@ declare module '@revenge-mod/externals/react-navigation' {
type RevengeSettingsParamList = {
[K in (typeof RouteNames)[keyof typeof RouteNames]]: object
} & {
[K in (typeof RouteNames)[(typeof Setting)['RevengePlugins']]]: {
sort?: string
filter?: string[]
matchAll?: boolean
reverse?: boolean
}
}

View file

@ -1,38 +0,0 @@
import {
disablePlugin,
enablePlugin,
getInternalPluginMeta,
getPluginDependencies,
isPluginEnabled,
runPluginLate,
} from '@revenge-mod/plugins/_'
import {
showPluginHasDependenciesAlert,
showPluginHasDependentsAlert,
} from './alerts'
import type { AnyPlugin } from '@revenge-mod/plugins/_'
export async function handleEnablePlugin(plugin: AnyPlugin) {
const dependencies = getPluginDependencies(plugin)!
const disabledDeps = dependencies?.filter(dep => !isPluginEnabled(dep))
async function action() {
await enablePlugin(plugin)
await runPluginLate(plugin)
}
if (disabledDeps?.length)
showPluginHasDependenciesAlert(plugin, disabledDeps, action)
else await action()
}
export async function handleDisablePlugin(plugin: AnyPlugin) {
const { dependents } = getInternalPluginMeta(plugin)
const action = () => disablePlugin(plugin)
const enabledDeps = dependents.filter(isPluginEnabled)
if (enabledDeps.length)
showPluginHasDependentsAlert(plugin, enabledDeps, action)
else await action()
}

View file

@ -6,12 +6,12 @@ import { instead } from '@revenge-mod/patcher'
import {
InternalPluginFlags,
PluginFlags,
registerPlugin,
registerInternalPlugin,
} from '@revenge-mod/plugins/_'
import { lookupGeneratedIconComponent } from '@revenge-mod/utils/discord'
import type { DiscordModules } from '@revenge-mod/discord/types'
registerPlugin(
registerInternalPlugin(
{
id: 'revenge.staff-settings',
name: 'Staff Settings',

View file

@ -5,7 +5,7 @@ import { withProps } from '@revenge-mod/modules/finders/filters'
import {
InternalPluginFlags,
PluginFlags,
registerPlugin,
registerInternalPlugin,
} from '@revenge-mod/plugins/_'
import { afterJSX, beforeJSX } from '@revenge-mod/react/jsx-runtime'
import { findInReactFiber } from '@revenge-mod/utils/react'
@ -50,7 +50,7 @@ type ViewWithProfileBadges = ReactElement<{
children: Array<ReactElement<ProfileBadgeProps, FC<ProfileBadgeProps>>>
}>
registerPlugin(
registerInternalPlugin(
{
id: 'revenge.user-badges',
name: 'User Badges',