Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[needs websockets] plugin install progress #614

Merged
merged 6 commits into from
May 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions backend/decky_loader/browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,8 @@ async def uninstall_plugin(self, name: str):
self.loader.watcher.disabled = False

async def _install(self, artifact: str, name: str, version: str, hash: str):
await self.loader.ws.emit("loader/plugin_download_start", name)
await self.loader.ws.emit("loader/plugin_download_info", 5, "Store.download_progress_info.start")
# Will be set later in code
res_zip = None

Expand All @@ -164,12 +166,17 @@ async def _install(self, artifact: str, name: str, version: str, hash: str):
# Check if the file is a local file or a URL
if artifact.startswith("file://"):
logger.info(f"Installing {name} from local ZIP file (Version: {version})")
await self.loader.ws.emit("loader/plugin_download_info", 10, "Store.download_progress_info.open_zip")
res_zip = BytesIO(open(artifact[7:], "rb").read())
else:
logger.info(f"Installing {name} from URL (Version: {version})")
await self.loader.ws.emit("loader/plugin_download_info", 10, "Store.download_progress_info.download_zip")

async with ClientSession() as client:
logger.debug(f"Fetching {artifact}")
res = await client.get(artifact, ssl=get_ssl_context())
#TODO track progress of this download in chunks like with decky updates
#TODO but squish with min 15 and max 75
if res.status == 200:
logger.debug("Got 200. Reading...")
data = await res.read()
Expand All @@ -178,6 +185,7 @@ async def _install(self, artifact: str, name: str, version: str, hash: str):
else:
logger.fatal(f"Could not fetch from URL. {await res.text()}")

await self.loader.ws.emit("loader/plugin_download_info", 80, "Store.download_progress_info.increment_count")
storeUrl = ""
match self.settings.getSetting("store", 0):
case 0: storeUrl = "https://plugins.deckbrew.xyz/plugins" # default
Expand All @@ -190,6 +198,7 @@ async def _install(self, artifact: str, name: str, version: str, hash: str):
if res.status != 200:
logger.error(f"Server did not accept install count increment request. code: {res.status}")

await self.loader.ws.emit("loader/plugin_download_info", 85, "Store.download_progress_info.parse_zip")
if res_zip and version == "dev":
with ZipFile(res_zip) as plugin_zip:
plugin_json_list = [file for file in plugin_zip.namelist() if file.endswith("/plugin.json") and file.count("/") == 1]
Expand Down Expand Up @@ -219,19 +228,23 @@ async def _install(self, artifact: str, name: str, version: str, hash: str):

# If plugin is installed, uninstall it
if isInstalled:
await self.loader.ws.emit("loader/plugin_download_info", 90, "Store.download_progress_info.uninstalling_previous")
try:
logger.debug("Uninstalling existing plugin...")
await self.uninstall_plugin(name)
except:
logger.error(f"Plugin {name} could not be uninstalled.")


await self.loader.ws.emit("loader/plugin_download_info", 95, "Store.download_progress_info.installing_plugin")
# Install the plugin
logger.debug("Unzipping...")
ret = self._unzip_to_plugin_dir(res_zip, name, hash)
if ret:
plugin_folder = self.find_plugin_folder(name)
assert plugin_folder is not None
plugin_dir = path.join(self.plugin_path, plugin_folder)
#TODO count again from 0% to 100% quickly for this one if it does anything
ret = await self._download_remote_binaries_for_plugin_with_name(plugin_dir)
if ret:
logger.info(f"Installed {name} (Version: {version})")
Expand All @@ -251,6 +264,7 @@ async def _install(self, artifact: str, name: str, version: str, hash: str):
logger.fatal(f"SHA-256 Mismatch!!!! {name} (Version: {version})")
if self.loader.watcher:
self.loader.watcher.disabled = False
await self.loader.ws.emit("loader/plugin_download_finish", name)

async def request_plugin_install(self, artifact: str, name: str, version: str, hash: str, install_type: PluginInstallType):
request_id = str(time())
Expand Down
9 changes: 9 additions & 0 deletions backend/locales/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,15 @@
"store_testing_warning": {
"desc": "You can use this store channel to test bleeding-edge plugin versions. Be sure to leave feedback on GitHub so the plugin can be updated for all users.",
"label": "Welcome to the Testing Store Channel"
},
"download_progress_info": {
"start": "Initializing",
"open_zip": "Opening zip file",
"download_zip": "Downloading plugin",
"increment_count": "Incrementing download count",
"parse_zip": "Parsing zip file",
"uninstalling_previous": "Uninstalling previous copy",
"installing_plugin": "Installing plugin"
}
},
"StoreSelect": {
Expand Down
55 changes: 52 additions & 3 deletions frontend/src/components/modals/MultiplePluginsInstallModal.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ConfirmModal, Navigation, QuickAccessTab } from '@decky/ui';
import { FC, useMemo, useState } from 'react';
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 { InstallType } from '../../plugin';

Expand All @@ -27,8 +28,42 @@ const MultiplePluginsInstallModal: FC<MultiplePluginsInstallModalProps> = ({
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 => {
Expand Down Expand Up @@ -66,14 +101,28 @@ const MultiplePluginsInstallModal: FC<MultiplePluginsInstallModalProps> = ({

return (
<li key={i} style={{ display: 'flex', flexDirection: 'column' }}>
<div>{description}</div>
<span>
{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>
);
Expand Down
30 changes: 28 additions & 2 deletions frontend/src/components/modals/PluginInstallModal.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ConfirmModal, Navigation, QuickAccessTab } from '@decky/ui';
import { FC, useState } from 'react';
import { ConfirmModal, Navigation, ProgressBarWithInfo, QuickAccessTab } from '@decky/ui';
import { FC, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';

import TranslationHelper, { TranslationClass } from '../../utils/TranslationHelper';
Expand All @@ -24,8 +24,26 @@ const PluginInstallModal: FC<PluginInstallModalProps> = ({
closeModal,
}) => {
const [loading, setLoading] = useState<boolean>(false);
const [percentage, setPercentage] = useState<number>(0);
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));
}
}

useEffect(() => {
DeckyBackend.addEventListener('loader/plugin_download_info', updateDownloadState);
return () => {
DeckyBackend.removeEventListener('loader/plugin_download_info', updateDownloadState);
};
}, []);

return (
<ConfirmModal
bOKDisabled={loading}
Expand Down Expand Up @@ -80,6 +98,14 @@ const PluginInstallModal: FC<PluginInstallModalProps> = ({
install_type={installType}
/>
</div>
{loading && (
<ProgressBarWithInfo
layout="inline"
bottomSeparator="none"
nProgress={percentage}
sOperationText={downloadInfo}
/>
)}
{hash == 'False' && <span style={{ color: 'red' }}>{t('PluginInstallModal.no_hash')}</span>}
</ConfirmModal>
);
Expand Down
Loading