diff --git a/src/backend/__tests__/services/ExtractZipService.test.ts b/src/backend/__tests__/services/ExtractZipService.test.ts new file mode 100644 index 000000000..d644f3c44 --- /dev/null +++ b/src/backend/__tests__/services/ExtractZipService.test.ts @@ -0,0 +1,408 @@ +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')) + } else if (event === 'end') { + streamCallback() + } + }) + }), + rm: jest.fn(), + rmSync: 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, + destroyed: false, + pipe: jest.fn((args) => args), + unpipe: jest.fn(), + destroy: jest.fn(() => { + stream.destroyed = true + }), + resume: jest.fn(), + pause: jest.fn(), + on: jest.fn( + ( + event: string, + streamCallback: (...args: unknown[]) => ReadableStream + ) => { + 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() + } + } + ) + } + + const mockZipFile = { + 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) => { + if (event === 'entry') { + entryCallback({ + fileName, + uncompressedSize: 1000, + compressedSize: 600 + }) + } + }), + openReadStream: jest.fn((entry, openReadStreamCallback) => { + openReadStreamCallback(error, stream) + }) + } + + ;(yauzl.open as jest.Mock).mockImplementation( + (_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, + makeFakeProgress, + stream + } +} + +describe('ExtractZipService', () => { + let extractZipService: ExtractZipService + const zipFile = resolve('./src/backend/__mocks__/test.zip') + const destinationPath = resolve('./src/backend/__mocks__/test') + + beforeEach(() => { + yauzlMockupLib('test.zip', false) + jest.useFakeTimers('modern') + extractZipService = new ExtractZipService(zipFile, destinationPath) + extractZipService.getUncompressedSize = async () => Promise.resolve(15000) + }, 1000) + + afterEach(() => { + jest.clearAllMocks() + jest.useRealTimers() + }) + + 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) + + extractZipService.extract() + + process.nextTick(() => { + makeFakeProgress() + + expect(progressListener).toHaveBeenCalled() + }) + }) + + it('should emit end event on successful extraction', async () => { + const endListener = jest.fn() + extractZipService.on('finished', 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) + + extractZipService.extract() + + 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() + const onCanceledListener = jest.fn() + extractZipService.on('finished', endListener) + extractZipService.on('progress', progressListener) + extractZipService.on('canceled', onCanceledListener) + + extractZipService.extract() + extractZipService.cancel() + + process.nextTick(() => { + makeFakeProgress() + + expect(progressListener).not.toHaveBeenCalled() + expect(endListener).not.toHaveBeenCalled() + expect(onCanceledListener).toHaveBeenCalled() + }) + }) + + it('should have the state as canceled once is canceled', () => { + extractZipService.extract() + extractZipService.cancel() + + 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('finished', endListener) + + await extractZipService.extract() + + expect(mkdirSync).toHaveBeenCalledWith( + expect.stringContaining('directory'), + expect.anything() + ) + expect(endListener).toHaveBeenCalled() + }) + + it('should emit correct progress values', async () => { + const { makeFakeProgress } = yauzlMockupLib('test.zip') + + const progressListener = jest.fn(() => returnDataMockup) + extractZipService.on('progress', progressListener) + + extractZipService.extract() + + 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('finished', endListener) + + await extractZipService.extract() + + 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('finished', onEnd) + + await extractZipService.extract() + + process.nextTick(() => { + makeFakeProgress() + + expect(onProgress).toHaveBeenCalled() + expect(onEnd).toHaveBeenCalled() + expect(mkdirSync).toHaveBeenCalled() + }) + }) + + it('should throttle emit progress', async () => { + const { makeFakeProgress } = yauzlMockupLib('test.zip') + + const mockEventListener = jest.fn() + extractZipService.on('progress', mockEventListener) + + extractZipService.extract() + + 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() + + removeAllListenersSpy.mockRestore() + }) +}) diff --git a/src/backend/__tests__/utils.test.ts b/src/backend/__tests__/utils.test.ts index f6617d962..f08adc372 100644 --- a/src/backend/__tests__/utils.test.ts +++ b/src/backend/__tests__/utils.test.ts @@ -1,18 +1,8 @@ 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' +import { logError } from '../logger/logger' jest.mock('electron') jest.mock('../logger/logger') @@ -257,59 +247,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/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/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 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/backend/services/ExtractZipService.ts b/src/backend/services/ExtractZipService.ts new file mode 100644 index 000000000..bf1b10ed5 --- /dev/null +++ b/src/backend/services/ExtractZipService.ts @@ -0,0 +1,354 @@ +import { EventEmitter } from 'node:events' +import { Readable } from 'node:stream' +import { open, ZipFile, Entry } from 'yauzl' +import { mkdirSync, createWriteStream, rmSync } 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 zipFileInstance: ZipFile | null = null + private extractionPromise: Promise | null = null + private resolveExtraction: ((value: boolean) => void) | null = null + private rejectExtraction: ((reason: Error | unknown) => 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() + + this.resolveExtraction = () => null + this.rejectExtraction = () => null + } + + /** + * Checks if the extraction process was canceled. + * @returns {boolean} - True if the extraction was canceled, false otherwise. + */ + get isCanceled(): boolean { + return this.canceled + } + + /** + * Get if is paused or not + * @returns {boolean} - Current state + */ + get isPaused(): boolean { + return this.paused + } + + /** + * 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 + } + + /** + * 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()) + } + } + + /** + * Resume the extraction process. + * @returns {Promise} + */ + public async resume(): Promise { + if (!this.extractionPromise) { + throw new Error('Extraction has not started or has already completed.') + } + + if (this.isPaused) { + this.paused = false + + this.readStream?.resume() + } + + this.emit('resumed', this.computeProgress()) + + return this.extractionPromise + } + + /** + * Cancels the extraction process. + * @returns {void} + */ + public cancel() { + if (!this.zipFileInstance?.isOpen) { + throw new Error('Extraction has not started or has already completed.') + } + + this.canceled = true + + this.readStream?.unpipe() + + this.readStream?.destroy(new Error('Extraction canceled')) + + if (this.zipFileInstance && this.zipFileInstance.isOpen) { + this.zipFileInstance.close() + } + + this.emit('canceled') + + rmSync(this.source, { recursive: true, force: true }) + + this.removeAllListeners() + } + + /** + * 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.canProgress) { + 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.isCanceled) { + return + } + + this.emit('finished', this.computeProgress()) + + rmSync(this.source, { recursive: true, force: true }) + + this.removeAllListeners() + } + + /** + * Handles error events during extraction. + * @private + * @param {Error} error - The error that occurred. + */ + private onError(error: Error) { + this.emit('error', error) + + rmSync(this.source, { recursive: true, force: true }) + + 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. + */ + async extract() { + this.extractionPromise = new Promise((resolve, reject) => { + this.resolveExtraction = resolve + this.rejectExtraction = reject + + open( + this.zipFile, + { lazyEntries: true, autoClose: true }, + (err, file: ZipFile) => { + if (err) { + this.rejectExtraction?.(err) + return + } + + this.zipFileInstance = file + + this.zipFileInstance.readEntry() + + this.zipFileInstance.on('entry', (entry: Entry) => { + if (this.isCanceled) { + this.zipFileInstance?.close() + return + } + + if (/\/$/.test(entry.fileName)) { + // Directory file names end with '/' + mkdirSync(join(this.destinationPath, entry.fileName), { + recursive: true + }) + this.zipFileInstance?.readEntry() + } else { + // Ensure parent directory exists + mkdirSync( + join( + this.destinationPath, + entry.fileName.split('/').slice(0, -1).join('/') + ), + { recursive: true } + ) + + this.zipFileInstance?.openReadStream(entry, (err, readStream) => { + if (err && this.rejectExtraction) { + this.rejectExtraction(err) + return + } + + this.readStream = readStream + const writeStream = createWriteStream( + join(this.destinationPath, entry.fileName) + ) + this.readStream.pipe(writeStream) + this.readStream.on('data', (chunk) => { + this.onData(chunk.length) + }) + writeStream.once('close', () => { + if (this.isCanceled) { + return + } + this.zipFileInstance?.readEntry() + }) + }) + } + }) + + this.zipFileInstance.once('end', () => { + if (this.isCanceled) { + return + } + + this.onEnd() + + this.resolveExtraction?.(true) + }) + + this.zipFileInstance.once('error', this.onError.bind(this)) + } + ) + }).catch(this.onError.bind(this)) + try { + this.totalSize = await this.getUncompressedSize(this.zipFile) + return await this.extractionPromise + } catch (error) { + this.rejectExtraction?.(error) + } finally { + 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 18488aa6e..3d1a459cf 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' @@ -60,8 +63,10 @@ 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() export async function getSettings(appName: string): Promise { return getSettingsSideload(appName) @@ -224,6 +229,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 +293,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 +317,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 +350,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 +494,29 @@ async function resumeIfPaused(appName: string): Promise { return isPaused } +export async function cancelExtraction(appName: string) { + 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 + ) + } +} + export async function install( appName: string, { path: dirpath, platformToInstall, channelName, accessCode }: InstallArgs @@ -551,6 +596,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, @@ -569,54 +630,203 @@ 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 - // 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 + sendFrontendMessage('gameStatusUpdate', { + appName, + status: 'extracting', + runner: 'hyperplay', + folder: destinationPath + }) - if (isMac && executable.endsWith('.app')) { - const macAppExecutable = readdirSync( - join(executable, 'Contents', 'MacOS') - )[0] - executable = join(executable, 'Contents', 'MacOS', macAppExecutable) + 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 } + }) - const installedInfo: InstalledInfo = { - appName, - install_path: destinationPath, - executable: executable, - install_size: platformInfo.installSize ?? '0', - is_dlc: false, - version: installVersion, - platform: appPlatform, - channelName - } + 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( + 'finished', + async ({ + progressPercentage, + speed, + totalSize, + processedSize + }: ExtractZipProgressResponse) => { + 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.once('error', (error: Error) => { + logError(`Extracting Error ${error.message}`, LogPrefix.HyperPlay) + + cancelQueueExtraction() + callAbortController(appName) + + cleanUpDownload(appName, directory) - updateInstalledInfo(appName, installedInfo) + sendFrontendMessage('refreshLibrary', 'hyperplay') - notify({ - title, - body: `Installed` + 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 + }) - cleanUpDownload(appName, directory) + 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 + } + }) - sendFrontendMessage('refreshLibrary', 'hyperplay') + 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, @@ -628,6 +838,8 @@ export async function install( } return { status: 'done' } } catch (error) { + process.noAsar = false + logInfo( `Error while downloading and extracting game: ${error}`, LogPrefix.HyperPlay diff --git a/src/backend/utils.ts b/src/backend/utils.ts index 1517836fe..21cf4de8f 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' @@ -35,14 +34,7 @@ import { SpawnOptions, spawnSync } from 'child_process' -import { - appendFileSync, - existsSync, - rmSync, - mkdirSync, - createWriteStream, - rm -} from 'graceful-fs' +import { appendFileSync, existsSync, rmSync } from 'graceful-fs' import { promisify } from 'util' import i18next, { t } from 'i18next' import si from 'systeminformation' @@ -1418,56 +1410,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, downloadSpeed: number, 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 } diff --git a/src/frontend/components/UI/DownloadToastManager/index.tsx b/src/frontend/components/UI/DownloadToastManager/index.tsx index 7cd0f2dcc..c4977b2c9 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' @@ -13,6 +8,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 +26,22 @@ 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 isExtracting = status === 'extracting' - 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(() => { @@ -135,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 <> @@ -196,13 +177,10 @@ export default function DownloadToastManager() { let imgUrl = currentElement?.params.gameInfo.art_cover ? currentElement?.params.gameInfo.art_cover : '' + 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 <> @@ -215,19 +193,23 @@ 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' } + const adjustedDownloadedInBytes = downloadedMB * 1024 * 1024 + const adjustedDownloadSizeInBytes = downloadSizeInMB * 1024 * 1024 + return (
{showDownloadToast ? ( { setShowStopInstallModal(true) @@ -259,6 +241,7 @@ export default function DownloadToastManager() { }) }} status={getDownloadStatus()} + statusText={downloadStatusText ?? 'Downloading 2'} /> ) : ( downloadIcon() @@ -270,6 +253,7 @@ export default function DownloadToastManager() { folderName={folder_name} progress={progress} runner={runner} + status={status} onClose={() => setShowStopInstallModal(false)} /> ) : null} diff --git a/src/frontend/components/UI/StopInstallationModal/index.tsx b/src/frontend/components/UI/StopInstallationModal/index.tsx index 0d48f2a24..d973443e0 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 ( @@ -68,12 +71,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/hooks/hasStatus.ts b/src/frontend/hooks/hasStatus.ts index 6afecd188..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, + gameInfo?: GameInfo, 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..e8811b0b7 --- /dev/null +++ b/src/frontend/hooks/useGetDownloadStatusText.ts @@ -0,0 +1,41 @@ +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' +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/DownloadManager/components/DownloadManagerItem/index.tsx b/src/frontend/screens/DownloadManager/components/DownloadManagerItem/index.tsx index bf32373ef..d1625c960 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 @@ -92,6 +93,11 @@ const DownloadManagerItem = observer(({ element, current, state }: Props) => { } = params const [gameInfo, setGameInfo] = useState(DmGameInfo) + const { status: gameProgressStatus = '' } = hasStatus( + appName, + DmGameInfo, + size || '0' + ) useEffect(() => { const getNewInfo = async () => { @@ -123,6 +129,7 @@ const DownloadManagerItem = observer(({ element, current, state }: Props) => { 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 +183,9 @@ const DownloadManagerItem = observer(({ element, current, state }: Props) => { return } else if (state === 'running') { return - } else { - return <> } + + return <> } const getTime = () => { @@ -250,6 +257,7 @@ const DownloadManagerItem = observer(({ element, current, state }: Props) => { folderName={gameInfo.folder_name ? gameInfo.folder_name : ''} appName={appName} runner={runner} + status={status} progress={progress} /> ) : null} @@ -281,7 +289,7 @@ const DownloadManagerItem = observer(({ element, current, state }: Props) => { {mainActionIcon()} - {current && ( + {current && !isExtracting && ( ) : null} {gameInfo.runner !== 'sideload' && showModal.show && ( @@ -624,8 +625,7 @@ export default observer(function GamePage(): JSX.Element | null { isReparing || isMoving || isUninstalling || - notSupportedGame || - isExtracting + notSupportedGame } autoFocus={true} type={getButtonClass(is_installed)} @@ -774,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 ? '' @@ -799,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')}...` @@ -862,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') @@ -907,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() 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 } } diff --git a/src/frontend/screens/Library/components/GameCard/index.tsx b/src/frontend/screens/Library/components/GameCard/index.tsx index 2dda3fb3a..165342c7b 100644 --- a/src/frontend/screens/Library/components/GameCard/index.tsx +++ b/src/frontend/screens/Library/components/GameCard/index.tsx @@ -23,6 +23,7 @@ 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' @@ -72,7 +73,11 @@ const GameCard = ({ ...gameInstallInfo } - const { status, folder } = hasStatus(appName, gameInfo, size) + const { status = '', folder } = hasStatus(appName, gameInfo, size) + const { statusText: downloadStatusText } = useGetDownloadStatusText( + appName, + gameInfo + ) useEffect(() => { setIsLaunching(false) @@ -100,7 +105,8 @@ const GameCard = ({ isPlaying, notAvailable, isUpdating, - isPaused + isPaused, + isExtracting } = getCardStatus(status, isInstalled, layout) const handleRemoveFromQueue = () => { @@ -116,6 +122,9 @@ const GameCard = ({ if (isUninstalling) { return 'UNINSTALLING' } + if (isExtracting) { + return 'EXTRACTING' + } if (isQueued) { return 'QUEUED' } @@ -140,21 +149,7 @@ 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 isBrowserGame = installPlatform === 'Browser' const onUninstallClick = function () { @@ -304,6 +299,7 @@ const GameCard = ({ appName={appName} runner={runner} folderName={gameInfo.folder_name ? gameInfo.folder_name : ''} + status={status} /> ) : null} {showUninstallModal && ( @@ -364,7 +360,7 @@ const GameCard = ({ )} onUpdateClick={handleClickStopBubbling(async () => handleUpdate())} progress={progress} - message={getMessage()} + message={downloadStatusText} actionDisabled={isLaunching} alwaysShowInColor={allTilesInColor} store={runner} @@ -374,7 +370,7 @@ const GameCard = ({ ) async function mainAction(runner: Runner) { - if (isInstalling || isPaused) { + if (isInstalling || isExtracting || isPaused) { return setShowStopInstallModal(true) } diff --git a/src/frontend/screens/Library/components/InstallModal/DownloadDialog/index.tsx b/src/frontend/screens/Library/components/InstallModal/DownloadDialog/index.tsx index 7e8caa3d5..a587e2e83 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, @@ -160,6 +173,11 @@ 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 const sdlList = useMemo(() => { @@ -304,17 +322,15 @@ export default function DownloadDialog({ installPath ) if (gameInstallInfo?.manifest?.disk_size) { - let notEnoughDiskSpace = free < gameInstallInfo.manifest.disk_size - let spaceLeftAfter = size( - free - Number(gameInstallInfo.manifest.disk_size) - ) + let notEnoughDiskSpace = free < uncompressedSize + let spaceLeftAfter = 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 +343,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 && @@ -350,8 +366,7 @@ export default function DownloadDialog({ } const installSize = - gameInstallInfo?.manifest?.disk_size !== undefined && - size(Number(gameInstallInfo?.manifest?.disk_size)) + gameInstallInfo?.manifest?.disk_size !== undefined && size(uncompressedSize) const getLanguageName = useMemo(() => { return (language: string) => { 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..16d428bdb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1669,9 +1669,9 @@ 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== + 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"