import { TypedEventEmitter } from '@revenge-mod/discord/common/utils' import { callNativeMethod, registerJSMethod } from '@revenge-mod/modules/native' 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 { return callNativeMethod('revenge.plugins.repos.list', []) } export function setRepos(config: RepoConfigEntry[]): Promise { return callNativeMethod('revenge.plugins.repos.set', [config]) } export function refreshRepo(url: string): Promise { 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 }) }, ), ), ) 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 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 { 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, filteredRepos?: string[], ): Promise { return callNativeMethod('revenge.plugins.planInstall', [ id, version ?? null, channel ?? null, filteredRepos ?? 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 { 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, filteredRepos: string[] | null, ], InstallPlan, ] 'revenge.plugins.install': [ [plan: InstallPlan], { installed: string[]; pending: string[]; skipped: string[] }, ] } }