From 010feddf36646fb9695106bd64eab41e47e962fe Mon Sep 17 00:00:00 2001 From: Jonas Dellinger Date: Mon, 29 May 2023 18:29:36 +0200 Subject: Add update all button to plugin list (#466) --- backend/browser.py | 28 +++++--- backend/locales/en-US.json | 26 ++++++- backend/utilities.py | 8 ++- .../modals/MultiplePluginsInstallModal.tsx | 82 ++++++++++++++++++++++ .../settings/pages/plugin_list/index.tsx | 49 +++++++++++-- frontend/src/plugin-loader.tsx | 16 ++++- frontend/src/store.tsx | 25 ++++++- 7 files changed, 215 insertions(+), 19 deletions(-) create mode 100644 frontend/src/components/modals/MultiplePluginsInstallModal.tsx diff --git a/backend/browser.py b/backend/browser.py index db3d2f50..388a01e3 100644 --- a/backend/browser.py +++ b/backend/browser.py @@ -49,7 +49,7 @@ class PluginBrowser: logger.error(f"chown/chmod exited with a non-zero exit code") return False return True - + async def _download_remote_binaries_for_plugin_with_name(self, pluginBasePath): rv = False try: @@ -63,10 +63,8 @@ class PluginBrowser: # create bin directory if needed. chmod(pluginBasePath, 777) if access(pluginBasePath, W_OK): - if not path.exists(pluginBinPath): mkdir(pluginBinPath) - if not access(pluginBinPath, W_OK): chmod(pluginBinPath, 777) @@ -85,7 +83,7 @@ class PluginBrowser: else: rv = True logger.debug(f"No Remote Binaries to Download") - + except Exception as e: rv = False logger.debug(str(e)) @@ -174,7 +172,7 @@ class PluginBrowser: if res_zip is None: logger.fatal(f"Could not fetch {artifact}") return - + # If plugin is installed, uninstall it if isInstalled: try: @@ -196,7 +194,7 @@ class PluginBrowser: self.loader.plugins[name].stop() self.loader.plugins.pop(name, None) await sleep(1) - + current_plugin_order = self.settings.getSetting("pluginOrder") current_plugin_order.append(name) self.settings.setSetting("pluginOrder", current_plugin_order) @@ -216,9 +214,23 @@ class PluginBrowser: await tab.open_websocket() await tab.evaluate_js(f"DeckyPluginLoader.addPluginInstallPrompt('{name}', '{version}', '{request_id}', '{hash}', {install_type})") + async def request_multiple_plugin_installs(self, requests): + request_id = str(time()) + self.install_requests[request_id] = [PluginInstallContext(req['artifact'], req['name'], req['version'], req['hash']) for req in requests] + js_requests_parameter = ','.join([ + f"{{ name: '{req['name']}', version: '{req['version']}', hash: '{req['hash']}', install_type: {req['install_type']}}}" for req in requests + ]) + + tab = await get_gamepadui_tab() + await tab.open_websocket() + await tab.evaluate_js(f"DeckyPluginLoader.addMultiplePluginsInstallPrompt('{request_id}', [{js_requests_parameter}])") + async def confirm_plugin_install(self, request_id): - request = self.install_requests.pop(request_id) - await self._install(request.artifact, request.name, request.version, request.hash) + requestOrRequests = self.install_requests.pop(request_id) + if isinstance(requestOrRequests, list): + [await self._install(req.artifact, req.name, req.version, req.hash) for req in requestOrRequests] + else: + await self._install(requestOrRequests.artifact, requestOrRequests.name, requestOrRequests.version, requestOrRequests.hash) def cancel_plugin_install(self, request_id): self.install_requests.pop(request_id) diff --git a/backend/locales/en-US.json b/backend/locales/en-US.json index c442b7c0..fce3850b 100644 --- a/backend/locales/en-US.json +++ b/backend/locales/en-US.json @@ -44,13 +44,37 @@ "title": "Update {{artifact}}" } }, + "MultiplePluginsInstallModal": { + "title": { + "mixed_one": "Modify 1 plugin", + "mixed_other": "Modify {{count}} plugins", + "update_one": "Update 1 plugin", + "update_other": "Update {{count}} plugins", + "reinstall_one": "Reinstall 1 plugin", + "reinstall_other": "Reinstall {{count}} plugins", + "install_one": "Install 1 plugin", + "install_other": "Install {{count}} plugins" + }, + "ok_button": { + "idle": "Confirm", + "loading": "Working" + }, + "confirm": "Are you sure you want to make the following modifications?", + "description": { + "install": "Install {{name}} {{version}}", + "update": "Update {{name}} to {{version}}", + "reinstall": "Reinstall {{name}} {{version}}" + } + }, "PluginListIndex": { "no_plugin": "No plugins installed!", "plugin_actions": "Plugin Actions", "reinstall": "Reinstall", "reload": "Reload", "uninstall": "Uninstall", - "update_to": "Update to {{name}}" + "update_to": "Update to {{name}}", + "update_all_one": "Update 1 plugin", + "update_all_other": "Update {{count}} plugins" }, "PluginLoader": { "decky_title": "Decky", diff --git a/backend/utilities.py b/backend/utilities.py index d3db51c9..94f5e26b 100644 --- a/backend/utilities.py +++ b/backend/utilities.py @@ -19,6 +19,7 @@ class Utilities: "ping": self.ping, "http_request": self.http_request, "install_plugin": self.install_plugin, + "install_plugins": self.install_plugins, "cancel_plugin_install": self.cancel_plugin_install, "confirm_plugin_install": self.confirm_plugin_install, "uninstall_plugin": self.uninstall_plugin, @@ -70,6 +71,11 @@ class Utilities: install_type=install_type ) + async def install_plugins(self, requests): + return await self.context.plugin_browser.request_multiple_plugin_installs( + requests=requests + ) + async def confirm_plugin_install(self, request_id): return await self.context.plugin_browser.confirm_plugin_install(request_id) @@ -266,7 +272,7 @@ class Utilities: await close_old_tabs() result = await tab.reload_and_evaluate(script) self.logger.info(result) - + except Exception: self.logger.error("Failed to connect to React DevTools") self.logger.error(format_exc()) diff --git a/frontend/src/components/modals/MultiplePluginsInstallModal.tsx b/frontend/src/components/modals/MultiplePluginsInstallModal.tsx new file mode 100644 index 00000000..febdc38d --- /dev/null +++ b/frontend/src/components/modals/MultiplePluginsInstallModal.tsx @@ -0,0 +1,82 @@ +import { ConfirmModal, Navigation, QuickAccessTab } from 'decky-frontend-lib'; +import { FC, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { InstallType } from '../../plugin'; + +interface MultiplePluginsInstallModalProps { + requests: { name: string; version: string; hash: string; install_type: InstallType }[]; + onOK(): void | Promise; + onCancel(): void | Promise; + closeModal?(): void; +} + +// values are the JSON keys used in the translation file +const InstallTypeTranslationMapping = { + [InstallType.INSTALL]: 'install', + [InstallType.REINSTALL]: 'reinstall', + [InstallType.UPDATE]: 'update', +} as const satisfies Record; + +type TitleTranslationMapping = 'mixed' | (typeof InstallTypeTranslationMapping)[InstallType]; + +const MultiplePluginsInstallModal: FC = ({ + requests, + onOK, + onCancel, + closeModal, +}) => { + const [loading, setLoading] = useState(false); + const { t } = useTranslation(); + + // 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'; + return 'mixed'; + }, [requests]); + + return ( + { + setLoading(true); + await onOK(); + setTimeout(() => Navigation.OpenQuickAccessMenu(QuickAccessTab.Decky), 250); + setTimeout(() => window.DeckyPluginLoader.checkPluginUpdates(), 1000); + }} + onCancel={async () => { + await onCancel(); + }} + strTitle={
{t(`MultiplePluginsInstallModal.title.${installTypeGrouped}`, { count: requests.length })}
} + strOKButtonText={t(`MultiplePluginsInstallModal.ok_button.${loading ? 'loading' : 'idle'}`)} + > +
+ {t('MultiplePluginsInstallModal.confirm')} +
    + {requests.map(({ name, version, install_type, hash }, i) => { + const installTypeStr = InstallTypeTranslationMapping[install_type]; + const description = t(`MultiplePluginsInstallModal.description.${installTypeStr}`, { + name, + version, + }); + + return ( +
  • +
    {description}
    + {hash === 'False' && ( +
    {t('PluginInstallModal.no_hash')}
    + )} +
  • + ); + })} +
+
+
+ ); +}; + +export default MultiplePluginsInstallModal; diff --git a/frontend/src/components/settings/pages/plugin_list/index.tsx b/frontend/src/components/settings/pages/plugin_list/index.tsx index d7ff7bd9..ce349393 100644 --- a/frontend/src/components/settings/pages/plugin_list/index.tsx +++ b/frontend/src/components/settings/pages/plugin_list/index.tsx @@ -14,7 +14,12 @@ import { useTranslation } from 'react-i18next'; import { FaDownload, FaEllipsisH, FaRecycle } from 'react-icons/fa'; import { InstallType } from '../../../../plugin'; -import { StorePluginVersion, getPluginList, requestPluginInstall } from '../../../../store'; +import { + StorePluginVersion, + getPluginList, + requestMultiplePluginInstalls, + requestPluginInstall, +} from '../../../../store'; import { useSetting } from '../../../../utils/hooks/useSetting'; import { useDeckyState } from '../../../DeckyState'; @@ -67,9 +72,9 @@ function PluginInteractables(props: { entry: ReorderableEntry }) { onClick={() => requestPluginInstall(pluginName, data?.update as StorePluginVersion, InstallType.UPDATE)} onOKButton={() => requestPluginInstall(pluginName, data?.update as StorePluginVersion, InstallType.UPDATE)} > -
+
{t('PluginListIndex.update_to', { name: data?.update?.name })} - +
) : ( @@ -78,14 +83,22 @@ function PluginInteractables(props: { entry: ReorderableEntry }) { onClick={() => reinstallPlugin(pluginName, data?.version)} onOKButton={() => reinstallPlugin(pluginName, data?.version)} > -
+
{t('PluginListIndex.reinstall')} - +
)} @@ -146,6 +159,30 @@ export default function PluginList() { return ( + {updates && updates.size > 0 && ( + + requestMultiplePluginInstalls( + [...updates.entries()].map(([plugin, selectedVer]) => ({ + installType: InstallType.UPDATE, + plugin, + selectedVer, + })), + ) + } + style={{ + position: 'absolute', + top: '57px', + right: '2.8vw', + width: 'auto', + display: 'flex', + alignItems: 'center', + }} + > + {t('PluginListIndex.update_all', { count: updates.size })} + + + )} entries={pluginEntries} onSave={onSave} interactables={PluginInteractables} /> diff --git a/frontend/src/plugin-loader.tsx b/frontend/src/plugin-loader.tsx index 15a0ca36..57483293 100644 --- a/frontend/src/plugin-loader.tsx +++ b/frontend/src/plugin-loader.tsx @@ -16,12 +16,13 @@ import { FaExclamationCircle, FaPlug } from 'react-icons/fa'; import { DeckyState, DeckyStateContextProvider, useDeckyState } from './components/DeckyState'; import LegacyPlugin from './components/LegacyPlugin'; import { deinitFilepickerPatches, initFilepickerPatches } from './components/modals/filepicker/patches'; +import MultiplePluginsInstallModal from './components/modals/MultiplePluginsInstallModal'; import PluginInstallModal from './components/modals/PluginInstallModal'; import NotificationBadge from './components/NotificationBadge'; import PluginView from './components/PluginView'; import WithSuspense from './components/WithSuspense'; import Logger from './logger'; -import { Plugin } from './plugin'; +import { InstallType, Plugin } from './plugin'; import RouterHook from './router-hook'; import { deinitSteamFixes, initSteamFixes } from './steamfixes'; import { checkForUpdates } from './store'; @@ -168,6 +169,19 @@ class PluginLoader extends Logger { ); } + public addMultiplePluginsInstallPrompt( + request_id: string, + requests: { name: string; version: string; hash: string; install_type: InstallType }[], + ) { + showModal( + this.callServerMethod('confirm_plugin_install', { request_id })} + onCancel={() => this.callServerMethod('cancel_plugin_install', { request_id })} + />, + ); + } + public uninstallPlugin(name: string, title: string, button_text: string, description: string) { showModal( ; @@ -74,8 +80,7 @@ export async function installFromURL(url: string) { } export async function requestPluginInstall(plugin: string, selectedVer: StorePluginVersion, installType: InstallType) { - const artifactUrl = - selectedVer.artifact ?? `https://cdn.tzatzikiweeb.moe/file/steam-deck-homebrew/versions/${selectedVer.hash}.zip`; + const artifactUrl = selectedVer.artifact ?? pluginUrl(selectedVer.hash); await window.DeckyPluginLoader.callServerMethod('install_plugin', { name: plugin, artifact: artifactUrl, @@ -85,6 +90,18 @@ export async function requestPluginInstall(plugin: string, selectedVer: StorePlu }); } +export async function requestMultiplePluginInstalls(requests: PluginInstallRequest[]) { + await window.DeckyPluginLoader.callServerMethod('install_plugins', { + requests: requests.map(({ plugin, installType, selectedVer }) => ({ + name: plugin, + artifact: selectedVer.artifact ?? pluginUrl(selectedVer.hash), + version: selectedVer.name, + hash: selectedVer.hash, + install_type: installType, + })), + }); +} + export async function checkForUpdates(plugins: Plugin[]): Promise { const serverData = await getPluginList(); const updateMap = new Map(); @@ -96,3 +113,7 @@ export async function checkForUpdates(plugins: Plugin[]): Promise