Files
decky-loader/frontend/src/components/modals/MultiplePluginsInstallModal.tsx
T
jbofill 9f586a1b97 Feat: Disable plugins (#850)
* implement base frontend changes necessary for plugin disabling

* implement frontend diisable functions/ modal

* plugin disable boilerplate / untested

* Feat disable plugins (#810)

* implement base frontend changes necessary for plugin disabling

* implement frontend diisable functions/ modal

---------

Co-authored-by: Jesse Bofill <jesse_bofill@yahoo.com>

* fix mistakes

* add frontend

* working plugin disable, not tested extensively

* fix uninstalled hidden plugins remaining in list

* hide plugin irrelevant plugin setting menu option when disabled

* fix hidden plugin issues

* reset disabled plugin on uninstall

* fix plugin load on reenable

* move disable settings uninstall cleanup

* add engilsh tranlsations for enable/ disable elements

* fix bug where wrong loadType can get passed to importPlugin

* show correct number of hidden plugins if plugin is both hidden and disabled

* fix: get fresh list of plugin updates when changed in settings plugin list

* fix: fix invalid semver plugin version from preventing latest updates

* retain x position when changing focus in list items  that have multiple horizontal focusables

* correction to pluging version checking validation

* make sure disabled plugins get checked for updates

* show number of disabled plugins at bottom of plugin view

* add notice to update modals that disabled plugins will be enabled upon installation

* run formatter

* Update backend/decky_loader/locales/en-US.json

Co-authored-by: EMERALD <hudson.samuels@gmail.com>

* chore: correct filename typo

* chore: change disabled icon

* chore: revert accidental defsettings changes

* format

* add timeout to frontend importPlugin

if a request hangs this prevent it from blocking other plugin loads.
backend diaptch_plugin which calls this for individual plugin load (as opposed to batch) is set to 15s.
other callers of importPlugin are not using timeout, same as before.

* fix plugin update checking loop

---------

Co-authored-by: marios <marios8543@gmail.com>
Co-authored-by: EMERALD <hudson.samuels@gmail.com>
2025-12-30 13:29:08 -06:00

152 lines
6.4 KiB
TypeScript

import { ConfirmModal, Navigation, ProgressBarWithInfo, QuickAccessTab } from '@decky/ui';
import { FC, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FaCheck, FaDownload } from 'react-icons/fa';
import { DisabledPlugin, InstallType, InstallTypeTranslationMapping } from '../../plugin';
interface MultiplePluginsInstallModalProps {
requests: { name: string; version: string; hash: string; install_type: InstallType }[];
disabledPlugins: DisabledPlugin[];
onOK(): void | Promise<void>;
onCancel(): void | Promise<void>;
closeModal?(): void;
}
// IMPORTANT! Keep in sync with `t(...)` comments below
type TitleTranslationMapping = 'mixed' | (typeof InstallTypeTranslationMapping)[InstallType];
const MultiplePluginsInstallModal: FC<MultiplePluginsInstallModalProps> = ({
requests,
disabledPlugins,
onOK,
onCancel,
closeModal,
}) => {
const [loading, setLoading] = useState<boolean>(false);
const [percentage, setPercentage] = useState<number>(0);
const [pluginsCompleted, setPluginsCompleted] = useState<string[]>([]);
const [pluginInProgress, setInProgress] = useState<string | null>();
const [downloadInfo, setDownloadInfo] = useState<string | null>(null);
const { t } = useTranslation();
function updateDownloadState(percent: number, trans_text: string | undefined, trans_info: Record<string, string>) {
setPercentage(percent);
if (trans_text === undefined) {
setDownloadInfo(null);
} else {
setDownloadInfo(t(trans_text, trans_info));
}
}
function startDownload(name: string) {
setInProgress(name);
setPercentage(0);
}
function finishDownload(name: string) {
setPluginsCompleted((list) => [...list, name]);
}
useEffect(() => {
DeckyBackend.addEventListener('loader/plugin_download_info', updateDownloadState);
DeckyBackend.addEventListener('loader/plugin_download_start', startDownload);
DeckyBackend.addEventListener('loader/plugin_download_finish', finishDownload);
return () => {
DeckyBackend.removeEventListener('loader/plugin_download_info', updateDownloadState);
DeckyBackend.removeEventListener('loader/plugin_download_start', startDownload);
DeckyBackend.removeEventListener('loader/plugin_download_finish', finishDownload);
};
}, []);
// used as part of the title translation
// if we know all operations are of a specific type, we can show so in the title to make decision easier
const installTypeGrouped = useMemo((): TitleTranslationMapping => {
if (requests.every(({ install_type }) => install_type === InstallType.INSTALL)) return 'install';
if (requests.every(({ install_type }) => install_type === InstallType.REINSTALL)) return 'reinstall';
if (requests.every(({ install_type }) => install_type === InstallType.UPDATE)) return 'update';
if (requests.every(({ install_type }) => install_type === InstallType.DOWNGRADE)) return 'downgrade';
if (requests.every(({ install_type }) => install_type === InstallType.OVERWRITE)) return 'overwrite';
return 'mixed';
}, [requests]);
return (
<ConfirmModal
bOKDisabled={loading}
closeModal={closeModal}
onOK={async () => {
setLoading(true);
await onOK();
setTimeout(() => Navigation.OpenQuickAccessMenu(QuickAccessTab.Decky), 250);
setTimeout(() => DeckyPluginLoader.checkPluginUpdates(), 1000);
}}
onCancel={async () => {
await onCancel();
}}
strTitle={
<div>
{
// IMPORTANT! These comments are not cosmetic and are needed for `extracttext` task to work
// t('MultiplePluginsInstallModal.title.install', { count: n })
// t('MultiplePluginsInstallModal.title.reinstall', { count: n })
// t('MultiplePluginsInstallModal.title.update', { count: n })
// t('MultiplePluginsInstallModal.title.downgrade', { count: n })
// t('MultiplePluginsInstallModal.title.overwrite', { count: n })
// t('MultiplePluginsInstallModal.title.mixed', { count: n })
t(`MultiplePluginsInstallModal.title.${installTypeGrouped}`, { count: requests.length })
}
</div>
}
strOKButtonText={
loading ? t('MultiplePluginsInstallModal.ok_button.loading') : t('MultiplePluginsInstallModal.ok_button.idle')
}
>
<div>
{t('MultiplePluginsInstallModal.confirm')}
<ul style={{ listStyle: 'none', display: 'flex', flexDirection: 'column', gap: '4px' }}>
{requests.map(({ name, version, install_type, hash }, i) => {
const installTypeStr = InstallTypeTranslationMapping[install_type];
// IMPORTANT! These comments are not cosmetic and are needed for `extracttext` task to work
// t('MultiplePluginsInstallModal.description.install')
// t('MultiplePluginsInstallModal.description.reinstall')
// t('MultiplePluginsInstallModal.description.update')
// t('MultiplePluginsInstallModal.description.downgrade')
// t('MultiplePluginsInstallModal.description.overwrite')
const description = t(`MultiplePluginsInstallModal.description.${installTypeStr}`, {
name,
version,
});
const disabled = disabledPlugins.some((p) => p.name === name);
return (
<li key={i} style={{ display: 'flex', flexDirection: 'column' }}>
<span>
{disabled ? `${description} - ${t('PluginInstallModal.disabled')}` : description}{' '}
{(pluginsCompleted.includes(name) && <FaCheck />) || (name === pluginInProgress && <FaDownload />)}
</span>
{hash === 'False' && (
<div style={{ color: 'red', paddingLeft: '10px' }}>{t('PluginInstallModal.no_hash')}</div>
)}
</li>
);
})}
</ul>
{/* TODO: center the progress bar and make it 80% width */}
{loading && (
<ProgressBarWithInfo
// when the key changes, react considers this a new component so resets the progress without the smoothing animation
key={pluginInProgress}
bottomSeparator="none"
focusable={false}
nProgress={percentage}
sOperationText={downloadInfo}
/>
)}
</div>
</ConfirmModal>
);
};
export default MultiplePluginsInstallModal;