1109 lines
31 KiB
TypeScript
1109 lines
31 KiB
TypeScript
import { TypedEventEmitter } from '@revenge-mod/discord/common/utils'
|
|
import {
|
|
callNativeMethod,
|
|
callNativeMethodSync,
|
|
registerJSMethod,
|
|
} from '@revenge-mod/modules/native'
|
|
import { exists, rm } from '@revenge-mod/modules/native/fs'
|
|
import { getErrorStack } from '@revenge-mod/utils/error'
|
|
import { sleepReject } from '@revenge-mod/utils/promise'
|
|
import { pUnscopedApi as uapi } from '../apis'
|
|
import { pluginStorageDirFor, PluginStatus as Status } from '../constants'
|
|
import {
|
|
addPluginApiDecorator,
|
|
decoratePluginApi,
|
|
pApis,
|
|
pDecoratorsInit,
|
|
pDecoratorsPreInit,
|
|
pDecoratorsStart,
|
|
} from './decorators'
|
|
import {
|
|
ApiDependencyId,
|
|
computePendingNodes,
|
|
DiscordDependencyId,
|
|
pLeafOrSingleNodes,
|
|
pListOrdered,
|
|
pPending,
|
|
} from './dependency-graph'
|
|
import type {
|
|
InitPluginApi,
|
|
Plugin,
|
|
PluginApi,
|
|
PluginApiExtensionsOptions,
|
|
PluginCleanup,
|
|
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
|
|
|
|
export const PluginFlags = {
|
|
Enabled: 1 << 0,
|
|
PendingReload: 1 << 1,
|
|
StartedLate: 1 << 2,
|
|
/**
|
|
* 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
|
|
|
|
const PluginApiLevel = {
|
|
None: 0,
|
|
PreInit: 1,
|
|
Init: 2,
|
|
Start: 3,
|
|
} as const
|
|
|
|
export const InternalPluginFlags = {
|
|
/**
|
|
* Marks the plugin as internal.
|
|
*/
|
|
Internal: 1 << 0,
|
|
/**
|
|
* Marks the plugin as essential. This means it should not be removed, disabled, or stopped by normal means.
|
|
*/
|
|
Essential: 1 << 1,
|
|
/**
|
|
* Marks the plugin as an API plugin, which decorates all other plugins.
|
|
* API plugins themselves won't be decorated by other API plugins unless explicitly declared in dependencies.
|
|
*/
|
|
API: 1 << 2,
|
|
}
|
|
|
|
export interface InternalPluginMeta {
|
|
handleError: (e: unknown) => Promise<void>
|
|
promises: Promise<void>[]
|
|
cleanups: PluginCleanup[]
|
|
iflags: number
|
|
apiLevel: number
|
|
unsatisfiedOptionalDependencies: readonly string[]
|
|
options: PluginOptions<any>
|
|
optionsFactory?: PluginOptionsFactory<any>
|
|
flags: number
|
|
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
|
|
export const pEmitter = new TypedEventEmitter<{
|
|
register: [AnyPlugin, PluginOptions<any>, update?: true]
|
|
unregister: [AnyPlugin]
|
|
disabled: [AnyPlugin]
|
|
enabled: [AnyPlugin]
|
|
preInited: [AnyPlugin]
|
|
inited: [AnyPlugin]
|
|
started: [AnyPlugin]
|
|
stopped: [AnyPlugin]
|
|
errored: [AnyPlugin, unknown]
|
|
flagUpdate: [AnyPlugin]
|
|
install: [PluginInstallEvent]
|
|
installReady: [PluginInstallReadyEvent]
|
|
}>()
|
|
|
|
/**
|
|
* 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
|
|
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>()
|
|
|
|
/// STATE-SYNC
|
|
|
|
const { states: InitialPersistedStates, savedStates }: PersistedPluginStates =
|
|
callNativeMethodSync('revenge.plugins.states.read', [])
|
|
|
|
/**
|
|
* Whether this boot is running with default plugins only, ignoring the user's saved states.
|
|
*/
|
|
export const isDefaultsOnlyBoot = savedStates != null
|
|
|
|
/**
|
|
* The user's real saved states, only sent when this boot ignores them (recovery/defaults-only).
|
|
*
|
|
* @see {@link isDefaultsOnlyBoot}
|
|
*/
|
|
export const SavedPluginStates = savedStates ?? null
|
|
|
|
/**
|
|
* Whether a plugin is enabled in the user's saved setup, which in a defaults-only boot is not
|
|
* what's running. Falls back to the session state on a normal boot.
|
|
*/
|
|
export function isPluginEnabledInSavedStates(plugin: AnyPlugin): boolean {
|
|
const saved = SavedPluginStates?.[plugin.manifest.id]
|
|
return saved ? Boolean(saved.enabled) : isPluginEnabled(plugin)
|
|
}
|
|
|
|
export function forgetInitialPluginState(id: PluginManifest['id']) {
|
|
delete InitialPersistedStates[id]
|
|
}
|
|
|
|
// Native is the source of truth, we're expecting dispatches from native to sync states
|
|
registerJSMethod('revenge.plugins.states.update', (id, state) => {
|
|
applyPluginFlags(
|
|
id as PluginManifest['id'],
|
|
pluginStateToFlags(state as PluginStateObject),
|
|
)
|
|
})
|
|
|
|
function pluginStateToFlags(state: PluginStateObject): number {
|
|
return (
|
|
(state.enabled ? Flag.Enabled : 0) |
|
|
(state.pendingReload ? Flag.PendingReload : 0) |
|
|
(state.enabledLate || state.startedLate ? Flag.StartedLate : 0)
|
|
)
|
|
}
|
|
|
|
async function applyPluginFlags(id: PluginManifest['id'], flags: number) {
|
|
const plugin = pList.get(id)
|
|
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
|
|
const nowEnabled = flags & Flag.Enabled
|
|
|
|
if (wasEnabled && !nowEnabled) {
|
|
if (plugin.status && !(plugin.status & Status.Stopping))
|
|
await stopPlugin(plugin)
|
|
meta.flags = flags
|
|
pEmitter.emit('disabled', plugin)
|
|
} else {
|
|
meta.flags = flags
|
|
if (!wasEnabled && nowEnabled) pEmitter.emit('enabled', plugin)
|
|
}
|
|
}
|
|
|
|
/// NATIVE-ERROR SYNC
|
|
|
|
registerJSMethod(
|
|
'revenge.plugins.events.pluginErrored',
|
|
(id: string, errors: PluginError[]) => {
|
|
const plugin = pList.get(id)
|
|
if (!plugin) return
|
|
|
|
const meta = getInternalPluginMeta(plugin)
|
|
meta.nativeErrors = Object.freeze(errors)
|
|
// TODO: Do we need an errored event specifically?
|
|
// Nudge open UI to re-render the errors row
|
|
pEmitter.emit('flagUpdate', plugin)
|
|
},
|
|
)
|
|
|
|
/// REST OF THE SYSTEM
|
|
|
|
/**
|
|
* Registers a new plugin with the system.
|
|
*
|
|
* @param manifest The manifest of the plugin.
|
|
* @param options The options for the plugin.
|
|
* @param defflags The default 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
|
|
// TODO: This has to be shared from native somehow.
|
|
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,
|
|
) {
|
|
if (pList.has(manifest.id)) {
|
|
// Ignore re-registration if internal
|
|
if (!iflags)
|
|
throw new Error(
|
|
`Plugin with ID "${manifest.id}" already registered`,
|
|
)
|
|
}
|
|
|
|
const factory = typeof options === 'function' ? options : undefined
|
|
const resolved = typeof options === 'function' ? undefined : options
|
|
|
|
const plugin = {
|
|
errors: [],
|
|
manifest,
|
|
lifecycles: {
|
|
preInit: resolved?.preInit,
|
|
init: resolved?.init,
|
|
start: resolved?.start,
|
|
stop: resolved?.stop,
|
|
},
|
|
SettingsComponent: resolved?.SettingsComponent,
|
|
status: 0,
|
|
get startedLate(): boolean {
|
|
return isPluginStartedLate(plugin)
|
|
},
|
|
disable: (): Promise<void> => disablePlugin(plugin),
|
|
stop: (): Promise<void> => stopPlugin(plugin),
|
|
reportError: (e: unknown) => handlePluginError(e, plugin),
|
|
requireReload: () => {
|
|
meta.flags |= Flag.PendingReload
|
|
},
|
|
api: undefined,
|
|
} satisfies AnyPlugin
|
|
|
|
let flags = InitialPersistedStates[manifest.id]
|
|
? pluginStateToFlags(InitialPersistedStates[manifest.id])
|
|
: defflags
|
|
|
|
const meta: InternalPluginMeta = {
|
|
cleanups: [],
|
|
nativeErrors: Object.freeze([]),
|
|
promises: [],
|
|
iflags,
|
|
apiLevel: PluginApiLevel.None,
|
|
unsatisfiedOptionalDependencies: Object.freeze([]),
|
|
handleError: e => handlePluginError(e, plugin),
|
|
options: resolved ?? {},
|
|
optionsFactory: factory,
|
|
set flags(newFlags: number) {
|
|
if (newFlags === flags) return
|
|
flags = newFlags
|
|
pEmitter.emit('flagUpdate', plugin)
|
|
},
|
|
get flags() {
|
|
return flags
|
|
},
|
|
}
|
|
|
|
pMetadata.set(plugin, meta)
|
|
pList.set(manifest.id, plugin)
|
|
|
|
if (iflags & InternalPluginFlags.API) {
|
|
pLeafOrSingleNodes.add(plugin)
|
|
pApis.add(plugin)
|
|
}
|
|
// Only add to pending if the plugin is enabled
|
|
else if (isPluginEnabled(plugin)) pPending.add(plugin)
|
|
|
|
pEmitter.emit('register', plugin, meta.options)
|
|
|
|
return manifest.id
|
|
}
|
|
|
|
export function getPluginDependencies(
|
|
plugin: AnyPlugin,
|
|
throwOnMissing = true,
|
|
): AnyPlugin[] {
|
|
const { dependencies, id } = plugin.manifest
|
|
const deps: AnyPlugin[] = []
|
|
const { unsatisfiedOptionalDependencies } = getInternalPluginMeta(plugin)
|
|
|
|
if (dependencies)
|
|
for (const [depId, spec] of Object.entries(dependencies)) {
|
|
const dep = pList.get(depId)
|
|
|
|
if (dep) {
|
|
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 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 {
|
|
const meta = getInternalPluginMeta(plugin)
|
|
return Boolean(meta && meta.flags & Flag.Enabled)
|
|
}
|
|
|
|
export function isPluginStartedLate(plugin: AnyPlugin): boolean {
|
|
const meta = getInternalPluginMeta(plugin)
|
|
return Boolean(meta && meta.flags & Flag.StartedLate)
|
|
}
|
|
|
|
export function isPluginEssential({ iflags }: InternalPluginMeta): boolean {
|
|
return Boolean(iflags & InternalPluginFlags.Essential)
|
|
}
|
|
|
|
export function isPluginInternal({ iflags }: InternalPluginMeta): boolean {
|
|
return Boolean(iflags & InternalPluginFlags.Internal)
|
|
}
|
|
|
|
export function isPluginErrored(plugin: AnyPlugin): boolean {
|
|
return plugin.errors.length > 0
|
|
}
|
|
|
|
export function isPluginPendingReload(plugin: AnyPlugin): boolean {
|
|
const meta = getInternalPluginMeta(plugin)
|
|
return Boolean(meta && meta.flags & Flag.PendingReload)
|
|
}
|
|
|
|
export function isPluginPendingUpdate(plugin: AnyPlugin): boolean {
|
|
const meta = getInternalPluginMeta(plugin)
|
|
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 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 (isPluginPendingReload(plugin))
|
|
throw new Error(
|
|
`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)`,
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Checks if a plugin is startable.
|
|
*
|
|
* @see {@link requirePluginStartableState}.
|
|
*/
|
|
export function isPluginStartable(plugin: AnyPlugin): boolean {
|
|
try {
|
|
requirePluginStartableState(plugin)
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handles errors that occur in plugins.
|
|
*/
|
|
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: ${
|
|
isPluginError(e) ? formatPluginError(e) : getErrorStack(e)
|
|
}\u001b[0m`,
|
|
2,
|
|
)
|
|
|
|
plugin.api?.logger?.error('Plugin encountered an error', e)
|
|
|
|
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)
|
|
)
|
|
await plugin.disable()
|
|
}
|
|
|
|
/**
|
|
* Resolves a deferred options factory, populating the plugin's lifecycles and options.
|
|
*/
|
|
function resolvePluginOptions(plugin: AnyPlugin, meta: InternalPluginMeta) {
|
|
const { optionsFactory } = meta
|
|
if (!optionsFactory) return
|
|
|
|
meta.optionsFactory = undefined
|
|
|
|
try {
|
|
const options = optionsFactory()
|
|
|
|
meta.options = options
|
|
plugin.lifecycles.preInit = options.preInit
|
|
plugin.lifecycles.init = options.init
|
|
plugin.lifecycles.start = options.start
|
|
plugin.lifecycles.stop = options.stop
|
|
plugin.SettingsComponent = options.SettingsComponent
|
|
} catch (e) {
|
|
meta.handleError(e)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Prepares the plugin API for the preInit lifecycle.
|
|
*/
|
|
function tryPreparePluginPreInit(plugin: AnyPlugin) {
|
|
const meta = getInternalPluginMeta(plugin)
|
|
if (meta.apiLevel >= PluginApiLevel.PreInit) return
|
|
|
|
// Clear errors from previous runs
|
|
plugin.errors = []
|
|
|
|
resolvePluginOptions(plugin, meta)
|
|
|
|
plugin.api = {
|
|
cleanup: (...items) => {
|
|
meta.cleanups.push(...items)
|
|
},
|
|
plugin,
|
|
unscoped: pUnscopedApi,
|
|
decorate: decorator => {
|
|
addPluginApiDecorator(pDecoratorsPreInit, plugin, decorator)
|
|
},
|
|
} satisfies PreInitPluginApi
|
|
|
|
decoratePluginApi(pDecoratorsPreInit, plugin, meta)
|
|
meta.apiLevel = PluginApiLevel.PreInit
|
|
}
|
|
|
|
/**
|
|
* Prepares the plugin API for the init lifecycle.
|
|
*/
|
|
function tryPreparePluginInit(plugin: AnyPlugin) {
|
|
const meta = getInternalPluginMeta(plugin)
|
|
if (meta.apiLevel >= PluginApiLevel.Init) return
|
|
|
|
const api = plugin.api as InitPluginApi
|
|
|
|
api.decorate = decorator => {
|
|
addPluginApiDecorator(pDecoratorsInit, plugin, decorator)
|
|
}
|
|
|
|
decoratePluginApi(pDecoratorsInit, plugin, meta)
|
|
meta.apiLevel = PluginApiLevel.Init
|
|
}
|
|
|
|
/**
|
|
* Prepares the plugin API for the start lifecycle.
|
|
*/
|
|
function tryPreparePluginStart(plugin: AnyPlugin) {
|
|
const meta = getInternalPluginMeta(plugin)
|
|
if (meta.apiLevel >= PluginApiLevel.Start) return
|
|
|
|
const api = plugin.api as PluginApi
|
|
|
|
api.decorate = decorator => {
|
|
addPluginApiDecorator(pDecoratorsStart, plugin, decorator)
|
|
}
|
|
|
|
decoratePluginApi(pDecoratorsStart, plugin, meta)
|
|
meta.apiLevel = PluginApiLevel.Start
|
|
}
|
|
|
|
/**
|
|
* Writes the enabled state of a plugin to the native side, and updates the real saved states.
|
|
*/
|
|
async function writePluginEnabledState(plugin: AnyPlugin, enabled: boolean) {
|
|
const result = await callNativeMethod('revenge.plugins.setEnabled', [
|
|
plugin.manifest.id,
|
|
enabled,
|
|
])
|
|
|
|
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}`,
|
|
)
|
|
}
|
|
|
|
if (SavedPluginStates)
|
|
SavedPluginStates[plugin.manifest.id] = {
|
|
...SavedPluginStates[plugin.manifest.id],
|
|
enabled,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Disables a plugin, as well as all its dependents.
|
|
*/
|
|
export async function disablePlugin(plugin: AnyPlugin) {
|
|
// PendingReload/PendingUpdate never block disabling
|
|
if (!isPluginEnabledInSavedStates(plugin))
|
|
throw new Error(`Plugin "${plugin.manifest.id}" is not enabled`)
|
|
|
|
const meta = getInternalPluginMeta(plugin)
|
|
|
|
if (isPluginEssential(meta))
|
|
throw new Error(
|
|
`Plugin "${plugin.manifest.id}" is essential and cannot be disabled`,
|
|
)
|
|
|
|
// Disable dependents
|
|
await Promise.all(
|
|
getPluginDependents(plugin).map(dep => {
|
|
if (getInternalPluginMeta(dep)!.flags & Flag.Enabled)
|
|
return disablePlugin(dep)
|
|
}),
|
|
)
|
|
|
|
// Stop the plugin if needed
|
|
if (plugin.status && !(plugin.status & Status.Stopping))
|
|
await stopPlugin(plugin)
|
|
|
|
await writePluginEnabledState(plugin, false)
|
|
|
|
meta.flags &= ~Flag.Enabled
|
|
pEmitter.emit('disabled', plugin)
|
|
}
|
|
|
|
/**
|
|
* Enables a plugin, as well as all its dependencies.
|
|
*/
|
|
export async function enablePlugin(plugin: AnyPlugin) {
|
|
if (isPluginEnabledInSavedStates(plugin))
|
|
throw new Error(`Plugin "${plugin.manifest.id}" is already enabled`)
|
|
|
|
await Promise.all(
|
|
getPluginDependencies(plugin).map(dep => {
|
|
if (!isPluginEnabledInSavedStates(dep)) return enablePlugin(dep)
|
|
}),
|
|
)
|
|
|
|
await writePluginEnabledState(plugin, true)
|
|
|
|
const meta = getInternalPluginMeta(plugin)
|
|
meta.flags |= Flag.Enabled
|
|
|
|
pEmitter.emit('enabled', plugin)
|
|
}
|
|
|
|
export async function runPluginLate(plugin: AnyPlugin) {
|
|
if (isDefaultsOnlyBoot)
|
|
throw new Error(
|
|
`Cannot start plugin "${plugin.manifest.id}" while running with default plugins. Reload to apply your changes.`,
|
|
)
|
|
|
|
requirePluginStartableState(plugin)
|
|
|
|
if (plugin.status & Status.Started)
|
|
throw new Error(`Plugin "${plugin.manifest.id}" is already started`)
|
|
|
|
// Reset previous computations
|
|
pListOrdered.length = 0
|
|
pPending.add(plugin)
|
|
computePendingNodes()
|
|
|
|
await Promise.all(
|
|
// If the plugin is stopped, we should initialize it
|
|
pListOrdered
|
|
.filter(plugin => !plugin.status)
|
|
.map(async function runLate(plugin) {
|
|
const meta = getInternalPluginMeta(plugin)
|
|
meta.flags |= Flag.StartedLate
|
|
|
|
// Plugin would already be started native-side if it was not started late
|
|
// But on late starts (enable, fresh install), we must start the native side too
|
|
await callNativeMethod('revenge.plugins.startNative', [
|
|
plugin.manifest.id,
|
|
])
|
|
|
|
// Prepare the plugin API
|
|
await preInitPlugin(plugin)
|
|
await initPlugin(plugin)
|
|
await startPlugin(plugin)
|
|
}),
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Runs the preInit lifecycle of a plugin.
|
|
*/
|
|
export async function preInitPlugin(plugin: AnyPlugin) {
|
|
requirePluginStartableState(plugin)
|
|
|
|
const {
|
|
manifest: { id },
|
|
} = plugin
|
|
|
|
if (plugin.status & (Status.PreIniting | Status.PreInited))
|
|
throw new Error(
|
|
`Plugin preInit lifecycle for "${id}" is already running`,
|
|
)
|
|
|
|
tryPreparePluginPreInit(plugin)
|
|
|
|
const { lifecycles } = plugin
|
|
const { promises, handleError } = getInternalPluginMeta(plugin)
|
|
|
|
try {
|
|
if (!lifecycles.preInit) return
|
|
|
|
plugin.status |= Status.PreIniting
|
|
|
|
try {
|
|
const prom = lifecycles.preInit.apply(plugin, [
|
|
plugin.api as PreInitPluginApi,
|
|
])
|
|
promises.push(prom)
|
|
await prom
|
|
} catch (e) {
|
|
await handleError(e)
|
|
} finally {
|
|
plugin.status &= ~Status.PreIniting
|
|
}
|
|
} finally {
|
|
if (!isPluginErrored(plugin)) {
|
|
plugin.status |= Status.PreInited
|
|
pEmitter.emit('preInited', plugin)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Runs the init lifecycle of a plugin.
|
|
*/
|
|
export async function initPlugin(plugin: AnyPlugin) {
|
|
requirePluginStartableState(plugin)
|
|
|
|
const {
|
|
manifest: { id },
|
|
} = plugin
|
|
|
|
const meta = getInternalPluginMeta(plugin)!
|
|
|
|
if (plugin.status & (Status.Initing | Status.Inited))
|
|
throw new Error(`Plugin init lifecycle for "${id}" is already running`)
|
|
|
|
tryPreparePluginPreInit(plugin)
|
|
tryPreparePluginInit(plugin)
|
|
|
|
const { lifecycles } = plugin
|
|
const { promises, handleError } = meta
|
|
|
|
try {
|
|
if (!lifecycles.init) return
|
|
|
|
plugin.status |= Status.Initing
|
|
|
|
try {
|
|
const prom = lifecycles.init.apply(plugin, [
|
|
plugin.api as InitPluginApi,
|
|
])
|
|
promises.push(prom)
|
|
await prom
|
|
} catch (e) {
|
|
await handleError(e)
|
|
} finally {
|
|
plugin.status &= ~Status.Initing
|
|
}
|
|
} finally {
|
|
if (!isPluginErrored(plugin)) {
|
|
plugin.status |= Status.Inited
|
|
pEmitter.emit('inited', plugin)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Starts a plugin by running its start lifecycle.
|
|
*/
|
|
export async function startPlugin(plugin: AnyPlugin) {
|
|
requirePluginStartableState(plugin)
|
|
|
|
const {
|
|
manifest: { id },
|
|
} = plugin
|
|
|
|
if (plugin.status & (Status.Starting | Status.Started))
|
|
throw new Error(`Plugin start lifecycle for "${id}" is already running`)
|
|
|
|
tryPreparePluginPreInit(plugin)
|
|
tryPreparePluginInit(plugin)
|
|
tryPreparePluginStart(plugin)
|
|
|
|
const { lifecycles } = plugin
|
|
const { promises, handleError } = getInternalPluginMeta(plugin)
|
|
|
|
try {
|
|
if (!lifecycles.start) return
|
|
|
|
plugin.status |= Status.Starting
|
|
|
|
try {
|
|
const prom = lifecycles.start.apply(plugin, [
|
|
plugin.api as PluginApi,
|
|
])
|
|
promises.push(prom)
|
|
await prom
|
|
} catch (e) {
|
|
await handleError(e)
|
|
} finally {
|
|
plugin.status &= ~Status.Starting
|
|
}
|
|
} finally {
|
|
if (!isPluginErrored(plugin)) {
|
|
plugin.status |= Status.Started
|
|
pEmitter.emit('started', plugin)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stops a plugin by running its stop lifecycle and cleanup functions.
|
|
*/
|
|
export async function stopPlugin(plugin: AnyPlugin) {
|
|
// PendingReload/PendingUpdate shouldn't block stopping
|
|
if (!isPluginEnabled(plugin))
|
|
throw new Error(`Plugin "${plugin.manifest.id}" is not enabled`)
|
|
|
|
const {
|
|
manifest: { id },
|
|
} = plugin
|
|
|
|
const meta = getInternalPluginMeta(plugin)
|
|
|
|
if (isPluginEssential(meta))
|
|
throw new Error(`Plugin "${id}" is essential and cannot be stopped`)
|
|
|
|
if (plugin.status & Status.Stopping)
|
|
throw new Error(`Plugin "${id}" is already stopping`)
|
|
|
|
const { lifecycles } = plugin
|
|
const { promises, handleError } = meta
|
|
|
|
// Wait for in-progress lifecycles to finish or timeout
|
|
if (plugin.status & (Status.PreIniting | Status.Initing | Status.Starting))
|
|
await Promise.race([
|
|
!isPluginErrored(plugin) && Promise.all(promises),
|
|
sleepReject(
|
|
MaxWaitTime,
|
|
'Plugin lifecycles timed out, force stopping',
|
|
),
|
|
]).catch(e => {
|
|
meta.flags |= Flag.PendingReload
|
|
return handlePluginError(e, plugin)
|
|
})
|
|
else if (
|
|
!(plugin.status & (Status.PreInited | Status.Inited | Status.Started))
|
|
)
|
|
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 {
|
|
if (lifecycles.stop)
|
|
await Promise.race([
|
|
lifecycles.stop.apply(plugin, [plugin.api as PluginApi]),
|
|
sleepReject(
|
|
MaxWaitTime,
|
|
'Plugin stop lifecycle timed out, force stopping',
|
|
),
|
|
])
|
|
} catch (e) {
|
|
await handleError(e)
|
|
} finally {
|
|
// Run cleanups
|
|
await cleanupPlugin(plugin, meta)
|
|
|
|
// Reset state
|
|
plugin.api = undefined
|
|
meta.apiLevel = PluginApiLevel.None
|
|
meta.promises.length = 0
|
|
meta.cleanups.length = 0
|
|
plugin.status = 0
|
|
|
|
pEmitter.emit('stopped', plugin)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Runs all cleanup functions registered by a plugin.
|
|
*/
|
|
async function cleanupPlugin(plugin: AnyPlugin, meta: InternalPluginMeta) {
|
|
async function handleStopError(e: unknown) {
|
|
// Some cleanup was unsuccessful, so we need to reload the app
|
|
meta.flags |= Flag.PendingReload
|
|
return handlePluginError(e, plugin)
|
|
}
|
|
|
|
const proms: Promise<any>[] = []
|
|
|
|
for (const cleanup of meta.cleanups)
|
|
try {
|
|
proms.push(cleanup())
|
|
} catch (e) {
|
|
await handleStopError(e)
|
|
}
|
|
|
|
await Promise.all(proms)
|
|
}
|
|
|
|
export function getInternalPluginMeta(plugin: AnyPlugin): InternalPluginMeta {
|
|
const meta = pMetadata.get(plugin)
|
|
if (!meta)
|
|
throw new Error(
|
|
`Plugin "${plugin.manifest.id}" has no internal metadata, is it registered?`,
|
|
)
|
|
|
|
return meta
|
|
}
|
|
|
|
export async function deleteStorageForPlugin(plugin: Plugin<any, any>) {
|
|
const dir = pluginStorageDirFor(plugin.manifest.id)
|
|
|
|
if (await exists(dir)) await rm(dir)
|
|
}
|
|
|
|
export function requestNextBootDefaultsOnly() {
|
|
callNativeMethodSync(
|
|
'revenge.plugins.states.requestNextBootDefaultsOnly',
|
|
[],
|
|
)
|
|
}
|
|
|
|
export {
|
|
confirmInstall,
|
|
resyncPluginSources,
|
|
uninstallExternalPlugin,
|
|
} from './external-plugins'
|
|
|
|
declare module '@revenge-mod/modules/native' {
|
|
interface NativeMethods {
|
|
'revenge.plugins.startNative': [[id: PluginManifest['id']], null]
|
|
'revenge.plugins.states.read': [[], PersistedPluginStates]
|
|
'revenge.plugins.states.requestNextBootDefaultsOnly': [[], void]
|
|
'revenge.plugins.setEnabled': [
|
|
[id: PluginManifest['id'], enabled: boolean],
|
|
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
|
|
errored?: boolean
|
|
/** @deprecated TODO: (2026-07-26) Remove this in a month's time. */
|
|
enabledLate?: boolean
|
|
startedLate?: boolean
|
|
}
|
|
|
|
interface PersistedPluginStates {
|
|
states: {
|
|
[id: PluginManifest['id']]: PluginStateObject
|
|
}
|
|
savedStates?: {
|
|
[id: PluginManifest['id']]: PluginStateObject
|
|
}
|
|
}
|