summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--backend/browser.py28
-rw-r--r--backend/locales/en-US.json26
-rw-r--r--backend/utilities.py8
-rw-r--r--frontend/src/components/modals/MultiplePluginsInstallModal.tsx82
-rw-r--r--frontend/src/components/settings/pages/plugin_list/index.tsx49
-rw-r--r--frontend/src/plugin-loader.tsx16
-rw-r--r--frontend/src/store.tsx25
7 files changed, 215 insertions, 19 deletions
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<void>;
+ onCancel(): void | Promise<void>;
+ 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<InstallType, string>;
+
+type TitleTranslationMapping = 'mixed' | (typeof InstallTypeTranslationMapping)[InstallType];
+
+const MultiplePluginsInstallModal: FC<MultiplePluginsInstallModalProps> = ({
+ requests,
+ onOK,
+ onCancel,
+ closeModal,
+}) => {
+ const [loading, setLoading] = useState<boolean>(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 (
+ <ConfirmModal
+ bOKDisabled={loading}
+ closeModal={closeModal}
+ onOK={async () => {
+ setLoading(true);
+ await onOK();
+ setTimeout(() => Navigation.OpenQuickAccessMenu(QuickAccessTab.Decky), 250);
+ setTimeout(() => window.DeckyPluginLoader.checkPluginUpdates(), 1000);
+ }}
+ onCancel={async () => {
+ await onCancel();
+ }}
+ strTitle={<div>{t(`MultiplePluginsInstallModal.title.${installTypeGrouped}`, { count: requests.length })}</div>}
+ strOKButtonText={t(`MultiplePluginsInstallModal.ok_button.${loading ? 'loading' : '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];
+ const description = t(`MultiplePluginsInstallModal.description.${installTypeStr}`, {
+ name,
+ version,
+ });
+
+ return (
+ <li key={i} style={{ display: 'flex', flexDirection: 'column' }}>
+ <div>{description}</div>
+ {hash === 'False' && (
+ <div style={{ color: 'red', paddingLeft: '10px' }}>{t('PluginInstallModal.no_hash')}</div>
+ )}
+ </li>
+ );
+ })}
+ </ul>
+ </div>
+ </ConfirmModal>
+ );
+};
+
+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<PluginData> }) {
onClick={() => requestPluginInstall(pluginName, data?.update as StorePluginVersion, InstallType.UPDATE)}
onOKButton={() => requestPluginInstall(pluginName, data?.update as StorePluginVersion, InstallType.UPDATE)}
>
- <div style={{ display: 'flex', flexDirection: 'row' }}>
+ <div style={{ display: 'flex', minWidth: '180px', justifyContent: 'space-between', alignItems: 'center' }}>
{t('PluginListIndex.update_to', { name: data?.update?.name })}
- <FaDownload style={{ paddingLeft: '2rem' }} />
+ <FaDownload style={{ paddingLeft: '1rem' }} />
</div>
</DialogButton>
) : (
@@ -78,14 +83,22 @@ function PluginInteractables(props: { entry: ReorderableEntry<PluginData> }) {
onClick={() => reinstallPlugin(pluginName, data?.version)}
onOKButton={() => reinstallPlugin(pluginName, data?.version)}
>
- <div style={{ display: 'flex', flexDirection: 'row' }}>
+ <div style={{ display: 'flex', minWidth: '180px', justifyContent: 'space-between', alignItems: 'center' }}>
{t('PluginListIndex.reinstall')}
- <FaRecycle style={{ paddingLeft: '5.3rem' }} />
+ <FaRecycle style={{ paddingLeft: '1rem' }} />
</div>
</DialogButton>
)}
<DialogButton
- style={{ height: '40px', width: '40px', padding: '10px 12px', minWidth: '40px' }}
+ style={{
+ height: '40px',
+ width: '40px',
+ padding: '10px 12px',
+ minWidth: '40px',
+ display: 'flex',
+ flexDirection: 'column',
+ justifyContent: 'center',
+ }}
onClick={showCtxMenu}
onOKButton={showCtxMenu}
>
@@ -146,6 +159,30 @@ export default function PluginList() {
return (
<DialogBody>
+ {updates && updates.size > 0 && (
+ <DialogButton
+ onClick={() =>
+ 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 })}
+ <FaDownload style={{ paddingLeft: '1rem' }} />
+ </DialogButton>
+ )}
<DialogControlsSection>
<ReorderableList<PluginData> entries={pluginEntries} onSave={onSave} interactables={PluginInteractables} />
</DialogControlsSection>
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(
+ <MultiplePluginsInstallModal
+ requests={requests}
+ onOK={() => 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(
<ConfirmModal
diff --git a/frontend/src/store.tsx b/frontend/src/store.tsx
index 80485252..f0ad0c1b 100644
--- a/frontend/src/store.tsx
+++ b/frontend/src/store.tsx
@@ -23,6 +23,12 @@ export interface StorePlugin {
image_url: string;
}
+export interface PluginInstallRequest {
+ plugin: string;
+ selectedVer: StorePluginVersion;
+ installType: InstallType;
+}
+
// name: version
export type PluginUpdateMapping = Map<string, StorePluginVersion>;
@@ -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<PluginUpdateMapping> {
const serverData = await getPluginList();
const updateMap = new Map<string, StorePluginVersion>();
@@ -96,3 +113,7 @@ export async function checkForUpdates(plugins: Plugin[]): Promise<PluginUpdateMa
}
return updateMap;
}
+
+function pluginUrl(hash: string) {
+ return `https://cdn.tzatzikiweeb.moe/file/steam-deck-homebrew/versions/${hash}.zip`;
+}