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.
This commit is contained in:
parent
fb35d7af95
commit
9b412a9404
63 changed files with 2746 additions and 501 deletions
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<AnyPlugin>()
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
|
||||
// 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<any>
|
||||
optionsFactory?: PluginOptionsFactory<any>
|
||||
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<PluginManifest['id'], AnyPlugin>()
|
||||
const pMetadata = new WeakMap<AnyPlugin, InternalPluginMeta>()
|
||||
|
|
@ -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<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
|
||||
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,
|
||||
) {
|
||||
// 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<O extends PluginApiExtensionsOptions>(
|
|||
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<O extends PluginApiExtensionsOptions>(
|
|||
|
||||
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<PluginManifest['id'], [PluginManifest, Flags: number, PluginCode: string]>
|
||||
// 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<any, any>) {
|
|||
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
|
||||
|
|
|
|||
296
lib/plugins/src/_internal/repositories.ts
Normal file
296
lib/plugins/src/_internal/repositories.ts
Normal file
|
|
@ -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<Repo[]> {
|
||||
return callNativeMethod('revenge.plugins.repos.list', [])
|
||||
}
|
||||
|
||||
export function setRepos(config: RepoConfigEntry[]): Promise<null> {
|
||||
return callNativeMethod('revenge.plugins.repos.set', [config])
|
||||
}
|
||||
|
||||
export function refreshRepo(url: string): Promise<Repo> {
|
||||
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<string, string>
|
||||
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<RepoPluginListing[]> {
|
||||
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<InstallPlan> {
|
||||
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<RepoUpdate[]> {
|
||||
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[] },
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -136,26 +136,14 @@ export interface PluginApi<
|
|||
plugin: Plugin<O, 'Start'>
|
||||
}
|
||||
|
||||
// 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<string, PluginDependency>
|
||||
/**
|
||||
* 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<O> = keyof PluginApiInLifecycleMap<O>,
|
||||
> {
|
||||
// TODO(plugins): support plugin bundles
|
||||
// /**
|
||||
// * The plugin bundle this plugin belongs to.
|
||||
// */
|
||||
// bundle: PluginBundle
|
||||
manifest: PluginManifest
|
||||
lifecycles: PluginLifecycles<O>
|
||||
|
||||
|
|
|
|||
7
lib/plugins/src/utils.ts
Normal file
7
lib/plugins/src/utils.ts
Normal file
|
|
@ -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}` : '')
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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<Plugin<any, any>, PluginJsonStorageOptions>()
|
||||
|
||||
registerPlugin(
|
||||
registerInternalPlugin(
|
||||
{
|
||||
id: 'revenge.api.json-storage',
|
||||
name: 'JSON Storage API',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -29,6 +29,6 @@ export function register() {
|
|||
[Setting.AssetBrowser]: AssetBrowserSetting,
|
||||
[Setting.TestErrorBoundary]: TestErrorBoundarySetting,
|
||||
}),
|
||||
addSettingsItemToSection('REVENGE', Setting.RevengeDeveloper),
|
||||
addSettingsItemToSection('REVENGE', Setting.RevengeDeveloper, -1),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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<BrowseSortKey, AssetId> = {
|
||||
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 (
|
||||
<ActionSheet>
|
||||
<BottomSheetTitleHeader title="Filter & Sort" />
|
||||
<TableRowGroup title="Filter by Repositories">
|
||||
{repos.map(repo => {
|
||||
const isChecked = checked_.includes(repo.url)
|
||||
|
||||
return (
|
||||
<TableCheckboxRow
|
||||
key={repo.url}
|
||||
label={repo.name ?? repo.url}
|
||||
subLabel={repo.name ? repo.url : undefined}
|
||||
value={repo.url}
|
||||
checked={isChecked}
|
||||
onPress={() => {
|
||||
const v = isChecked
|
||||
? checked_.filter(url => url !== repo.url)
|
||||
: [...checked_, repo.url]
|
||||
|
||||
setChecked(v)
|
||||
setChecked_(v)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</TableRowGroup>
|
||||
<TableRadioGroup
|
||||
title="Sort by"
|
||||
defaultValue={sort}
|
||||
onChange={v => setSort(v as BrowseSortKey)}
|
||||
>
|
||||
{Object.entries(BrowseSortLabels).map(([key, label]) => (
|
||||
<TableRadioRow
|
||||
key={key}
|
||||
icon={
|
||||
<TableRowAssetIcon
|
||||
id={SortIcons[key as BrowseSortKey]}
|
||||
/>
|
||||
}
|
||||
label={label}
|
||||
value={key}
|
||||
/>
|
||||
))}
|
||||
</TableRadioGroup>
|
||||
<TableRowGroup>
|
||||
<TableSwitchRow
|
||||
label="Reverse results"
|
||||
value={reverse_}
|
||||
onValueChange={v => {
|
||||
setReverse(v)
|
||||
setReverse_(v)
|
||||
}}
|
||||
/>
|
||||
</TableRowGroup>
|
||||
</ActionSheet>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<ActionSheet>
|
||||
<Stack spacing={24} style={{ paddingTop: 8 }}>
|
||||
<PluginInfo
|
||||
name={name}
|
||||
author={author}
|
||||
version={version}
|
||||
description={description}
|
||||
icon={icon}
|
||||
actions={
|
||||
<Button
|
||||
size="sm"
|
||||
text="Install"
|
||||
icon={DownloadIcon}
|
||||
onPress={() => {
|
||||
ActionSheetActionCreators.hideActionSheet(
|
||||
sheetKey,
|
||||
)
|
||||
onInstall()
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<TableRowGroup title="Advanced">
|
||||
<IdRow id={id} />
|
||||
<RepositoryRow text={repositoryText} copyable />
|
||||
</TableRowGroup>
|
||||
</Stack>
|
||||
</ActionSheet>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<Card
|
||||
style={[
|
||||
styles_.card,
|
||||
styles.grow,
|
||||
columnEnd && styles_.columnEnd,
|
||||
rowEnd && styles_.rowEnd,
|
||||
]}
|
||||
>
|
||||
<Card style={[styles_.card, styles.grow]}>
|
||||
<PluginInfo
|
||||
name={name}
|
||||
description={description}
|
||||
author={author}
|
||||
icon={icon}
|
||||
actions={actions}
|
||||
version={version}
|
||||
aligned
|
||||
/>
|
||||
</Card>
|
||||
|
|
@ -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]}
|
||||
>
|
||||
<Image
|
||||
source={icon ? getAssetIdByName(icon) : PuzzlePieceIcon}
|
||||
style={styles_.icon}
|
||||
/>
|
||||
<PluginIcon icon={icon} />
|
||||
<Text
|
||||
variant="heading-lg/semibold"
|
||||
textBreakStrategy="balanced"
|
||||
|
|
@ -121,6 +118,7 @@ export const PluginInfo = memo(function PluginInfo({
|
|||
variant="heading-md/medium"
|
||||
>
|
||||
by {author}
|
||||
{version ? ` \u2022 ${version}` : ''}
|
||||
</Text>
|
||||
<Text style={styles.grow} variant="text-md/medium">
|
||||
{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({
|
|||
<PluginCard
|
||||
name={name}
|
||||
description={description}
|
||||
version={formatVersion(version)}
|
||||
author={author}
|
||||
icon={icon}
|
||||
rowEnd={rowEnd}
|
||||
columnEnd={columnEnd}
|
||||
actions={
|
||||
<>
|
||||
<IconButton
|
||||
|
|
@ -231,24 +224,73 @@ export const InstalledPluginCard = memo(function InstalledPluginCard({
|
|||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* Card for a plugin that isn't installed yet, shown on the Browse screen.
|
||||
* No switch or settings button, just a more menu and a small Install button.
|
||||
*/
|
||||
export const BrowsePluginCard = memo(function BrowsePluginCard({
|
||||
name,
|
||||
description,
|
||||
version,
|
||||
author,
|
||||
icon,
|
||||
id,
|
||||
repositoryText,
|
||||
onInstall,
|
||||
}: {
|
||||
name: string
|
||||
description: string
|
||||
version: string
|
||||
author: string
|
||||
icon?: string
|
||||
id: string
|
||||
repositoryText: string
|
||||
onInstall: () => void
|
||||
}) {
|
||||
return (
|
||||
<PluginCard
|
||||
name={name}
|
||||
description={description}
|
||||
version={version}
|
||||
author={author}
|
||||
icon={icon}
|
||||
actions={
|
||||
<>
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon={MoreVerticalIcon}
|
||||
onPress={() => {
|
||||
showBrowsePluginActionSheet({
|
||||
name,
|
||||
author,
|
||||
description,
|
||||
version,
|
||||
icon,
|
||||
id,
|
||||
repositoryText,
|
||||
onInstall,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
text="Install"
|
||||
icon={DownloadIcon}
|
||||
onPress={onInstall}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
const usePluginCardStyles = createStyles({
|
||||
icon: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
tintColor: Tokens.default.colors.TEXT_DEFAULT,
|
||||
},
|
||||
card: {
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 12,
|
||||
gap: 4,
|
||||
marginBottom: 12,
|
||||
marginRight: 12,
|
||||
},
|
||||
rowEnd: {
|
||||
marginBottom: 0,
|
||||
},
|
||||
columnEnd: {
|
||||
marginRight: 0,
|
||||
margin: PLUGIN_CARD_HALF_GUTTER,
|
||||
},
|
||||
topContainer: {
|
||||
alignItems: 'center',
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { Design } from '@revenge-mod/discord/design'
|
||||
import { useState } from 'react'
|
||||
import { PixelRatio, useWindowDimensions, View } from 'react-native'
|
||||
import { PLUGIN_CARD_ESTIMATED_SIZE } from './PluginCard'
|
||||
import { PluginFlashList } from './PluginList'
|
||||
|
|
@ -17,6 +18,9 @@ export default function PluginHasDependenciesAlert({
|
|||
}) {
|
||||
const { height: windowHeight } = useWindowDimensions()
|
||||
const maxHeight = PixelRatio.get() * windowHeight * 0.35 - 64
|
||||
const [height, setHeight] = useState(
|
||||
PLUGIN_CARD_ESTIMATED_SIZE * dependencies.length,
|
||||
)
|
||||
|
||||
return (
|
||||
<AlertModal
|
||||
|
|
@ -35,14 +39,14 @@ export default function PluginHasDependenciesAlert({
|
|||
extraContent={
|
||||
<View
|
||||
style={{
|
||||
height: Math.min(
|
||||
PLUGIN_CARD_ESTIMATED_SIZE * dependencies.length,
|
||||
maxHeight,
|
||||
),
|
||||
height,
|
||||
maxHeight,
|
||||
}}
|
||||
>
|
||||
<PluginFlashList plugins={dependencies} />
|
||||
<PluginFlashList
|
||||
plugins={dependencies}
|
||||
onContentSizeChange={(_, h) => setHeight(h)}
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
actions={
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { Design } from '@revenge-mod/discord/design'
|
||||
import { useState } from 'react'
|
||||
import { PixelRatio, useWindowDimensions, View } from 'react-native'
|
||||
import { PLUGIN_CARD_ESTIMATED_SIZE } from './PluginCard'
|
||||
import { PluginFlashList } from './PluginList'
|
||||
|
|
@ -17,6 +18,9 @@ export default function PluginHasDependentsAlert({
|
|||
}) {
|
||||
const { height: windowHeight } = useWindowDimensions()
|
||||
const maxHeight = PixelRatio.get() * windowHeight * 0.35 - 64
|
||||
const [height, setHeight] = useState(
|
||||
PLUGIN_CARD_ESTIMATED_SIZE * dependents.length,
|
||||
)
|
||||
|
||||
return (
|
||||
<AlertModal
|
||||
|
|
@ -35,14 +39,14 @@ export default function PluginHasDependentsAlert({
|
|||
extraContent={
|
||||
<View
|
||||
style={{
|
||||
height: Math.min(
|
||||
PLUGIN_CARD_ESTIMATED_SIZE * dependents.length,
|
||||
maxHeight,
|
||||
),
|
||||
height,
|
||||
maxHeight,
|
||||
}}
|
||||
>
|
||||
<PluginFlashList plugins={dependents} />
|
||||
<PluginFlashList
|
||||
plugins={dependents}
|
||||
onContentSizeChange={(_, h) => setHeight(h)}
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
actions={
|
||||
39
src/plugins/start/settings.plugins/components/PluginIcon.tsx
Normal file
39
src/plugins/start/settings.plugins/components/PluginIcon.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { getAssetIdByName } from '@revenge-mod/assets'
|
||||
import { Tokens } from '@revenge-mod/discord/common'
|
||||
import { Design } from '@revenge-mod/discord/design'
|
||||
import { Image } from 'react-native'
|
||||
|
||||
const PuzzlePieceIcon = getAssetIdByName('PuzzlePieceIcon', 'png')!
|
||||
|
||||
export function PluginIcon({
|
||||
icon,
|
||||
size = 20,
|
||||
}: {
|
||||
icon?: string
|
||||
dataUrl?: boolean
|
||||
size?: number
|
||||
}) {
|
||||
const styles = usePluginIconStyles()
|
||||
// Icons are either a packaged asset name or an inline data: URL, never fetched.
|
||||
// Asset icons are monochrome and get tinted, data: URLs render as-is.
|
||||
const dataUrl = icon?.startsWith('data:')
|
||||
|
||||
return (
|
||||
<Image
|
||||
source={
|
||||
dataUrl
|
||||
? { uri: icon }
|
||||
: icon
|
||||
? (getAssetIdByName(icon) ?? PuzzlePieceIcon)
|
||||
: PuzzlePieceIcon
|
||||
}
|
||||
style={[!dataUrl && styles.icon, { width: size, height: size }]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const usePluginIconStyles = Design.createStyles({
|
||||
icon: {
|
||||
tintColor: Tokens.default.colors.TEXT_DEFAULT,
|
||||
},
|
||||
})
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import { Design } from '@revenge-mod/discord/design'
|
||||
import { confirmInstall } from '@revenge-mod/plugins/_'
|
||||
import type { PluginInstallReadyEvent } from '@revenge-mod/plugins/_'
|
||||
|
||||
const { AlertModal, AlertActionButton, Text } = Design
|
||||
|
||||
export default function PluginInstallConfirmAlert({
|
||||
prompt,
|
||||
}: {
|
||||
prompt: PluginInstallReadyEvent
|
||||
}) {
|
||||
const { manifest } = prompt
|
||||
|
||||
return (
|
||||
<AlertModal
|
||||
title={`Install ${manifest.name}?`}
|
||||
content={`${manifest.id} v${manifest.version}${manifest.author ? ` by ${manifest.author}` : ''}`}
|
||||
extraContent={
|
||||
prompt.replaces != null ? (
|
||||
<Text
|
||||
variant="text-md/medium"
|
||||
color="text-feedback-critical"
|
||||
>
|
||||
This replaces the installed version {prompt.replaces}.
|
||||
The update applies after a reload.
|
||||
</Text>
|
||||
) : undefined
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<AlertActionButton
|
||||
text="Install"
|
||||
variant="primary"
|
||||
onPress={() => {
|
||||
confirmInstall(prompt.token, true)
|
||||
}}
|
||||
/>
|
||||
<AlertActionButton
|
||||
text="Cancel"
|
||||
variant="secondary"
|
||||
onPress={() => {
|
||||
confirmInstall(prompt.token, false)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import { Design } from '@revenge-mod/discord/design'
|
||||
import { Clipboard } from '@revenge-mod/externals/react-native-clipboard'
|
||||
import { formatPluginError } from '@revenge-mod/plugins/_'
|
||||
import type { PluginError } from '@revenge-mod/plugins/_'
|
||||
|
||||
const { AlertModal, AlertActionButton, Text } = Design
|
||||
|
||||
export default function PluginInstallFailedAlert({
|
||||
error,
|
||||
}: {
|
||||
error: PluginError
|
||||
}) {
|
||||
return (
|
||||
<AlertModal
|
||||
title="Plugin install failed"
|
||||
content={
|
||||
<Text variant="text-md/medium" color="text-feedback-critical">
|
||||
{error.message}
|
||||
</Text>
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<AlertActionButton
|
||||
text="Copy details"
|
||||
variant="secondary"
|
||||
onPress={() => {
|
||||
Clipboard.setString(formatPluginError(error))
|
||||
}}
|
||||
/>
|
||||
<AlertActionButton text="Got it" />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
165
src/plugins/start/settings.plugins/components/PluginList.tsx
Normal file
165
src/plugins/start/settings.plugins/components/PluginList.tsx
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import { styles } from '@revenge-mod/components/_'
|
||||
import { Design } from '@revenge-mod/discord/design'
|
||||
import { formatVersion } from '@revenge-mod/plugins/utils'
|
||||
import { FlashList } from '@shopify/flash-list'
|
||||
import { useWindowDimensions } from 'react-native'
|
||||
import {
|
||||
BrowsePluginCard,
|
||||
InstalledPluginCard,
|
||||
PLUGIN_CARD_HALF_GUTTER,
|
||||
PluginCard,
|
||||
} from './PluginCard'
|
||||
import { useResetTooltips } from './TooltipProvider'
|
||||
import type { AnyPlugin, InternalPluginMeta } from '@revenge-mod/plugins/_'
|
||||
import type { RepoPluginListing } from '@revenge-mod/plugins/_/repositories'
|
||||
import type { FlashListProps } from '@shopify/flash-list'
|
||||
|
||||
const { Text } = Design
|
||||
|
||||
const gutterCompensation = { margin: -PLUGIN_CARD_HALF_GUTTER }
|
||||
|
||||
export function PluginFlashList({
|
||||
plugins,
|
||||
onContentSizeChange,
|
||||
}: { plugins: AnyPlugin[] } & Pick<
|
||||
FlashListProps<AnyPlugin>,
|
||||
'onContentSizeChange'
|
||||
>) {
|
||||
const resetTooltips = useResetTooltips()
|
||||
|
||||
return (
|
||||
<FlashList
|
||||
style={gutterCompensation}
|
||||
onContentSizeChange={onContentSizeChange}
|
||||
data={plugins}
|
||||
onScrollBeginDrag={resetTooltips}
|
||||
fadingEdgeLength={plugins.length === 1 ? 0 : 16}
|
||||
keyExtractor={plugin => plugin.manifest.id}
|
||||
renderItem={({
|
||||
item: {
|
||||
manifest: { name, description, version, author, icon },
|
||||
},
|
||||
}) => (
|
||||
<PluginCard
|
||||
name={name}
|
||||
description={description}
|
||||
version={formatVersion(version)}
|
||||
author={author}
|
||||
icon={icon}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function InstalledPluginMasonryFlashList({
|
||||
plugins,
|
||||
}: {
|
||||
plugins: (readonly [AnyPlugin, InternalPluginMeta])[]
|
||||
}) {
|
||||
const numColumns = useNumColumns()
|
||||
const resetTooltips = useResetTooltips()
|
||||
|
||||
return (
|
||||
<FlashList
|
||||
masonry
|
||||
style={gutterCompensation}
|
||||
// FAB is 56px tall, plus 16px spacing on top and bottom
|
||||
contentContainerStyle={{ paddingBottom: 56 + 2 * 16 }}
|
||||
data={plugins}
|
||||
onScrollBeginDrag={resetTooltips}
|
||||
fadingEdgeLength={16}
|
||||
keyExtractor={([plugin]) => plugin.manifest.id}
|
||||
numColumns={numColumns}
|
||||
ListEmptyComponent={NoPlugins}
|
||||
renderItem={({ item: [plugin, meta] }) => (
|
||||
<InstalledPluginCard plugin={plugin} meta={meta} />
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function NoPlugins() {
|
||||
return (
|
||||
<Text variant="heading-md/medium" style={{ textAlign: 'center' }}>
|
||||
No plugins found. Try changing your query or filters.
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
function useNumColumns() {
|
||||
const { width } = useWindowDimensions()
|
||||
const actualWidth = width - styles.pagePadding.paddingHorizontal * 2
|
||||
return Math.floor(actualWidth / 448) || 1
|
||||
}
|
||||
|
||||
/**
|
||||
* One plugin offered by one repository on the Browse screen.
|
||||
* The same ID can appear as separate entries from different repositories.
|
||||
*/
|
||||
export interface BrowseEntry {
|
||||
/** `repoUrl#id` */
|
||||
key: string
|
||||
listing: RepoPluginListing
|
||||
/** For repository filtering. */
|
||||
repoUrl: string
|
||||
repoName: string | null
|
||||
/** Display text for the Repository row/sheet, eg. `Name (url)`. */
|
||||
repositoryText: string
|
||||
version: string
|
||||
size: number
|
||||
installed?: readonly [AnyPlugin, InternalPluginMeta]
|
||||
}
|
||||
|
||||
export function BrowsePluginMasonryFlashList({
|
||||
entries,
|
||||
onInstall,
|
||||
}: {
|
||||
entries: BrowseEntry[]
|
||||
onInstall: (entry: BrowseEntry) => void
|
||||
}) {
|
||||
const numColumns = useNumColumns()
|
||||
const resetTooltips = useResetTooltips()
|
||||
|
||||
return (
|
||||
<FlashList
|
||||
masonry
|
||||
style={gutterCompensation}
|
||||
contentContainerStyle={{ paddingBottom: 16 }}
|
||||
data={entries}
|
||||
onScrollBeginDrag={resetTooltips}
|
||||
fadingEdgeLength={16}
|
||||
keyExtractor={entry => entry.key}
|
||||
numColumns={numColumns}
|
||||
ListEmptyComponent={NoBrowsePlugins}
|
||||
renderItem={({ item: entry }) => {
|
||||
if (entry.installed) {
|
||||
const [plugin, meta] = entry.installed
|
||||
return <InstalledPluginCard plugin={plugin} meta={meta} />
|
||||
}
|
||||
|
||||
const { listing } = entry
|
||||
return (
|
||||
<BrowsePluginCard
|
||||
name={listing.name}
|
||||
description={listing.description}
|
||||
version={entry.version}
|
||||
author={listing.author}
|
||||
icon={listing.icon ?? undefined}
|
||||
id={listing.id}
|
||||
repositoryText={entry.repositoryText}
|
||||
onInstall={() => onInstall(entry)}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function NoBrowsePlugins() {
|
||||
return (
|
||||
<Text variant="heading-md/medium" style={{ textAlign: 'center' }}>
|
||||
No plugins available. Add repositories to browse plugins.
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import { Design } from '@revenge-mod/discord/design'
|
||||
import type { AnyPlugin } from '@revenge-mod/plugins/_'
|
||||
|
||||
const { AlertModal, AlertActionButton, Text, TableRowGroup, TableRow } = Design
|
||||
|
||||
export default function PluginMissingDependenciesAlert({
|
||||
plugin,
|
||||
dependencies,
|
||||
action,
|
||||
}: {
|
||||
plugin: AnyPlugin
|
||||
/** The missing required dependencies: id + the declared version range. */
|
||||
dependencies: { id: string; range: string }[]
|
||||
action: () => unknown
|
||||
}) {
|
||||
return (
|
||||
<AlertModal
|
||||
title="Plugin has missing dependencies"
|
||||
content={
|
||||
<Text color="text-default">
|
||||
Plugin{' '}
|
||||
<Text variant="text-md/semibold" color="text-default">
|
||||
{plugin.manifest.name}
|
||||
</Text>{' '}
|
||||
requires plugins that are not installed. Install them from
|
||||
your repositories to enable it.
|
||||
</Text>
|
||||
}
|
||||
extraContent={
|
||||
<TableRowGroup>
|
||||
{dependencies.map(dep => (
|
||||
<TableRow
|
||||
key={dep.id}
|
||||
label={dep.id}
|
||||
subLabel={`Requires version ${dep.range}`}
|
||||
/>
|
||||
))}
|
||||
</TableRowGroup>
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<AlertActionButton
|
||||
text="Install"
|
||||
variant="primary"
|
||||
onPress={action}
|
||||
/>
|
||||
<AlertActionButton text="Cancel" variant="secondary" />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -7,17 +7,24 @@ import {
|
|||
import { Design } from '@revenge-mod/discord/design'
|
||||
import { Clipboard } from '@revenge-mod/externals/react-native-clipboard'
|
||||
import {
|
||||
formatPluginError,
|
||||
getInternalPluginMeta,
|
||||
getPluginDependencies,
|
||||
getPluginDependents,
|
||||
InternalPluginFlags,
|
||||
isPluginEssential,
|
||||
isPluginInternal,
|
||||
isPluginPendingUpdate,
|
||||
isPluginStartable,
|
||||
PluginFlags,
|
||||
runPluginLate,
|
||||
stopPlugin,
|
||||
} from '@revenge-mod/plugins/_'
|
||||
import { listRepos } from '@revenge-mod/plugins/_/repositories'
|
||||
import { PluginStatus } from '@revenge-mod/plugins/constants'
|
||||
import { formatVersion } from '@revenge-mod/plugins/utils'
|
||||
import { lookupGeneratedIconComponent } from '@revenge-mod/utils/discord'
|
||||
import { getErrorStack } from '@revenge-mod/utils/error'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Pressable } from 'react-native'
|
||||
import { ClickOutsideProvider } from 'react-native-click-outside'
|
||||
import { handleDisablePlugin, handleEnablePlugin } from '../utils/actions'
|
||||
|
|
@ -26,6 +33,7 @@ import {
|
|||
showPluginClearDataConfirmation,
|
||||
showPluginUninstallConfirmation,
|
||||
} from '../utils/alerts'
|
||||
import { messageOf, showErrorToast } from '../utils/repos'
|
||||
import { PluginInfo } from './PluginCard'
|
||||
import { usePluginEnabled, usePluginStatus } from './PluginStateProvider'
|
||||
import {
|
||||
|
|
@ -43,23 +51,20 @@ export interface PluginOptionsActionSheetProps {
|
|||
const { ActionSheet, IconButton, TableRowGroup, TableRow, Stack } = Design
|
||||
|
||||
const FileWarningIcon = getAssetIdByName('FileWarningIcon', 'png')!
|
||||
const PlayIcon = getAssetIdByName('PlayIcon', 'png')!
|
||||
const SettingsIcon = getAssetIdByName('SettingsIcon', 'png')!
|
||||
const StopIcon = getAssetIdByName('StopIcon', 'png')!
|
||||
const TrashIcon = getAssetIdByName('TrashIcon', 'png')!
|
||||
|
||||
export default function PluginOptionsActionSheet({
|
||||
plugin,
|
||||
sheetKey,
|
||||
}: PluginOptionsActionSheetProps) {
|
||||
const status = usePluginStatus(plugin)
|
||||
const enabled = usePluginEnabled(plugin)
|
||||
const meta = getInternalPluginMeta(plugin)
|
||||
const { dependencies, dependents } = meta!
|
||||
const essential = isPluginEssential(meta)
|
||||
const pendingUpdate = isPluginPendingUpdate(plugin)
|
||||
const {
|
||||
manifest: { id, name, author, description, icon },
|
||||
} = plugin
|
||||
const errors = [...plugin.errors, ...meta.nativeErrors]
|
||||
const { name, author, description, icon, version } = plugin.manifest
|
||||
|
||||
return (
|
||||
<ActionSheet>
|
||||
|
|
@ -67,6 +72,7 @@ export default function PluginOptionsActionSheet({
|
|||
<PluginInfo
|
||||
name={name}
|
||||
author={author}
|
||||
version={formatVersion(version)}
|
||||
description={description}
|
||||
icon={icon}
|
||||
actions={
|
||||
|
|
@ -94,79 +100,95 @@ export default function PluginOptionsActionSheet({
|
|||
/>
|
||||
</EnablePluginTooltipProvider>
|
||||
</ClickOutsideProvider>
|
||||
<TableRowGroup title="Status">
|
||||
<TableRow
|
||||
icon={
|
||||
<TableRowAssetIcon name="CircleInformationIcon" />
|
||||
}
|
||||
label="Status (TODO)"
|
||||
subLabel={bitFieldToString(PluginStatus, status)}
|
||||
/>
|
||||
{errors.length && (
|
||||
<TableRow
|
||||
variant="danger"
|
||||
label="Errors"
|
||||
icon={
|
||||
<TableRowAssetIcon
|
||||
variant="danger"
|
||||
name="CircleErrorIcon"
|
||||
/>
|
||||
}
|
||||
subLabel={`${errors.length} errors. Tap to copy.`}
|
||||
onPress={() => {
|
||||
Clipboard.setString(
|
||||
errors.map(getErrorStack).join('\n'),
|
||||
)
|
||||
showCopiedToClipboardToast()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</TableRowGroup>
|
||||
<TableRowGroup title="Advanced">
|
||||
<TableRow
|
||||
icon={<TableRowAssetIcon name="IdIcon" />}
|
||||
label="ID"
|
||||
subLabel={id}
|
||||
onPress={() => {
|
||||
Clipboard.setString(id)
|
||||
showCopiedToClipboardToast()
|
||||
}}
|
||||
/>
|
||||
<TableRow
|
||||
icon={<TableRowAssetIcon name="FlagIcon" />}
|
||||
label="Flags"
|
||||
subLabel={bitFieldToString(PluginFlags, meta.flags)}
|
||||
/>
|
||||
{meta.iflags && (
|
||||
<TableRow
|
||||
icon={<TableRowAssetIcon name="FlagIcon" />}
|
||||
label="Internal Flags"
|
||||
subLabel={bitFieldToString(
|
||||
InternalPluginFlags,
|
||||
meta.iflags,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{dependencies?.length && (
|
||||
<TableRow
|
||||
icon={<TableRowAssetIcon name="ListBulletsIcon" />}
|
||||
label="Dependencies (TODO)"
|
||||
subLabel={`${name} depends on ${dependencies.length} other plugins`}
|
||||
/>
|
||||
)}
|
||||
{dependents?.length && (
|
||||
<TableRow
|
||||
icon={<TableRowAssetIcon name="ListBulletsIcon" />}
|
||||
label="Dependents (TODO)"
|
||||
subLabel={`${dependents.length} other plugins depend on ${name}`}
|
||||
/>
|
||||
)}
|
||||
</TableRowGroup>
|
||||
<StatusSection plugin={plugin} />
|
||||
<AdvancedSection plugin={plugin} />
|
||||
</Stack>
|
||||
</ActionSheet>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusSection({ plugin }: { plugin: AnyPlugin }) {
|
||||
const status = usePluginStatus(plugin)
|
||||
const meta = getInternalPluginMeta(plugin)
|
||||
const errors = [...plugin.errors, ...meta.nativeErrors]
|
||||
|
||||
return (
|
||||
<TableRowGroup title="Status">
|
||||
<TableRow
|
||||
icon={<TableRowAssetIcon name="CircleInformationIcon" />}
|
||||
label="Status (TODO)"
|
||||
subLabel={bitFieldToString(PluginStatus, status)}
|
||||
/>
|
||||
{errors.length > 0 && (
|
||||
<TableRow
|
||||
variant="danger"
|
||||
label="Errors"
|
||||
icon={
|
||||
<TableRowAssetIcon
|
||||
variant="danger"
|
||||
name="CircleErrorIcon"
|
||||
/>
|
||||
}
|
||||
subLabel={`${errors.length} errors. Tap to copy.`}
|
||||
onPress={() => {
|
||||
Clipboard.setString(
|
||||
errors.map(formatPluginError).join('\n\n'),
|
||||
)
|
||||
showCopiedToClipboardToast()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</TableRowGroup>
|
||||
)
|
||||
}
|
||||
|
||||
function AdvancedSection({ plugin }: { plugin: AnyPlugin }) {
|
||||
const meta = getInternalPluginMeta(plugin)
|
||||
const dependents = getPluginDependents(plugin, true)
|
||||
const dependencies = getPluginDependencies(plugin, false)
|
||||
const repositoryText = usePluginRepositoryText(plugin)
|
||||
const { id, name } = plugin.manifest
|
||||
|
||||
return (
|
||||
<TableRowGroup title="Advanced">
|
||||
<IdRow id={id} />
|
||||
<RepositoryRow
|
||||
text={repositoryText}
|
||||
copyable={!!meta.source?.repo}
|
||||
/>
|
||||
<TableRow
|
||||
icon={<TableRowAssetIcon name="FlagIcon" />}
|
||||
label="Flags"
|
||||
subLabel={bitFieldToString(PluginFlags, meta.flags)}
|
||||
/>
|
||||
{meta.iflags > 0 && (
|
||||
<TableRow
|
||||
icon={<TableRowAssetIcon name="FlagIcon" />}
|
||||
label="Internal Flags"
|
||||
subLabel={bitFieldToString(
|
||||
InternalPluginFlags,
|
||||
meta.iflags,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{dependencies.length > 0 && (
|
||||
<TableRow
|
||||
icon={<TableRowAssetIcon name="ListBulletsIcon" />}
|
||||
label="Dependencies (TODO)"
|
||||
subLabel={`${name} depends on ${dependencies.length} other plugins`}
|
||||
/>
|
||||
)}
|
||||
{dependents.length > 0 && (
|
||||
<TableRow
|
||||
icon={<TableRowAssetIcon name="ListBulletsIcon" />}
|
||||
label="Dependents (TODO)"
|
||||
subLabel={`${dependents.length} other plugins depend on ${name}`}
|
||||
/>
|
||||
)}
|
||||
</TableRowGroup>
|
||||
)
|
||||
}
|
||||
|
||||
function bitFieldToString(map: Record<string, number>, bitField: number) {
|
||||
return (
|
||||
Object.entries(map)
|
||||
|
|
@ -176,6 +198,72 @@ function bitFieldToString(map: Record<string, number>, bitField: number) {
|
|||
)
|
||||
}
|
||||
|
||||
export function IdRow({ id }: { id: string }) {
|
||||
return (
|
||||
<TableRow
|
||||
icon={<TableRowAssetIcon name="IdIcon" />}
|
||||
label="ID"
|
||||
subLabel={id}
|
||||
onPress={() => {
|
||||
Clipboard.setString(id)
|
||||
showCopiedToClipboardToast()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function RepositoryRow({
|
||||
text,
|
||||
copyable,
|
||||
}: {
|
||||
text: string
|
||||
copyable?: boolean
|
||||
}) {
|
||||
return (
|
||||
<TableRow
|
||||
icon={<TableRowAssetIcon name="GlobeEarthIcon" />}
|
||||
label="Repository"
|
||||
subLabel={text}
|
||||
onPress={
|
||||
copyable
|
||||
? () => {
|
||||
Clipboard.setString(text)
|
||||
showCopiedToClipboardToast()
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function usePluginRepositoryText(plugin: AnyPlugin) {
|
||||
const meta = getInternalPluginMeta(plugin)
|
||||
const internal = isPluginInternal(meta)
|
||||
const repoUrl = meta.source?.repo ?? null
|
||||
const hasUrl = !internal && repoUrl
|
||||
|
||||
const [repoName, setRepoName] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasUrl) return
|
||||
listRepos().then(
|
||||
repos => {
|
||||
const repo = repos.find(r => r.url === repoUrl)
|
||||
if (repo?.name) setRepoName(repo.name)
|
||||
},
|
||||
() => {},
|
||||
)
|
||||
}, [repoUrl, hasUrl])
|
||||
|
||||
return hasUrl
|
||||
? repoName
|
||||
? `${repoName} (${repoUrl})`
|
||||
: repoUrl
|
||||
: internal
|
||||
? 'Built-in'
|
||||
: 'Sideloaded'
|
||||
}
|
||||
|
||||
function PluginActions({
|
||||
plugin,
|
||||
closeSheet,
|
||||
|
|
@ -187,6 +275,9 @@ function PluginActions({
|
|||
const settingsRef = useClickOutsideTooltip(useEnablePluginTooltip, () => {})
|
||||
const meta = getInternalPluginMeta(plugin)
|
||||
const startable = isPluginStartable(plugin)
|
||||
const enabled = usePluginEnabled(plugin)
|
||||
// Any lifecycle progress counts as running, stop waits for in-flight lifecycles
|
||||
const running = Boolean(usePluginStatus(plugin))
|
||||
|
||||
return (
|
||||
<Stack
|
||||
|
|
@ -194,6 +285,23 @@ function PluginActions({
|
|||
justify="space-around"
|
||||
style={{ paddingHorizontal: 8, paddingVertical: 16 }}
|
||||
>
|
||||
{enabled && !isPluginEssential(meta) && (
|
||||
<IconButton
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
icon={running ? StopIcon : PlayIcon}
|
||||
label={running ? 'Stop' : 'Start'}
|
||||
disabled={!running && !startable}
|
||||
onPress={async () => {
|
||||
try {
|
||||
if (running) await stopPlugin(plugin)
|
||||
else await runPluginLate(plugin)
|
||||
} catch (e) {
|
||||
showErrorToast(messageOf(e))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<IconButton
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
|
|
@ -245,7 +353,7 @@ function PluginActions({
|
|||
|
||||
const CopyIcon = lookupGeneratedIconComponent('CopyIcon')!
|
||||
|
||||
function showCopiedToClipboardToast() {
|
||||
export function showCopiedToClipboardToast() {
|
||||
ToastActionCreators.open({
|
||||
key: 'REVENGE_PLUGIN_SETTINGS_COPIED',
|
||||
content: 'Copied to clipboard',
|
||||
|
|
@ -64,6 +64,11 @@ export default function PluginStatesProvider({
|
|||
pEmitter.on('disabled', handleDisabled)
|
||||
|
||||
return () => {
|
||||
pEmitter.off('preInited', handleStatusChange)
|
||||
pEmitter.off('inited', handleStatusChange)
|
||||
pEmitter.off('started', handleStatusChange)
|
||||
pEmitter.off('stopped', handleStatusChange)
|
||||
|
||||
pEmitter.off('enabled', handleEnabled)
|
||||
pEmitter.off('disabled', handleDisabled)
|
||||
}
|
||||
11
src/plugins/start/settings.plugins/constants.ts
Normal file
11
src/plugins/start/settings.plugins/constants.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
export const Setting = {
|
||||
RevengePlugins: 'RevengePlugins',
|
||||
RevengePluginsAdvanced: 'RevengePluginsAdvanced',
|
||||
RevengePluginsBrowse: 'RevengePluginsBrowse',
|
||||
} as const
|
||||
|
||||
export const RouteNames = {
|
||||
[Setting.RevengePlugins]: 'Revenge Plugins',
|
||||
[Setting.RevengePluginsAdvanced]: 'Revenge Plugins Advanced',
|
||||
[Setting.RevengePluginsBrowse]: 'Browse Plugins',
|
||||
} as const
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import TableRowAssetIcon from '@revenge-mod/components/TableRowAssetIcon'
|
||||
import { RouteNames, Setting } from '../constants'
|
||||
import defer * as RevengePluginsAdvancedSettingScreen from '../screens/RevengePluginsAdvancedSettingScreen'
|
||||
import type { SettingsItem } from '@revenge-mod/discord/modules/settings'
|
||||
|
||||
const RevengePluginsAdvancedSetting: SettingsItem = {
|
||||
parent: null,
|
||||
type: 'route',
|
||||
IconComponent: () => <TableRowAssetIcon name="SettingsIcon" />,
|
||||
useTitle: () => 'Advanced',
|
||||
screen: {
|
||||
route: RouteNames[Setting.RevengePluginsAdvanced],
|
||||
getComponent: () => RevengePluginsAdvancedSettingScreen.default,
|
||||
},
|
||||
}
|
||||
|
||||
export default RevengePluginsAdvancedSetting
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import TableRowAssetIcon from '@revenge-mod/components/TableRowAssetIcon'
|
||||
import { RouteNames, Setting } from '../constants'
|
||||
import defer * as RevengePluginsBrowseSettingScreen from '../screens/RevengePluginsBrowseSettingScreen'
|
||||
import type { SettingsItem } from '@revenge-mod/discord/modules/settings'
|
||||
|
||||
const RevengePluginsBrowseSetting: SettingsItem = {
|
||||
parent: null,
|
||||
type: 'route',
|
||||
IconComponent: () => <TableRowAssetIcon name="PlusLargeIcon" />,
|
||||
useTitle: () => 'Browse Plugins',
|
||||
screen: {
|
||||
route: RouteNames[Setting.RevengePluginsBrowse],
|
||||
getComponent: () => RevengePluginsBrowseSettingScreen.default,
|
||||
},
|
||||
}
|
||||
|
||||
export default RevengePluginsBrowseSetting
|
||||
104
src/plugins/start/settings.plugins/index.ts
Normal file
104
src/plugins/start/settings.plugins/index.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { ToastActionCreators } from '@revenge-mod/discord/actions'
|
||||
import {
|
||||
onSettingsModulesLoaded,
|
||||
refreshSettingsNavigator,
|
||||
refreshSettingsOverviewScreen,
|
||||
} from '@revenge-mod/discord/modules/settings'
|
||||
import {
|
||||
InternalPluginFlags,
|
||||
isPluginEnabledLate,
|
||||
PluginFlags,
|
||||
pEmitter,
|
||||
registerInternalPlugin,
|
||||
} from '@revenge-mod/plugins/_'
|
||||
import {
|
||||
refreshAllRepos,
|
||||
updateAllPlugins,
|
||||
} from '@revenge-mod/plugins/_/repositories'
|
||||
import { lookupGeneratedIconComponent } from '@revenge-mod/utils/discord'
|
||||
import pluginSettings from '../settings'
|
||||
import type { PluginApi } from '@revenge-mod/plugins/types'
|
||||
|
||||
export interface Storage {
|
||||
autoUpdate: boolean
|
||||
lastUpdateCheck?: number
|
||||
}
|
||||
|
||||
const OUTDATED_THRESHOLD = 24 * 60 * 60 * 1000 // 1 day
|
||||
const AUTO_UPDATE_CHECK_DELAY = 10_000
|
||||
|
||||
const CircleXIconComponent = lookupGeneratedIconComponent(
|
||||
'CircleXIcon',
|
||||
'CircleXIcon-primary',
|
||||
'CircleXIcon-secondary',
|
||||
)!
|
||||
|
||||
registerInternalPlugin<{ jsonStorage: Storage }>(
|
||||
{
|
||||
id: 'revenge.settings.plugins',
|
||||
name: 'Plugin Settings',
|
||||
description: 'Plugin management UI for Revenge.',
|
||||
author: 'Revenge',
|
||||
icon: 'PuzzlePieceIcon',
|
||||
dependencies: { [pluginSettings]: {} },
|
||||
},
|
||||
{
|
||||
jsonStorage: {
|
||||
load: true,
|
||||
default: {
|
||||
autoUpdate: true,
|
||||
},
|
||||
},
|
||||
async start(api_) {
|
||||
api = api_
|
||||
|
||||
// @as-require
|
||||
import('./plugins')
|
||||
|
||||
onSettingsModulesLoaded(() => {
|
||||
// @as-require
|
||||
import('./register')
|
||||
})
|
||||
|
||||
if (isPluginEnabledLate(api_.plugin)) {
|
||||
refreshSettingsOverviewScreen()
|
||||
refreshSettingsNavigator()
|
||||
}
|
||||
|
||||
pEmitter.addListener('allReposRefreshed', () => {
|
||||
api.jsonStorage.set({ lastUpdateCheck: Date.now() })
|
||||
})
|
||||
|
||||
const settings = await api.jsonStorage.get()
|
||||
if (!settings.autoUpdate) return
|
||||
if (
|
||||
settings.lastUpdateCheck !== undefined &&
|
||||
Date.now() - settings.lastUpdateCheck <= OUTDATED_THRESHOLD
|
||||
)
|
||||
return
|
||||
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await refreshAllRepos()
|
||||
await updateAllPlugins()
|
||||
} catch (e) {
|
||||
ToastActionCreators.open({
|
||||
key: 'PLUGIN_UPDATE_CHECK_FAILED',
|
||||
content: 'Failed to check for plugin updates',
|
||||
IconComponent: CircleXIconComponent,
|
||||
})
|
||||
|
||||
api.logger.warn('Failed to check for plugin updates', e)
|
||||
}
|
||||
}, AUTO_UPDATE_CHECK_DELAY)
|
||||
},
|
||||
stop({ cleanup }) {
|
||||
cleanup(refreshSettingsOverviewScreen, refreshSettingsNavigator)
|
||||
},
|
||||
},
|
||||
PluginFlags.Enabled,
|
||||
InternalPluginFlags.Internal | InternalPluginFlags.Essential,
|
||||
)
|
||||
|
||||
// Exposed for the Advanced settings screen (auto-update toggle)
|
||||
export let api: PluginApi<{ jsonStorage: Storage }>
|
||||
|
|
@ -16,11 +16,12 @@ import {
|
|||
} from '@revenge-mod/plugins/_'
|
||||
import { lookupGeneratedIconComponent } from '@revenge-mod/utils/discord'
|
||||
import { useLayoutEffect } from 'react'
|
||||
import { Setting } from '../settings/constants'
|
||||
import defer * as NavigatorHeaderWithIcon from './components/NavigatorHeaderWithIcon'
|
||||
import PluginInstallConfirmAlert from './components/PluginInstallConfirmAlert'
|
||||
import PluginInstallFailedAlert from './components/PluginInstallFailedAlert'
|
||||
import PluginsFailedToStartAlert from './components/PluginsFailedToStartAlert'
|
||||
import PluginsRequireReloadAlert from './components/PluginsRequireReloadAlert'
|
||||
import { Setting } from './constants'
|
||||
import type { StackScreenProps } from '@react-navigation/stack'
|
||||
import type { ReactNavigationParamList } from '@revenge-mod/externals/react-navigation'
|
||||
import type { PluginApi } from '@revenge-mod/plugins/types'
|
||||
|
|
@ -124,6 +125,15 @@ pEmitter.on('stopped', plugin => {
|
|||
|
||||
const pluginInstallToastKeyFor = (id: string) => `REVENGE_PLUGIN_INSTALL:${id}`
|
||||
const PluginInstallFailedAlertKey = 'plugin-install-failed'
|
||||
const PluginInstallConfirmAlertKey = 'plugin-install-confirm'
|
||||
|
||||
pEmitter.on('installReady', prompt => {
|
||||
AlertActionCreators.dismissAlert(PluginInstallConfirmAlertKey)
|
||||
AlertActionCreators.openAlert(
|
||||
PluginInstallConfirmAlertKey,
|
||||
<PluginInstallConfirmAlert prompt={prompt} />,
|
||||
)
|
||||
})
|
||||
|
||||
pEmitter.on('install', result => {
|
||||
if (result.error !== false) {
|
||||
|
|
@ -135,6 +145,15 @@ pEmitter.on('install', result => {
|
|||
return
|
||||
}
|
||||
|
||||
if (result.pending) {
|
||||
// Applied on disk only, the new version loads at next reload
|
||||
showInstallToast(
|
||||
`Downloaded ${pList.get(result.id)?.manifest.name || result.id} ${result.version}, reload to apply`,
|
||||
result.id,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
showInstallToast(
|
||||
result.updated
|
||||
? `Updated ${result.manifest.name}`
|
||||
17
src/plugins/start/settings.plugins/register.tsx
Normal file
17
src/plugins/start/settings.plugins/register.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import {
|
||||
addSettingsItemToSection,
|
||||
registerSettingsItems,
|
||||
} from '@revenge-mod/discord/modules/settings'
|
||||
import { Setting } from './constants'
|
||||
import RevengePluginsAdvancedSetting from './definitions/RevengePluginsAdvancedSetting'
|
||||
import RevengePluginsBrowseSetting from './definitions/RevengePluginsBrowseSetting'
|
||||
import RevengePluginsSetting from './definitions/RevengePluginsSetting'
|
||||
|
||||
registerSettingsItems({
|
||||
[Setting.RevengePlugins]: RevengePluginsSetting,
|
||||
[Setting.RevengePluginsAdvanced]: RevengePluginsAdvancedSetting,
|
||||
[Setting.RevengePluginsBrowse]: RevengePluginsBrowseSetting,
|
||||
})
|
||||
|
||||
// The settings plugin registers this section, and it always starts before us (we depend on it).
|
||||
addSettingsItemToSection('REVENGE', Setting.RevengePlugins, 1)
|
||||
|
|
@ -0,0 +1,368 @@
|
|||
import { getAssetIdByName } from '@revenge-mod/assets'
|
||||
import { FormSwitch } from '@revenge-mod/components'
|
||||
import { styles } from '@revenge-mod/components/_'
|
||||
import Page from '@revenge-mod/components/Page'
|
||||
import TableRowAssetIcon from '@revenge-mod/components/TableRowAssetIcon'
|
||||
import { Design } from '@revenge-mod/discord/design'
|
||||
import { callNativeMethod } from '@revenge-mod/modules/native'
|
||||
import { resyncPluginSources } from '@revenge-mod/plugins/_'
|
||||
import {
|
||||
listAllUpdates,
|
||||
listRepos,
|
||||
refreshAllRepos,
|
||||
refreshRepo,
|
||||
repoEvents,
|
||||
setRepos,
|
||||
updateAllPlugins,
|
||||
} from '@revenge-mod/plugins/_/repositories'
|
||||
import { lookupGeneratedIconComponent } from '@revenge-mod/utils/discord'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { ScrollView, View } from 'react-native'
|
||||
import { api } from '..'
|
||||
import { formatBytes, messageOf, showErrorToast } from '../utils/repos'
|
||||
import type {
|
||||
DownloadProgressEvent,
|
||||
Repo,
|
||||
RepoConfigEntry,
|
||||
RepoStateEvent,
|
||||
RepoUpdate,
|
||||
} from '@revenge-mod/plugins/_/repositories'
|
||||
|
||||
const {
|
||||
Button,
|
||||
ContextMenu,
|
||||
IconButton,
|
||||
Stack,
|
||||
TableRow,
|
||||
TableRowGroup,
|
||||
TableSwitchRow,
|
||||
TextInput,
|
||||
} = Design
|
||||
|
||||
const MoreIcon = getAssetIdByName('MoreVerticalIcon')!
|
||||
const UpIconComponent = lookupGeneratedIconComponent('ArrowSmallUpIcon')!
|
||||
const DownIconComponent = lookupGeneratedIconComponent('ArrowSmallDownIcon')!
|
||||
const TrashIconComponent = lookupGeneratedIconComponent('TrashIcon')!
|
||||
|
||||
function toConfig(list: Repo[]): RepoConfigEntry[] {
|
||||
return list.map(({ url, enabled }) => ({ url, enabled }))
|
||||
}
|
||||
|
||||
function repoSubLabel(repo: Repo, state?: RepoStateEvent['state']) {
|
||||
if (state === 'refreshing') return `${repo.url} (refreshing...)`
|
||||
if (state === 'error') return `${repo.url} (refresh failed)`
|
||||
return repo.url
|
||||
}
|
||||
|
||||
interface UserRepoRowProps {
|
||||
repo: Repo
|
||||
state?: RepoStateEvent['state']
|
||||
onMove: (repo: Repo, delta: number) => void
|
||||
onRemove: (repo: Repo) => void
|
||||
onToggle: (repo: Repo, enabled: boolean) => void
|
||||
}
|
||||
|
||||
function UserRepoRow({
|
||||
repo,
|
||||
state,
|
||||
onMove,
|
||||
onRemove,
|
||||
onToggle,
|
||||
}: UserRepoRowProps) {
|
||||
const menuItems = [
|
||||
[
|
||||
{
|
||||
label: 'Move up',
|
||||
IconComponent: UpIconComponent,
|
||||
action: () => onMove(repo, -1),
|
||||
},
|
||||
{
|
||||
label: 'Move down',
|
||||
IconComponent: DownIconComponent,
|
||||
action: () => onMove(repo, 1),
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
label: 'Refresh',
|
||||
IconComponent: lookupGeneratedIconComponent('RefreshIcon')!,
|
||||
action: () =>
|
||||
refreshRepo(repo.url).catch(e => {
|
||||
showErrorToast(messageOf(e))
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
IconComponent: TrashIconComponent,
|
||||
variant: 'destructive' as const,
|
||||
action: () => onRemove(repo),
|
||||
},
|
||||
],
|
||||
]
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
label={repo.name ?? repo.url}
|
||||
subLabel={repoSubLabel(repo, state)}
|
||||
trailing={
|
||||
<Stack direction="horizontal" spacing={8}>
|
||||
<ContextMenu
|
||||
items={menuItems}
|
||||
title={repo.name ?? repo.url}
|
||||
>
|
||||
{props => (
|
||||
<IconButton
|
||||
{...props}
|
||||
icon={MoreIcon}
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
/>
|
||||
)}
|
||||
</ContextMenu>
|
||||
<FormSwitch
|
||||
value={repo.enabled}
|
||||
onValueChange={enabled => onToggle(repo, enabled)}
|
||||
/>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default function RevengePluginsAdvancedSettingScreen() {
|
||||
// Internal repositories are native-managed and not part of the config.
|
||||
const [repos, setReposState] = useState<Repo[]>([])
|
||||
const [url, setUrl] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [updates, setUpdates] = useState<RepoUpdate[] | null>(null)
|
||||
const [repoStates, setRepoStates] = useState<
|
||||
Record<string, RepoStateEvent['state']>
|
||||
>({})
|
||||
const [progress, setProgress] = useState<DownloadProgressEvent | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const onRepoState = (event: RepoStateEvent) => {
|
||||
setRepoStates(states => ({ ...states, [event.url]: event.state }))
|
||||
}
|
||||
const onProgress = (event: DownloadProgressEvent) => {
|
||||
setProgress(event.received >= event.total ? null : event)
|
||||
}
|
||||
|
||||
repoEvents.on('repoState', onRepoState)
|
||||
repoEvents.on('downloadProgress', onProgress)
|
||||
return () => {
|
||||
repoEvents.off('repoState', onRepoState)
|
||||
repoEvents.off('downloadProgress', onProgress)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const settings = api.jsonStorage.use()
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
listRepos().then(setReposState, e => showErrorToast(messageOf(e)))
|
||||
}, [])
|
||||
|
||||
useEffect(refresh, [refresh])
|
||||
|
||||
const commit = useCallback(
|
||||
async (config: RepoConfigEntry[]) => {
|
||||
try {
|
||||
await setRepos(config)
|
||||
} catch (e) {
|
||||
showErrorToast(messageOf(e))
|
||||
}
|
||||
refresh()
|
||||
},
|
||||
[refresh],
|
||||
)
|
||||
|
||||
const userRepos = repos.filter(repo => !repo.internal)
|
||||
|
||||
const move = useCallback(
|
||||
(repo: Repo, delta: number) => {
|
||||
const config = toConfig(userRepos)
|
||||
const from = config.findIndex(entry => entry.url === repo.url)
|
||||
const to = from + delta
|
||||
if (from < 0 || to < 0 || to >= config.length) return
|
||||
;[config[from], config[to]] = [config[to], config[from]]
|
||||
commit(config)
|
||||
},
|
||||
[commit, userRepos],
|
||||
)
|
||||
|
||||
const addRepo = useCallback(async () => {
|
||||
const added = url.trim()
|
||||
await commit([...toConfig(userRepos), { url: added, enabled: true }])
|
||||
setUrl('')
|
||||
|
||||
try {
|
||||
// Refresh the new repository right away so its plugins show up
|
||||
await refreshRepo(added)
|
||||
} catch (e) {
|
||||
showErrorToast(messageOf(e))
|
||||
}
|
||||
refresh()
|
||||
}, [commit, refresh, url, userRepos])
|
||||
|
||||
const removeRepo = useCallback(
|
||||
async (repo: Repo) => {
|
||||
await commit(toConfig(userRepos.filter(r => r.url !== repo.url)))
|
||||
|
||||
// Plugins installed from the removed repository turn Sideloaded
|
||||
resyncPluginSources().catch(() => {})
|
||||
},
|
||||
[commit, userRepos],
|
||||
)
|
||||
|
||||
const toggleRepo = useCallback(
|
||||
(repo: Repo, enabled: boolean) => {
|
||||
commit(
|
||||
toConfig(userRepos).map(entry =>
|
||||
entry.url === repo.url ? { ...entry, enabled } : entry,
|
||||
),
|
||||
)
|
||||
},
|
||||
[commit, userRepos],
|
||||
)
|
||||
|
||||
const checkForUpdates = useCallback(async () => {
|
||||
setBusy(true)
|
||||
try {
|
||||
const { errors } = await refreshAllRepos()
|
||||
if (errors.length)
|
||||
showErrorToast(
|
||||
errors.map(e => `${e.url}: ${e.error}`).join('\n'),
|
||||
)
|
||||
const result = await listAllUpdates()
|
||||
setUpdates(result.updates)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
refresh()
|
||||
}
|
||||
}, [refresh])
|
||||
|
||||
const updateAll = useCallback(async () => {
|
||||
setBusy(true)
|
||||
try {
|
||||
const { errors } = await updateAllPlugins()
|
||||
if (errors.length)
|
||||
showErrorToast(
|
||||
errors.map(e => `${e.id}: ${e.error}`).join('\n'),
|
||||
)
|
||||
setUpdates(null)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
refresh()
|
||||
}
|
||||
}, [refresh])
|
||||
|
||||
const lastCheckedSubLabel =
|
||||
updates !== null && !updates.length
|
||||
? 'All plugins up to date!'
|
||||
: settings?.lastUpdateCheck &&
|
||||
`Last checked: ${new Date(settings.lastUpdateCheck).toLocaleString()}`
|
||||
|
||||
return (
|
||||
<Page spacing={0}>
|
||||
<ScrollView keyboardShouldPersistTaps="handled" style={styles.flex}>
|
||||
<Stack spacing={16} style={{ paddingBottom: 32 }}>
|
||||
<Stack direction="horizontal" spacing={8}>
|
||||
<View style={styles.grow}>
|
||||
<TextInput
|
||||
label="Repository URL"
|
||||
onChange={setUrl}
|
||||
placeholder="https://example.com/plugins"
|
||||
size="md"
|
||||
value={url}
|
||||
/>
|
||||
</View>
|
||||
</Stack>
|
||||
<Button
|
||||
disabled={!url.trim()}
|
||||
onPress={addRepo}
|
||||
text="Add repository"
|
||||
variant="primary"
|
||||
/>
|
||||
<TableRowGroup title="Repositories">
|
||||
{repos.map(repo =>
|
||||
repo.internal ? (
|
||||
<TableRow
|
||||
icon={<TableRowAssetIcon name="LockIcon" />}
|
||||
key={repo.url}
|
||||
label={repo.name ?? repo.url}
|
||||
subLabel={repo.description ?? repo.url}
|
||||
/>
|
||||
) : (
|
||||
<UserRepoRow
|
||||
key={repo.url}
|
||||
repo={repo}
|
||||
state={repoStates[repo.url]}
|
||||
onMove={move}
|
||||
onRemove={removeRepo}
|
||||
onToggle={toggleRepo}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</TableRowGroup>
|
||||
<TableRowGroup title="Updates">
|
||||
<TableSwitchRow
|
||||
label="Update plugins automatically"
|
||||
subLabel="Check repositories and apply plugin updates after startup."
|
||||
onValueChange={autoUpdate => {
|
||||
api.jsonStorage.set({ autoUpdate })
|
||||
}}
|
||||
value={settings?.autoUpdate ?? true}
|
||||
/>
|
||||
<TableRow
|
||||
icon={<TableRowAssetIcon name="RefreshIcon" />}
|
||||
label="Check for updates"
|
||||
subLabel={lastCheckedSubLabel}
|
||||
disabled={busy || !userRepos.length}
|
||||
onPress={checkForUpdates}
|
||||
/>
|
||||
{progress ? (
|
||||
<TableRow
|
||||
icon={<TableRowAssetIcon name="<EMPTY>" />}
|
||||
label={`Downloading ${progress.id} ${progress.version}`}
|
||||
subLabel={`${formatBytes(progress.received)} / ${formatBytes(progress.total)} (${progress.index} of ${progress.count})`}
|
||||
/>
|
||||
) : null}
|
||||
{updates?.length ? (
|
||||
<>
|
||||
{updates.map(update => (
|
||||
<TableRow
|
||||
icon={
|
||||
<TableRowAssetIcon name="DownloadIcon" />
|
||||
}
|
||||
key={update.id}
|
||||
label={update.id}
|
||||
subLabel={`${update.installed} → ${update.available} (${update.channel})`}
|
||||
/>
|
||||
))}
|
||||
<TableRow
|
||||
icon={
|
||||
<TableRowAssetIcon name="DownloadIcon" />
|
||||
}
|
||||
label="Update all"
|
||||
onPress={updateAll}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</TableRowGroup>
|
||||
<TableRowGroup title="Advanced">
|
||||
<TableRow
|
||||
icon={<TableRowAssetIcon name="DownloadIcon" />}
|
||||
label="Install from file"
|
||||
onPress={() =>
|
||||
callNativeMethod(
|
||||
'revenge.plugins.installFile',
|
||||
[],
|
||||
)
|
||||
}
|
||||
/>
|
||||
</TableRowGroup>
|
||||
</Stack>
|
||||
</ScrollView>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,229 @@
|
|||
import { getAssetIdByName } from '@revenge-mod/assets'
|
||||
import { styles } from '@revenge-mod/components/_'
|
||||
import Page from '@revenge-mod/components/Page'
|
||||
import SearchInput from '@revenge-mod/components/SearchInput'
|
||||
import { ActionSheetActionCreators } from '@revenge-mod/discord/actions'
|
||||
import { Design } from '@revenge-mod/discord/design'
|
||||
import { getInternalPluginMeta, pEmitter, pList } from '@revenge-mod/plugins/_'
|
||||
import {
|
||||
listRepoPlugins,
|
||||
listRepos,
|
||||
refreshAllRepos,
|
||||
} from '@revenge-mod/plugins/_/repositories'
|
||||
import { debounce } from '@revenge-mod/utils/callback'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { View } from 'react-native'
|
||||
import { ClickOutsideProvider } from 'react-native-click-outside'
|
||||
import { BrowsePluginMasonryFlashList } from '../components/PluginList'
|
||||
import PluginStatesProvider from '../components/PluginStateProvider'
|
||||
import {
|
||||
EnablePluginTooltipProvider,
|
||||
EssentialPluginTooltipProvider,
|
||||
} from '../components/TooltipProvider'
|
||||
import { runInstallFlow } from '../utils/repos'
|
||||
import type { RepoPluginListing } from '@revenge-mod/plugins/_/repositories'
|
||||
import type { BrowseSortKey } from '../components/BrowseFilterAndSortActionSheet'
|
||||
import type { BrowseEntry } from '../components/PluginList'
|
||||
|
||||
const { Stack, IconButton, LayerScope } = Design
|
||||
|
||||
const FiltersHorizontalIcon = getAssetIdByName('FiltersHorizontalIcon', 'png')!
|
||||
|
||||
const SearchDebounceTime = 100
|
||||
|
||||
export default function RevengePluginsBrowseSettingScreen() {
|
||||
return (
|
||||
<LayerScope>
|
||||
<ClickOutsideProvider>
|
||||
<PluginStatesProvider>
|
||||
<Page spacing={16}>
|
||||
<EssentialPluginTooltipProvider>
|
||||
<EnablePluginTooltipProvider>
|
||||
<Screen />
|
||||
</EnablePluginTooltipProvider>
|
||||
</EssentialPluginTooltipProvider>
|
||||
</Page>
|
||||
</PluginStatesProvider>
|
||||
</ClickOutsideProvider>
|
||||
</LayerScope>
|
||||
)
|
||||
}
|
||||
|
||||
// TODO: Let the user pick a release channel
|
||||
/** The version a listing displays: its `latest` channel, else the first channel. */
|
||||
function displayVersionOf(listing: RepoPluginListing) {
|
||||
return listing.channels.latest ?? Object.values(listing.channels)[0] ?? ''
|
||||
}
|
||||
|
||||
function compareNames(a: BrowseEntry, b: BrowseEntry) {
|
||||
return a.listing.name.localeCompare(b.listing.name, undefined, {
|
||||
sensitivity: 'base',
|
||||
})
|
||||
}
|
||||
|
||||
const Sorts: Record<BrowseSortKey, (a: BrowseEntry, b: BrowseEntry) => number> =
|
||||
{
|
||||
name: compareNames,
|
||||
size: (a, b) => a.size - b.size || compareNames(a, b),
|
||||
}
|
||||
|
||||
function Screen() {
|
||||
const [entries, setEntries] = useState<BrowseEntry[]>([])
|
||||
|
||||
const [search, setSearch] = useState('')
|
||||
const debouncedSetSearch = useCallback(
|
||||
debounce(setSearch, SearchDebounceTime),
|
||||
[],
|
||||
)
|
||||
|
||||
// Unchecked repository URLs, so repos that appear later default to checked
|
||||
const [excluded, setExcluded] = useState<string[]>([])
|
||||
const [sort, setSort] = useState<BrowseSortKey>('name')
|
||||
const [reverse, setReverse] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const repos = await listRepos()
|
||||
const all: BrowseEntry[] = []
|
||||
|
||||
await Promise.all(
|
||||
repos
|
||||
.filter(repo => !repo.internal && repo.enabled)
|
||||
.map(repo =>
|
||||
listRepoPlugins(repo.url).then(
|
||||
listings => {
|
||||
const repositoryText = repo.name
|
||||
? `${repo.name} (${repo.url})`
|
||||
: repo.url
|
||||
|
||||
for (const listing of listings) {
|
||||
const plugin = pList.get(listing.id)
|
||||
const displayVersion = displayVersionOf(listing)
|
||||
all.push({
|
||||
key: `${repo.url}#${listing.id}`,
|
||||
listing,
|
||||
repoUrl: repo.url,
|
||||
repoName: repo.name ?? null,
|
||||
repositoryText,
|
||||
version: displayVersion,
|
||||
size:
|
||||
listing.versions[displayVersion]
|
||||
?.size ?? 0,
|
||||
installed: plugin
|
||||
? ([
|
||||
plugin,
|
||||
getInternalPluginMeta(plugin),
|
||||
] as const)
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
},
|
||||
// No cached index yet is normal, ignore
|
||||
() => {},
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
setEntries(all)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// Show cached indexes right away, then refresh everything
|
||||
load()
|
||||
refreshAllRepos().then(load, () => {})
|
||||
}, [load])
|
||||
|
||||
// Fresh installs register live, re-mark entries as installed when they do
|
||||
useEffect(() => {
|
||||
const handleUpdate = () => load()
|
||||
|
||||
pEmitter.on('register', handleUpdate)
|
||||
pEmitter.on('unregister', handleUpdate)
|
||||
|
||||
return () => {
|
||||
pEmitter.off('register', handleUpdate)
|
||||
pEmitter.off('unregister', handleUpdate)
|
||||
}
|
||||
}, [load])
|
||||
|
||||
const install = useCallback(
|
||||
async (entry: BrowseEntry) => {
|
||||
await runInstallFlow(entry.listing.id)
|
||||
load()
|
||||
},
|
||||
[load],
|
||||
)
|
||||
|
||||
// Repositories that currently have entries, for the filter sheet
|
||||
const repos = useMemo(() => {
|
||||
const seen = new Map<string, string | null>()
|
||||
for (const entry of entries)
|
||||
if (!seen.has(entry.repoUrl))
|
||||
seen.set(entry.repoUrl, entry.repoName)
|
||||
|
||||
return [...seen].map(([url, name]) => ({ url, name }))
|
||||
}, [entries])
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const query = search.toLowerCase()
|
||||
|
||||
const result = entries.filter(entry => {
|
||||
if (excluded.includes(entry.repoUrl)) return false
|
||||
if (!query) return true
|
||||
|
||||
const { name, description, author, id } = entry.listing
|
||||
return (
|
||||
name.toLowerCase().includes(query) ||
|
||||
description.toLowerCase().includes(query) ||
|
||||
author.toLowerCase().includes(query) ||
|
||||
id.toLowerCase().includes(query)
|
||||
)
|
||||
})
|
||||
|
||||
result.sort(Sorts[sort])
|
||||
if (reverse) result.reverse()
|
||||
|
||||
return result
|
||||
}, [entries, excluded, search, sort, reverse])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack direction="horizontal">
|
||||
<View style={styles.grow}>
|
||||
<SearchInput onChange={debouncedSetSearch} size="md" />
|
||||
</View>
|
||||
<IconButton
|
||||
icon={FiltersHorizontalIcon}
|
||||
variant="tertiary"
|
||||
onPress={() =>
|
||||
ActionSheetActionCreators.openLazy(
|
||||
import(
|
||||
'../components/BrowseFilterAndSortActionSheet'
|
||||
),
|
||||
'browse-filter-and-sort',
|
||||
{
|
||||
repos,
|
||||
checked: repos
|
||||
.map(repo => repo.url)
|
||||
.filter(url => !excluded.includes(url)),
|
||||
setChecked: (urls: string[]) =>
|
||||
setExcluded(
|
||||
repos
|
||||
.map(repo => repo.url)
|
||||
.filter(url => !urls.includes(url)),
|
||||
),
|
||||
sort,
|
||||
setSort,
|
||||
reverse,
|
||||
setReverse,
|
||||
},
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
<BrowsePluginMasonryFlashList
|
||||
entries={visible}
|
||||
onInstall={install}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -5,13 +5,14 @@ import Page from '@revenge-mod/components/Page'
|
|||
import SearchInput from '@revenge-mod/components/SearchInput'
|
||||
import { ActionSheetActionCreators } from '@revenge-mod/discord/actions'
|
||||
import { Design } from '@revenge-mod/discord/design'
|
||||
import { callNativeMethod } from '@revenge-mod/modules/native'
|
||||
import {
|
||||
getInternalPluginMeta,
|
||||
InternalPluginFlags,
|
||||
isPluginEnabled,
|
||||
isPluginEssential,
|
||||
isPluginInternal,
|
||||
isPluginPendingReload,
|
||||
isPluginPendingUpdate,
|
||||
pEmitter,
|
||||
pList,
|
||||
} from '@revenge-mod/plugins/_'
|
||||
|
|
@ -32,15 +33,16 @@ import {
|
|||
EnablePluginTooltipProvider,
|
||||
EssentialPluginTooltipProvider,
|
||||
} from '../components/TooltipProvider'
|
||||
import type { RouteProp } from '@react-navigation/core'
|
||||
import { RouteNames, Setting } from '../constants'
|
||||
import type { NavigationProp, RouteProp } from '@react-navigation/core'
|
||||
import type { ReactNavigationParamList } from '@revenge-mod/externals/react-navigation'
|
||||
import type { FilterAndSortActionSheetProps } from '../components/FilterAndSortActionSheet'
|
||||
import type { RouteNames, Setting } from '../constants'
|
||||
|
||||
const { Stack, IconButton, LayerScope, ContextMenu } = Design
|
||||
const { Stack, IconButton, FloatingActionButton, LayerScope } = Design
|
||||
|
||||
const FiltersHorizontalIcon = getAssetIdByName('FiltersHorizontalIcon', 'png')!
|
||||
const MoreHorizontalIcon = getAssetIdByName('MoreHorizontalIcon')!
|
||||
const SettingsIcon = getAssetIdByName('SettingsIcon')!
|
||||
const PlusLargeIcon = getAssetIdByName('PlusLargeIcon')!
|
||||
|
||||
export default function RevengePluginsSettingScreen() {
|
||||
return (
|
||||
|
|
@ -75,6 +77,14 @@ const Filters: FilterAndSortActionSheetProps['filters'] = {
|
|||
icon: getAssetIdByName('CircleErrorIcon')!,
|
||||
filter: plugin => plugin.errors.length > 0,
|
||||
},
|
||||
'Pending Reload': {
|
||||
icon: getAssetIdByName('RetryIcon')!,
|
||||
filter: plugin => isPluginPendingReload(plugin),
|
||||
},
|
||||
'Pending Update': {
|
||||
icon: getAssetIdByName('RefreshIcon')!,
|
||||
filter: plugin => isPluginPendingUpdate(plugin),
|
||||
},
|
||||
Internal: {
|
||||
icon: RevengeIcon,
|
||||
desc: 'Included with Revenge.',
|
||||
|
|
@ -110,27 +120,31 @@ const Sorts = {
|
|||
],
|
||||
} satisfies FilterAndSortActionSheetProps['sorts']
|
||||
|
||||
function HeaderContextMenu() {
|
||||
function HeaderButton() {
|
||||
const navigation = useNavigation<NavigationProp<any>>()
|
||||
|
||||
return (
|
||||
<ContextMenu
|
||||
title="More options"
|
||||
align="below"
|
||||
items={[
|
||||
{
|
||||
label: 'Install from file',
|
||||
action: () =>
|
||||
callNativeMethod('revenge.plugins.install', []),
|
||||
},
|
||||
]}
|
||||
>
|
||||
{props => (
|
||||
<IconButton
|
||||
{...props}
|
||||
icon={MoreHorizontalIcon}
|
||||
variant="tertiary"
|
||||
/>
|
||||
)}
|
||||
</ContextMenu>
|
||||
<IconButton
|
||||
icon={SettingsIcon}
|
||||
onPress={() =>
|
||||
navigation.navigate(RouteNames[Setting.RevengePluginsAdvanced])
|
||||
}
|
||||
variant="tertiary"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BrowseFloatingActionButton() {
|
||||
const navigation = useNavigation<NavigationProp<any>>()
|
||||
|
||||
return (
|
||||
<FloatingActionButton
|
||||
icon={PlusLargeIcon}
|
||||
accessibilityLabel="Browse plugins"
|
||||
onPress={() =>
|
||||
navigation.navigate(RouteNames[Setting.RevengePluginsBrowse])
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -158,7 +172,7 @@ function Screen() {
|
|||
|
||||
useLayoutEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerRight: () => <HeaderContextMenu />,
|
||||
headerRight: () => <HeaderButton />,
|
||||
})
|
||||
}, [navigation])
|
||||
|
||||
|
|
@ -242,6 +256,7 @@ function Screen() {
|
|||
/>
|
||||
</Stack>
|
||||
<InstalledPluginMasonryFlashList plugins={plugins} />
|
||||
<BrowseFloatingActionButton />
|
||||
</>
|
||||
)
|
||||
}
|
||||
16
src/plugins/start/settings.plugins/types.ts
Normal file
16
src/plugins/start/settings.plugins/types.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import type { RouteNames, Setting } from './constants'
|
||||
|
||||
declare module '@revenge-mod/externals/react-navigation' {
|
||||
interface ReactNavigationParamList extends PluginsSettingsParamList {}
|
||||
}
|
||||
|
||||
type PluginsSettingsParamList = {
|
||||
[K in (typeof RouteNames)[keyof typeof RouteNames]]: object
|
||||
} & {
|
||||
[K in (typeof RouteNames)[(typeof Setting)['RevengePlugins']]]: {
|
||||
sort?: string
|
||||
filter?: string[]
|
||||
matchAll?: boolean
|
||||
reverse?: boolean
|
||||
}
|
||||
}
|
||||
87
src/plugins/start/settings.plugins/utils/actions.tsx
Normal file
87
src/plugins/start/settings.plugins/utils/actions.tsx
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import {
|
||||
disablePlugin,
|
||||
enablePlugin,
|
||||
getMissingPluginDependencies,
|
||||
getPluginDependencies,
|
||||
getPluginDependents,
|
||||
isPluginEnabled,
|
||||
runPluginLate,
|
||||
} from '@revenge-mod/plugins/_'
|
||||
import {
|
||||
installFromRepo,
|
||||
planInstall,
|
||||
} from '@revenge-mod/plugins/_/repositories'
|
||||
import { noop } from '@revenge-mod/utils/callback'
|
||||
import {
|
||||
showPluginHasDependenciesAlert,
|
||||
showPluginHasDependentsAlert,
|
||||
showPluginMissingDependenciesAlert,
|
||||
} from './alerts'
|
||||
import { confirmPlan, messageOf, showErrorToast } from './repos'
|
||||
import type { AnyPlugin } from '@revenge-mod/plugins/_'
|
||||
import type { InstallPlan } from '@revenge-mod/plugins/_/repositories'
|
||||
|
||||
export async function handleEnablePlugin(plugin: AnyPlugin) {
|
||||
const missing = getMissingPluginDependencies(plugin)
|
||||
if (missing.length) {
|
||||
const deps = missing.map(id => ({
|
||||
id,
|
||||
range: plugin.manifest.dependencies?.[id]?.version ?? '*',
|
||||
}))
|
||||
|
||||
// Required deps can be uninstalled. Offer installing them back.
|
||||
showPluginMissingDependenciesAlert(plugin, deps, () => {
|
||||
// Non-async because we can't show 2 dialogs at the same time, so we need for the PluginMissing to close first
|
||||
// Then the ConfirmPlan dialog will open, and then the install will run after that.
|
||||
Promise.all(missing.map(id => planInstall(id)))
|
||||
.then(
|
||||
it =>
|
||||
it.reduce(
|
||||
(acc, cur) => ({
|
||||
actions: [...acc.actions, ...cur.actions],
|
||||
warnings: [...acc.warnings, ...cur.warnings],
|
||||
}),
|
||||
{ actions: [], warnings: [] },
|
||||
) satisfies InstallPlan,
|
||||
)
|
||||
.then(async plan => {
|
||||
const accepted = await confirmPlan(plan)
|
||||
if (!accepted) return false
|
||||
|
||||
await installFromRepo(plan)
|
||||
|
||||
await handleEnablePlugin(plugin)
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const dependencies = getPluginDependencies(plugin)
|
||||
const disabledDeps = dependencies.filter(dep => !isPluginEnabled(dep))
|
||||
|
||||
async function action() {
|
||||
try {
|
||||
await enablePlugin(plugin)
|
||||
} catch (e) {
|
||||
// Requirements not satisfied by native
|
||||
showErrorToast(messageOf(e))
|
||||
}
|
||||
|
||||
await runPluginLate(plugin).catch(noop)
|
||||
}
|
||||
|
||||
if (disabledDeps.length)
|
||||
showPluginHasDependenciesAlert(plugin, disabledDeps, action)
|
||||
else await action()
|
||||
}
|
||||
|
||||
export async function handleDisablePlugin(plugin: AnyPlugin) {
|
||||
const dependents = getPluginDependents(plugin, true)
|
||||
const action = () => disablePlugin(plugin)
|
||||
|
||||
const enabledDeps = dependents.filter(isPluginEnabled)
|
||||
|
||||
if (enabledDeps.length)
|
||||
showPluginHasDependentsAlert(plugin, enabledDeps, action)
|
||||
else await action()
|
||||
}
|
||||
|
|
@ -5,10 +5,12 @@ import {
|
|||
isPluginStartable,
|
||||
uninstallExternalPlugin,
|
||||
} from '@revenge-mod/plugins/_'
|
||||
import { getErrorStack } from '@revenge-mod/utils/error'
|
||||
import { deleteJsonStorageForPlugin } from '~plugins/preinit/api.json-storage'
|
||||
import PluginClearDataConfirmationAlert from '../components/PluginClearDataConfirmationAlert'
|
||||
import PluginHasDependenciesAlert from '../components/PluginHasDependenciesAlert'
|
||||
import PluginHasDependentsAlert from '../components/PluginHasDependentsAlert'
|
||||
import PluginMissingDependenciesAlert from '../components/PluginMissingDependenciesAlert'
|
||||
import PluginStatesProvider from '../components/PluginStateProvider'
|
||||
import PluginUninstallConfirmationAlert from '../components/PluginUninstallConfirmationAlert'
|
||||
import type { AnyPlugin } from '@revenge-mod/plugins/_'
|
||||
|
|
@ -20,9 +22,13 @@ export function showPluginClearDataConfirmation(
|
|||
const KEY = 'plugin-clear-data-confirmation'
|
||||
|
||||
async function action() {
|
||||
await deleteStorageForPlugin(plugin)
|
||||
// Trigger update for JSON storage
|
||||
await deleteJsonStorageForPlugin(plugin)
|
||||
try {
|
||||
await deleteStorageForPlugin(plugin)
|
||||
// Trigger update for JSON storage
|
||||
await deleteJsonStorageForPlugin(plugin)
|
||||
} catch (e) {
|
||||
alert(getErrorStack(e))
|
||||
}
|
||||
callback()
|
||||
}
|
||||
|
||||
|
|
@ -79,6 +85,21 @@ export function showPluginHasDependenciesAlert(
|
|||
)
|
||||
}
|
||||
|
||||
export function showPluginMissingDependenciesAlert(
|
||||
plugin: AnyPlugin,
|
||||
dependencies: { id: string; range: string }[],
|
||||
action: () => unknown,
|
||||
) {
|
||||
AlertActionCreators.openAlert(
|
||||
'plugin-missing-dependencies',
|
||||
<PluginMissingDependenciesAlert
|
||||
plugin={plugin}
|
||||
dependencies={dependencies}
|
||||
action={action}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
export function showPluginHasDependentsAlert(
|
||||
plugin: AnyPlugin,
|
||||
dependents: AnyPlugin[],
|
||||
159
src/plugins/start/settings.plugins/utils/repos.tsx
Normal file
159
src/plugins/start/settings.plugins/utils/repos.tsx
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import {
|
||||
AlertActionCreators,
|
||||
ToastActionCreators,
|
||||
} from '@revenge-mod/discord/actions'
|
||||
import { Design } from '@revenge-mod/discord/design'
|
||||
import { Clipboard } from '@revenge-mod/externals/react-native-clipboard'
|
||||
import { isPluginError } from '@revenge-mod/plugins/_'
|
||||
import {
|
||||
installFromRepo,
|
||||
listRepoPlugins,
|
||||
listRepos,
|
||||
planInstall,
|
||||
} from '@revenge-mod/plugins/_/repositories'
|
||||
import { lookupGeneratedIconComponent } from '@revenge-mod/utils/discord'
|
||||
import { PluginIcon } from '../components/PluginIcon'
|
||||
import type { InstallPlan } from '@revenge-mod/plugins/_/repositories'
|
||||
|
||||
const { AlertActionButton, AlertModal } = Design
|
||||
|
||||
const CircleXIconComponent = lookupGeneratedIconComponent(
|
||||
'CircleXIcon',
|
||||
'CircleXIcon-primary',
|
||||
'CircleXIcon-secondary',
|
||||
)!
|
||||
|
||||
export function showErrorToast(message: string) {
|
||||
ToastActionCreators.open({
|
||||
key: 'REVENGE_REPOSITORIES_ERROR',
|
||||
content: message,
|
||||
IconComponent: CircleXIconComponent,
|
||||
})
|
||||
}
|
||||
|
||||
export function messageOf(e: unknown) {
|
||||
if (e instanceof Error) return e.message
|
||||
if (isPluginError(e)) return e.message
|
||||
return String(e)
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number) {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
const PlanConfirmAlertKey = 'repo-install-plan-confirm'
|
||||
|
||||
/**
|
||||
* Shows the resolved plan and asks before anything downloads.
|
||||
* Resolves true only when the user presses Install.
|
||||
*/
|
||||
export async function confirmPlan(plan: InstallPlan): Promise<boolean> {
|
||||
const repos = await listRepos()
|
||||
|
||||
let resolve: (value: boolean) => void
|
||||
const promise = new Promise<boolean>(r => (resolve = r))
|
||||
|
||||
const summary = await Promise.all(
|
||||
plan.actions.map(async action => {
|
||||
const row = (
|
||||
label: string,
|
||||
repo: string,
|
||||
author?: string,
|
||||
icon?: string | null,
|
||||
) => (
|
||||
<Design.TableRow
|
||||
icon={<PluginIcon icon={icon ?? undefined} size={24} />}
|
||||
onPress={() => {
|
||||
Clipboard.setString(
|
||||
`${label} (${action.id}) v${action.version} (${action.channel})${author ? ` by ${author}` : ''} from ${repo} (${action.repo})\n\n` +
|
||||
`Replaces: ${action.replaces ?? '(None)'}\n` +
|
||||
`Download: ${action.url} (size: ${action.size})\n` +
|
||||
`SHA256: ${action.sha256}`,
|
||||
)
|
||||
}}
|
||||
label={
|
||||
action.replaces
|
||||
? `${label} • ${action.replaces} → ${action.version}`
|
||||
: `${label} • ${action.version}`
|
||||
}
|
||||
subLabel={`${author ? `${author} • ` : ''}${repo} • ${formatBytes(action.size)}`}
|
||||
/>
|
||||
)
|
||||
|
||||
const repo = repos.find(r => r.url === action.repo)
|
||||
if (!repo) return row(action.id, action.repo)
|
||||
|
||||
const plugins = await listRepoPlugins(repo.url)
|
||||
const plugin = plugins.find(p => p.id === action.id)
|
||||
if (!plugin) return row(action.id, repo.name || action.repo)
|
||||
|
||||
return row(
|
||||
plugin.name || action.id,
|
||||
repo.name || action.repo,
|
||||
plugin.author,
|
||||
plugin.icon,
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
AlertActionCreators.openAlert(
|
||||
PlanConfirmAlertKey,
|
||||
<AlertModal
|
||||
title={
|
||||
plan.actions.length === 1
|
||||
? 'Install plugin?'
|
||||
: `Install ${plan.actions.length} plugins?`
|
||||
}
|
||||
content={
|
||||
plan.actions.length > 1
|
||||
? 'Some plugins require other plugins to be installed first. The following will be installed:'
|
||||
: undefined
|
||||
}
|
||||
extraContent={
|
||||
<Design.TableRowGroup>{summary}</Design.TableRowGroup>
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<AlertActionButton
|
||||
text="Install"
|
||||
variant="primary"
|
||||
onPress={() => resolve(true)}
|
||||
/>
|
||||
<AlertActionButton
|
||||
text="Cancel"
|
||||
variant="secondary"
|
||||
onPress={() => resolve(false)}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>,
|
||||
)
|
||||
|
||||
return await promise
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the install flow for one plugin: resolve, show warnings, confirm the plan, and execute.
|
||||
* Errors show as toasts.
|
||||
*
|
||||
* Resolves true only when the plan was confirmed and installed.
|
||||
*/
|
||||
// TODO: Let the user pick a release channel
|
||||
export async function runInstallFlow(id: string): Promise<boolean> {
|
||||
try {
|
||||
const plan = await planInstall(id)
|
||||
if (plan.warnings.length) showErrorToast(plan.warnings.join('\n'))
|
||||
|
||||
const accepted = await confirmPlan(plan)
|
||||
AlertActionCreators.dismissAlert(PlanConfirmAlertKey)
|
||||
if (!accepted) return false
|
||||
|
||||
await installFromRepo(plan)
|
||||
return true
|
||||
} catch (e) {
|
||||
showErrorToast(messageOf(e))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { ActionSheetActionCreators } from '@revenge-mod/discord/actions'
|
||||
import PluginStatesProvider from '../components/PluginStateProvider'
|
||||
import type { AnyPlugin } from '@revenge-mod/plugins/_'
|
||||
import type { BrowsePluginActionSheetProps } from '../components/BrowsePluginActionSheet'
|
||||
import type { PluginOptionsActionSheetProps } from '../components/PluginOptionsActionSheet'
|
||||
|
||||
export function showPluginOptionsActionSheet(plugin: AnyPlugin) {
|
||||
|
|
@ -18,3 +19,15 @@ export function showPluginOptionsActionSheet(plugin: AnyPlugin) {
|
|||
{ plugin, sheetKey: KEY },
|
||||
)
|
||||
}
|
||||
|
||||
export function showBrowsePluginActionSheet(
|
||||
props: Omit<BrowsePluginActionSheetProps, 'sheetKey'>,
|
||||
) {
|
||||
const KEY = 'browse-plugin-action-sheet'
|
||||
|
||||
ActionSheetActionCreators.openLazy(
|
||||
import('../components/BrowsePluginActionSheet'),
|
||||
KEY,
|
||||
{ ...props, sheetKey: KEY },
|
||||
)
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
import { Design } from '@revenge-mod/discord/design'
|
||||
|
||||
const { AlertModal, AlertActionButton, Text } = Design
|
||||
|
||||
export default function PluginInstallFailedAlert({ error }: { error: string }) {
|
||||
return (
|
||||
<AlertModal
|
||||
title="Plugin install failed"
|
||||
content={
|
||||
<Text variant="text-md/medium" color="text-feedback-critical">
|
||||
{error}
|
||||
</Text>
|
||||
}
|
||||
actions={<AlertActionButton text="Got it" />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
import { styles } from '@revenge-mod/components/_'
|
||||
import { Design } from '@revenge-mod/discord/design'
|
||||
import { FlashList, MasonryFlashList } from '@shopify/flash-list'
|
||||
import { useWindowDimensions } from 'react-native'
|
||||
import {
|
||||
InstalledPluginCard,
|
||||
PLUGIN_CARD_ESTIMATED_SIZE,
|
||||
PluginCard,
|
||||
} from './PluginCard'
|
||||
import { useResetTooltips } from './TooltipProvider'
|
||||
import type { AnyPlugin, InternalPluginMeta } from '@revenge-mod/plugins/_'
|
||||
|
||||
const { Text } = Design
|
||||
|
||||
export function PluginFlashList({ plugins }: { plugins: AnyPlugin[] }) {
|
||||
const { width, height } = useWindowDimensions()
|
||||
const actualWidth = width - styles.pagePadding.paddingHorizontal * 2
|
||||
const resetTooltips = useResetTooltips()
|
||||
|
||||
return (
|
||||
<FlashList
|
||||
data={plugins}
|
||||
onScrollBeginDrag={resetTooltips}
|
||||
fadingEdgeLength={plugins.length === 1 ? 0 : 16}
|
||||
keyExtractor={plugin => plugin.manifest.id}
|
||||
estimatedItemSize={PLUGIN_CARD_ESTIMATED_SIZE}
|
||||
estimatedListSize={{ width: actualWidth, height }}
|
||||
renderItem={({
|
||||
item: {
|
||||
manifest: { name, description, author, icon },
|
||||
},
|
||||
index,
|
||||
}) => (
|
||||
<PluginCard
|
||||
name={name}
|
||||
description={description}
|
||||
author={author}
|
||||
icon={icon}
|
||||
rowEnd={index === plugins.length - 1}
|
||||
columnEnd
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function InstalledPluginMasonryFlashList({
|
||||
plugins,
|
||||
}: {
|
||||
plugins: (readonly [AnyPlugin, InternalPluginMeta])[]
|
||||
}) {
|
||||
const { width, height } = useWindowDimensions()
|
||||
const actualWidth = width - styles.pagePadding.paddingHorizontal * 2
|
||||
const numColumns = Math.floor(actualWidth / 448) || 1
|
||||
const resetTooltips = useResetTooltips()
|
||||
|
||||
return (
|
||||
<MasonryFlashList
|
||||
contentContainerStyle={{ paddingBottom: 16 }}
|
||||
data={plugins}
|
||||
onScrollBeginDrag={resetTooltips}
|
||||
fadingEdgeLength={16}
|
||||
keyExtractor={([plugin]) => plugin.manifest.id}
|
||||
estimatedListSize={{ width: actualWidth, height }}
|
||||
estimatedItemSize={PLUGIN_CARD_ESTIMATED_SIZE}
|
||||
numColumns={numColumns}
|
||||
ListEmptyComponent={NoPlugins}
|
||||
renderItem={({ item: [plugin, meta], columnIndex, index }) => (
|
||||
<InstalledPluginCard
|
||||
plugin={plugin}
|
||||
meta={meta}
|
||||
rowEnd={
|
||||
index > plugins.length - numColumns &&
|
||||
columnIndex <= plugins.length % numColumns
|
||||
}
|
||||
columnEnd={columnIndex === numColumns - 1}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function NoPlugins() {
|
||||
return (
|
||||
<Text variant="heading-md/medium" style={{ textAlign: 'center' }}>
|
||||
No plugins found. Try changing your query or filters.
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
|
@ -2,7 +2,6 @@ export const Setting = {
|
|||
// MAIN SETTINGS
|
||||
|
||||
Revenge: 'Revenge',
|
||||
RevengePlugins: 'RevengePlugins',
|
||||
|
||||
// SUBSETTINGS
|
||||
|
||||
|
|
@ -20,5 +19,4 @@ export const Setting = {
|
|||
|
||||
export const RouteNames = {
|
||||
[Setting.Revenge]: 'Revenge',
|
||||
[Setting.RevengePlugins]: 'Revenge Plugins',
|
||||
} as const
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { instead } from '@revenge-mod/patcher'
|
|||
import {
|
||||
InternalPluginFlags,
|
||||
PluginFlags,
|
||||
registerPlugin,
|
||||
registerInternalPlugin,
|
||||
} from '@revenge-mod/plugins/_'
|
||||
import { React } from '@revenge-mod/react'
|
||||
import { asap, noop } from '@revenge-mod/utils/callback'
|
||||
|
|
@ -18,7 +18,7 @@ import type { FC } from 'react'
|
|||
|
||||
let DEBUG_patchedNavigator = false
|
||||
|
||||
const pluginSettings = registerPlugin(
|
||||
const pluginSettings = registerInternalPlugin(
|
||||
{
|
||||
id: 'revenge.settings',
|
||||
name: 'Settings',
|
||||
|
|
@ -28,9 +28,6 @@ const pluginSettings = registerPlugin(
|
|||
},
|
||||
{
|
||||
start() {
|
||||
// @as-require
|
||||
import('./plugins')
|
||||
|
||||
onSettingsModulesLoaded(() => {
|
||||
// @as-require
|
||||
import('./register')
|
||||
|
|
|
|||
|
|
@ -10,14 +10,12 @@ import ReactVersionSetting from './definitions/ReactVersionSetting'
|
|||
import ReloadSetting from './definitions/ReloadSetting'
|
||||
import RevengeDiscordSetting from './definitions/RevengeDiscordSetting'
|
||||
import RevengeLicenseSetting from './definitions/RevengeLicenseSetting'
|
||||
import RevengePluginsSetting from './definitions/RevengePluginsSetting'
|
||||
import RevengeSetting from './definitions/RevengeSetting'
|
||||
import RevengeSourceRepositorySetting from './definitions/RevengeSourceRepositorySetting'
|
||||
import RevengeVersionSetting from './definitions/RevengeVersionSetting'
|
||||
|
||||
registerSettingsItems({
|
||||
[Setting.Revenge]: RevengeSetting,
|
||||
[Setting.RevengePlugins]: RevengePluginsSetting,
|
||||
[Setting.RevengeSourceRepository]: RevengeSourceRepositorySetting,
|
||||
[Setting.RevengeLicense]: RevengeLicenseSetting,
|
||||
[Setting.RevengeDiscord]: RevengeDiscordSetting,
|
||||
|
|
@ -31,5 +29,5 @@ registerSettingsItems({
|
|||
|
||||
registerSettingsSection('REVENGE', {
|
||||
label: 'Revenge',
|
||||
settings: [Setting.Revenge, Setting.RevengePlugins],
|
||||
settings: [Setting.Revenge],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { RouteNames, Setting } from './constants'
|
||||
import type { RouteNames } from './constants'
|
||||
|
||||
declare module '@revenge-mod/externals/react-navigation' {
|
||||
interface ReactNavigationParamList extends RevengeSettingsParamList {}
|
||||
|
|
@ -6,11 +6,4 @@ declare module '@revenge-mod/externals/react-navigation' {
|
|||
|
||||
type RevengeSettingsParamList = {
|
||||
[K in (typeof RouteNames)[keyof typeof RouteNames]]: object
|
||||
} & {
|
||||
[K in (typeof RouteNames)[(typeof Setting)['RevengePlugins']]]: {
|
||||
sort?: string
|
||||
filter?: string[]
|
||||
matchAll?: boolean
|
||||
reverse?: boolean
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,38 +0,0 @@
|
|||
import {
|
||||
disablePlugin,
|
||||
enablePlugin,
|
||||
getInternalPluginMeta,
|
||||
getPluginDependencies,
|
||||
isPluginEnabled,
|
||||
runPluginLate,
|
||||
} from '@revenge-mod/plugins/_'
|
||||
import {
|
||||
showPluginHasDependenciesAlert,
|
||||
showPluginHasDependentsAlert,
|
||||
} from './alerts'
|
||||
import type { AnyPlugin } from '@revenge-mod/plugins/_'
|
||||
|
||||
export async function handleEnablePlugin(plugin: AnyPlugin) {
|
||||
const dependencies = getPluginDependencies(plugin)!
|
||||
const disabledDeps = dependencies?.filter(dep => !isPluginEnabled(dep))
|
||||
|
||||
async function action() {
|
||||
await enablePlugin(plugin)
|
||||
await runPluginLate(plugin)
|
||||
}
|
||||
|
||||
if (disabledDeps?.length)
|
||||
showPluginHasDependenciesAlert(plugin, disabledDeps, action)
|
||||
else await action()
|
||||
}
|
||||
|
||||
export async function handleDisablePlugin(plugin: AnyPlugin) {
|
||||
const { dependents } = getInternalPluginMeta(plugin)
|
||||
const action = () => disablePlugin(plugin)
|
||||
|
||||
const enabledDeps = dependents.filter(isPluginEnabled)
|
||||
|
||||
if (enabledDeps.length)
|
||||
showPluginHasDependentsAlert(plugin, enabledDeps, action)
|
||||
else await action()
|
||||
}
|
||||
|
|
@ -6,12 +6,12 @@ import { instead } from '@revenge-mod/patcher'
|
|||
import {
|
||||
InternalPluginFlags,
|
||||
PluginFlags,
|
||||
registerPlugin,
|
||||
registerInternalPlugin,
|
||||
} from '@revenge-mod/plugins/_'
|
||||
import { lookupGeneratedIconComponent } from '@revenge-mod/utils/discord'
|
||||
import type { DiscordModules } from '@revenge-mod/discord/types'
|
||||
|
||||
registerPlugin(
|
||||
registerInternalPlugin(
|
||||
{
|
||||
id: 'revenge.staff-settings',
|
||||
name: 'Staff Settings',
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { withProps } from '@revenge-mod/modules/finders/filters'
|
|||
import {
|
||||
InternalPluginFlags,
|
||||
PluginFlags,
|
||||
registerPlugin,
|
||||
registerInternalPlugin,
|
||||
} from '@revenge-mod/plugins/_'
|
||||
import { afterJSX, beforeJSX } from '@revenge-mod/react/jsx-runtime'
|
||||
import { findInReactFiber } from '@revenge-mod/utils/react'
|
||||
|
|
@ -50,7 +50,7 @@ type ViewWithProfileBadges = ReactElement<{
|
|||
children: Array<ReactElement<ProfileBadgeProps, FC<ProfileBadgeProps>>>
|
||||
}>
|
||||
|
||||
registerPlugin(
|
||||
registerInternalPlugin(
|
||||
{
|
||||
id: 'revenge.user-badges',
|
||||
name: 'User Badges',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue