revenge-bundle-next/src/plugins/start/settings.plugins/plugins.tsx
PalmDevs 9b412a9404
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.
2026-07-25 23:43:44 +07:00

180 lines
5.8 KiB
TypeScript

import TableRowAssetIcon from '@revenge-mod/components/TableRowAssetIcon'
import {
AlertActionCreators,
ToastActionCreators,
} from '@revenge-mod/discord/actions'
import {
refreshSettingsNavigator,
registerSettingsItem,
} from '@revenge-mod/discord/modules/settings'
import {
isPluginErrored,
isPluginPendingReload,
isPluginPendingUpdate,
pEmitter,
pList,
} 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 type { StackScreenProps } from '@react-navigation/stack'
import type { ReactNavigationParamList } from '@revenge-mod/externals/react-navigation'
import type { PluginApi } from '@revenge-mod/plugins/types'
const PluginsRequireReloadAlertKey = 'plugins-require-reload'
const PluginsFailedToStartAlertKey = 'plugins-failed-to-start'
/// SETTINGS ROUTES
pEmitter.on('started', plugin => {
if (plugin.SettingsComponent) {
const api = plugin.api as PluginApi<any>
const Component = plugin.SettingsComponent!
function PluginSettings({
navigation,
}: StackScreenProps<ReactNavigationParamList>) {
// biome-ignore lint/correctness/useExhaustiveDependencies: We only want to set options once
useLayoutEffect(() => {
if (plugin.manifest.icon)
navigation.setOptions({
headerTitle: () => (
<NavigatorHeaderWithIcon.default
title={plugin.manifest.name}
icon={plugin.manifest.icon!}
/>
),
})
else
navigation.setOptions({
title: plugin.manifest.name,
})
}, [])
return <Component api={api} />
}
api.cleanup(
registerSettingsItem(plugin.manifest.id, {
parent: Setting.Revenge,
type: 'route',
IconComponent: plugin.manifest.icon
? () => <TableRowAssetIcon name={plugin.manifest.icon!} />
: undefined,
useTitle: () => plugin.manifest.name,
screen: {
route: plugin.manifest.id,
getComponent: () => PluginSettings,
},
}),
refreshSettingsNavigator,
// TODO(PalmDevs): In the future, we may allow pinning plugin settings, so we'll need this cleanup
// () => refreshSettingsOverviewScreen(),
)
refreshSettingsNavigator()
}
})
/// RELOAD REQUIRED ALERT
function showPendingReloadAlertIfNeeded() {
const plugins = [...pList.values()].filter(
plugin =>
isPluginPendingReload(plugin) || isPluginPendingUpdate(plugin),
)
if (!plugins.length) return
AlertActionCreators.dismissAlert(PluginsRequireReloadAlertKey)
AlertActionCreators.openAlert(
PluginsRequireReloadAlertKey,
<PluginsRequireReloadAlert plugins={plugins} />,
)
}
function showErrorAlertIfNeeded() {
const plugins = [...pList.values()].filter(plugin =>
isPluginErrored(plugin),
)
if (!plugins.length) return
AlertActionCreators.dismissAlert(PluginsFailedToStartAlertKey)
AlertActionCreators.openAlert(
PluginsFailedToStartAlertKey,
<PluginsFailedToStartAlert plugins={plugins} />,
)
}
pEmitter.on('flagUpdate', plugin => {
if (isPluginPendingReload(plugin) || isPluginPendingUpdate(plugin))
showPendingReloadAlertIfNeeded()
})
pEmitter.on('stopped', plugin => {
if (isPluginErrored(plugin)) showErrorAlertIfNeeded()
})
/// PLUGIN INSTALL FEEDBACK
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) {
AlertActionCreators.dismissAlert(PluginInstallFailedAlertKey)
AlertActionCreators.openAlert(
PluginInstallFailedAlertKey,
<PluginInstallFailedAlert error={result.error} />,
)
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}`
: `Installed ${result.manifest.name}`,
result.manifest.id,
)
})
const CircleCheckIcon = lookupGeneratedIconComponent(
'CircleCheckIcon',
'CircleCheckIcon-secondary',
'CircleCheckIcon-primary',
)
function showInstallToast(content: string, id: string) {
ToastActionCreators.open({
key: pluginInstallToastKeyFor(id),
content,
IconComponent: CircleCheckIcon,
})
}
showErrorAlertIfNeeded()
showPendingReloadAlertIfNeeded()