revenge-bundle-next/lib/plugins/src/_internal/external-plugins.ts

239 lines
7.3 KiB
TypeScript

import {
callNativeMethod,
callNativeMethodSync,
registerJSMethod,
} from '@revenge-mod/modules/native'
import { getErrorStack } from '@revenge-mod/utils/error'
import { pUnscopedApi } from '../apis'
import {
disablePlugin,
forgetInitialPluginState,
getInternalPluginMeta,
handlePluginError,
InternalPluginFlags,
isPluginEnabled,
PluginFlags,
pEmitter,
pList,
registerInternalPlugin,
registerPlugin,
toPluginError,
} from '.'
import { pPending } from './dependency-graph'
import { registerRepositoryEvents } from './repositories'
import type { PluginManifest, PluginOptionsFactory } from '../types'
import type {
AnyPlugin,
PluginError,
PluginInstallReadyEvent,
PluginSource,
} from '.'
interface ExternalPlugin {
manifest: PluginManifest
script?: string
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?: PluginError[]
}
type PluginInstallResult =
| { error: false; plugin: ExternalPlugin }
| { error: PluginError }
export function registerExternalPlugins() {
registerJSMethod(
'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
try {
// Drop the stale boot snapshot entry (from before a mid-session uninstall)
// so the fresh install registers disabled
forgetInitialPluginState(plugin.manifest.id)
registerExternalPlugin(plugin)
} catch (e) {
pEmitter.emit('install', { error: toPluginError(e) })
return
}
pEmitter.emit('install', {
error: false,
manifest: plugin.manifest,
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
for (const external of externals)
try {
// Skip plugins whose JS counterpart is already registered
if (pList.has(external.manifest.id)) continue
registerExternalPlugin(external)
} catch (e) {
nativeLoggingHook(
`\u001b[31mFailed to register external plugin "${external.manifest.id}": ${getErrorStack(e)}\u001b[0m`,
2,
)
}
}
export function registerExternalPlugin(external: ExternalPlugin) {
const {
manifest,
script,
internal,
essential,
enabledByDefault,
api,
failed,
source,
unsatisfiedOptionalDependencies,
errors,
} = external
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)
}
// Sync errors the native side already caught
if (errors?.length)
for (const error of errors) handlePluginError(error, plugin)
return id
}
export async function uninstallExternalPlugin(plugin: AnyPlugin) {
if (isPluginEnabled(plugin)) await disablePlugin(plugin)
await callNativeMethod('revenge.plugins.uninstall', [plugin.manifest.id])
// Native cleared its persisted flags, drop our boot snapshot entry too
forgetInitialPluginState(plugin.manifest.id)
pList.delete(plugin.manifest.id)
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)
}
declare module '@revenge-mod/modules/native' {
interface NativeMethods {
'revenge.plugins.list': [[], ExternalPlugin[] | null]
'revenge.plugins.installFile': [[], null]
'revenge.plugins.uninstall': [[string], null]
'revenge.plugins.confirmInstall': [
[token: string, accepted: boolean],
{ result: 'installed' | 'pending' | 'cancelled' },
]
}
}