From 8aa4a311bc53082661b303cf8f48e6020e4a55b2 Mon Sep 17 00:00:00 2001 From: Brett Cleary <27568879+BrettCleary@users.noreply.github.com> Date: Tue, 26 Sep 2023 22:30:50 -0700 Subject: [PATCH 01/32] fix extracting state, <99% when DL finishes --- package.json | 1 - .../UI/DownloadToastManager/index.tsx | 58 ++++++------------- src/frontend/hooks/hasStatus.ts | 4 +- src/frontend/hooks/useGetDmState.ts | 31 ++++++++++ .../hooks/useGetDownloadStatusText.ts | 41 +++++++++++++ .../Library/components/GameCard/index.tsx | 28 +++++---- src/frontend/screens/Library/constants.ts | 16 +++++ yarn.lock | 5 -- 8 files changed, 122 insertions(+), 62 deletions(-) create mode 100644 src/frontend/hooks/useGetDmState.ts create mode 100644 src/frontend/hooks/useGetDownloadStatusText.ts diff --git a/package.json b/package.json index c4e9df30e..37072aa54 100644 --- a/package.json +++ b/package.json @@ -156,7 +156,6 @@ "@fortawesome/free-regular-svg-icons": "^6.1.1", "@fortawesome/free-solid-svg-icons": "^6.1.1", "@fortawesome/react-fontawesome": "^0.1.18", - "@hyperplay/ui": "^0.1.27", "@mantine/carousel": "^6.0.19", "@mantine/core": "^6.0.19", "@mantine/hooks": "^6.0.19", diff --git a/src/frontend/components/UI/DownloadToastManager/index.tsx b/src/frontend/components/UI/DownloadToastManager/index.tsx index 7cd0f2dcc..f63d90d11 100644 --- a/src/frontend/components/UI/DownloadToastManager/index.tsx +++ b/src/frontend/components/UI/DownloadToastManager/index.tsx @@ -13,6 +13,8 @@ import DownloadToastManagerStyles from './index.module.scss' import { launch } from 'frontend/helpers' import StopInstallationModal from '../StopInstallationModal' import { downloadStatus } from '@hyperplay/ui/dist/components/DownloadToast' +import { useGetDownloadStatusText } from 'frontend/hooks/useGetDownloadStatusText' +import { useGetDmState } from 'frontend/hooks/useGetDmState' const nullProgress: InstallProgress = { bytes: '0', @@ -29,44 +31,21 @@ export default function DownloadToastManager() { const [showPlay, setShowPlay] = useState(false) const [showStopInstallModal, setShowStopInstallModal] = useState(false) - let showPlayTimeout: NodeJS.Timeout | undefined = undefined - - const [dmState, setDMState] = useState('idle') - - useEffect(() => { - window.api.getDMQueueInformation().then(({ state }: DMQueue) => { - setDMState(state) - }) + const appName = currentElement?.params.gameInfo.app_name + ? currentElement?.params.gameInfo.app_name + : '' + const gameInfo = currentElement?.params.gameInfo + const { statusText: downloadStatusText, status } = useGetDownloadStatusText( + appName, + gameInfo + ) - const removeHandleDMQueueInformation = window.api.handleDMQueueInformation( - ( - e: Electron.IpcRendererEvent, - elements: DMQueueElement[], - state: DownloadManagerState - ) => { - if (elements) { - setDMState(state) - } - } - ) + let showPlayTimeout: NodeJS.Timeout | undefined = undefined - return () => { - removeHandleDMQueueInformation() - } - }, []) + const dmState = useGetDmState() useEffect(() => { - // if download queue finishes, show toast in done state with play button for 10 seconds - // if the last progress data point is < 99, then it will not show the done state - // technically if < 100, we shouldn't show in order to handle the cancelled download case - // but legendary sends progress updates infrequently and this gives margin of error for % calc - // TODO: receive a reason from download manager as to why the previous download was removed - // whether it was cancelled or the download finished - if ( - latestElement === undefined && - progress.percent && - progress.percent > 99 - ) { + if (latestElement === undefined && status === 'installed') { setShowPlay(true) // after 10 seconds remove and reset the toast showPlayTimeout = setTimeout(() => { @@ -198,11 +177,7 @@ export default function DownloadToastManager() { : '' if (!imgUrl.includes('http')) imgUrl = currentElement.params.gameInfo.art_square - const appName = currentElement?.params.gameInfo.app_name - ? currentElement?.params.gameInfo.app_name - : '' - const gameInfo = currentElement?.params.gameInfo if (gameInfo === undefined) { console.error('game info was undefined in download toast manager') return <> @@ -226,7 +201,11 @@ export default function DownloadToastManager() { { @@ -259,6 +238,7 @@ export default function DownloadToastManager() { }) }} status={getDownloadStatus()} + statusText={downloadStatusText ?? 'Downloading 2'} /> ) : ( downloadIcon() diff --git a/src/frontend/hooks/hasStatus.ts b/src/frontend/hooks/hasStatus.ts index 6afecd188..5602d8b9d 100644 --- a/src/frontend/hooks/hasStatus.ts +++ b/src/frontend/hooks/hasStatus.ts @@ -9,7 +9,7 @@ import libraryState from 'frontend/state/libraryState' // the consuming code needs to be wrapped in observer when using this hook export const hasStatus = ( appName: string, - gameInfo: GameInfo, + gameInfo: GameInfo | undefined, gameSize?: string ) => { const { libraryStatus } = React.useContext(ContextProvider) @@ -25,7 +25,7 @@ export const hasStatus = ( const { thirdPartyManagedApp = undefined, is_installed, - runner + runner = 'hyperplay' } = { ...gameInfo } React.useEffect(() => { diff --git a/src/frontend/hooks/useGetDmState.ts b/src/frontend/hooks/useGetDmState.ts new file mode 100644 index 000000000..667279d15 --- /dev/null +++ b/src/frontend/hooks/useGetDmState.ts @@ -0,0 +1,31 @@ +import { useEffect, useState } from 'react' +import { DMQueueElement, DownloadManagerState } from 'common/types' +import { DMQueue } from 'frontend/types' + +export function useGetDmState() { + const [dmState, setDMState] = useState('idle') + + useEffect(() => { + window.api.getDMQueueInformation().then(({ state }: DMQueue) => { + setDMState(state) + }) + + const removeHandleDMQueueInformation = window.api.handleDMQueueInformation( + ( + e: Electron.IpcRendererEvent, + elements: DMQueueElement[], + state: DownloadManagerState + ) => { + if (elements) { + setDMState(state) + } + } + ) + + return () => { + removeHandleDMQueueInformation() + } + }, []) + + return dmState +} diff --git a/src/frontend/hooks/useGetDownloadStatusText.ts b/src/frontend/hooks/useGetDownloadStatusText.ts new file mode 100644 index 000000000..db2ee376c --- /dev/null +++ b/src/frontend/hooks/useGetDownloadStatusText.ts @@ -0,0 +1,41 @@ +import React, { useContext } from 'react' +import { GameInfo } from 'common/types' +import { getMessage } from 'frontend/screens/Library/constants' +import { getCardStatus } from 'frontend/screens/Library/components/GameCard/constants' +import { hasStatus } from './hasStatus' +import { useTranslation } from 'react-i18next' +import ContextProvider from 'frontend/state/ContextProvider' +import { useGetDmState } from './useGetDmState' + +export function useGetDownloadStatusText( + appName: string, + gameInfo: GameInfo | undefined +) { + const dmState = useGetDmState() + const { status } = hasStatus(appName, gameInfo) + const { t } = useTranslation('gamepage') + const { layout } = useContext(ContextProvider) + const { isInstalling } = getCardStatus( + status, + !!gameInfo?.is_installed, + layout + ) + + function getStatus() { + if (status === 'extracting') { + return 'extracting' + } + if (dmState === 'paused') { + return 'paused' + } + if (isInstalling) { + return 'installing' + } + if (gameInfo?.is_installed) { + return 'installed' + } + return 'installing' + } + + return { statusText: getMessage(t, getStatus()), status: getStatus() } +} diff --git a/src/frontend/screens/Library/components/GameCard/index.tsx b/src/frontend/screens/Library/components/GameCard/index.tsx index 2dda3fb3a..3c20a5aea 100644 --- a/src/frontend/screens/Library/components/GameCard/index.tsx +++ b/src/frontend/screens/Library/components/GameCard/index.tsx @@ -25,6 +25,7 @@ import { import classNames from 'classnames' import libraryState from 'frontend/state/libraryState' import DMQueueState from 'frontend/state/DMQueueState' +import { useGetDownloadStatusText } from 'frontend/hooks/useGetDownloadStatusText' interface Card { buttonClick: () => void @@ -73,6 +74,8 @@ const GameCard = ({ } const { status, folder } = hasStatus(appName, gameInfo, size) + const { statusText: downloadStatusText, status: downloadStatus } = + useGetDownloadStatusText(appName, gameInfo) useEffect(() => { setIsLaunching(false) @@ -140,20 +143,11 @@ const GameCard = ({ return 'NOT_INSTALLED' } - const getMessage = (): string | undefined => { - if (status === 'extracting') { - return t('hyperplay.gamecard.extracting', 'Extracting...') - } - if (isPaused) { - return t('hyperplay.gamecard.paused', 'Paused') - } - if (isInstalling) { - return t('hyperplay.gamecard.installing', 'Downloading...') - } - return undefined - } - - const isHiddenGame = libraryState.isGameHidden(appName) + const isHiddenGame = useMemo(() => { + return !!hiddenGames.list.find( + (hiddenGame: HiddenGame) => hiddenGame.appName === appName + ) + }, [hiddenGames, appName]) const isBrowserGame = installPlatform === 'Browser' @@ -294,6 +288,10 @@ const GameCard = ({ const { activeController } = useContext(ContextProvider) + if (downloadStatus === 'extracting') { + progress.percent = 100 + } + return ( <> {showStopInstallModal ? ( @@ -364,7 +362,7 @@ const GameCard = ({ )} onUpdateClick={handleClickStopBubbling(async () => handleUpdate())} progress={progress} - message={getMessage()} + message={downloadStatusText} actionDisabled={isLaunching} alwaysShowInColor={allTilesInColor} store={runner} diff --git a/src/frontend/screens/Library/constants.ts b/src/frontend/screens/Library/constants.ts index 3af5136f4..57600f174 100644 --- a/src/frontend/screens/Library/constants.ts +++ b/src/frontend/screens/Library/constants.ts @@ -40,3 +40,19 @@ export function translateChannelName( return channelNameEnglish } } + +export function getMessage( + t: TFunction<'translation'>, + status: 'extracting' | 'paused' | 'installing' | 'installed' +): string | undefined { + switch (status) { + case 'extracting': + return t('hyperplay.gamecard.extracting', 'Extracting...') + case 'paused': + return t('hyperplay.gamecard.paused', 'Paused') + case 'installing': + return t('hyperplay.gamecard.installing', 'Downloading...') + case 'installed': + return t('hyperplay.gamecard.installed', 'Ready to play') + } +} diff --git a/yarn.lock b/yarn.lock index 292112a74..d9639c68d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1668,11 +1668,6 @@ resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz#b520529ec21d8e5945a1851dfd1c32e94e39ff45" integrity sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA== -"@hyperplay/ui@^0.1.27": - version "0.1.27" - resolved "https://registry.yarnpkg.com/@hyperplay/ui/-/ui-0.1.27.tgz#f7bd511ca6cbd946c117fd1ed59446e988aff726" - integrity sha512-rCkFP2L/+P5IhlMsaEOCxedgOjQ9Mk+8YZ2GUjQeeNpyMezQLMFRVOzHP59EEc0BjX2uE2zvEr7H15vMifCSDQ== - "@ioredis/commands@^1.1.1": version "1.2.0" resolved "https://registry.yarnpkg.com/@ioredis/commands/-/commands-1.2.0.tgz#6d61b3097470af1fdbbe622795b8921d42018e11" From 5ba4bd821c488fb83a49f3c802c450684f868e0c Mon Sep 17 00:00:00 2001 From: Red Date: Tue, 24 Oct 2023 06:22:37 +0200 Subject: [PATCH 02/32] merge: Fixed conflicts --- .../screens/Library/components/GameCard/index.tsx | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/frontend/screens/Library/components/GameCard/index.tsx b/src/frontend/screens/Library/components/GameCard/index.tsx index 3c20a5aea..ef4a54f0a 100644 --- a/src/frontend/screens/Library/components/GameCard/index.tsx +++ b/src/frontend/screens/Library/components/GameCard/index.tsx @@ -1,8 +1,8 @@ import './index.css' -import React, { useContext, useState, useEffect } from 'react' +import React, { useContext, useState, useEffect, useMemo } from 'react' -import { GameInfo, Runner } from 'common/types' +import { GameInfo, HiddenGame, Runner } from 'common/types' import { Link, useNavigate } from 'react-router-dom' import { getGameInfo, install, launch } from 'frontend/helpers' @@ -23,9 +23,9 @@ import { SettingsButtons } from '@hyperplay/ui' import classNames from 'classnames' +import { useGetDownloadStatusText } from 'frontend/hooks/useGetDownloadStatusText' import libraryState from 'frontend/state/libraryState' import DMQueueState from 'frontend/state/DMQueueState' -import { useGetDownloadStatusText } from 'frontend/hooks/useGetDownloadStatusText' interface Card { buttonClick: () => void @@ -54,8 +54,13 @@ const GameCard = ({ const navigate = useNavigate() - const { layout, allTilesInColor, showDialogModal, setIsSettingsModalOpen } = - useContext(ContextProvider) + const { + layout, + hiddenGames, + allTilesInColor, + showDialogModal, + setIsSettingsModalOpen + } = useContext(ContextProvider) const { title, From ef947a25c6cd80278b41722361f85324ea102dd1 Mon Sep 17 00:00:00 2001 From: Red Date: Tue, 24 Oct 2023 20:01:17 +0200 Subject: [PATCH 03/32] fix: Lint, codecheck, prettier issues --- .../components/UI/DownloadToastManager/index.tsx | 7 +------ src/frontend/hooks/useGetDownloadStatusText.ts | 2 +- .../screens/Library/components/GameCard/index.tsx | 12 +++--------- 3 files changed, 5 insertions(+), 16 deletions(-) diff --git a/src/frontend/components/UI/DownloadToastManager/index.tsx b/src/frontend/components/UI/DownloadToastManager/index.tsx index f63d90d11..275c2e5d7 100644 --- a/src/frontend/components/UI/DownloadToastManager/index.tsx +++ b/src/frontend/components/UI/DownloadToastManager/index.tsx @@ -1,11 +1,6 @@ import React, { useContext, useEffect, useState } from 'react' import { DownloadToast, Images, CircularButton } from '@hyperplay/ui' -import { - DMQueueElement, - DownloadManagerState, - GameStatus, - InstallProgress -} from 'common/types' +import { DMQueueElement, GameStatus, InstallProgress } from 'common/types' import { DMQueue } from 'frontend/types' import ContextProvider from 'frontend/state/ContextProvider' import { useTranslation } from 'react-i18next' diff --git a/src/frontend/hooks/useGetDownloadStatusText.ts b/src/frontend/hooks/useGetDownloadStatusText.ts index db2ee376c..e8811b0b7 100644 --- a/src/frontend/hooks/useGetDownloadStatusText.ts +++ b/src/frontend/hooks/useGetDownloadStatusText.ts @@ -1,4 +1,4 @@ -import React, { useContext } from 'react' +import { useContext } from 'react' import { GameInfo } from 'common/types' import { getMessage } from 'frontend/screens/Library/constants' import { getCardStatus } from 'frontend/screens/Library/components/GameCard/constants' diff --git a/src/frontend/screens/Library/components/GameCard/index.tsx b/src/frontend/screens/Library/components/GameCard/index.tsx index ef4a54f0a..325bb1b71 100644 --- a/src/frontend/screens/Library/components/GameCard/index.tsx +++ b/src/frontend/screens/Library/components/GameCard/index.tsx @@ -1,8 +1,8 @@ import './index.css' -import React, { useContext, useState, useEffect, useMemo } from 'react' +import React, { useContext, useState, useEffect } from 'react' -import { GameInfo, HiddenGame, Runner } from 'common/types' +import { GameInfo, Runner } from 'common/types' import { Link, useNavigate } from 'react-router-dom' import { getGameInfo, install, launch } from 'frontend/helpers' @@ -56,7 +56,6 @@ const GameCard = ({ const { layout, - hiddenGames, allTilesInColor, showDialogModal, setIsSettingsModalOpen @@ -148,12 +147,7 @@ const GameCard = ({ return 'NOT_INSTALLED' } - const isHiddenGame = useMemo(() => { - return !!hiddenGames.list.find( - (hiddenGame: HiddenGame) => hiddenGame.appName === appName - ) - }, [hiddenGames, appName]) - + const isHiddenGame = libraryState.isGameHidden(appName) const isBrowserGame = installPlatform === 'Browser' const onUninstallClick = function () { From b9fd67f5ec08819c0db625a5a0cb1c12120aa12e Mon Sep 17 00:00:00 2001 From: Red Date: Tue, 24 Oct 2023 20:05:56 +0200 Subject: [PATCH 04/32] fix: prettier issues --- .../screens/Library/components/GameCard/index.tsx | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/frontend/screens/Library/components/GameCard/index.tsx b/src/frontend/screens/Library/components/GameCard/index.tsx index 325bb1b71..b363cc635 100644 --- a/src/frontend/screens/Library/components/GameCard/index.tsx +++ b/src/frontend/screens/Library/components/GameCard/index.tsx @@ -54,12 +54,8 @@ const GameCard = ({ const navigate = useNavigate() - const { - layout, - allTilesInColor, - showDialogModal, - setIsSettingsModalOpen - } = useContext(ContextProvider) + const { layout, allTilesInColor, showDialogModal, setIsSettingsModalOpen } = + useContext(ContextProvider) const { title, From a2fd8d64d82450e978226bada8b17d911c1d3722 Mon Sep 17 00:00:00 2001 From: Red Date: Tue, 24 Oct 2023 20:15:39 +0200 Subject: [PATCH 05/32] fix: Add hyperplay-ui package for test to pass --- package.json | 1 + yarn.lock | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/package.json b/package.json index 37072aa54..c4e9df30e 100644 --- a/package.json +++ b/package.json @@ -156,6 +156,7 @@ "@fortawesome/free-regular-svg-icons": "^6.1.1", "@fortawesome/free-solid-svg-icons": "^6.1.1", "@fortawesome/react-fontawesome": "^0.1.18", + "@hyperplay/ui": "^0.1.27", "@mantine/carousel": "^6.0.19", "@mantine/core": "^6.0.19", "@mantine/hooks": "^6.0.19", diff --git a/yarn.lock b/yarn.lock index d9639c68d..508d53025 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1614,6 +1614,11 @@ resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.2.0.tgz#76467a94aa888aeb22aafa43eb6ff889df3a5a7f" integrity sha512-rBevIsj2nclStJ7AxTdfsa3ovHb1H+qApwrxcTVo+NNdeJiB9V75hsKfrkG5AwNcRUNxrPPiScGYCNmLMoh8pg== +"@fortawesome/fontawesome-common-types@6.4.2": + version "6.4.2" + resolved "https://npm.fontawesome.com/@fortawesome/fontawesome-common-types/-/6.4.2/fontawesome-common-types-6.4.2.tgz#1766039cad33f8ad87f9467b98e0d18fbc8f01c5" + integrity sha512-1DgP7f+XQIJbLFCTX1V2QnxVmpLdKdzzo2k8EmvDOePfchaIGQ9eCHj2up3/jNEbZuBqel5OxiaOJf37TWauRA== + "@fortawesome/fontawesome-svg-core@^6.1.1": version "6.2.0" resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.2.0.tgz#11856eaf4dd1d865c442ddea1eed8ee855186ba2" @@ -1621,6 +1626,13 @@ dependencies: "@fortawesome/fontawesome-common-types" "6.2.0" +"@fortawesome/fontawesome-svg-core@^6.4.0": + version "6.4.2" + resolved "https://npm.fontawesome.com/@fortawesome/fontawesome-svg-core/-/6.4.2/fontawesome-svg-core-6.4.2.tgz#37f4507d5ec645c8b50df6db14eced32a6f9be09" + integrity sha512-gjYDSKv3TrM2sLTOKBc5rH9ckje8Wrwgx1CxAPbN5N3Fm4prfi7NsJVWd1jklp7i5uSCVwhZS5qlhMXqLrpAIg== + dependencies: + "@fortawesome/fontawesome-common-types" "6.4.2" + "@fortawesome/free-brands-svg-icons@^6.1.1": version "6.2.0" resolved "https://registry.yarnpkg.com/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.2.0.tgz#ce072179677f9b5d6767f918cfbf296f357cc1d0" @@ -1635,6 +1647,13 @@ dependencies: "@fortawesome/fontawesome-common-types" "6.2.0" +"@fortawesome/free-regular-svg-icons@^6.4.0": + version "6.4.2" + resolved "https://npm.fontawesome.com/@fortawesome/free-regular-svg-icons/-/6.4.2/free-regular-svg-icons-6.4.2.tgz#aee79ed76ce5dd04931352f9d83700761b8b1b25" + integrity sha512-0+sIUWnkgTVVXVAPQmW4vxb9ZTHv0WstOa3rBx9iPxrrrDH6bNLsDYuwXF9b6fGm+iR7DKQvQshUH/FJm3ed9Q== + dependencies: + "@fortawesome/fontawesome-common-types" "6.4.2" + "@fortawesome/free-solid-svg-icons@^6.1.1": version "6.2.0" resolved "https://registry.yarnpkg.com/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.2.0.tgz#8dcde48109354fd7a5ece8ea48d678bb91d4b5f0" @@ -1642,6 +1661,13 @@ dependencies: "@fortawesome/fontawesome-common-types" "6.2.0" +"@fortawesome/free-solid-svg-icons@^6.4.0": + version "6.4.2" + resolved "https://npm.fontawesome.com/@fortawesome/free-solid-svg-icons/-/6.4.2/free-solid-svg-icons-6.4.2.tgz#33a02c4cb6aa28abea7bc082a9626b7922099df4" + integrity sha512-sYwXurXUEQS32fZz9hVCUUv/xu49PEJEyUOsA51l6PU/qVgfbTb2glsTEaJngVVT8VqBATRIdh7XVgV1JF1LkA== + dependencies: + "@fortawesome/fontawesome-common-types" "6.4.2" + "@fortawesome/react-fontawesome@^0.1.18": version "0.1.19" resolved "https://registry.yarnpkg.com/@fortawesome/react-fontawesome/-/react-fontawesome-0.1.19.tgz#2b36917578596f31934e71f92b7cf9c425fd06e4" @@ -1649,6 +1675,13 @@ dependencies: prop-types "^15.8.1" +"@fortawesome/react-fontawesome@^0.2.0": + version "0.2.0" + resolved "https://npm.fontawesome.com/@fortawesome/react-fontawesome/-/0.2.0/react-fontawesome-0.2.0.tgz#d90dd8a9211830b4e3c08e94b63a0ba7291ddcf4" + integrity sha512-uHg75Rb/XORTtVt7OS9WoK8uM276Ufi7gCzshVWkUJbHhh3svsUUeqXerrM96Wm7fRiDzfKRwSoahhMIkGAYHw== + dependencies: + prop-types "^15.8.1" + "@humanwhocodes/config-array@^0.10.5": version "0.10.7" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.10.7.tgz#6d53769fd0c222767e6452e8ebda825c22e9f0dc" @@ -1668,6 +1701,18 @@ resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz#b520529ec21d8e5945a1851dfd1c32e94e39ff45" integrity sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA== +"@hyperplay/chains@^0.0.2": + version "0.0.2" + resolved "https://registry.yarnpkg.com/@hyperplay/chains/-/chains-0.0.2.tgz#329aabdd30d5f1c25233fcb342840624fff235d1" + integrity sha512-yex3/IOnmxtM1N0uU20cceVKUDAjAGFO0LKNxH45Nn+bbOaVgXJCsXqEk3NfVLGk08X71fSDzRCcX2UCSUwEmA== + dependencies: + axios "^1.4.0" + +"@hyperplay/ui@^0.1.27": + version "0.1.28" + resolved "https://registry.yarnpkg.com/@hyperplay/ui/-/ui-0.1.28.tgz#6a9af1dcf5dfd6df47edbe68c2d2ff99c5fac163" + integrity sha512-jBiBu3CV6sUFrr0koHm4a4R45KKDoc+HlGL5KingcM9OsXRyrrAXOOMMIRIEojW/y9Zxup0pkZKNO6y7bAGfyA== + "@ioredis/commands@^1.1.1": version "1.2.0" resolved "https://registry.yarnpkg.com/@ioredis/commands/-/commands-1.2.0.tgz#6d61b3097470af1fdbbe622795b8921d42018e11" @@ -4615,6 +4660,15 @@ axios@^1.0.0: form-data "^4.0.0" proxy-from-env "^1.1.0" +axios@^1.4.0: + version "1.5.1" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.5.1.tgz#11fbaa11fc35f431193a9564109c88c1f27b585f" + integrity sha512-Q28iYCWzNHjAm+yEAot5QaAMxhMghWLFVf7rRdwhUI+c2jix2DUXjAHXVi+s1ibs3mjPO/cCgbA++3BjD0vP/A== + dependencies: + follow-redirects "^1.15.0" + form-data "^4.0.0" + proxy-from-env "^1.1.0" + babel-jest@^28.1.3: version "28.1.3" resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-28.1.3.tgz#c1187258197c099072156a0a121c11ee1e3917d5" From 35f37bafaf844b8d3cdc9ca5f73b276885da762b Mon Sep 17 00:00:00 2001 From: Red Date: Tue, 24 Oct 2023 23:57:06 +0200 Subject: [PATCH 06/32] fix: Updated yarn lock, as app wont start --- yarn.lock | 49 ------------------------------------------------- 1 file changed, 49 deletions(-) diff --git a/yarn.lock b/yarn.lock index 508d53025..16d428bdb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1614,11 +1614,6 @@ resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.2.0.tgz#76467a94aa888aeb22aafa43eb6ff889df3a5a7f" integrity sha512-rBevIsj2nclStJ7AxTdfsa3ovHb1H+qApwrxcTVo+NNdeJiB9V75hsKfrkG5AwNcRUNxrPPiScGYCNmLMoh8pg== -"@fortawesome/fontawesome-common-types@6.4.2": - version "6.4.2" - resolved "https://npm.fontawesome.com/@fortawesome/fontawesome-common-types/-/6.4.2/fontawesome-common-types-6.4.2.tgz#1766039cad33f8ad87f9467b98e0d18fbc8f01c5" - integrity sha512-1DgP7f+XQIJbLFCTX1V2QnxVmpLdKdzzo2k8EmvDOePfchaIGQ9eCHj2up3/jNEbZuBqel5OxiaOJf37TWauRA== - "@fortawesome/fontawesome-svg-core@^6.1.1": version "6.2.0" resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.2.0.tgz#11856eaf4dd1d865c442ddea1eed8ee855186ba2" @@ -1626,13 +1621,6 @@ dependencies: "@fortawesome/fontawesome-common-types" "6.2.0" -"@fortawesome/fontawesome-svg-core@^6.4.0": - version "6.4.2" - resolved "https://npm.fontawesome.com/@fortawesome/fontawesome-svg-core/-/6.4.2/fontawesome-svg-core-6.4.2.tgz#37f4507d5ec645c8b50df6db14eced32a6f9be09" - integrity sha512-gjYDSKv3TrM2sLTOKBc5rH9ckje8Wrwgx1CxAPbN5N3Fm4prfi7NsJVWd1jklp7i5uSCVwhZS5qlhMXqLrpAIg== - dependencies: - "@fortawesome/fontawesome-common-types" "6.4.2" - "@fortawesome/free-brands-svg-icons@^6.1.1": version "6.2.0" resolved "https://registry.yarnpkg.com/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.2.0.tgz#ce072179677f9b5d6767f918cfbf296f357cc1d0" @@ -1647,13 +1635,6 @@ dependencies: "@fortawesome/fontawesome-common-types" "6.2.0" -"@fortawesome/free-regular-svg-icons@^6.4.0": - version "6.4.2" - resolved "https://npm.fontawesome.com/@fortawesome/free-regular-svg-icons/-/6.4.2/free-regular-svg-icons-6.4.2.tgz#aee79ed76ce5dd04931352f9d83700761b8b1b25" - integrity sha512-0+sIUWnkgTVVXVAPQmW4vxb9ZTHv0WstOa3rBx9iPxrrrDH6bNLsDYuwXF9b6fGm+iR7DKQvQshUH/FJm3ed9Q== - dependencies: - "@fortawesome/fontawesome-common-types" "6.4.2" - "@fortawesome/free-solid-svg-icons@^6.1.1": version "6.2.0" resolved "https://registry.yarnpkg.com/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.2.0.tgz#8dcde48109354fd7a5ece8ea48d678bb91d4b5f0" @@ -1661,13 +1642,6 @@ dependencies: "@fortawesome/fontawesome-common-types" "6.2.0" -"@fortawesome/free-solid-svg-icons@^6.4.0": - version "6.4.2" - resolved "https://npm.fontawesome.com/@fortawesome/free-solid-svg-icons/-/6.4.2/free-solid-svg-icons-6.4.2.tgz#33a02c4cb6aa28abea7bc082a9626b7922099df4" - integrity sha512-sYwXurXUEQS32fZz9hVCUUv/xu49PEJEyUOsA51l6PU/qVgfbTb2glsTEaJngVVT8VqBATRIdh7XVgV1JF1LkA== - dependencies: - "@fortawesome/fontawesome-common-types" "6.4.2" - "@fortawesome/react-fontawesome@^0.1.18": version "0.1.19" resolved "https://registry.yarnpkg.com/@fortawesome/react-fontawesome/-/react-fontawesome-0.1.19.tgz#2b36917578596f31934e71f92b7cf9c425fd06e4" @@ -1675,13 +1649,6 @@ dependencies: prop-types "^15.8.1" -"@fortawesome/react-fontawesome@^0.2.0": - version "0.2.0" - resolved "https://npm.fontawesome.com/@fortawesome/react-fontawesome/-/0.2.0/react-fontawesome-0.2.0.tgz#d90dd8a9211830b4e3c08e94b63a0ba7291ddcf4" - integrity sha512-uHg75Rb/XORTtVt7OS9WoK8uM276Ufi7gCzshVWkUJbHhh3svsUUeqXerrM96Wm7fRiDzfKRwSoahhMIkGAYHw== - dependencies: - prop-types "^15.8.1" - "@humanwhocodes/config-array@^0.10.5": version "0.10.7" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.10.7.tgz#6d53769fd0c222767e6452e8ebda825c22e9f0dc" @@ -1701,13 +1668,6 @@ resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz#b520529ec21d8e5945a1851dfd1c32e94e39ff45" integrity sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA== -"@hyperplay/chains@^0.0.2": - version "0.0.2" - resolved "https://registry.yarnpkg.com/@hyperplay/chains/-/chains-0.0.2.tgz#329aabdd30d5f1c25233fcb342840624fff235d1" - integrity sha512-yex3/IOnmxtM1N0uU20cceVKUDAjAGFO0LKNxH45Nn+bbOaVgXJCsXqEk3NfVLGk08X71fSDzRCcX2UCSUwEmA== - dependencies: - axios "^1.4.0" - "@hyperplay/ui@^0.1.27": version "0.1.28" resolved "https://registry.yarnpkg.com/@hyperplay/ui/-/ui-0.1.28.tgz#6a9af1dcf5dfd6df47edbe68c2d2ff99c5fac163" @@ -4660,15 +4620,6 @@ axios@^1.0.0: form-data "^4.0.0" proxy-from-env "^1.1.0" -axios@^1.4.0: - version "1.5.1" - resolved "https://registry.yarnpkg.com/axios/-/axios-1.5.1.tgz#11fbaa11fc35f431193a9564109c88c1f27b585f" - integrity sha512-Q28iYCWzNHjAm+yEAot5QaAMxhMghWLFVf7rRdwhUI+c2jix2DUXjAHXVi+s1ibs3mjPO/cCgbA++3BjD0vP/A== - dependencies: - follow-redirects "^1.15.0" - form-data "^4.0.0" - proxy-from-env "^1.1.0" - babel-jest@^28.1.3: version "28.1.3" resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-28.1.3.tgz#c1187258197c099072156a0a121c11ee1e3917d5" From 0fb04bd7aa95095649ef3427fea1c151ec207262 Mon Sep 17 00:00:00 2001 From: Red Date: Sat, 28 Oct 2023 01:42:09 +0200 Subject: [PATCH 07/32] feat: Added extraction zip service --- src/backend/services/ExtractZipService.ts | 257 ++++++++++++++++++++++ 1 file changed, 257 insertions(+) create mode 100644 src/backend/services/ExtractZipService.ts diff --git a/src/backend/services/ExtractZipService.ts b/src/backend/services/ExtractZipService.ts new file mode 100644 index 000000000..2ba9e8ddd --- /dev/null +++ b/src/backend/services/ExtractZipService.ts @@ -0,0 +1,257 @@ +import { EventEmitter } from 'node:events' +import { Readable } from 'node:stream'; +import { open, ZipFile } from 'yauzl' +import { mkdirSync, createWriteStream, rm } from 'graceful-fs' +import { join } from 'path' + +export interface ExtractZipProgressResponse { + /** Percentage of extraction progress. */ + progressPercentage: number + /** Speed of extraction in bytes per second. */ + speed: number + /** Total size of the ZIP file in bytes. */ + totalSize: number + /** Size of the ZIP content processed so far in bytes. */ + processedSize: number +} + +/** + * Service class to handle extraction of ZIP files. + * @extends {EventEmitter} + */ +export class ExtractZipService extends EventEmitter { + private readStream: Readable | null = null; + private canceled = false + private paused = false; + + private totalSize = 0 + private processedSize = 0 + private startTime = Date.now() + private lastUpdateTime = Date.now() + private dataDelay = 1000 + + private extractionPromise: Promise | null = null; + private resolveExtraction: ((value: boolean) => void) | null = () => null; + private rejectExtraction: ((reason: Error) => void) | null = () => null; + + /** + * Creates an instance of ExtractZipService. + * @param {string} zipFile - The path to the ZIP file. + * @param {string} destinationPath - The path where the extracted files should be saved. + */ + constructor( + private zipFile: string, + private destinationPath: string, + ) { + super() + } + + /** + * Checks if the extraction process was canceled. + * @returns {boolean} - True if the extraction was canceled, false otherwise. + */ + get isCanceled(): boolean { + return this.canceled; + } + + get isPaused(): boolean { + return this.readStream?.isPaused() || false; + } + + /** + * Gets the source ZIP file path. + * @returns {string} - The path to the ZIP file. + */ + get source(): string { + return this.zipFile; + } + + /** + * Gets the destination path where files will be extracted. + * @returns {string} - The destination path. + */ + get destination(): string { + return this.destinationPath; + } + + + public pause(): void { + if (!this.isPaused) { + this.readStream?.pause(); + } + } + + public async resume(): Promise { + if (!this.extractionPromise) { + throw new Error('Extraction has not started or has already completed.'); + } + + if (this.isPaused) { + this.readStream?.resume(); + } + + return this.extractionPromise; + } + + /** + * Cancels the extraction process. + */ + public cancel() { + this.canceled = true + this.readStream?.destroy(); + } + + /** + * Computes the progress of the extraction process. + * @private + * @returns {ExtractZipProgressResponse} - The progress details. + */ + private computeProgress(): ExtractZipProgressResponse { + const progressPercentage = Math.min( + 100, + (this.processedSize / this.totalSize) * 100 + ) + const elapsedTime = (Date.now() - this.startTime) / 1000 + const speed = this.processedSize / elapsedTime + + return { + progressPercentage, + speed, + totalSize: this.totalSize, + processedSize: this.processedSize + } + } + + /** + * Handles data events during extraction. + * @private + * @param {number} chunkLength - The length of the data chunk being processed. + */ + private onData(chunkLength: number) { + if (this.canceled) { + return; + } + + this.processedSize += chunkLength + const currentTime = Date.now() + + // Make always sure to have a delay, unless it will be too much spam + performance issues eventually, + // especially on electron with webContents.send* + if (currentTime - this.lastUpdateTime > this.dataDelay) { + this.emit('progress', this.computeProgress()) + this.lastUpdateTime = currentTime + } + } + + /** + * Handles end events after extraction completes. + * @private + */ + private onEnd() { + if (!this.canceled) { + this.emit('end', this.computeProgress()) + rm(this.zipFile, console.log) + } + } + + /** + * Handles error events during extraction. + * @private + * @param {Error} error - The error that occurred. + */ + private onError(error: Error) { + this.emit('error', error) + } + + /** + * Extracts the ZIP file to the specified destination. + * @returns {Promise} - A promise that resolves when the extraction is complete. + */ + async extract() { + this.extractionPromise = new Promise((resolve, reject) => { + this.resolveExtraction = resolve; + this.rejectExtraction = reject; + + open(this.zipFile, { lazyEntries: true }, (err, file: ZipFile) => { + if (err && this.rejectExtraction) { + this.rejectExtraction(err) + return + } + + this.totalSize = file.fileSize + + file.readEntry() + + file.on('entry', (entry) => { + if (this.canceled) { + file.close() + return + } + + if (/\/$/.test(entry.fileName)) { + // Directory file names end with '/' + mkdirSync(join(this.destinationPath, entry.fileName), { + recursive: true + }) + file.readEntry() + } else { + // Ensure parent directory exists + mkdirSync( + join( + this.destinationPath, + entry.fileName.split('/').slice(0, -1).join('/') + ), + { recursive: true } + ) + + file.openReadStream(entry, (err, readStream) => { + if (err && this.rejectExtraction) { + this.rejectExtraction(err) + return + } + + this.readStream = readStream; + const writeStream = createWriteStream( + join(this.destinationPath, entry.fileName) + ) + readStream.pipe(writeStream) + readStream.on('data', (chunk) => { + this.onData(chunk.length) + }) + writeStream.on('close', () => { + file.readEntry() + }) + }) + } + }) + + file.once('end', () => { + if (!this.canceled) { + file.close() + this.onEnd() + } + + if (this.resolveExtraction) { + this.resolveExtraction(true) + } + }) + }) + }).catch((error) => { + this.onError(error); + }) + try { + return await this.extractionPromise; + } catch (error: unknown) { + if (this.rejectExtraction) { + this.rejectExtraction(error as Error); + } + } finally { + this.extractionPromise = null; + this.resolveExtraction = null; + this.rejectExtraction = null; + this.removeAllListeners(); + } + + return false; + } +} From 6b71a4d95cf795fc6db55bb77f65d985a3a034f7 Mon Sep 17 00:00:00 2001 From: Red Date: Sat, 28 Oct 2023 01:42:23 +0200 Subject: [PATCH 08/32] feat: Added extraction zip service tests --- .../services/ExtractZipService.test.ts | 248 ++++++++++++++++++ 1 file changed, 248 insertions(+) create mode 100644 src/backend/__tests__/services/ExtractZipService.test.ts diff --git a/src/backend/__tests__/services/ExtractZipService.test.ts b/src/backend/__tests__/services/ExtractZipService.test.ts new file mode 100644 index 000000000..84cfd9a45 --- /dev/null +++ b/src/backend/__tests__/services/ExtractZipService.test.ts @@ -0,0 +1,248 @@ +import { EventEmitter } from 'node:events' +import yauzl from 'yauzl' +import { resolve } from 'path' +import { mkdirSync } from 'graceful-fs' +import { ExtractZipService } from '../../services/ExtractZipService' + +const returnDataMockup = { + progressPercentage: 0, + speed: 0, + totalSize: 1000, + processedSize: 500 +} + +jest.mock('yauzl', () => ({ + open: jest.fn() +})) +jest.mock('graceful-fs', () => ({ + mkdirSync: jest.fn(), + createWriteStream: () => ({ + pipe: jest.fn((writeStream) => writeStream), + once: jest.fn(), + on: jest.fn((event, streamCallback) => { + if (event === 'close') { + streamCallback() + } else if (event === 'data') { + streamCallback(new Array(500).fill(0)) + } else if (event === 'error') { + streamCallback(new Error('Error')) + } + }) + }), + rm: jest.fn(), + copyFileSync: jest.fn() +})) + +const yauzlMockupLib = ( + fileName = 'test.zip', + withError?: boolean, + fileSize = 1000 +) => { + const error = withError ? new Error('Error example') : null + + const stream = { + _read: () => null, + pipe: jest.fn((args) => args), + on: jest.fn( + ( + event: string, + streamCallback: (...args: unknown[]) => ReadableStream + ) => { + if (event === 'close') { + streamCallback() + } else if (event === 'data') { + streamCallback(new Array(500).fill(0)) + } else if (event === 'error') { + streamCallback(new Error('Error')) + } + } + ) + } + + const mockZipFile = { + fileSize, + readEntry: jest.fn(), + close: jest.fn(), + once: jest.fn((event, streamCallback) => { + if (event === 'end') { + streamCallback() + } + }), + on: jest.fn((event, entryCallback) => { + if (event === 'entry') { + entryCallback({ + fileName, + uncompressedSize: 1000 + }) + } + }), + openReadStream: jest.fn((entry, openReadStreamCallback) => { + openReadStreamCallback(error, stream) + }) + } + + ;(yauzl.open as jest.Mock).mockImplementation( + (_path, _options, yauzlOpenCallback) => { + yauzlOpenCallback(error, mockZipFile) + } + ) + + return { + openReadStream: mockZipFile.openReadStream, + zipFile: mockZipFile, + stream + } +} + +describe('ExtractZipService', () => { + let extractZipService: ExtractZipService + const zipFile = resolve('./src/backend/__mocks__/test.zip') + const destinationPath = resolve('./src/backend/__mocks__/test') + + beforeEach((done) => { + yauzlMockupLib('test.zip', false) + extractZipService = new ExtractZipService(zipFile, destinationPath) + setTimeout(done, 1000) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + describe('should have `source` and `destination` always available', () => { + it('should initialize properly', () => { + expect(extractZipService).toBeInstanceOf(EventEmitter) + expect(extractZipService.source).toBe(zipFile) + expect(extractZipService.destination).toBe(destinationPath) + }) + }) + + it('should emit progress events', async () => { + const progressListener = jest.fn() + extractZipService.on('progress', progressListener) + + await extractZipService.extract() + + expect(progressListener).toHaveBeenCalled() + }) + + it('should emit end event on successful extraction', async () => { + const endListener = jest.fn() + extractZipService.on('end', endListener) + + await extractZipService.extract() + + expect(endListener).toHaveBeenCalled() + }) + + it('should emit error event on extraction failure', async () => { + yauzlMockupLib('test.zip', true) + + const errorListener = jest.fn() + extractZipService.on('error', errorListener) + + await extractZipService.extract() + + expect(errorListener).toHaveBeenCalledWith( + expect.objectContaining(new Error('Mock example')) + ) + }) + + it('should cancel extraction when cancel is called', async () => { + const endListener = jest.fn() + const progressListener = jest.fn() + extractZipService.on('end', endListener) + extractZipService.on('progress', progressListener) + + extractZipService.cancel() + + await extractZipService.extract() + + expect(endListener).not.toHaveBeenCalled() + expect(progressListener).not.toHaveBeenCalled() + }) + + it('should have the state as cancelled once is cancelled', async () => { + extractZipService.extract() + extractZipService.cancel() + + expect(extractZipService.isCanceled).toBe(true) + }) + + it('should handle directory entry', async () => { + yauzlMockupLib('directory/test.zip', false) + + const endListener = jest.fn() + extractZipService.on('end', endListener) + + await extractZipService.extract() + + expect(mkdirSync).toHaveBeenCalledWith( + expect.stringContaining('directory'), + expect.anything() + ) + expect(endListener).toHaveBeenCalled() + }) + + it('should emit correct progress values', async () => { + const progressListener = jest.fn(() => returnDataMockup) + extractZipService.on('progress', progressListener) + + await extractZipService.extract() + + expect(progressListener).toHaveBeenCalledWith( + expect.objectContaining({ + processedSize: 500, + progressPercentage: 50, + speed: expect.any(Number), + totalSize: 1000 + }) + ) + }) + + it('should emit correct end values', async () => { + const endListener = jest.fn(() => returnDataMockup) + extractZipService.on('end', endListener) + + await extractZipService.extract() + + expect(endListener).toHaveBeenCalledWith( + expect.objectContaining({ + processedSize: 500, + progressPercentage: 50, + speed: expect.any(Number), + totalSize: 1000 + }) + ) + }) + + it('should extract files successfully', async () => { + const onProgress = jest.fn() + const onEnd = jest.fn() + extractZipService.on('progress', onProgress) + extractZipService.on('end', onEnd) + + await extractZipService.extract() + + expect(onProgress).toHaveBeenCalled() + expect(onEnd).toHaveBeenCalled() + expect(mkdirSync).toHaveBeenCalled() + }) + + it('should throttle emit progress', async () => { + const { stream } = yauzlMockupLib('test.zip') + + const mockEventListener = jest.fn() + extractZipService.on('progress', mockEventListener) + + await extractZipService.extract() + + for (let i = 0; i < 10; i++) { + stream.on.mock.calls[stream.on.mock.calls.length - 1][1]( + Buffer.alloc(500) + ) + } + + expect(mockEventListener).toHaveBeenCalledTimes(1) + }) +}) From 19cca3dddb836b212a8ebea75ea31d46986e7dc2 Mon Sep 17 00:00:00 2001 From: Red Date: Sat, 28 Oct 2023 01:42:43 +0200 Subject: [PATCH 09/32] feat: Removed previous function for extract and its tests --- src/backend/__tests__/utils.test.ts | 66 ----------------------------- src/backend/utils.ts | 50 +--------------------- 2 files changed, 1 insertion(+), 115 deletions(-) diff --git a/src/backend/__tests__/utils.test.ts b/src/backend/__tests__/utils.test.ts index f6617d962..d3ed781b2 100644 --- a/src/backend/__tests__/utils.test.ts +++ b/src/backend/__tests__/utils.test.ts @@ -1,18 +1,7 @@ import axios from 'axios' import { app } from 'electron' -import { extractZip } from '../../backend/utils' -import { logError } from '../logger/logger' import * as utils from '../utils' import { test_data } from './test_data/github-api-heroic-test-data.json' -import path from 'path' -import { - copyFileSync, - existsSync, - readFileSync, - rmSync, - rmdirSync, - renameSync -} from 'graceful-fs' jest.mock('electron') jest.mock('../logger/logger') @@ -257,59 +246,4 @@ describe('backend/utils.ts', () => { expect(utils.bytesToSize(2059 * 1024 * 3045 * 4000)).toEqual('23.36 TB') }) }) - - describe('extractZip', () => { - let testCopyZipPath: string - let destFilePath: string - - beforeEach(() => { - const testZipPath = path.resolve('./src/backend/__mocks__/test.zip') - //copy zip because extract will delete it - testCopyZipPath = path.resolve('./src/backend/__mocks__/test2.zip') - copyFileSync(testZipPath, testCopyZipPath) - destFilePath = path.resolve('./src/backend/__mocks__/test') - }) - - afterEach(async () => { - const extractPromise = utils.extractZip(testCopyZipPath, destFilePath) - await extractPromise - expect(extractPromise).resolves - - const testTxtFilePath = path.resolve(destFilePath, './test.txt') - console.log('checking dest file path ', testTxtFilePath) - expect(existsSync(testTxtFilePath)).toBe(true) - - const testMessage = readFileSync(testTxtFilePath).toString() - console.log('unzipped file contents: ', testMessage) - expect(testMessage).toEqual('this is a test message') - - //extract deletes the zip file used to extract async so we wait and then check - await utils.wait(100) - expect(existsSync(testCopyZipPath)).toBe(false) - - //clean up test - rmSync(testTxtFilePath) - rmdirSync(destFilePath) - expect(existsSync(testTxtFilePath)).toBe(false) - expect(existsSync(destFilePath)).toBe(false) - }) - - test('extract a normal test zip', async () => { - console.log('extracting test.zip') - }) - - test('extract a test zip with non ascii characters', async () => { - const renamedZipFilePath = path.resolve( - './src/backend/__mocks__/谷���新道ひばりヶ�.zip' - ) - renameSync(testCopyZipPath, renamedZipFilePath) - testCopyZipPath = renamedZipFilePath - }) - - it('should throw an error if the zip file does not exist', async () => { - await expect( - extractZip('nonexistent.zip', destFilePath) - ).rejects.toThrow() - }) - }) }) diff --git a/src/backend/utils.ts b/src/backend/utils.ts index 1517836fe..c535ac449 100644 --- a/src/backend/utils.ts +++ b/src/backend/utils.ts @@ -43,6 +43,7 @@ import { createWriteStream, rm } from 'graceful-fs' +import { EventEmitter } from 'events'; import { promisify } from 'util' import i18next, { t } from 'i18next' import si from 'systeminformation' @@ -1418,55 +1419,6 @@ function removeFolder(path: string, folderName: string) { return } -export async function extractZip(zipFile: string, destinationPath: string) { - return new Promise((resolve, reject) => { - yauzl.open(zipFile, { lazyEntries: true }, (err, zipfile) => { - if (err) { - reject(err) - return - } - - zipfile.readEntry() - zipfile.on('entry', (entry) => { - if (/\/$/.test(entry.fileName)) { - // Directory file names end with '/' - mkdirSync(join(destinationPath, entry.fileName), { recursive: true }) - zipfile.readEntry() - } else { - // Ensure parent directory exists - mkdirSync( - join( - destinationPath, - entry.fileName.split('/').slice(0, -1).join('/') - ), - { recursive: true } - ) - - // Extract file - zipfile.openReadStream(entry, (err, readStream) => { - if (err) { - reject(err) - return - } - - const writeStream = createWriteStream( - join(destinationPath, entry.fileName) - ) - readStream.pipe(writeStream) - writeStream.on('close', () => { - zipfile.readEntry() - }) - }) - } - }) - - zipfile.on('end', () => { - resolve() - rm(zipFile, console.log) - }) - }) - }) -} export function calculateEta( downloadedBytes: number, From 631780bb4268e896a39de58ec3717a961d99809b Mon Sep 17 00:00:00 2001 From: Red Date: Sat, 28 Oct 2023 01:43:19 +0200 Subject: [PATCH 10/32] feat: Integrated the service on install --- src/backend/storeManagers/hyperplay/games.ts | 223 +++++++++++++++---- 1 file changed, 175 insertions(+), 48 deletions(-) diff --git a/src/backend/storeManagers/hyperplay/games.ts b/src/backend/storeManagers/hyperplay/games.ts index 18488aa6e..bcffd64aa 100644 --- a/src/backend/storeManagers/hyperplay/games.ts +++ b/src/backend/storeManagers/hyperplay/games.ts @@ -13,6 +13,10 @@ import { InstallPlatform } from 'common/types' import { hpLibraryStore } from './electronStore' import { sendFrontendMessage, getMainWindow } from 'backend/main_window' import { LogPrefix, logError, logInfo, logWarning } from 'backend/logger/logger' +import { + ExtractZipService, + ExtractZipProgressResponse +} from 'backend/services/ExtractZipService' import { existsSync, mkdirSync, rmSync, readdirSync } from 'graceful-fs' import { isMac, @@ -27,7 +31,6 @@ import { spawnAsync, killPattern, shutdownWine, - extractZip, calculateEta } from 'backend/utils' import { notify } from 'backend/dialog/dialog' @@ -62,6 +65,7 @@ import { DownloadItem } from 'electron' import { waitForItemToDownload } from 'backend/utils/downloadFile/download_file' const inProgressDownloadsMap: Map = new Map() +const inProgressExtractionsMap: Map = new Map() export async function getSettings(appName: string): Promise { return getSettingsSideload(appName) @@ -224,6 +228,7 @@ const installDistributables = async (gamePath: string) => { function cleanUpDownload(appName: string, directory: string) { inProgressDownloadsMap.delete(appName) + inProgressExtractionsMap.delete(appName) deleteAbortController(appName) rmSync(directory, { recursive: true, force: true }) } @@ -287,10 +292,12 @@ async function downloadGame( diskWriteSpeed: number, progress: number ) { - const eta = calculateEta( + const currentProgress = calculateProgress( downloadedBytes, + Number.parseInt(platformInfo.downloadSize ?? '0'), downloadSpeed, - Number.parseInt(platformInfo.downloadSize ?? '0') + diskWriteSpeed, + progress ) if (downloadedBytes > 0 && !downloadStarted) { @@ -309,12 +316,8 @@ async function downloadGame( runner: 'hyperplay', folder: destinationPath, progress: { - percent: roundToTenth(progress), - diskSpeed: roundToTenth(diskWriteSpeed / 1024 / 1024), - downSpeed: roundToTenth(downloadSpeed / 1024 / 1024), - bytes: roundToTenth(downloadedBytes / 1024 / 1024), folder: destinationPath, - eta + ...currentProgress } }) } @@ -346,6 +349,24 @@ async function downloadGame( }) } +function calculateProgress( + downloadedBytes: number, + downloadSize: number, + downloadSpeed: number, + diskWriteSpeed: number, + progress: number +) { + const eta = calculateEta(downloadedBytes, downloadSpeed, downloadSize) + + return { + percent: roundToTenth(progress), + diskSpeed: roundToTenth(diskWriteSpeed / 1024 / 1024), + downSpeed: roundToTenth(downloadSpeed / 1024 / 1024), + bytes: roundToTenth(downloadedBytes / 1024 / 1024), + eta + } +} + function sanitizeFileName(filename: string) { return filename.replace(/[/\\?%*:|"<>]/g, '-') } @@ -472,6 +493,13 @@ async function resumeIfPaused(appName: string): Promise { return isPaused } +export async function cancelExtraction(appName: string) { + const extractZipService = inProgressExtractionsMap.get(appName) + if (extractZipService) { + extractZipService.cancel() + } +} + export async function install( appName: string, { path: dirpath, platformToInstall, channelName, accessCode }: InstallArgs @@ -569,53 +597,152 @@ export async function install( const zipFile = path.join(directory, fileName) logInfo(`Extracting ${zipFile} to ${destinationPath}`, LogPrefix.HyperPlay) - try { - window.webContents.send('gameStatusUpdate', { - appName, - runner: 'hyperplay', - folder: destinationPath, - status: 'extracting' - }) - - // disables electron's fs wrapper called when extracting .asar files - // which is necessary to extract electron app/game zip files - process.noAsar = true - if (isWindows) { - await extractZip(zipFile, destinationPath) - await installDistributables(destinationPath) - } else { - await extractZip(zipFile, destinationPath) - } - process.noAsar = false + // disables electron's fs wrapper called when extracting .asar files + // which is necessary to extract electron app/game zip files + process.noAsar = true - if (isMac && executable.endsWith('.app')) { - const macAppExecutable = readdirSync( - join(executable, 'Contents', 'MacOS') - )[0] - executable = join(executable, 'Contents', 'MacOS', macAppExecutable) - } + sendFrontendMessage('gameStatusUpdate', { + appName, + status: 'extracting', + runner: 'hyperplay', + folder: destinationPath + }) - const installedInfo: InstalledInfo = { - appName, - install_path: destinationPath, - executable: executable, - install_size: platformInfo.installSize ?? '0', - is_dlc: false, - version: installVersion, - platform: appPlatform, - channelName + window.webContents.send(`progressUpdate-${appName}`, { + appName, + runner: 'hyperplay', + folder: destinationPath, + status: 'extracting', + progress: { + folder: destinationPath, + percent: 0, + diskSpeed: 0, + downSpeed: 0, + bytes: 0, + eta: null } + }) - updateInstalledInfo(appName, installedInfo) + try { + const extractService = new ExtractZipService(zipFile, destinationPath) + + inProgressExtractionsMap.set(appName, extractService); + + extractService.on( + 'progress', + ({ + processedSize, + totalSize, + speed, + progressPercentage + }: ExtractZipProgressResponse) => { + logInfo( + `Extracting Progress: ${progressPercentage}% Speed: ${speed} B/s | Total size ${totalSize} and ${processedSize}`, + LogPrefix.HyperPlay + ) + const currentProgress = calculateProgress( + processedSize, + totalSize, + speed, + speed, + progressPercentage + ) + + window.webContents.send(`progressUpdate-${appName}`, { + appName, + runner: 'hyperplay', + folder: destinationPath, + status: 'extracting', + progress: { + folder: destinationPath, + ...currentProgress + } + }) + } + ) + extractService.once( + 'end', + async ({ + progressPercentage, + speed, + totalSize, + processedSize + }: ExtractZipProgressResponse) => { + extractService.removeAllListeners() + + logInfo( + `Extracting End: ${progressPercentage}% Speed: ${speed} B/s | Total size ${totalSize} and ${processedSize}`, + LogPrefix.HyperPlay + ) + const currentProgress = calculateProgress( + processedSize, + totalSize, + speed, + speed, + progressPercentage + ) + + window.webContents.send(`progressUpdate-${appName}`, { + appName, + runner: 'hyperplay', + folder: destinationPath, + status: 'extracting', + progress: { + folder: destinationPath, + ...currentProgress + } + }) + + window.webContents.send('gameStatusUpdate', { + appName, + runner: 'hyperplay', + folder: destinationPath, + status: 'extracting' + }) + + if (isWindows) { + await installDistributables(destinationPath) + } + + process.noAsar = false + + if (isMac && executable.endsWith('.app')) { + const macAppExecutable = readdirSync( + join(executable, 'Contents', 'MacOS') + )[0] + executable = join(executable, 'Contents', 'MacOS', macAppExecutable) + } + + const installedInfo: InstalledInfo = { + appName, + install_path: destinationPath, + executable: executable, + install_size: platformInfo?.installSize ?? '0', + is_dlc: false, + version: installVersion, + platform: appPlatform, + channelName + } + + updateInstalledInfo(appName, installedInfo) + + notify({ + title, + body: `Installed` + }) + + cleanUpDownload(appName, directory) + + sendFrontendMessage('refreshLibrary', 'hyperplay') + } + ) + extractService.on('error', (error: Error) => { + logError(`Extracting Error ${error.message}`, LogPrefix.HyperPlay) - notify({ - title, - body: `Installed` + throw error }) - cleanUpDownload(appName, directory) - - sendFrontendMessage('refreshLibrary', 'hyperplay') + await extractService.extract() } catch (error) { logInfo(`Error while extracting game ${error}`, LogPrefix.HyperPlay) window.webContents.send('gameStatusUpdate', { From cfdc7d1055074ebd151bec6c55b02419f9ac586e Mon Sep 17 00:00:00 2001 From: Red Date: Sat, 28 Oct 2023 01:43:45 +0200 Subject: [PATCH 11/32] feat: Removed the download status to be always bounded to 100 for GameCard --- src/frontend/screens/Library/components/GameCard/index.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/frontend/screens/Library/components/GameCard/index.tsx b/src/frontend/screens/Library/components/GameCard/index.tsx index b363cc635..3c1e60cf1 100644 --- a/src/frontend/screens/Library/components/GameCard/index.tsx +++ b/src/frontend/screens/Library/components/GameCard/index.tsx @@ -283,10 +283,6 @@ const GameCard = ({ const { activeController } = useContext(ContextProvider) - if (downloadStatus === 'extracting') { - progress.percent = 100 - } - return ( <> {showStopInstallModal ? ( From 75ae8139d1148591b36fa2ddfa4668b948192e5d Mon Sep 17 00:00:00 2001 From: Red Date: Sat, 28 Oct 2023 01:44:13 +0200 Subject: [PATCH 12/32] feat: Integrated the new changes for extraction data from BE --- .../UI/DownloadToastManager/index.tsx | 44 +++++++++++-------- 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/src/frontend/components/UI/DownloadToastManager/index.tsx b/src/frontend/components/UI/DownloadToastManager/index.tsx index 275c2e5d7..084bd2b65 100644 --- a/src/frontend/components/UI/DownloadToastManager/index.tsx +++ b/src/frontend/components/UI/DownloadToastManager/index.tsx @@ -34,6 +34,7 @@ export default function DownloadToastManager() { appName, gameInfo ) + const isExtracting = status === 'extracting' let showPlayTimeout: NodeJS.Timeout | undefined = undefined @@ -109,6 +110,12 @@ export default function DownloadToastManager() { } }, [currentElement]) + useEffect(() => { + if (isExtracting) { + setProgress(nullProgress) // reset progress to 0 + } + }, [isExtracting]) + if (currentElement === undefined) { console.debug('no downloads active in download toast manager') return <> @@ -158,18 +165,19 @@ export default function DownloadToastManager() { downloadedMB = Number(progress.bytes) } - const title = currentElement?.params.gameInfo.title - ? currentElement?.params.gameInfo.title - : 'Game' - const downloadSizeInMB = progress.percent - ? (downloadedMB / progress.percent) * 100 - : 0 - const estimatedCompletionTimeInMs = progress.downSpeed - ? (downloadSizeInMB / progress.downSpeed) * 1000 - : 0 - let imgUrl = currentElement?.params.gameInfo.art_cover - ? currentElement?.params.gameInfo.art_cover - : '' +const title = currentElement?.params.gameInfo.title + ? currentElement?.params.gameInfo.title + : 'Game' +const downloadSizeInMB = progress.percent + ? (downloadedMB / progress.percent) * 100 + : 0 +const estimatedCompletionTimeInMs = progress.downSpeed + ? (downloadSizeInMB / progress.downSpeed) * 1000 + : 0 +let imgUrl = currentElement?.params.gameInfo.art_cover + ? currentElement?.params.gameInfo.art_cover + : '' + if (!imgUrl.includes('http')) imgUrl = currentElement.params.gameInfo.art_square @@ -190,18 +198,18 @@ export default function DownloadToastManager() { return 'inProgress' } +const adjustedDownloadedInBytes = downloadedMB * 1024 * 1024; +const adjustedDownloadSizeInBytes = downloadSizeInMB * 1024 * 1024; + return (
{showDownloadToast ? ( { setShowStopInstallModal(true) From b0ec5c44849bd677b0f194733c3319068c7044bb Mon Sep 17 00:00:00 2001 From: Red Date: Sun, 29 Oct 2023 21:15:39 +0100 Subject: [PATCH 13/32] feat: Added new status for extraction --- src/frontend/screens/Library/components/GameCard/constants.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/frontend/screens/Library/components/GameCard/constants.ts b/src/frontend/screens/Library/components/GameCard/constants.ts index f5c06f62c..2d8c57abd 100644 --- a/src/frontend/screens/Library/components/GameCard/constants.ts +++ b/src/frontend/screens/Library/components/GameCard/constants.ts @@ -32,6 +32,7 @@ export function getCardStatus( const syncingSaves = status === 'syncing-saves' const isPaused = status === 'paused' const isPreparing = status === 'preparing' + const isExtracting = status === 'extracting' const haveStatus = isMoving || @@ -58,6 +59,7 @@ export function getCardStatus( isUpdating, isPaused, isPreparing, + isExtracting, haveStatus } } From ece4aa9b0c8a84f562861725d8f43f1a1513fb35 Mon Sep 17 00:00:00 2001 From: Red Date: Sun, 29 Oct 2023 21:20:48 +0100 Subject: [PATCH 14/32] feat: Added cancel extraction api from InstallationToast --- .../UI/StopInstallationModal/index.tsx | 19 ++++++++++++++++++- .../components/DownloadManagerItem/index.tsx | 1 + .../Library/components/GameCard/index.tsx | 12 ++++++------ 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/src/frontend/components/UI/StopInstallationModal/index.tsx b/src/frontend/components/UI/StopInstallationModal/index.tsx index 0d48f2a24..e8d889244 100644 --- a/src/frontend/components/UI/StopInstallationModal/index.tsx +++ b/src/frontend/components/UI/StopInstallationModal/index.tsx @@ -17,11 +17,14 @@ interface StopInstallProps { appName: string runner: Runner progress: InstallProgress + status: string } export default function StopInstallationModal(props: StopInstallProps) { const { t } = useTranslation('gamepage') const checkbox = useRef(null) + const isExtracting = props.status === 'extracting'; + return ( @@ -56,6 +59,7 @@ export default function StopInstallationModal(props: StopInstallProps) { type="secondary" size="large" onClick={async () => { + console.log('isExtracting', isExtracting) // if user wants to keep downloaded files and cancel download if (checkbox.current && checkbox.current.checked) { props.onClose() @@ -68,12 +72,25 @@ export default function StopInstallationModal(props: StopInstallProps) { folder: props.installPath } storage.setItem(props.appName, JSON.stringify(latestProgress)) + + if (isExtracting) { + window.api.cancelExtraction(props.appName) + + return + } + window.api.cancelDownload(false) } // if user does not want to keep downloaded files but still wants to cancel download else { props.onClose() - window.api.cancelDownload(true) + + if (isExtracting) { + window.api.cancelExtraction(props.appName) + } else { + window.api.cancelDownload(true) + } + storage.removeItem(props.appName) } }} diff --git a/src/frontend/screens/DownloadManager/components/DownloadManagerItem/index.tsx b/src/frontend/screens/DownloadManager/components/DownloadManagerItem/index.tsx index bf32373ef..d9a2f2afe 100644 --- a/src/frontend/screens/DownloadManager/components/DownloadManagerItem/index.tsx +++ b/src/frontend/screens/DownloadManager/components/DownloadManagerItem/index.tsx @@ -250,6 +250,7 @@ const DownloadManagerItem = observer(({ element, current, state }: Props) => { folderName={gameInfo.folder_name ? gameInfo.folder_name : ''} appName={appName} runner={runner} + status={status || ''} progress={progress} /> ) : null} diff --git a/src/frontend/screens/Library/components/GameCard/index.tsx b/src/frontend/screens/Library/components/GameCard/index.tsx index 3c1e60cf1..7802cee06 100644 --- a/src/frontend/screens/Library/components/GameCard/index.tsx +++ b/src/frontend/screens/Library/components/GameCard/index.tsx @@ -73,8 +73,8 @@ const GameCard = ({ ...gameInstallInfo } - const { status, folder } = hasStatus(appName, gameInfo, size) - const { statusText: downloadStatusText, status: downloadStatus } = + const { status = '', folder } = hasStatus(appName, gameInfo, size) + const { statusText: downloadStatusText } = useGetDownloadStatusText(appName, gameInfo) useEffect(() => { @@ -103,7 +103,8 @@ const GameCard = ({ isPlaying, notAvailable, isUpdating, - isPaused + isPaused, + isExtracting, } = getCardStatus(status, isInstalled, layout) const handleRemoveFromQueue = () => { @@ -293,6 +294,7 @@ const GameCard = ({ appName={appName} runner={runner} folderName={gameInfo.folder_name ? gameInfo.folder_name : ''} + status={status} /> ) : null} {showUninstallModal && ( @@ -337,9 +339,7 @@ const GameCard = ({ window.api.resumeCurrentDownload() )} onPlayClick={handleClickStopBubbling(async () => mainAction(runner))} - onStopDownloadClick={handleClickStopBubbling(async () => - setShowStopInstallModal(true) - )} + onStopDownloadClick={handleClickStopBubbling(async () => setShowStopInstallModal(true))} state={getState()} settingsItems={items.filter((val) => val.show).slice(0, 6)} showSettings={showSettings} From 10c36bf52ef391708909f9f3d6635ae3747b69f8 Mon Sep 17 00:00:00 2001 From: Red Date: Sun, 29 Oct 2023 21:21:21 +0100 Subject: [PATCH 15/32] feat: Passed status through DownloadToastManager --- src/frontend/components/UI/DownloadToastManager/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/frontend/components/UI/DownloadToastManager/index.tsx b/src/frontend/components/UI/DownloadToastManager/index.tsx index 084bd2b65..be2663fef 100644 --- a/src/frontend/components/UI/DownloadToastManager/index.tsx +++ b/src/frontend/components/UI/DownloadToastManager/index.tsx @@ -253,6 +253,7 @@ downloadSizeInBytes = { adjustedDownloadSizeInBytes } folderName={folder_name} progress={progress} runner={runner} + status={status} onClose={() => setShowStopInstallModal(false)} /> ) : null} From fcf2332e46c25f6f97b0d0579a70645f3fe38786 Mon Sep 17 00:00:00 2001 From: Red Date: Sun, 29 Oct 2023 21:22:27 +0100 Subject: [PATCH 16/32] feat: Added ipc handlers for cancel extraction --- src/backend/api/downloadmanager.ts | 3 +++ src/backend/downloadmanager/ipc_handler.ts | 5 +++++ src/common/typedefs/ipcBridge.d.ts | 1 + 3 files changed, 9 insertions(+) diff --git a/src/backend/api/downloadmanager.ts b/src/backend/api/downloadmanager.ts index 9062796c3..ee4cd32a0 100644 --- a/src/backend/api/downloadmanager.ts +++ b/src/backend/api/downloadmanager.ts @@ -78,6 +78,9 @@ export const handleDMQueueInformation = ( export const cancelDownload = (removeDownloaded: boolean) => ipcRenderer.send('cancelDownload', removeDownloaded) +export const cancelExtraction = (appName: string) => + ipcRenderer.send('cancelExtraction', appName) + export const resumeCurrentDownload = () => ipcRenderer.send('resumeCurrentDownload') diff --git a/src/backend/downloadmanager/ipc_handler.ts b/src/backend/downloadmanager/ipc_handler.ts index 1cb804568..e6f49093b 100644 --- a/src/backend/downloadmanager/ipc_handler.ts +++ b/src/backend/downloadmanager/ipc_handler.ts @@ -7,6 +7,7 @@ import { removeFromQueue, resumeCurrentDownload } from './downloadqueue' +import { cancelExtraction } from 'backend/storeManagers/hyperplay/games' ipcMain.handle('addToDMQueue', async (e, element) => { await addToQueue(element) @@ -26,4 +27,8 @@ ipcMain.on('cancelDownload', (e, removeDownloaded) => { cancelCurrentDownload({ removeDownloaded }) }) +ipcMain.on('cancelExtraction', (e, appName: string) => { + cancelExtraction(appName) +}) + ipcMain.handle('getDMQueueInformation', getQueueInformation) diff --git a/src/common/typedefs/ipcBridge.d.ts b/src/common/typedefs/ipcBridge.d.ts index 42c794523..e8c5aa7c7 100644 --- a/src/common/typedefs/ipcBridge.d.ts +++ b/src/common/typedefs/ipcBridge.d.ts @@ -126,6 +126,7 @@ interface SyncIPCFunctions extends HyperPlaySyncIPCFunctions { openGameInEpicStore: (url: string) => void resumeCurrentDownload: () => void cancelDownload: (removeDownloaded: boolean) => void + cancelExtraction: (appName: string) => void copyWalletConnectBaseURIToClipboard: () => void } From 7e7d2017ab2a113c2495d154b887d2fa2a12eaee Mon Sep 17 00:00:00 2001 From: Red Date: Sun, 29 Oct 2023 21:22:47 +0100 Subject: [PATCH 17/32] feat: Added cancel extraction queue --- src/backend/downloadmanager/downloadqueue.ts | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/backend/downloadmanager/downloadqueue.ts b/src/backend/downloadmanager/downloadqueue.ts index 0748190d9..77084146d 100644 --- a/src/backend/downloadmanager/downloadqueue.ts +++ b/src/backend/downloadmanager/downloadqueue.ts @@ -188,6 +188,23 @@ function getQueueInformation() { return { elements, finished, state: queueState } } +function cancelQueueExtraction() { + if (currentElement) { + if (Array.isArray(currentElement.params.installDlcs)) { + const dlcsToRemove = currentElement.params.installDlcs + for (const dlc of dlcsToRemove) { + removeFromQueue(dlc) + } + } + if (isRunning()) { + stopCurrentDownload() + } + removeFromQueue(currentElement.params.appName) + + currentElement = null + } +} + function cancelCurrentDownload({ removeDownloaded = false }) { if (currentElement) { if (Array.isArray(currentElement.params.installDlcs)) { @@ -255,8 +272,6 @@ function processNotification(element: DMQueueElement, status: DMStatus) { element.params.appName ) - console.log('processNotification', status) - if (status === 'abort') { if (isPaused()) { logWarning( @@ -320,6 +335,7 @@ export { removeFromQueue, getQueueInformation, cancelCurrentDownload, + cancelQueueExtraction, pauseCurrentDownload, resumeCurrentDownload, getFirstQueueElement From 14c96ba1049818fd4064c5896b70c06f6a9ed16f Mon Sep 17 00:00:00 2001 From: Red Date: Sun, 29 Oct 2023 21:24:16 +0100 Subject: [PATCH 18/32] feat: Added cancellation to the service --- src/backend/services/ExtractZipService.ts | 141 ++++++++++++++-------- 1 file changed, 94 insertions(+), 47 deletions(-) diff --git a/src/backend/services/ExtractZipService.ts b/src/backend/services/ExtractZipService.ts index 2ba9e8ddd..6594b0f3f 100644 --- a/src/backend/services/ExtractZipService.ts +++ b/src/backend/services/ExtractZipService.ts @@ -1,7 +1,7 @@ import { EventEmitter } from 'node:events' import { Readable } from 'node:stream'; import { open, ZipFile } from 'yauzl' -import { mkdirSync, createWriteStream, rm } from 'graceful-fs' +import { mkdirSync, createWriteStream, rmSync } from 'graceful-fs' import { join } from 'path' export interface ExtractZipProgressResponse { @@ -20,29 +20,25 @@ export interface ExtractZipProgressResponse { * @extends {EventEmitter} */ export class ExtractZipService extends EventEmitter { - private readStream: Readable | null = null; + private readStream: Readable | null = null private canceled = false - private paused = false; - private totalSize = 0 private processedSize = 0 private startTime = Date.now() private lastUpdateTime = Date.now() private dataDelay = 1000 - private extractionPromise: Promise | null = null; - private resolveExtraction: ((value: boolean) => void) | null = () => null; - private rejectExtraction: ((reason: Error) => void) | null = () => null; + private zipFileInstance: ZipFile | null = null; + private extractionPromise: Promise | null = null + private resolveExtraction: ((value: boolean) => void) | null = () => null + private rejectExtraction: ((reason: Error) => void) | null = () => null /** * Creates an instance of ExtractZipService. * @param {string} zipFile - The path to the ZIP file. * @param {string} destinationPath - The path where the extracted files should be saved. */ - constructor( - private zipFile: string, - private destinationPath: string, - ) { + constructor(private zipFile: string, private destinationPath: string) { super() } @@ -51,11 +47,15 @@ export class ExtractZipService extends EventEmitter { * @returns {boolean} - True if the extraction was canceled, false otherwise. */ get isCanceled(): boolean { - return this.canceled; + return this.canceled } + /** + * Get if is paused or not + * @returns {boolean} - Current state + */ get isPaused(): boolean { - return this.readStream?.isPaused() || false; + return this.readStream?.isPaused() || false } /** @@ -63,7 +63,7 @@ export class ExtractZipService extends EventEmitter { * @returns {string} - The path to the ZIP file. */ get source(): string { - return this.zipFile; + return this.zipFile } /** @@ -71,34 +71,50 @@ export class ExtractZipService extends EventEmitter { * @returns {string} - The destination path. */ get destination(): string { - return this.destinationPath; + return this.destinationPath } - + /** + * Pause the extraction process. + * @returns {void} + */ public pause(): void { if (!this.isPaused) { - this.readStream?.pause(); + this.readStream?.pause() } } + /** + * Resume the extraction process. + * @returns {Promise} + */ public async resume(): Promise { if (!this.extractionPromise) { - throw new Error('Extraction has not started or has already completed.'); + throw new Error('Extraction has not started or has already completed.') } if (this.isPaused) { - this.readStream?.resume(); + this.readStream?.resume() } - return this.extractionPromise; + return this.extractionPromise } /** * Cancels the extraction process. + * @returns {void} */ public cancel() { this.canceled = true - this.readStream?.destroy(); + this.readStream?.unpipe(); + + this.readStream?.destroy(new Error('Extraction canceled')) + + if (this.zipFileInstance && this.zipFileInstance.isOpen) { + this.zipFileInstance.close(); + } + + this.onCancel() } /** @@ -129,7 +145,7 @@ export class ExtractZipService extends EventEmitter { */ private onData(chunkLength: number) { if (this.canceled) { - return; + return } this.processedSize += chunkLength @@ -148,10 +164,27 @@ export class ExtractZipService extends EventEmitter { * @private */ private onEnd() { - if (!this.canceled) { - this.emit('end', this.computeProgress()) - rm(this.zipFile, console.log) + if (this.isCanceled) { + return } + + this.emit('end', this.computeProgress()) + + rmSync(this.source, { recursive: true, force: true }); + + this.removeAllListeners() + } + + /** + * Handles cancel events during extraction. + * @private + */ + private onCancel() { + this.emit('canceled') + + rmSync(this.source, { recursive: true, force: true }); + + this.removeAllListeners() } /** @@ -161,6 +194,10 @@ export class ExtractZipService extends EventEmitter { */ private onError(error: Error) { this.emit('error', error) + + rmSync(this.source, { recursive: true, force: true }); + + this.removeAllListeners() } /** @@ -169,18 +206,19 @@ export class ExtractZipService extends EventEmitter { */ async extract() { this.extractionPromise = new Promise((resolve, reject) => { - this.resolveExtraction = resolve; - this.rejectExtraction = reject; + this.resolveExtraction = resolve + this.rejectExtraction = reject - open(this.zipFile, { lazyEntries: true }, (err, file: ZipFile) => { - if (err && this.rejectExtraction) { - this.rejectExtraction(err) + open(this.zipFile, { lazyEntries: true, autoClose: true }, (err, file: ZipFile) => { + if (err) { + this.rejectExtraction?.(err) return } this.totalSize = file.fileSize file.readEntry() + file.emit('end') file.on('entry', (entry) => { if (this.canceled) { @@ -210,7 +248,8 @@ export class ExtractZipService extends EventEmitter { return } - this.readStream = readStream; + this.zipFileInstance = file; + this.readStream = readStream const writeStream = createWriteStream( join(this.destinationPath, entry.fileName) ) @@ -219,6 +258,11 @@ export class ExtractZipService extends EventEmitter { this.onData(chunk.length) }) writeStream.on('close', () => { + if (this.isCanceled) { + file.emit('end') + + return + } file.readEntry() }) }) @@ -226,32 +270,35 @@ export class ExtractZipService extends EventEmitter { }) file.once('end', () => { - if (!this.canceled) { - file.close() - this.onEnd() - } + if (this.isCanceled) { + this.rejectExtraction?.(new Error('Extraction was canceled')) - if (this.resolveExtraction) { - this.resolveExtraction(true) + return } + + this.onEnd() + + this.resolveExtraction?.(true) }) + + file.once('error', (error) => { + this.onError(error); + }); }) }).catch((error) => { - this.onError(error); + this.onError(error) }) try { - return await this.extractionPromise; + return await this.extractionPromise } catch (error: unknown) { - if (this.rejectExtraction) { - this.rejectExtraction(error as Error); - } + this.rejectExtraction?.(error as Error) } finally { - this.extractionPromise = null; - this.resolveExtraction = null; - this.rejectExtraction = null; - this.removeAllListeners(); + this.zipFileInstance = null; + this.extractionPromise = null + this.resolveExtraction = null + this.rejectExtraction = null } - return false; + return false } } From 7edd56281e3724fb9008d4c1e975f8beac6b7905 Mon Sep 17 00:00:00 2001 From: Red Date: Sun, 29 Oct 2023 21:24:57 +0100 Subject: [PATCH 19/32] feat: Integrated cancellation event, and fixed progress bug that initially does not start from 0 --- src/backend/storeManagers/hyperplay/games.ts | 95 ++++++++++++++++++-- 1 file changed, 89 insertions(+), 6 deletions(-) diff --git a/src/backend/storeManagers/hyperplay/games.ts b/src/backend/storeManagers/hyperplay/games.ts index bcffd64aa..36fe12105 100644 --- a/src/backend/storeManagers/hyperplay/games.ts +++ b/src/backend/storeManagers/hyperplay/games.ts @@ -63,6 +63,7 @@ import { PlatformsMetaInterface } from '@valist/sdk/dist/typesShared' import { Channel } from '@valist/sdk/dist/typesApi' import { DownloadItem } from 'electron' import { waitForItemToDownload } from 'backend/utils/downloadFile/download_file' +import { cancelQueueExtraction } from 'backend/downloadmanager/downloadqueue' const inProgressDownloadsMap: Map = new Map() const inProgressExtractionsMap: Map = new Map() @@ -494,9 +495,23 @@ async function resumeIfPaused(appName: string): Promise { } export async function cancelExtraction(appName: string) { - const extractZipService = inProgressExtractionsMap.get(appName) - if (extractZipService) { - extractZipService.cancel() + logInfo( + `cancelExtraction: Extraction will be canceled and downloaded zip will be removed`, + LogPrefix.HyperPlay + ) + + try { + process.noAsar = false + + const extractZipService = inProgressExtractionsMap.get(appName) + if (extractZipService) { + extractZipService.cancel() + } + } catch (error: unknown) { + logInfo( + `cancelExtraction: Error while canceling the operation ${(error as Error).message} `, + LogPrefix.HyperPlay + ) } } @@ -579,6 +594,22 @@ export async function install( platformInfo = gatedPlatforms[appPlatform] ?? platformInfo } + // Reset the download progress + window.webContents.send(`progressUpdate-${appName}`, { + appName, + runner: 'hyperplay', + folder: destinationPath, + status: 'done', + progress: { + folder: destinationPath, + percent: 0, + diskSpeed: 0, + downSpeed: 0, + bytes: 0, + eta: null + } + }) + await downloadGame( appName, directory, @@ -668,12 +699,11 @@ export async function install( totalSize, processedSize }: ExtractZipProgressResponse) => { - extractService.removeAllListeners() - logInfo( `Extracting End: ${progressPercentage}% Speed: ${speed} B/s | Total size ${totalSize} and ${processedSize}`, LogPrefix.HyperPlay ) + const currentProgress = calculateProgress( processedSize, totalSize, @@ -736,14 +766,65 @@ export async function install( sendFrontendMessage('refreshLibrary', 'hyperplay') } ) - extractService.on('error', (error: Error) => { + extractService.once('error', (error: Error) => { logError(`Extracting Error ${error.message}`, LogPrefix.HyperPlay) + + cancelQueueExtraction() + callAbortController(appName) + + cleanUpDownload(appName, directory) + + sendFrontendMessage('refreshLibrary', 'hyperplay') throw error }) + extractService.once('canceled', () => { + logInfo( + `Canceled Extracting: Cancellation completed on ${appName} - Destination ${destinationPath}`, + LogPrefix.HyperPlay + ) + + process.noAsar = false + + cancelQueueExtraction() + callAbortController(appName) + + sendFrontendMessage('gameStatusUpdate', { + appName, + status: 'done', + runner: 'hyperplay', + folder: destinationPath + }) + + window.webContents.send(`progressUpdate-${appName}`, { + appName, + runner: 'hyperplay', + folder: destinationPath, + status: 'done', + progress: { + folder: destinationPath, + percent: 0, + diskSpeed: 0, + downSpeed: 0, + bytes: 0, + eta: null + } + }) + + notify({ + title, + body: 'Installation Stopped' + }) + + cleanUpDownload(appName, directory) + + sendFrontendMessage('refreshLibrary', 'hyperplay') + }) await extractService.extract() } catch (error) { + process.noAsar = false + logInfo(`Error while extracting game ${error}`, LogPrefix.HyperPlay) window.webContents.send('gameStatusUpdate', { appName, @@ -755,6 +836,8 @@ export async function install( } return { status: 'done' } } catch (error) { + process.noAsar = false + logInfo( `Error while downloading and extracting game: ${error}`, LogPrefix.HyperPlay From 9d7952a2c60f0d284e663af3afd8b2046498043c Mon Sep 17 00:00:00 2001 From: Red Date: Sun, 29 Oct 2023 21:26:08 +0100 Subject: [PATCH 20/32] chore: Updated tests after renaming cancel in service --- src/backend/__tests__/services/ExtractZipService.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend/__tests__/services/ExtractZipService.test.ts b/src/backend/__tests__/services/ExtractZipService.test.ts index 84cfd9a45..f81175385 100644 --- a/src/backend/__tests__/services/ExtractZipService.test.ts +++ b/src/backend/__tests__/services/ExtractZipService.test.ts @@ -162,7 +162,7 @@ describe('ExtractZipService', () => { expect(progressListener).not.toHaveBeenCalled() }) - it('should have the state as cancelled once is cancelled', async () => { + it('should have the state as canceled once is canceled', async () => { extractZipService.extract() extractZipService.cancel() From a882d76bba329895e4b4a590238775d98f2b5da7 Mon Sep 17 00:00:00 2001 From: Red Date: Sun, 29 Oct 2023 21:33:43 +0100 Subject: [PATCH 21/32] fix: Fixed prettier & lint --- src/backend/services/ExtractZipService.ts | 146 +++++++++--------- src/backend/storeManagers/hyperplay/games.ts | 12 +- src/backend/utils.ts | 6 - .../UI/DownloadToastManager/index.tsx | 33 ++-- .../UI/StopInstallationModal/index.tsx | 6 +- .../components/DownloadManagerItem/index.tsx | 2 +- src/frontend/screens/Game/GamePage/index.tsx | 1 + .../Library/components/GameCard/index.tsx | 11 +- 8 files changed, 110 insertions(+), 107 deletions(-) diff --git a/src/backend/services/ExtractZipService.ts b/src/backend/services/ExtractZipService.ts index 6594b0f3f..2756e7a65 100644 --- a/src/backend/services/ExtractZipService.ts +++ b/src/backend/services/ExtractZipService.ts @@ -1,5 +1,5 @@ import { EventEmitter } from 'node:events' -import { Readable } from 'node:stream'; +import { Readable } from 'node:stream' import { open, ZipFile } from 'yauzl' import { mkdirSync, createWriteStream, rmSync } from 'graceful-fs' import { join } from 'path' @@ -28,7 +28,7 @@ export class ExtractZipService extends EventEmitter { private lastUpdateTime = Date.now() private dataDelay = 1000 - private zipFileInstance: ZipFile | null = null; + private zipFileInstance: ZipFile | null = null private extractionPromise: Promise | null = null private resolveExtraction: ((value: boolean) => void) | null = () => null private rejectExtraction: ((reason: Error) => void) | null = () => null @@ -106,14 +106,14 @@ export class ExtractZipService extends EventEmitter { */ public cancel() { this.canceled = true - this.readStream?.unpipe(); + this.readStream?.unpipe() this.readStream?.destroy(new Error('Extraction canceled')) if (this.zipFileInstance && this.zipFileInstance.isOpen) { - this.zipFileInstance.close(); + this.zipFileInstance.close() } - + this.onCancel() } @@ -170,7 +170,7 @@ export class ExtractZipService extends EventEmitter { this.emit('end', this.computeProgress()) - rmSync(this.source, { recursive: true, force: true }); + rmSync(this.source, { recursive: true, force: true }) this.removeAllListeners() } @@ -182,7 +182,7 @@ export class ExtractZipService extends EventEmitter { private onCancel() { this.emit('canceled') - rmSync(this.source, { recursive: true, force: true }); + rmSync(this.source, { recursive: true, force: true }) this.removeAllListeners() } @@ -195,7 +195,7 @@ export class ExtractZipService extends EventEmitter { private onError(error: Error) { this.emit('error', error) - rmSync(this.source, { recursive: true, force: true }); + rmSync(this.source, { recursive: true, force: true }) this.removeAllListeners() } @@ -209,82 +209,86 @@ export class ExtractZipService extends EventEmitter { this.resolveExtraction = resolve this.rejectExtraction = reject - open(this.zipFile, { lazyEntries: true, autoClose: true }, (err, file: ZipFile) => { - if (err) { - this.rejectExtraction?.(err) - return - } + open( + this.zipFile, + { lazyEntries: true, autoClose: true }, + (err, file: ZipFile) => { + if (err) { + this.rejectExtraction?.(err) + return + } - this.totalSize = file.fileSize + this.totalSize = file.fileSize - file.readEntry() - file.emit('end') + file.readEntry() + file.emit('end') - file.on('entry', (entry) => { - if (this.canceled) { - file.close() - return - } + file.on('entry', (entry) => { + if (this.canceled) { + file.close() + return + } - if (/\/$/.test(entry.fileName)) { - // Directory file names end with '/' - mkdirSync(join(this.destinationPath, entry.fileName), { - recursive: true - }) - file.readEntry() - } else { - // Ensure parent directory exists - mkdirSync( - join( - this.destinationPath, - entry.fileName.split('/').slice(0, -1).join('/') - ), - { recursive: true } - ) - - file.openReadStream(entry, (err, readStream) => { - if (err && this.rejectExtraction) { - this.rejectExtraction(err) - return - } - - this.zipFileInstance = file; - this.readStream = readStream - const writeStream = createWriteStream( - join(this.destinationPath, entry.fileName) - ) - readStream.pipe(writeStream) - readStream.on('data', (chunk) => { - this.onData(chunk.length) + if (/\/$/.test(entry.fileName)) { + // Directory file names end with '/' + mkdirSync(join(this.destinationPath, entry.fileName), { + recursive: true }) - writeStream.on('close', () => { - if (this.isCanceled) { - file.emit('end') + file.readEntry() + } else { + // Ensure parent directory exists + mkdirSync( + join( + this.destinationPath, + entry.fileName.split('/').slice(0, -1).join('/') + ), + { recursive: true } + ) + file.openReadStream(entry, (err, readStream) => { + if (err && this.rejectExtraction) { + this.rejectExtraction(err) return } - file.readEntry() + + this.zipFileInstance = file + this.readStream = readStream + const writeStream = createWriteStream( + join(this.destinationPath, entry.fileName) + ) + readStream.pipe(writeStream) + readStream.on('data', (chunk) => { + this.onData(chunk.length) + }) + writeStream.on('close', () => { + if (this.isCanceled) { + file.emit('end') + + return + } + file.readEntry() + }) }) - }) - } - }) + } + }) - file.once('end', () => { - if (this.isCanceled) { - this.rejectExtraction?.(new Error('Extraction was canceled')) + file.once('end', () => { + if (this.isCanceled) { + this.rejectExtraction?.(new Error('Extraction was canceled')) - return - } + return + } - this.onEnd() + this.onEnd() - this.resolveExtraction?.(true) - }) + this.resolveExtraction?.(true) + }) - file.once('error', (error) => { - this.onError(error); - }); - }) + file.once('error', (error) => { + this.onError(error) + }) + } + ) }).catch((error) => { this.onError(error) }) @@ -293,7 +297,7 @@ export class ExtractZipService extends EventEmitter { } catch (error: unknown) { this.rejectExtraction?.(error as Error) } finally { - this.zipFileInstance = null; + this.zipFileInstance = null this.extractionPromise = null this.resolveExtraction = null this.rejectExtraction = null diff --git a/src/backend/storeManagers/hyperplay/games.ts b/src/backend/storeManagers/hyperplay/games.ts index 36fe12105..b2a76bbbc 100644 --- a/src/backend/storeManagers/hyperplay/games.ts +++ b/src/backend/storeManagers/hyperplay/games.ts @@ -509,7 +509,9 @@ export async function cancelExtraction(appName: string) { } } catch (error: unknown) { logInfo( - `cancelExtraction: Error while canceling the operation ${(error as Error).message} `, + `cancelExtraction: Error while canceling the operation ${ + (error as Error).message + } `, LogPrefix.HyperPlay ) } @@ -657,7 +659,7 @@ export async function install( try { const extractService = new ExtractZipService(zipFile, destinationPath) - inProgressExtractionsMap.set(appName, extractService); + inProgressExtractionsMap.set(appName, extractService) extractService.on( 'progress', @@ -768,7 +770,7 @@ export async function install( ) extractService.once('error', (error: Error) => { logError(`Extracting Error ${error.message}`, LogPrefix.HyperPlay) - + cancelQueueExtraction() callAbortController(appName) @@ -810,7 +812,7 @@ export async function install( eta: null } }) - + notify({ title, body: 'Installation Stopped' @@ -824,7 +826,7 @@ export async function install( await extractService.extract() } catch (error) { process.noAsar = false - + logInfo(`Error while extracting game ${error}`, LogPrefix.HyperPlay) window.webContents.send('gameStatusUpdate', { appName, diff --git a/src/backend/utils.ts b/src/backend/utils.ts index c535ac449..229526c23 100644 --- a/src/backend/utils.ts +++ b/src/backend/utils.ts @@ -15,7 +15,6 @@ import { ProgressInfo } from 'common/types' import axios from 'axios' -import yauzl from 'yauzl' import download from 'backend/utils/downloadFile/download_file' import { File, Progress } from 'backend/utils/downloadFile/types' @@ -39,11 +38,7 @@ import { appendFileSync, existsSync, rmSync, - mkdirSync, - createWriteStream, - rm } from 'graceful-fs' -import { EventEmitter } from 'events'; import { promisify } from 'util' import i18next, { t } from 'i18next' import si from 'systeminformation' @@ -1419,7 +1414,6 @@ function removeFolder(path: string, folderName: string) { return } - export function calculateEta( downloadedBytes: number, downloadSpeed: number, diff --git a/src/frontend/components/UI/DownloadToastManager/index.tsx b/src/frontend/components/UI/DownloadToastManager/index.tsx index be2663fef..1e834129f 100644 --- a/src/frontend/components/UI/DownloadToastManager/index.tsx +++ b/src/frontend/components/UI/DownloadToastManager/index.tsx @@ -165,18 +165,18 @@ export default function DownloadToastManager() { downloadedMB = Number(progress.bytes) } -const title = currentElement?.params.gameInfo.title - ? currentElement?.params.gameInfo.title - : 'Game' -const downloadSizeInMB = progress.percent - ? (downloadedMB / progress.percent) * 100 - : 0 -const estimatedCompletionTimeInMs = progress.downSpeed - ? (downloadSizeInMB / progress.downSpeed) * 1000 - : 0 -let imgUrl = currentElement?.params.gameInfo.art_cover - ? currentElement?.params.gameInfo.art_cover - : '' + const title = currentElement?.params.gameInfo.title + ? currentElement?.params.gameInfo.title + : 'Game' + const downloadSizeInMB = progress.percent + ? (downloadedMB / progress.percent) * 100 + : 0 + const estimatedCompletionTimeInMs = progress.downSpeed + ? (downloadSizeInMB / progress.downSpeed) * 1000 + : 0 + let imgUrl = currentElement?.params.gameInfo.art_cover + ? currentElement?.params.gameInfo.art_cover + : '' if (!imgUrl.includes('http')) imgUrl = currentElement.params.gameInfo.art_square @@ -198,8 +198,8 @@ let imgUrl = currentElement?.params.gameInfo.art_cover return 'inProgress' } -const adjustedDownloadedInBytes = downloadedMB * 1024 * 1024; -const adjustedDownloadSizeInBytes = downloadSizeInMB * 1024 * 1024; + const adjustedDownloadedInBytes = downloadedMB * 1024 * 1024 + const adjustedDownloadSizeInBytes = downloadSizeInMB * 1024 * 1024 return (
@@ -207,9 +207,8 @@ const adjustedDownloadSizeInBytes = downloadSizeInMB * 1024 * 1024; { setShowStopInstallModal(true) diff --git a/src/frontend/components/UI/StopInstallationModal/index.tsx b/src/frontend/components/UI/StopInstallationModal/index.tsx index e8d889244..d3847094d 100644 --- a/src/frontend/components/UI/StopInstallationModal/index.tsx +++ b/src/frontend/components/UI/StopInstallationModal/index.tsx @@ -17,13 +17,13 @@ interface StopInstallProps { appName: string runner: Runner progress: InstallProgress - status: string + status?: string } export default function StopInstallationModal(props: StopInstallProps) { const { t } = useTranslation('gamepage') const checkbox = useRef(null) - const isExtracting = props.status === 'extracting'; + const isExtracting = props.status === 'extracting' return ( @@ -76,7 +76,7 @@ export default function StopInstallationModal(props: StopInstallProps) { if (isExtracting) { window.api.cancelExtraction(props.appName) - return + return } window.api.cancelDownload(false) diff --git a/src/frontend/screens/DownloadManager/components/DownloadManagerItem/index.tsx b/src/frontend/screens/DownloadManager/components/DownloadManagerItem/index.tsx index d9a2f2afe..2c074ed6d 100644 --- a/src/frontend/screens/DownloadManager/components/DownloadManagerItem/index.tsx +++ b/src/frontend/screens/DownloadManager/components/DownloadManagerItem/index.tsx @@ -250,7 +250,7 @@ const DownloadManagerItem = observer(({ element, current, state }: Props) => { folderName={gameInfo.folder_name ? gameInfo.folder_name : ''} appName={appName} runner={runner} - status={status || ''} + status={status} progress={progress} /> ) : null} diff --git a/src/frontend/screens/Game/GamePage/index.tsx b/src/frontend/screens/Game/GamePage/index.tsx index 0489911c6..bb2b3f335 100644 --- a/src/frontend/screens/Game/GamePage/index.tsx +++ b/src/frontend/screens/Game/GamePage/index.tsx @@ -364,6 +364,7 @@ export default observer(function GamePage(): JSX.Element | null { folderName={gameInfo.folder_name ? gameInfo.folder_name : ''} appName={gameInfo.app_name} runner={gameInfo.runner} + status={status} /> ) : null} {gameInfo.runner !== 'sideload' && showModal.show && ( diff --git a/src/frontend/screens/Library/components/GameCard/index.tsx b/src/frontend/screens/Library/components/GameCard/index.tsx index 7802cee06..3a1aff0ec 100644 --- a/src/frontend/screens/Library/components/GameCard/index.tsx +++ b/src/frontend/screens/Library/components/GameCard/index.tsx @@ -74,8 +74,10 @@ const GameCard = ({ } const { status = '', folder } = hasStatus(appName, gameInfo, size) - const { statusText: downloadStatusText } = - useGetDownloadStatusText(appName, gameInfo) + const { statusText: downloadStatusText } = useGetDownloadStatusText( + appName, + gameInfo + ) useEffect(() => { setIsLaunching(false) @@ -104,7 +106,6 @@ const GameCard = ({ notAvailable, isUpdating, isPaused, - isExtracting, } = getCardStatus(status, isInstalled, layout) const handleRemoveFromQueue = () => { @@ -339,7 +340,9 @@ const GameCard = ({ window.api.resumeCurrentDownload() )} onPlayClick={handleClickStopBubbling(async () => mainAction(runner))} - onStopDownloadClick={handleClickStopBubbling(async () => setShowStopInstallModal(true))} + onStopDownloadClick={handleClickStopBubbling(async () => + setShowStopInstallModal(true) + )} state={getState()} settingsItems={items.filter((val) => val.show).slice(0, 6)} showSettings={showSettings} From 38154f07823c5627e0bffc4db1641b97fb521288 Mon Sep 17 00:00:00 2001 From: Red Date: Mon, 30 Oct 2023 00:46:03 +0100 Subject: [PATCH 22/32] fix: Updated tests with the reflected changes (Does not include cancel, resume, pause yet) --- .../services/ExtractZipService.test.ts | 16 +++++-- src/backend/services/ExtractZipService.ts | 45 +++++++++---------- 2 files changed, 35 insertions(+), 26 deletions(-) diff --git a/src/backend/__tests__/services/ExtractZipService.test.ts b/src/backend/__tests__/services/ExtractZipService.test.ts index f81175385..2c7937dfd 100644 --- a/src/backend/__tests__/services/ExtractZipService.test.ts +++ b/src/backend/__tests__/services/ExtractZipService.test.ts @@ -26,10 +26,13 @@ jest.mock('graceful-fs', () => ({ streamCallback(new Array(500).fill(0)) } else if (event === 'error') { streamCallback(new Error('Error')) + } else if (event === 'end') { + streamCallback() } }) }), rm: jest.fn(), + rmSync: jest.fn(), copyFileSync: jest.fn() })) @@ -43,6 +46,8 @@ const yauzlMockupLib = ( const stream = { _read: () => null, pipe: jest.fn((args) => args), + unpipe: jest.fn(), + destroy: jest.fn(), on: jest.fn( ( event: string, @@ -54,6 +59,8 @@ const yauzlMockupLib = ( streamCallback(new Array(500).fill(0)) } else if (event === 'error') { streamCallback(new Error('Error')) + } else if (event === 'end') { + streamCallback() } } ) @@ -63,9 +70,12 @@ const yauzlMockupLib = ( fileSize, readEntry: jest.fn(), close: jest.fn(), + isOpen: true, once: jest.fn((event, streamCallback) => { if (event === 'end') { streamCallback() + } else if (event === 'error') { + streamCallback() } }), on: jest.fn((event, entryCallback) => { @@ -79,9 +89,9 @@ const yauzlMockupLib = ( openReadStream: jest.fn((entry, openReadStreamCallback) => { openReadStreamCallback(error, stream) }) - } + }; - ;(yauzl.open as jest.Mock).mockImplementation( + (yauzl.open as jest.Mock).mockImplementation( (_path, _options, yauzlOpenCallback) => { yauzlOpenCallback(error, mockZipFile) } @@ -162,7 +172,7 @@ describe('ExtractZipService', () => { expect(progressListener).not.toHaveBeenCalled() }) - it('should have the state as canceled once is canceled', async () => { + it('should have the state as canceled once is canceled', () => { extractZipService.extract() extractZipService.cancel() diff --git a/src/backend/services/ExtractZipService.ts b/src/backend/services/ExtractZipService.ts index 2756e7a65..007d7c25e 100644 --- a/src/backend/services/ExtractZipService.ts +++ b/src/backend/services/ExtractZipService.ts @@ -106,9 +106,12 @@ export class ExtractZipService extends EventEmitter { */ public cancel() { this.canceled = true - this.readStream?.unpipe() - this.readStream?.destroy(new Error('Extraction canceled')) + if (this.readStream) { + this.readStream.unpipe() + + this.readStream.destroy(new Error('Extraction canceled')) + } if (this.zipFileInstance && this.zipFileInstance.isOpen) { this.zipFileInstance.close() @@ -144,7 +147,7 @@ export class ExtractZipService extends EventEmitter { * @param {number} chunkLength - The length of the data chunk being processed. */ private onData(chunkLength: number) { - if (this.canceled) { + if (this.isCanceled) { return } @@ -218,14 +221,15 @@ export class ExtractZipService extends EventEmitter { return } + this.zipFileInstance = file this.totalSize = file.fileSize - file.readEntry() - file.emit('end') + this.zipFileInstance.readEntry() + //this.zipFileInstance.emit('end') - file.on('entry', (entry) => { - if (this.canceled) { - file.close() + this.zipFileInstance.on('entry', (entry) => { + if (this.isCanceled) { + this.zipFileInstance?.close() return } @@ -234,7 +238,7 @@ export class ExtractZipService extends EventEmitter { mkdirSync(join(this.destinationPath, entry.fileName), { recursive: true }) - file.readEntry() + this.zipFileInstance?.readEntry() } else { // Ensure parent directory exists mkdirSync( @@ -245,34 +249,33 @@ export class ExtractZipService extends EventEmitter { { recursive: true } ) - file.openReadStream(entry, (err, readStream) => { + this.zipFileInstance?.openReadStream(entry, (err, readStream) => { if (err && this.rejectExtraction) { this.rejectExtraction(err) return } - this.zipFileInstance = file this.readStream = readStream const writeStream = createWriteStream( join(this.destinationPath, entry.fileName) ) - readStream.pipe(writeStream) - readStream.on('data', (chunk) => { + this.readStream.pipe(writeStream) + this.readStream.on('data', (chunk) => { this.onData(chunk.length) }) - writeStream.on('close', () => { + writeStream.once('close', () => { if (this.isCanceled) { - file.emit('end') + this.zipFileInstance?.emit('end') return } - file.readEntry() + this.zipFileInstance?.readEntry() }) }) } }) - file.once('end', () => { + this.zipFileInstance.once('end', () => { if (this.isCanceled) { this.rejectExtraction?.(new Error('Extraction was canceled')) @@ -284,14 +287,10 @@ export class ExtractZipService extends EventEmitter { this.resolveExtraction?.(true) }) - file.once('error', (error) => { - this.onError(error) - }) + this.zipFileInstance.once('error', this.onError.bind(this)) } ) - }).catch((error) => { - this.onError(error) - }) + }).catch(this.onError.bind(this)) try { return await this.extractionPromise } catch (error: unknown) { From d2a138246dd6ed50a18e0512605993f2a6dcd28e Mon Sep 17 00:00:00 2001 From: Red Date: Mon, 30 Oct 2023 06:45:51 +0100 Subject: [PATCH 23/32] fix: Fixed failing unit tests on utils --- src/backend/__tests__/utils.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/backend/__tests__/utils.test.ts b/src/backend/__tests__/utils.test.ts index d3ed781b2..f08adc372 100644 --- a/src/backend/__tests__/utils.test.ts +++ b/src/backend/__tests__/utils.test.ts @@ -2,6 +2,7 @@ import axios from 'axios' import { app } from 'electron' import * as utils from '../utils' import { test_data } from './test_data/github-api-heroic-test-data.json' +import { logError } from '../logger/logger' jest.mock('electron') jest.mock('../logger/logger') From f091886ba9ea043ada74557ef70844561a3b6923 Mon Sep 17 00:00:00 2001 From: Red Date: Mon, 30 Oct 2023 19:04:53 +0100 Subject: [PATCH 24/32] feat: Removed logging --- src/frontend/components/UI/StopInstallationModal/index.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/frontend/components/UI/StopInstallationModal/index.tsx b/src/frontend/components/UI/StopInstallationModal/index.tsx index d3847094d..d973443e0 100644 --- a/src/frontend/components/UI/StopInstallationModal/index.tsx +++ b/src/frontend/components/UI/StopInstallationModal/index.tsx @@ -59,7 +59,6 @@ export default function StopInstallationModal(props: StopInstallProps) { type="secondary" size="large" onClick={async () => { - console.log('isExtracting', isExtracting) // if user wants to keep downloaded files and cancel download if (checkbox.current && checkbox.current.checked) { props.onClose() From 0b872ddf8921d400ce5fb3d6d0df07d41bc8eb61 Mon Sep 17 00:00:00 2001 From: Red Date: Mon, 30 Oct 2023 19:07:13 +0100 Subject: [PATCH 25/32] chore: Updated from end to finished event and couple of other changes --- src/backend/services/ExtractZipService.ts | 58 +++++++++++--------- src/backend/storeManagers/hyperplay/games.ts | 2 +- 2 files changed, 33 insertions(+), 27 deletions(-) diff --git a/src/backend/services/ExtractZipService.ts b/src/backend/services/ExtractZipService.ts index 007d7c25e..a079c5bbc 100644 --- a/src/backend/services/ExtractZipService.ts +++ b/src/backend/services/ExtractZipService.ts @@ -22,6 +22,7 @@ export interface ExtractZipProgressResponse { export class ExtractZipService extends EventEmitter { private readStream: Readable | null = null private canceled = false + private paused = false private totalSize = 0 private processedSize = 0 private startTime = Date.now() @@ -55,7 +56,7 @@ export class ExtractZipService extends EventEmitter { * @returns {boolean} - Current state */ get isPaused(): boolean { - return this.readStream?.isPaused() || false + return this.paused; } /** @@ -74,13 +75,25 @@ export class ExtractZipService extends EventEmitter { return this.destinationPath } + /** + * Check if can progress + * @returns {boolean} + */ + private get canProgress(): boolean { + return !(this.canceled || this.paused) + } + /** * Pause the extraction process. * @returns {void} */ public pause(): void { if (!this.isPaused) { + this.paused = true; + this.readStream?.pause() + + this.emit('paused', this.computeProgress()) } } @@ -94,9 +107,13 @@ export class ExtractZipService extends EventEmitter { } if (this.isPaused) { + this.paused = false; + this.readStream?.resume() } + this.emit('resumed', this.computeProgress()) + return this.extractionPromise } @@ -105,19 +122,25 @@ export class ExtractZipService extends EventEmitter { * @returns {void} */ public cancel() { + if (!this.zipFileInstance?.isOpen) { + throw new Error('Extraction has not started or has already completed.') + } + this.canceled = true - if (this.readStream) { - this.readStream.unpipe() + this.readStream?.unpipe() - this.readStream.destroy(new Error('Extraction canceled')) - } + this.readStream?.destroy(new Error('Extraction canceled')) if (this.zipFileInstance && this.zipFileInstance.isOpen) { this.zipFileInstance.close() } - this.onCancel() + this.emit('canceled') + + rmSync(this.source, { recursive: true, force: true }) + + this.removeAllListeners() } /** @@ -147,7 +170,7 @@ export class ExtractZipService extends EventEmitter { * @param {number} chunkLength - The length of the data chunk being processed. */ private onData(chunkLength: number) { - if (this.isCanceled) { + if (!this.canProgress) { return } @@ -171,19 +194,7 @@ export class ExtractZipService extends EventEmitter { return } - this.emit('end', this.computeProgress()) - - rmSync(this.source, { recursive: true, force: true }) - - this.removeAllListeners() - } - - /** - * Handles cancel events during extraction. - * @private - */ - private onCancel() { - this.emit('canceled') + this.emit('finished', this.computeProgress()) rmSync(this.source, { recursive: true, force: true }) @@ -205,7 +216,7 @@ export class ExtractZipService extends EventEmitter { /** * Extracts the ZIP file to the specified destination. - * @returns {Promise} - A promise that resolves when the extraction is complete. + * @returns {Promise} - A promise that resolves when the extraction is complete. */ async extract() { this.extractionPromise = new Promise((resolve, reject) => { @@ -225,7 +236,6 @@ export class ExtractZipService extends EventEmitter { this.totalSize = file.fileSize this.zipFileInstance.readEntry() - //this.zipFileInstance.emit('end') this.zipFileInstance.on('entry', (entry) => { if (this.isCanceled) { @@ -265,8 +275,6 @@ export class ExtractZipService extends EventEmitter { }) writeStream.once('close', () => { if (this.isCanceled) { - this.zipFileInstance?.emit('end') - return } this.zipFileInstance?.readEntry() @@ -277,8 +285,6 @@ export class ExtractZipService extends EventEmitter { this.zipFileInstance.once('end', () => { if (this.isCanceled) { - this.rejectExtraction?.(new Error('Extraction was canceled')) - return } diff --git a/src/backend/storeManagers/hyperplay/games.ts b/src/backend/storeManagers/hyperplay/games.ts index b2a76bbbc..3d1a459cf 100644 --- a/src/backend/storeManagers/hyperplay/games.ts +++ b/src/backend/storeManagers/hyperplay/games.ts @@ -694,7 +694,7 @@ export async function install( } ) extractService.once( - 'end', + 'finished', async ({ progressPercentage, speed, From 694787e48991daee7a8f3977b5a71f12ed8a844b Mon Sep 17 00:00:00 2001 From: Red Date: Mon, 30 Oct 2023 19:08:29 +0100 Subject: [PATCH 26/32] feat: Updated game page extraction details --- src/frontend/screens/Game/GamePage/index.tsx | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/frontend/screens/Game/GamePage/index.tsx b/src/frontend/screens/Game/GamePage/index.tsx index bb2b3f335..e532e5084 100644 --- a/src/frontend/screens/Game/GamePage/index.tsx +++ b/src/frontend/screens/Game/GamePage/index.tsx @@ -625,8 +625,7 @@ export default observer(function GamePage(): JSX.Element | null { isReparing || isMoving || isUninstalling || - notSupportedGame || - isExtracting + notSupportedGame } autoFocus={true} type={getButtonClass(is_installed)} @@ -775,10 +774,6 @@ export default observer(function GamePage(): JSX.Element | null { return `${t('status.moving', 'Moving Installation, please wait')} ...` } - if (isExtracting) { - return `${t('status.extracting', 'Extracting files')}...` - } - const currentProgress = getProgress(progress) >= 99 ? '' @@ -800,6 +795,10 @@ export default observer(function GamePage(): JSX.Element | null { return `${t('status.updating')} ${currentProgress}` } + if (isExtracting) { + return `${t('status.extracting')} ${currentProgress}` + } + if (!isUpdating && isInstalling) { if (!currentProgress) { return `${t('status.processing', 'Processing files, please wait')}...` @@ -863,7 +862,7 @@ export default observer(function GamePage(): JSX.Element | null { return t('submenu.settings') } if (isExtracting) { - return t('status.extracting', 'Extracting files') + return t('status.extracting.cancel', 'Cancel Extraction') } if (isInstalling || isPreparing) { return t('button.queue.cancel', 'Cancel Download') @@ -908,6 +907,11 @@ export default observer(function GamePage(): JSX.Element | null { return } + if (isExtracting) { + storage.removeItem(appName) + return window.api.cancelExtraction(appName); + } + // open install dialog if (!is_installed) { return handleModal() From 966a860fd7ac829caf6efd3f2f1218b5e4114682 Mon Sep 17 00:00:00 2001 From: Red Date: Tue, 31 Oct 2023 05:35:02 +0100 Subject: [PATCH 27/32] feat: Added disk size estimation for compressed file on install dialog --- .../InstallModal/DownloadDialog/index.tsx | 31 +++++++++++++------ 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/src/frontend/screens/Library/components/InstallModal/DownloadDialog/index.tsx b/src/frontend/screens/Library/components/InstallModal/DownloadDialog/index.tsx index 7e8caa3d5..741ba0255 100644 --- a/src/frontend/screens/Library/components/InstallModal/DownloadDialog/index.tsx +++ b/src/frontend/screens/Library/components/InstallModal/DownloadDialog/index.tsx @@ -101,6 +101,19 @@ function getDefaultInstallPath() { return defaultInstallPath } +/** + * Temporary we will need to have this information pre-computed and attached the manifest. + * @return {number} - Total size uncompressed estimated based on platform (windows: zip, winrar 60-80%, mac: zip 70-90%, linux: 60-80% ) + */ +const estimateUncompressedSize = (platform: string, compressedSize: number) => { + const baseEstimate = compressedSize * 2; + const gapPercentage = platform === 'osx' ? 0.05 : 0.1; + + const gap = baseEstimate * gapPercentage; + + return baseEstimate + gap; +} + export default function DownloadDialog({ backdropClick, appName, @@ -159,6 +172,8 @@ export default function DownloadDialog({ const { i18n, t } = useTranslation('gamepage') const { t: tr } = useTranslation() + + const uncompressedSize = estimateUncompressedSize(platformToInstall, gameInstallInfo?.manifest?.disk_size || 0) const haveSDL = sdls.length > 0 @@ -300,21 +315,19 @@ export default function DownloadDialog({ useEffect(() => { const getSpace = async () => { - const { message, free, validPath } = await window.api.checkDiskSpace( - installPath - ) + const { message, free, validPath } = await window.api.checkDiskSpace(installPath) if (gameInstallInfo?.manifest?.disk_size) { - let notEnoughDiskSpace = free < gameInstallInfo.manifest.disk_size + let notEnoughDiskSpace = free < uncompressedSize let spaceLeftAfter = size( - free - Number(gameInstallInfo.manifest.disk_size) + free - Number(uncompressedSize) ) if (previousProgress.folder === installPath) { const progress = 100 - getProgress(previousProgress) notEnoughDiskSpace = - free < (progress / 100) * Number(gameInstallInfo.manifest.disk_size) + free < (progress / 100) * Number(uncompressedSize) spaceLeftAfter = size( - free - (progress / 100) * Number(gameInstallInfo.manifest.disk_size) + free - (progress / 100) * Number(uncompressedSize) ) } @@ -327,7 +340,7 @@ export default function DownloadDialog({ } } getSpace() - }, [installPath, gameInstallInfo?.manifest?.disk_size]) + }, [installPath, uncompressedSize, gameInstallInfo?.manifest?.disk_size]) const haveDLCs: boolean = gameInstallInfo?.game?.owned_dlc !== undefined && @@ -351,7 +364,7 @@ export default function DownloadDialog({ const installSize = gameInstallInfo?.manifest?.disk_size !== undefined && - size(Number(gameInstallInfo?.manifest?.disk_size)) + size(uncompressedSize) const getLanguageName = useMemo(() => { return (language: string) => { From 7baec4c1746febda8242e92add88980780d0190e Mon Sep 17 00:00:00 2001 From: Red Date: Tue, 31 Oct 2023 05:51:08 +0100 Subject: [PATCH 28/32] feat: Removed pause button for extracting (Requires hyperplay-ui lib to be merged to take affect) --- .../UI/DownloadToastManager/index.tsx | 1 + .../components/DownloadManagerItem/index.tsx | 21 +++++++++++-------- .../Library/components/GameCard/index.tsx | 6 +++++- 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/src/frontend/components/UI/DownloadToastManager/index.tsx b/src/frontend/components/UI/DownloadToastManager/index.tsx index 1e834129f..c4977b2c9 100644 --- a/src/frontend/components/UI/DownloadToastManager/index.tsx +++ b/src/frontend/components/UI/DownloadToastManager/index.tsx @@ -193,6 +193,7 @@ export default function DownloadToastManager() { const installPath = currentElement?.params.path function getDownloadStatus(): downloadStatus { + if (isExtracting) return 'inExtraction' if (dmState === 'paused') return 'paused' if (showPlay) return 'done' return 'inProgress' diff --git a/src/frontend/screens/DownloadManager/components/DownloadManagerItem/index.tsx b/src/frontend/screens/DownloadManager/components/DownloadManagerItem/index.tsx index 2c074ed6d..bf944a3cc 100644 --- a/src/frontend/screens/DownloadManager/components/DownloadManagerItem/index.tsx +++ b/src/frontend/screens/DownloadManager/components/DownloadManagerItem/index.tsx @@ -28,6 +28,7 @@ import StopInstallationModal from 'frontend/components/UI/StopInstallationModal' import { observer } from 'mobx-react-lite' import libraryState from 'frontend/state/libraryState' import { NileInstallInfo } from 'common/types/nile' +import { hasStatus } from 'frontend/hooks/hasStatus' type Props = { element?: DMQueueElement @@ -91,7 +92,8 @@ const DownloadManagerItem = observer(({ element, current, state }: Props) => { platformToInstall } = params - const [gameInfo, setGameInfo] = useState(DmGameInfo) + const [gameInfo, setGameInfo] = useState(DmGameInfo); + const { status: gameProgressStatus = '' } = hasStatus(appName, DmGameInfo, (size || '0')); useEffect(() => { const getNewInfo = async () => { @@ -111,7 +113,7 @@ const DownloadManagerItem = observer(({ element, current, state }: Props) => { } } getNewInfo() - }, [element]) + }, [element]); const { art_cover, @@ -119,10 +121,11 @@ const DownloadManagerItem = observer(({ element, current, state }: Props) => { install: { is_dlc } } = gameInfo || {} - const [progress] = hasProgress(appName) - const { status } = element - const finished = status === 'done' - const canceled = status === 'error' || (status === 'abort' && !current) + const [progress] = hasProgress(appName); + const { status } = element; + const finished = status === 'done'; + const canceled = status === 'error' || (status === 'abort' && !current); + const isExtracting = gameProgressStatus === 'extracting'; const goToGamePage = () => { if (is_dlc) { @@ -176,9 +179,9 @@ const DownloadManagerItem = observer(({ element, current, state }: Props) => { return } else if (state === 'running') { return - } else { - return <> } + + return <>; } const getTime = () => { @@ -282,7 +285,7 @@ const DownloadManagerItem = observer(({ element, current, state }: Props) => { {mainActionIcon()} - {current && ( + {current && !isExtracting && ( { @@ -121,6 +122,9 @@ const GameCard = ({ if (isUninstalling) { return 'UNINSTALLING' } + if (isExtracting) { + return 'EXTRACTING' + } if (isQueued) { return 'QUEUED' } @@ -366,7 +370,7 @@ const GameCard = ({ ) async function mainAction(runner: Runner) { - if (isInstalling || isPaused) { + if (isInstalling || isExtracting || isPaused) { return setShowStopInstallModal(true) } From cddb194fc1d28888261a8aef97f9497838ea707f Mon Sep 17 00:00:00 2001 From: Red Date: Tue, 31 Oct 2023 19:52:59 +0100 Subject: [PATCH 29/32] feat: Added uncompressed size of the file zip passed --- src/backend/services/ExtractZipService.ts | 61 +++++++++++++++++++---- 1 file changed, 50 insertions(+), 11 deletions(-) diff --git a/src/backend/services/ExtractZipService.ts b/src/backend/services/ExtractZipService.ts index a079c5bbc..1c97d8b02 100644 --- a/src/backend/services/ExtractZipService.ts +++ b/src/backend/services/ExtractZipService.ts @@ -1,6 +1,6 @@ import { EventEmitter } from 'node:events' import { Readable } from 'node:stream' -import { open, ZipFile } from 'yauzl' +import { open, ZipFile, Entry } from 'yauzl' import { mkdirSync, createWriteStream, rmSync } from 'graceful-fs' import { join } from 'path' @@ -32,7 +32,8 @@ export class ExtractZipService extends EventEmitter { private zipFileInstance: ZipFile | null = null private extractionPromise: Promise | null = null private resolveExtraction: ((value: boolean) => void) | null = () => null - private rejectExtraction: ((reason: Error) => void) | null = () => null + private rejectExtraction: ((reason: Error | unknown) => void) | null = () => + null /** * Creates an instance of ExtractZipService. @@ -56,7 +57,7 @@ export class ExtractZipService extends EventEmitter { * @returns {boolean} - Current state */ get isPaused(): boolean { - return this.paused; + return this.paused } /** @@ -89,7 +90,7 @@ export class ExtractZipService extends EventEmitter { */ public pause(): void { if (!this.isPaused) { - this.paused = true; + this.paused = true this.readStream?.pause() @@ -107,7 +108,7 @@ export class ExtractZipService extends EventEmitter { } if (this.isPaused) { - this.paused = false; + this.paused = false this.readStream?.resume() } @@ -214,6 +215,46 @@ export class ExtractZipService extends EventEmitter { this.removeAllListeners() } + /** + * Get uncompressed size + * @param {string} - The zipfile to check the sizeof + * @returns {Promise} - The total uncompressed size + */ + async getUncompressedSize(zipFile: string): Promise { + return new Promise((resolve, reject) => { + let totalUncompressedSize = 0 + + open( + zipFile, + { lazyEntries: true, autoClose: true }, + (err, file: ZipFile) => { + if (err) { + reject(err) + return + } + + file.readEntry() + file.on('entry', (entry: Entry) => { + if (!/\/$/.test(entry.fileName)) { + totalUncompressedSize += entry.uncompressedSize + } + + file.readEntry() + }) + + file.on('end', () => { + resolve(totalUncompressedSize) + }) + + file.on('error', (err: Error) => { + this.onError(err) + resolve(0) + }) + } + ) + }).catch(() => 0) + } + /** * Extracts the ZIP file to the specified destination. * @returns {Promise} - A promise that resolves when the extraction is complete. @@ -233,11 +274,10 @@ export class ExtractZipService extends EventEmitter { } this.zipFileInstance = file - this.totalSize = file.fileSize this.zipFileInstance.readEntry() - this.zipFileInstance.on('entry', (entry) => { + this.zipFileInstance.on('entry', (entry: Entry) => { if (this.isCanceled) { this.zipFileInstance?.close() return @@ -298,16 +338,15 @@ export class ExtractZipService extends EventEmitter { ) }).catch(this.onError.bind(this)) try { + this.totalSize = await this.getUncompressedSize(this.zipFile) return await this.extractionPromise - } catch (error: unknown) { - this.rejectExtraction?.(error as Error) + } catch (error) { + this.rejectExtraction?.(error) } finally { this.zipFileInstance = null this.extractionPromise = null this.resolveExtraction = null this.rejectExtraction = null } - - return false } } From 5e695b66c740f81806a6a57906f31be83534220f Mon Sep 17 00:00:00 2001 From: Red Date: Tue, 31 Oct 2023 19:54:05 +0100 Subject: [PATCH 30/32] feat: Added tests for pause, resume, --- .../services/ExtractZipService.test.ts | 262 ++++++++++++++---- 1 file changed, 206 insertions(+), 56 deletions(-) diff --git a/src/backend/__tests__/services/ExtractZipService.test.ts b/src/backend/__tests__/services/ExtractZipService.test.ts index 2c7937dfd..f8af8901b 100644 --- a/src/backend/__tests__/services/ExtractZipService.test.ts +++ b/src/backend/__tests__/services/ExtractZipService.test.ts @@ -45,9 +45,14 @@ const yauzlMockupLib = ( const stream = { _read: () => null, + destroyed: false, pipe: jest.fn((args) => args), - unpipe: jest.fn(), - destroy: jest.fn(), + unpipe: jest.fn(), + destroy: jest.fn(() => { + stream.destroyed = true + }), + resume: jest.fn(), + pause: jest.fn(), on: jest.fn( ( event: string, @@ -56,10 +61,16 @@ const yauzlMockupLib = ( if (event === 'close') { streamCallback() } else if (event === 'data') { + if (stream.destroyed) { + return + } streamCallback(new Array(500).fill(0)) } else if (event === 'error') { streamCallback(new Error('Error')) } else if (event === 'end') { + if (stream.destroyed) { + return + } streamCallback() } } @@ -82,7 +93,8 @@ const yauzlMockupLib = ( if (event === 'entry') { entryCallback({ fileName, - uncompressedSize: 1000 + uncompressedSize: 1000, + compressedSize: 600 }) } }), @@ -95,12 +107,21 @@ const yauzlMockupLib = ( (_path, _options, yauzlOpenCallback) => { yauzlOpenCallback(error, mockZipFile) } - ) + ); + + const makeFakeProgress = () => { + for (let i = 0; i < 1000; i++) { + stream.on.mock.calls[stream.on.mock.calls.length - 1][1]( + Buffer.alloc(500) + ) + } + } return { openReadStream: mockZipFile.openReadStream, zipFile: mockZipFile, - stream + makeFakeProgress, + stream, } } @@ -109,36 +130,42 @@ describe('ExtractZipService', () => { const zipFile = resolve('./src/backend/__mocks__/test.zip') const destinationPath = resolve('./src/backend/__mocks__/test') - beforeEach((done) => { + beforeEach(() => { yauzlMockupLib('test.zip', false) + jest.useFakeTimers('modern') extractZipService = new ExtractZipService(zipFile, destinationPath) - setTimeout(done, 1000) - }) + extractZipService.getUncompressedSize = async () => Promise.resolve(15000) + }, 1000) afterEach(() => { jest.clearAllMocks() + jest.useRealTimers() }) - describe('should have `source` and `destination` always available', () => { - it('should initialize properly', () => { - expect(extractZipService).toBeInstanceOf(EventEmitter) - expect(extractZipService.source).toBe(zipFile) - expect(extractZipService.destination).toBe(destinationPath) - }) + it('should have `source` and `destination` always available', () => { + expect(extractZipService).toBeInstanceOf(EventEmitter) + expect(extractZipService.source).toBe(zipFile) + expect(extractZipService.destination).toBe(destinationPath) }) it('should emit progress events', async () => { + const { makeFakeProgress } = yauzlMockupLib('test.zip') + const progressListener = jest.fn() extractZipService.on('progress', progressListener) - await extractZipService.extract() + extractZipService.extract() + + process.nextTick(() => { + makeFakeProgress(); - expect(progressListener).toHaveBeenCalled() + expect(progressListener).toHaveBeenCalled() + }) }) it('should emit end event on successful extraction', async () => { const endListener = jest.fn() - extractZipService.on('end', endListener) + extractZipService.on('finished', endListener) await extractZipService.extract() @@ -151,25 +178,31 @@ describe('ExtractZipService', () => { const errorListener = jest.fn() extractZipService.on('error', errorListener) - await extractZipService.extract() + extractZipService.extract() - expect(errorListener).toHaveBeenCalledWith( - expect.objectContaining(new Error('Mock example')) - ) + expect.objectContaining(new Error('Mock example')) }) it('should cancel extraction when cancel is called', async () => { + const { makeFakeProgress } = yauzlMockupLib('test.zip') + const endListener = jest.fn() const progressListener = jest.fn() - extractZipService.on('end', endListener) + const onCanceledListener = jest.fn() + extractZipService.on('finished', endListener) extractZipService.on('progress', progressListener) + extractZipService.on('canceled', onCanceledListener) + extractZipService.extract() extractZipService.cancel() - await extractZipService.extract() + process.nextTick(() => { + makeFakeProgress() - expect(endListener).not.toHaveBeenCalled() - expect(progressListener).not.toHaveBeenCalled() + expect(progressListener).not.toHaveBeenCalled() + expect(endListener).not.toHaveBeenCalled() + expect(onCanceledListener).toHaveBeenCalled() + }) }) it('should have the state as canceled once is canceled', () => { @@ -179,11 +212,25 @@ describe('ExtractZipService', () => { expect(extractZipService.isCanceled).toBe(true) }) + it('should have the state as pause once is paused', () => { + extractZipService.extract() + extractZipService.pause() + + expect(extractZipService.isPaused).toBe(true) + }) + + it('should have the state as resume once is resumed', async () => { + extractZipService.extract() + await extractZipService.resume() + + expect(extractZipService.isPaused).toBe(false) + }) + it('should handle directory entry', async () => { yauzlMockupLib('directory/test.zip', false) const endListener = jest.fn() - extractZipService.on('end', endListener) + extractZipService.on('finished', endListener) await extractZipService.extract() @@ -195,64 +242,167 @@ describe('ExtractZipService', () => { }) it('should emit correct progress values', async () => { + const { makeFakeProgress } = yauzlMockupLib('test.zip') + const progressListener = jest.fn(() => returnDataMockup) extractZipService.on('progress', progressListener) - await extractZipService.extract() + extractZipService.extract() - expect(progressListener).toHaveBeenCalledWith( - expect.objectContaining({ - processedSize: 500, - progressPercentage: 50, - speed: expect.any(Number), - totalSize: 1000 - }) - ) + process.nextTick(() => { + makeFakeProgress() + + expect(progressListener).toHaveBeenCalledWith( + expect.objectContaining({ + processedSize: 500, + progressPercentage: 50, + speed: expect.any(Number), + totalSize: 1000 + }) + ) + }) }) it('should emit correct end values', async () => { const endListener = jest.fn(() => returnDataMockup) - extractZipService.on('end', endListener) + extractZipService.on('finished', endListener) await extractZipService.extract() - expect(endListener).toHaveBeenCalledWith( - expect.objectContaining({ - processedSize: 500, - progressPercentage: 50, - speed: expect.any(Number), - totalSize: 1000 - }) - ) + process.nextTick(() => { + expect(endListener).toHaveBeenCalledWith( + expect.objectContaining({ + processedSize: 500, + progressPercentage: 50, + speed: expect.any(Number), + totalSize: 1000 + }) + ) + }) + }) + + it('should emit correct pause values', async () => { + const { makeFakeProgress } = yauzlMockupLib('test.zip') + + const pausedListener = jest.fn(() => returnDataMockup) + extractZipService.on('paused', pausedListener) + + extractZipService.extract() + extractZipService.pause() + + process.nextTick(() => { + makeFakeProgress(); + + expect(pausedListener).toHaveBeenCalledWith( + expect.objectContaining({ + processedSize: 500, + progressPercentage: 50, + speed: expect.any(Number), + totalSize: 1000 + }) + ) + }) + }) + + it('should emit correct resume values', async () => { + const { makeFakeProgress } = yauzlMockupLib('test.zip') + + const resumedListener = jest.fn(() => returnDataMockup) + extractZipService.on('resumed', resumedListener) + + extractZipService.extract() + extractZipService.pause() + + process.nextTick(() => { + makeFakeProgress(); + + expect(resumedListener).toHaveBeenCalledWith( + expect.objectContaining({ + processedSize: 500, + progressPercentage: 50, + speed: expect.any(Number), + totalSize: 1000 + }) + ) + }) + }) + + it('should not continue the progress upon paused', async () => { + const { makeFakeProgress } = yauzlMockupLib('test.zip') + + const progressListener = jest.fn() + extractZipService.on('progress', progressListener) + + extractZipService.extract() + extractZipService.pause() + + makeFakeProgress(); + + expect(progressListener).not.toHaveBeenCalled() + }) + + it('should continue the progress after resumed', async () => { + const { makeFakeProgress } = yauzlMockupLib('test.zip') + + const progressListener = jest.fn() + extractZipService.on('progress', progressListener) + + extractZipService.extract() + extractZipService.pause() + + process.nextTick(() => { + extractZipService.resume() + + makeFakeProgress(); + + expect(progressListener).toHaveBeenCalled() + }) }) it('should extract files successfully', async () => { + const { makeFakeProgress } = yauzlMockupLib('test.zip') + const onProgress = jest.fn() const onEnd = jest.fn() extractZipService.on('progress', onProgress) - extractZipService.on('end', onEnd) + extractZipService.on('finished', onEnd) await extractZipService.extract() - expect(onProgress).toHaveBeenCalled() - expect(onEnd).toHaveBeenCalled() - expect(mkdirSync).toHaveBeenCalled() + process.nextTick(() => { + makeFakeProgress(); + + expect(onProgress).toHaveBeenCalled() + expect(onEnd).toHaveBeenCalled() + expect(mkdirSync).toHaveBeenCalled() + }) }) it('should throttle emit progress', async () => { - const { stream } = yauzlMockupLib('test.zip') + const { makeFakeProgress } = yauzlMockupLib('test.zip') const mockEventListener = jest.fn() extractZipService.on('progress', mockEventListener) - await extractZipService.extract() + extractZipService.extract() - for (let i = 0; i < 10; i++) { - stream.on.mock.calls[stream.on.mock.calls.length - 1][1]( - Buffer.alloc(500) - ) - } + process.nextTick(() => { + makeFakeProgress(); + + expect(mockEventListener).toHaveBeenCalledTimes(1) + }) + }) + + it('should clear all event listeners after finished, canceled or error', () => { + const removeAllListenersSpy = jest.spyOn( + extractZipService, + 'removeAllListeners' + ) + + extractZipService.extract() + + expect(removeAllListenersSpy).toHaveBeenCalled() - expect(mockEventListener).toHaveBeenCalledTimes(1) + removeAllListenersSpy.mockRestore() }) }) From b5655a71bb99e70826496e743e6c592175e95325 Mon Sep 17 00:00:00 2001 From: Red Date: Tue, 31 Oct 2023 20:49:41 +0100 Subject: [PATCH 31/32] fix: Fixed lint and lint issues --- .../services/ExtractZipService.test.ts | 24 ++++++++-------- src/backend/utils.ts | 6 +--- src/frontend/hooks/hasStatus.ts | 2 +- .../components/DownloadManagerItem/index.tsx | 22 +++++++++------ src/frontend/screens/Game/GamePage/index.tsx | 2 +- .../Library/components/GameCard/index.tsx | 2 +- .../InstallModal/DownloadDialog/index.tsx | 28 ++++++++++--------- 7 files changed, 44 insertions(+), 42 deletions(-) diff --git a/src/backend/__tests__/services/ExtractZipService.test.ts b/src/backend/__tests__/services/ExtractZipService.test.ts index f8af8901b..d644f3c44 100644 --- a/src/backend/__tests__/services/ExtractZipService.test.ts +++ b/src/backend/__tests__/services/ExtractZipService.test.ts @@ -101,13 +101,13 @@ const yauzlMockupLib = ( openReadStream: jest.fn((entry, openReadStreamCallback) => { openReadStreamCallback(error, stream) }) - }; + } - (yauzl.open as jest.Mock).mockImplementation( + ;(yauzl.open as jest.Mock).mockImplementation( (_path, _options, yauzlOpenCallback) => { yauzlOpenCallback(error, mockZipFile) } - ); + ) const makeFakeProgress = () => { for (let i = 0; i < 1000; i++) { @@ -121,7 +121,7 @@ const yauzlMockupLib = ( openReadStream: mockZipFile.openReadStream, zipFile: mockZipFile, makeFakeProgress, - stream, + stream } } @@ -157,9 +157,9 @@ describe('ExtractZipService', () => { extractZipService.extract() process.nextTick(() => { - makeFakeProgress(); + makeFakeProgress() - expect(progressListener).toHaveBeenCalled() + expect(progressListener).toHaveBeenCalled() }) }) @@ -291,7 +291,7 @@ describe('ExtractZipService', () => { extractZipService.pause() process.nextTick(() => { - makeFakeProgress(); + makeFakeProgress() expect(pausedListener).toHaveBeenCalledWith( expect.objectContaining({ @@ -314,7 +314,7 @@ describe('ExtractZipService', () => { extractZipService.pause() process.nextTick(() => { - makeFakeProgress(); + makeFakeProgress() expect(resumedListener).toHaveBeenCalledWith( expect.objectContaining({ @@ -336,7 +336,7 @@ describe('ExtractZipService', () => { extractZipService.extract() extractZipService.pause() - makeFakeProgress(); + makeFakeProgress() expect(progressListener).not.toHaveBeenCalled() }) @@ -353,7 +353,7 @@ describe('ExtractZipService', () => { process.nextTick(() => { extractZipService.resume() - makeFakeProgress(); + makeFakeProgress() expect(progressListener).toHaveBeenCalled() }) @@ -370,7 +370,7 @@ describe('ExtractZipService', () => { await extractZipService.extract() process.nextTick(() => { - makeFakeProgress(); + makeFakeProgress() expect(onProgress).toHaveBeenCalled() expect(onEnd).toHaveBeenCalled() @@ -387,7 +387,7 @@ describe('ExtractZipService', () => { extractZipService.extract() process.nextTick(() => { - makeFakeProgress(); + makeFakeProgress() expect(mockEventListener).toHaveBeenCalledTimes(1) }) diff --git a/src/backend/utils.ts b/src/backend/utils.ts index 229526c23..21cf4de8f 100644 --- a/src/backend/utils.ts +++ b/src/backend/utils.ts @@ -34,11 +34,7 @@ import { SpawnOptions, spawnSync } from 'child_process' -import { - appendFileSync, - existsSync, - rmSync, -} from 'graceful-fs' +import { appendFileSync, existsSync, rmSync } from 'graceful-fs' import { promisify } from 'util' import i18next, { t } from 'i18next' import si from 'systeminformation' diff --git a/src/frontend/hooks/hasStatus.ts b/src/frontend/hooks/hasStatus.ts index 5602d8b9d..09a1e97c9 100644 --- a/src/frontend/hooks/hasStatus.ts +++ b/src/frontend/hooks/hasStatus.ts @@ -9,7 +9,7 @@ import libraryState from 'frontend/state/libraryState' // the consuming code needs to be wrapped in observer when using this hook export const hasStatus = ( appName: string, - gameInfo: GameInfo | undefined, + gameInfo?: GameInfo, gameSize?: string ) => { const { libraryStatus } = React.useContext(ContextProvider) diff --git a/src/frontend/screens/DownloadManager/components/DownloadManagerItem/index.tsx b/src/frontend/screens/DownloadManager/components/DownloadManagerItem/index.tsx index bf944a3cc..d1625c960 100644 --- a/src/frontend/screens/DownloadManager/components/DownloadManagerItem/index.tsx +++ b/src/frontend/screens/DownloadManager/components/DownloadManagerItem/index.tsx @@ -92,8 +92,12 @@ const DownloadManagerItem = observer(({ element, current, state }: Props) => { platformToInstall } = params - const [gameInfo, setGameInfo] = useState(DmGameInfo); - const { status: gameProgressStatus = '' } = hasStatus(appName, DmGameInfo, (size || '0')); + const [gameInfo, setGameInfo] = useState(DmGameInfo) + const { status: gameProgressStatus = '' } = hasStatus( + appName, + DmGameInfo, + size || '0' + ) useEffect(() => { const getNewInfo = async () => { @@ -113,7 +117,7 @@ const DownloadManagerItem = observer(({ element, current, state }: Props) => { } } getNewInfo() - }, [element]); + }, [element]) const { art_cover, @@ -121,11 +125,11 @@ const DownloadManagerItem = observer(({ element, current, state }: Props) => { install: { is_dlc } } = gameInfo || {} - const [progress] = hasProgress(appName); - const { status } = element; - const finished = status === 'done'; - const canceled = status === 'error' || (status === 'abort' && !current); - const isExtracting = gameProgressStatus === 'extracting'; + const [progress] = hasProgress(appName) + const { status } = element + const finished = status === 'done' + const canceled = status === 'error' || (status === 'abort' && !current) + const isExtracting = gameProgressStatus === 'extracting' const goToGamePage = () => { if (is_dlc) { @@ -181,7 +185,7 @@ const DownloadManagerItem = observer(({ element, current, state }: Props) => { return } - return <>; + return <> } const getTime = () => { diff --git a/src/frontend/screens/Game/GamePage/index.tsx b/src/frontend/screens/Game/GamePage/index.tsx index e532e5084..c79da8785 100644 --- a/src/frontend/screens/Game/GamePage/index.tsx +++ b/src/frontend/screens/Game/GamePage/index.tsx @@ -909,7 +909,7 @@ export default observer(function GamePage(): JSX.Element | null { if (isExtracting) { storage.removeItem(appName) - return window.api.cancelExtraction(appName); + return window.api.cancelExtraction(appName) } // open install dialog diff --git a/src/frontend/screens/Library/components/GameCard/index.tsx b/src/frontend/screens/Library/components/GameCard/index.tsx index 3fe2327c6..165342c7b 100644 --- a/src/frontend/screens/Library/components/GameCard/index.tsx +++ b/src/frontend/screens/Library/components/GameCard/index.tsx @@ -106,7 +106,7 @@ const GameCard = ({ notAvailable, isUpdating, isPaused, - isExtracting, + isExtracting } = getCardStatus(status, isInstalled, layout) const handleRemoveFromQueue = () => { diff --git a/src/frontend/screens/Library/components/InstallModal/DownloadDialog/index.tsx b/src/frontend/screens/Library/components/InstallModal/DownloadDialog/index.tsx index 741ba0255..a587e2e83 100644 --- a/src/frontend/screens/Library/components/InstallModal/DownloadDialog/index.tsx +++ b/src/frontend/screens/Library/components/InstallModal/DownloadDialog/index.tsx @@ -106,12 +106,12 @@ function getDefaultInstallPath() { * @return {number} - Total size uncompressed estimated based on platform (windows: zip, winrar 60-80%, mac: zip 70-90%, linux: 60-80% ) */ const estimateUncompressedSize = (platform: string, compressedSize: number) => { - const baseEstimate = compressedSize * 2; - const gapPercentage = platform === 'osx' ? 0.05 : 0.1; + const baseEstimate = compressedSize * 2 + const gapPercentage = platform === 'osx' ? 0.05 : 0.1 - const gap = baseEstimate * gapPercentage; - - return baseEstimate + gap; + const gap = baseEstimate * gapPercentage + + return baseEstimate + gap } export default function DownloadDialog({ @@ -172,8 +172,11 @@ export default function DownloadDialog({ const { i18n, t } = useTranslation('gamepage') const { t: tr } = useTranslation() - - const uncompressedSize = estimateUncompressedSize(platformToInstall, gameInstallInfo?.manifest?.disk_size || 0) + + const uncompressedSize = estimateUncompressedSize( + platformToInstall, + gameInstallInfo?.manifest?.disk_size || 0 + ) const haveSDL = sdls.length > 0 @@ -315,12 +318,12 @@ export default function DownloadDialog({ useEffect(() => { const getSpace = async () => { - const { message, free, validPath } = await window.api.checkDiskSpace(installPath) + const { message, free, validPath } = await window.api.checkDiskSpace( + installPath + ) if (gameInstallInfo?.manifest?.disk_size) { let notEnoughDiskSpace = free < uncompressedSize - let spaceLeftAfter = size( - free - Number(uncompressedSize) - ) + let spaceLeftAfter = size(free - Number(uncompressedSize)) if (previousProgress.folder === installPath) { const progress = 100 - getProgress(previousProgress) notEnoughDiskSpace = @@ -363,8 +366,7 @@ export default function DownloadDialog({ } const installSize = - gameInstallInfo?.manifest?.disk_size !== undefined && - size(uncompressedSize) + gameInstallInfo?.manifest?.disk_size !== undefined && size(uncompressedSize) const getLanguageName = useMemo(() => { return (language: string) => { From 10aaa4b6398373b8ad4a5c12292a476bdf79537e Mon Sep 17 00:00:00 2001 From: Red Date: Wed, 1 Nov 2023 00:28:39 +0100 Subject: [PATCH 32/32] fix: Checking if build was fixed as supposed to be --- src/backend/services/ExtractZipService.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/backend/services/ExtractZipService.ts b/src/backend/services/ExtractZipService.ts index 1c97d8b02..bf1b10ed5 100644 --- a/src/backend/services/ExtractZipService.ts +++ b/src/backend/services/ExtractZipService.ts @@ -31,9 +31,8 @@ export class ExtractZipService extends EventEmitter { private zipFileInstance: ZipFile | null = null private extractionPromise: Promise | null = null - private resolveExtraction: ((value: boolean) => void) | null = () => null - private rejectExtraction: ((reason: Error | unknown) => void) | null = () => - null + private resolveExtraction: ((value: boolean) => void) | null = null + private rejectExtraction: ((reason: Error | unknown) => void) | null = null /** * Creates an instance of ExtractZipService. @@ -42,6 +41,9 @@ export class ExtractZipService extends EventEmitter { */ constructor(private zipFile: string, private destinationPath: string) { super() + + this.resolveExtraction = () => null + this.rejectExtraction = () => null } /**