239 lines
7.3 KiB
TypeScript
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' },
|
|
]
|
|
}
|
|
}
|