From 9b412a9404940549c176dd04b1445d36cf283f44 Mon Sep 17 00:00:00 2001 From: PalmDevs Date: Sat, 25 Jul 2026 23:35:23 +0700 Subject: [PATCH] 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. --- lib/plugins/package.json | 6 + lib/plugins/src/_internal/dependency-graph.ts | 41 +- lib/plugins/src/_internal/external-plugins.ts | 193 ++++++--- lib/plugins/src/_internal/index.ts | 338 +++++++++++++--- lib/plugins/src/_internal/repositories.ts | 296 ++++++++++++++ lib/plugins/src/apis/plugins.ts | 3 + lib/plugins/src/constants.ts | 2 +- lib/plugins/src/init.ts | 4 +- lib/plugins/src/preinit.ts | 4 +- lib/plugins/src/start.ts | 4 +- lib/plugins/src/types.ts | 56 ++- lib/plugins/src/utils.ts | 7 + src/plugins/preinit/api.discord/index.ts | 4 +- src/plugins/preinit/api.json-storage/index.ts | 4 +- src/plugins/preinit/api.utils/index.ts | 4 +- src/plugins/preinit/logging/index.ts | 4 +- src/plugins/preinit/no-track/index.ts | 4 +- src/plugins/start/developer-kit/index.ts | 6 +- src/plugins/start/developer-kit/utils.ts | 2 +- src/plugins/start/error-boundary/index.tsx | 4 +- .../BrowseFilterAndSortActionSheet.tsx | 109 ++++++ .../components/BrowsePluginActionSheet.tsx | 68 ++++ .../components/FilterAndSortActionSheet.tsx | 0 .../components/NavigatorHeaderWithIcon.tsx | 0 .../components/PluginCard.tsx | 124 ++++-- .../PluginClearDataConfirmationAlert.tsx | 0 .../components/PluginHasDependenciesAlert.tsx | 14 +- .../components/PluginHasDependentsAlert.tsx | 14 +- .../components/PluginIcon.tsx | 39 ++ .../components/PluginInstallConfirmAlert.tsx | 49 +++ .../components/PluginInstallFailedAlert.tsx | 35 ++ .../components/PluginList.tsx | 165 ++++++++ .../PluginMissingDependenciesAlert.tsx | 52 +++ .../components/PluginOptionsActionSheet.tsx | 260 +++++++++---- .../components/PluginStateProvider.tsx | 5 + .../PluginUninstallConfirmationAlert.tsx | 0 .../components/PluginsFailedToStartAlert.tsx | 0 .../components/PluginsRequireReloadAlert.tsx | 0 .../components/TooltipProvider.tsx | 0 .../start/settings.plugins/constants.ts | 11 + .../RevengePluginsAdvancedSetting.tsx | 17 + .../RevengePluginsBrowseSetting.tsx | 17 + .../definitions/RevengePluginsSetting.tsx | 0 src/plugins/start/settings.plugins/index.ts | 104 +++++ .../plugins.tsx | 21 +- .../start/settings.plugins/register.tsx | 17 + .../RevengePluginsAdvancedSettingScreen.tsx | 368 ++++++++++++++++++ .../RevengePluginsBrowseSettingScreen.tsx | 229 +++++++++++ .../screens/RevengePluginsSettingScreen.tsx | 67 ++-- src/plugins/start/settings.plugins/types.ts | 16 + .../start/settings.plugins/utils/actions.tsx | 87 +++++ .../utils/alerts.tsx | 27 +- .../start/settings.plugins/utils/repos.tsx | 159 ++++++++ .../utils/sheets.tsx | 13 + .../components/PluginInstallFailedAlert.tsx | 17 - .../start/settings/components/PluginList.tsx | 89 ----- src/plugins/start/settings/constants.ts | 2 - src/plugins/start/settings/index.ts | 7 +- src/plugins/start/settings/register.tsx | 4 +- src/plugins/start/settings/types.ts | 9 +- src/plugins/start/settings/utils/actions.tsx | 38 -- src/plugins/start/staff-settings/index.ts | 4 +- src/plugins/start/user-badges/index.tsx | 4 +- 63 files changed, 2746 insertions(+), 501 deletions(-) create mode 100644 lib/plugins/src/_internal/repositories.ts create mode 100644 lib/plugins/src/utils.ts create mode 100644 src/plugins/start/settings.plugins/components/BrowseFilterAndSortActionSheet.tsx create mode 100644 src/plugins/start/settings.plugins/components/BrowsePluginActionSheet.tsx rename src/plugins/start/{settings => settings.plugins}/components/FilterAndSortActionSheet.tsx (100%) rename src/plugins/start/{settings => settings.plugins}/components/NavigatorHeaderWithIcon.tsx (100%) rename src/plugins/start/{settings => settings.plugins}/components/PluginCard.tsx (72%) rename src/plugins/start/{settings => settings.plugins}/components/PluginClearDataConfirmationAlert.tsx (100%) rename src/plugins/start/{settings => settings.plugins}/components/PluginHasDependenciesAlert.tsx (83%) rename src/plugins/start/{settings => settings.plugins}/components/PluginHasDependentsAlert.tsx (83%) create mode 100644 src/plugins/start/settings.plugins/components/PluginIcon.tsx create mode 100644 src/plugins/start/settings.plugins/components/PluginInstallConfirmAlert.tsx create mode 100644 src/plugins/start/settings.plugins/components/PluginInstallFailedAlert.tsx create mode 100644 src/plugins/start/settings.plugins/components/PluginList.tsx create mode 100644 src/plugins/start/settings.plugins/components/PluginMissingDependenciesAlert.tsx rename src/plugins/start/{settings => settings.plugins}/components/PluginOptionsActionSheet.tsx (51%) rename src/plugins/start/{settings => settings.plugins}/components/PluginStateProvider.tsx (93%) rename src/plugins/start/{settings => settings.plugins}/components/PluginUninstallConfirmationAlert.tsx (100%) rename src/plugins/start/{settings => settings.plugins}/components/PluginsFailedToStartAlert.tsx (100%) rename src/plugins/start/{settings => settings.plugins}/components/PluginsRequireReloadAlert.tsx (100%) rename src/plugins/start/{settings => settings.plugins}/components/TooltipProvider.tsx (100%) create mode 100644 src/plugins/start/settings.plugins/constants.ts create mode 100644 src/plugins/start/settings.plugins/definitions/RevengePluginsAdvancedSetting.tsx create mode 100644 src/plugins/start/settings.plugins/definitions/RevengePluginsBrowseSetting.tsx rename src/plugins/start/{settings => settings.plugins}/definitions/RevengePluginsSetting.tsx (100%) create mode 100644 src/plugins/start/settings.plugins/index.ts rename src/plugins/start/{settings => settings.plugins}/plugins.tsx (87%) create mode 100644 src/plugins/start/settings.plugins/register.tsx create mode 100644 src/plugins/start/settings.plugins/screens/RevengePluginsAdvancedSettingScreen.tsx create mode 100644 src/plugins/start/settings.plugins/screens/RevengePluginsBrowseSettingScreen.tsx rename src/plugins/start/{settings => settings.plugins}/screens/RevengePluginsSettingScreen.tsx (84%) create mode 100644 src/plugins/start/settings.plugins/types.ts create mode 100644 src/plugins/start/settings.plugins/utils/actions.tsx rename src/plugins/start/{settings => settings.plugins}/utils/alerts.tsx (79%) create mode 100644 src/plugins/start/settings.plugins/utils/repos.tsx rename src/plugins/start/{settings => settings.plugins}/utils/sheets.tsx (66%) delete mode 100644 src/plugins/start/settings/components/PluginInstallFailedAlert.tsx delete mode 100644 src/plugins/start/settings/components/PluginList.tsx delete mode 100644 src/plugins/start/settings/utils/actions.tsx diff --git a/lib/plugins/package.json b/lib/plugins/package.json index 33012c3..a880495 100644 --- a/lib/plugins/package.json +++ b/lib/plugins/package.json @@ -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" } } } diff --git a/lib/plugins/src/_internal/dependency-graph.ts b/lib/plugins/src/_internal/dependency-graph.ts index 06cd828..f3499d1 100644 --- a/lib/plugins/src/_internal/dependency-graph.ts +++ b/lib/plugins/src/_internal/dependency-graph.ts @@ -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() +// 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) } diff --git a/lib/plugins/src/_internal/external-plugins.ts b/lib/plugins/src/_internal/external-plugins.ts index 660fb51..b9a34c7 100644 --- a/lib/plugins/src/_internal/external-plugins.ts +++ b/lib/plugins/src/_internal/external-plugins.ts @@ -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' }, + ] } } diff --git a/lib/plugins/src/_internal/index.ts b/lib/plugins/src/_internal/index.ts index e0f43c7..de36a7c 100644 --- a/lib/plugins/src/_internal/index.ts +++ b/lib/plugins/src/_internal/index.ts @@ -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 +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 optionsFactory?: PluginOptionsFactory 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() const pMetadata = new WeakMap() @@ -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( manifest: PluginManifest, options: PluginOptions | PluginOptionsFactory, defflags: number, +) { + return register(manifest, options, defflags, 0) +} + +export type InternalPluginManifest = Omit< + PluginManifest, + 'version' | 'format' | 'dependencies' +> & + Partial> + +export function registerInternalPlugin( + manifest: InternalPluginManifest, + options: PluginOptions | PluginOptionsFactory, + 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( + manifest: PluginManifest, + options: PluginOptions | PluginOptionsFactory, + 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( 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( 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 - // 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) { 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 diff --git a/lib/plugins/src/_internal/repositories.ts b/lib/plugins/src/_internal/repositories.ts new file mode 100644 index 0000000..869f487 --- /dev/null +++ b/lib/plugins/src/_internal/repositories.ts @@ -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 { + 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 }) + }, + ), + ), + ) + + 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 + 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, +): Promise { + 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 { + 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[] }, + ] + } +} diff --git a/lib/plugins/src/apis/plugins.ts b/lib/plugins/src/apis/plugins.ts index 78af575..4d9b5cb 100644 --- a/lib/plugins/src/apis/plugins.ts +++ b/lib/plugins/src/apis/plugins.ts @@ -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, } diff --git a/lib/plugins/src/constants.ts b/lib/plugins/src/constants.ts index 73794b1..665a6fe 100644 --- a/lib/plugins/src/constants.ts +++ b/lib/plugins/src/constants.ts @@ -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}` diff --git a/lib/plugins/src/init.ts b/lib/plugins/src/init.ts index 79d1c5b..5d49b7e 100644 --- a/lib/plugins/src/init.ts +++ b/lib/plugins/src/init.ts @@ -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) diff --git a/lib/plugins/src/preinit.ts b/lib/plugins/src/preinit.ts index 1756aef..48a6e0a 100644 --- a/lib/plugins/src/preinit.ts +++ b/lib/plugins/src/preinit.ts @@ -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) diff --git a/lib/plugins/src/start.ts b/lib/plugins/src/start.ts index 9ac8741..4adc68a 100644 --- a/lib/plugins/src/start.ts +++ b/lib/plugins/src/start.ts @@ -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) diff --git a/lib/plugins/src/types.ts b/lib/plugins/src/types.ts index b71757a..6417924 100644 --- a/lib/plugins/src/types.ts +++ b/lib/plugins/src/types.ts @@ -136,26 +136,14 @@ export interface PluginApi< plugin: Plugin } -// 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 + /** + * 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 = keyof PluginApiInLifecycleMap, > { - // TODO(plugins): support plugin bundles - // /** - // * The plugin bundle this plugin belongs to. - // */ - // bundle: PluginBundle manifest: PluginManifest lifecycles: PluginLifecycles diff --git a/lib/plugins/src/utils.ts b/lib/plugins/src/utils.ts new file mode 100644 index 0000000..cace47c --- /dev/null +++ b/lib/plugins/src/utils.ts @@ -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}` : '') diff --git a/src/plugins/preinit/api.discord/index.ts b/src/plugins/preinit/api.discord/index.ts index bc3331d..69d9c2c 100644 --- a/src/plugins/preinit/api.discord/index.ts +++ b/src/plugins/preinit/api.discord/index.ts @@ -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', diff --git a/src/plugins/preinit/api.json-storage/index.ts b/src/plugins/preinit/api.json-storage/index.ts index 62d5804..5e9b522 100644 --- a/src/plugins/preinit/api.json-storage/index.ts +++ b/src/plugins/preinit/api.json-storage/index.ts @@ -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, PluginJsonStorageOptions>() -registerPlugin( +registerInternalPlugin( { id: 'revenge.api.json-storage', name: 'JSON Storage API', diff --git a/src/plugins/preinit/api.utils/index.ts b/src/plugins/preinit/api.utils/index.ts index ba95910..badf6c6 100644 --- a/src/plugins/preinit/api.utils/index.ts +++ b/src/plugins/preinit/api.utils/index.ts @@ -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', diff --git a/src/plugins/preinit/logging/index.ts b/src/plugins/preinit/logging/index.ts index a5e021a..59f6b03 100644 --- a/src/plugins/preinit/logging/index.ts +++ b/src/plugins/preinit/logging/index.ts @@ -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', diff --git a/src/plugins/preinit/no-track/index.ts b/src/plugins/preinit/no-track/index.ts index 1832def..04611c7 100644 --- a/src/plugins/preinit/no-track/index.ts +++ b/src/plugins/preinit/no-track/index.ts @@ -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', diff --git a/src/plugins/start/developer-kit/index.ts b/src/plugins/start/developer-kit/index.ts index 184b4dd..6d7c17e 100644 --- a/src/plugins/start/developer-kit/index.ts +++ b/src/plugins/start/developer-kit/index.ts @@ -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: { diff --git a/src/plugins/start/developer-kit/utils.ts b/src/plugins/start/developer-kit/utils.ts index 28c81c2..14ca775 100644 --- a/src/plugins/start/developer-kit/utils.ts +++ b/src/plugins/start/developer-kit/utils.ts @@ -29,6 +29,6 @@ export function register() { [Setting.AssetBrowser]: AssetBrowserSetting, [Setting.TestErrorBoundary]: TestErrorBoundarySetting, }), - addSettingsItemToSection('REVENGE', Setting.RevengeDeveloper), + addSettingsItemToSection('REVENGE', Setting.RevengeDeveloper, -1), ) } diff --git a/src/plugins/start/error-boundary/index.tsx b/src/plugins/start/error-boundary/index.tsx index 542dab0..d419ab9 100644 --- a/src/plugins/start/error-boundary/index.tsx +++ b/src/plugins/start/error-boundary/index.tsx @@ -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', diff --git a/src/plugins/start/settings.plugins/components/BrowseFilterAndSortActionSheet.tsx b/src/plugins/start/settings.plugins/components/BrowseFilterAndSortActionSheet.tsx new file mode 100644 index 0000000..7ceed9f --- /dev/null +++ b/src/plugins/start/settings.plugins/components/BrowseFilterAndSortActionSheet.tsx @@ -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 = { + 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 ( + + + + {repos.map(repo => { + const isChecked = checked_.includes(repo.url) + + return ( + { + const v = isChecked + ? checked_.filter(url => url !== repo.url) + : [...checked_, repo.url] + + setChecked(v) + setChecked_(v) + }} + /> + ) + })} + + setSort(v as BrowseSortKey)} + > + {Object.entries(BrowseSortLabels).map(([key, label]) => ( + + } + label={label} + value={key} + /> + ))} + + + { + setReverse(v) + setReverse_(v) + }} + /> + + + ) +} diff --git a/src/plugins/start/settings.plugins/components/BrowsePluginActionSheet.tsx b/src/plugins/start/settings.plugins/components/BrowsePluginActionSheet.tsx new file mode 100644 index 0000000..46523ab --- /dev/null +++ b/src/plugins/start/settings.plugins/components/BrowsePluginActionSheet.tsx @@ -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 ( + + + { + ActionSheetActionCreators.hideActionSheet( + sheetKey, + ) + onInstall() + }} + /> + } + /> + + + + + + + ) +} diff --git a/src/plugins/start/settings/components/FilterAndSortActionSheet.tsx b/src/plugins/start/settings.plugins/components/FilterAndSortActionSheet.tsx similarity index 100% rename from src/plugins/start/settings/components/FilterAndSortActionSheet.tsx rename to src/plugins/start/settings.plugins/components/FilterAndSortActionSheet.tsx diff --git a/src/plugins/start/settings/components/NavigatorHeaderWithIcon.tsx b/src/plugins/start/settings.plugins/components/NavigatorHeaderWithIcon.tsx similarity index 100% rename from src/plugins/start/settings/components/NavigatorHeaderWithIcon.tsx rename to src/plugins/start/settings.plugins/components/NavigatorHeaderWithIcon.tsx diff --git a/src/plugins/start/settings/components/PluginCard.tsx b/src/plugins/start/settings.plugins/components/PluginCard.tsx similarity index 72% rename from src/plugins/start/settings/components/PluginCard.tsx rename to src/plugins/start/settings.plugins/components/PluginCard.tsx index 9b55a0c..b5ecd60 100644 --- a/src/plugins/start/settings/components/PluginCard.tsx +++ b/src/plugins/start/settings.plugins/components/PluginCard.tsx @@ -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 ( - + @@ -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]} > - + by {author} + {version ? ` \u2022 ${version}` : ''} {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({ void +}) { + return ( + + { + showBrowsePluginActionSheet({ + name, + author, + description, + version, + icon, + id, + repositoryText, + onInstall, + }) + }} + /> +