From 3b574e6578ec2ff9d46f76f305730f40329ce642 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Thu, 11 Dec 2025 15:25:44 +0200 Subject: [PATCH 1/3] feat: add extraction progress tracking and UI updates --- package.json | 1 + src/locales/en/translation.json | 2 + src/locales/pt-BR/translation.json | 2 + .../events/library/extract-game-download.ts | 1 + .../events/torrenting/start-game-download.ts | 1 + src/main/services/7zip.ts | 131 +++++++++--- .../services/download/download-manager.ts | 38 ++-- src/main/services/game-files-manager.ts | 197 +++++++++++------- src/main/services/node-7z.d.ts | 87 ++++++++ src/preload/index.ts | 12 ++ src/renderer/src/app.tsx | 11 +- .../components/bottom-panel/bottom-panel.tsx | 18 ++ src/renderer/src/declaration.d.ts | 3 + src/renderer/src/features/download-slice.ts | 28 ++- .../src/pages/downloads/download-group.scss | 4 + .../src/pages/downloads/download-group.tsx | 107 ++++++---- .../src/pages/downloads/downloads.tsx | 11 +- .../game-details/hero/hero-panel-playtime.tsx | 32 ++- .../pages/game-details/hero/hero-panel.scss | 6 + .../pages/game-details/hero/hero-panel.tsx | 16 +- src/types/level.types.ts | 1 + yarn.lock | 43 ++++ 22 files changed, 585 insertions(+), 167 deletions(-) create mode 100644 src/main/services/node-7z.d.ts diff --git a/package.json b/package.json index da6918b5..bb74198f 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "jsonwebtoken": "^9.0.2", "lodash-es": "^4.17.21", "lucide-react": "^0.544.0", + "node-7z": "^3.0.0", "parse-torrent": "^11.0.18", "rc-virtual-list": "^3.18.3", "react-dnd": "^16.0.1", diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index ed8c7d4e..3709a546 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -115,6 +115,7 @@ "downloading": "Downloading {{title}}… ({{percentage}} complete) - Completion {{eta}} - {{speed}}", "calculating_eta": "Downloading {{title}}… ({{percentage}} complete) - Calculating remaining time…", "checking_files": "Checking {{title}} files… ({{percentage}} complete)", + "extracting": "Extracting {{title}}… ({{percentage}} complete)", "installing_common_redist": "{{log}}…", "installation_complete": "Installation complete", "installation_complete_message": "Common redistributables installed successfully" @@ -202,6 +203,7 @@ "danger_zone_section_description": "Remove this game from your library or the files downloaded by Hydra", "download_in_progress": "Download in progress", "download_paused": "Download paused", + "extracting": "Extracting", "last_downloaded_option": "Last downloaded option", "new_download_option": "New", "create_steam_shortcut": "Create Steam shortcut", diff --git a/src/locales/pt-BR/translation.json b/src/locales/pt-BR/translation.json index 6702c310..30a46278 100755 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -115,6 +115,7 @@ "downloading": "Baixando {{title}}… ({{percentage}} concluído) - Conclusão {{eta}} - {{speed}}", "calculating_eta": "Baixando {{title}}… ({{percentage}} concluído) - Calculando tempo restante…", "checking_files": "Verificando arquivos de {{title}}…", + "extracting": "Extraindo {{title}}… ({{percentage}} concluído)", "installing_common_redist": "{{log}}…", "installation_complete": "Instalação concluída", "installation_complete_message": "Componentes recomendados instalados com sucesso" @@ -190,6 +191,7 @@ "danger_zone_section_description": "Remova o jogo da sua biblioteca ou os arquivos que foram baixados pelo Hydra", "download_in_progress": "Download em andamento", "download_paused": "Download pausado", + "extracting": "Extraindo", "last_downloaded_option": "Última opção baixada", "new_download_option": "Novo", "create_steam_shortcut": "Criar atalho na Steam", diff --git a/src/main/events/library/extract-game-download.ts b/src/main/events/library/extract-game-download.ts index 8fb24b81..b393e6b7 100644 --- a/src/main/events/library/extract-game-download.ts +++ b/src/main/events/library/extract-game-download.ts @@ -22,6 +22,7 @@ const extractGameDownload = async ( await downloadsSublevel.put(gameKey, { ...download, extracting: true, + extractionProgress: 0, }); const gameFilesManager = new GameFilesManager(shop, objectId); diff --git a/src/main/events/torrenting/start-game-download.ts b/src/main/events/torrenting/start-game-download.ts index 79d55ec3..4375698f 100644 --- a/src/main/events/torrenting/start-game-download.ts +++ b/src/main/events/torrenting/start-game-download.ts @@ -82,6 +82,7 @@ const startGameDownload = async ( queued: true, extracting: false, automaticallyExtract, + extractionProgress: 0, }; try { diff --git a/src/main/services/7zip.ts b/src/main/services/7zip.ts index 9a9f85be..0fa333dc 100644 --- a/src/main/services/7zip.ts +++ b/src/main/services/7zip.ts @@ -1,5 +1,5 @@ import { app } from "electron"; -import cp from "node:child_process"; +import Seven, { CommandLineSwitches } from "node-7z"; import path from "node:path"; import { logger } from "./logger"; @@ -9,6 +9,17 @@ export const binaryName = { win32: "7z.exe", }; +export interface ExtractionProgress { + percent: number; + fileCount: number; + file: string; +} + +export interface ExtractionResult { + success: boolean; + extractedFiles: string[]; +} + export class SevenZip { private static readonly binaryPath = app.isPackaged ? path.join(process.resourcesPath, binaryName[process.platform]) @@ -32,43 +43,109 @@ export class SevenZip { cwd?: string; passwords?: string[]; }, - successCb: () => void, - errorCb: () => void - ) { - const tryPassword = (index = -1) => { - const password = passwords[index] ?? ""; - logger.info(`Trying password ${password} on ${filePath}`); + onProgress?: (progress: ExtractionProgress) => void + ): Promise { + return new Promise((resolve, reject) => { + const tryPassword = (index = -1) => { + const password = passwords[index] ?? ""; + logger.info( + `Trying password "${password || "(empty)"}" on ${filePath}` + ); - const args = ["x", filePath, "-y", "-p" + password]; + const extractedFiles: string[] = []; + let fileCount = 0; - if (outputPath) { - args.push("-o" + outputPath); - } + const options: CommandLineSwitches = { + $bin: this.binaryPath, + $progress: true, + yes: true, + password: password || undefined, + }; - const child = cp.execFile(this.binaryPath, args, { - cwd, - }); - - child.once("exit", (code) => { - if (code === 0) { - successCb(); - return; + if (outputPath) { + options.outputDir = outputPath; } - if (index < passwords.length - 1) { + const stream = Seven.extractFull(filePath, outputPath || cwd || ".", { + ...options, + $spawnOptions: cwd ? { cwd } : undefined, + }); + + stream.on("progress", (progress) => { + if (onProgress) { + onProgress({ + percent: progress.percent, + fileCount: fileCount, + file: progress.fileCount?.toString() || "", + }); + } + }); + + stream.on("data", (data) => { + if (data.file) { + extractedFiles.push(data.file); + fileCount++; + } + }); + + stream.on("end", () => { logger.info( - `Failed to extract file: ${filePath} with password: ${password}. Trying next password...` + `Successfully extracted ${filePath} (${extractedFiles.length} files)` ); + resolve({ + success: true, + extractedFiles, + }); + }); - tryPassword(index + 1); - } else { - logger.info(`Failed to extract file: ${filePath}`); + stream.on("error", (err) => { + logger.error(`Extraction error for ${filePath}:`, err); - errorCb(); + if (index < passwords.length - 1) { + logger.info( + `Failed to extract file: ${filePath} with password: "${password}". Trying next password...` + ); + tryPassword(index + 1); + } else { + logger.error( + `Failed to extract file: ${filePath} after trying all passwords` + ); + reject(new Error(`Failed to extract file: ${filePath}`)); + } + }); + }; + + tryPassword(); + }); + } + + public static listFiles( + filePath: string, + password?: string + ): Promise { + return new Promise((resolve, reject) => { + const files: string[] = []; + + const options: CommandLineSwitches = { + $bin: this.binaryPath, + password: password || undefined, + }; + + const stream = Seven.list(filePath, options); + + stream.on("data", (data) => { + if (data.file) { + files.push(data.file); } }); - }; - tryPassword(); + stream.on("end", () => { + resolve(files); + }); + + stream.on("error", (err) => { + reject(err); + }); + }); } } diff --git a/src/main/services/download/download-manager.ts b/src/main/services/download/download-manager.ts index 1a79f8f0..c208fa32 100644 --- a/src/main/services/download/download-manager.ts +++ b/src/main/services/download/download-manager.ts @@ -126,21 +126,10 @@ export class DownloadManager { } ); - if (WindowManager.mainWindow && download) { - WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress); - WindowManager.mainWindow.webContents.send( - "on-download-progress", - JSON.parse( - JSON.stringify({ - ...status, - game, - }) - ) - ); - } - const shouldExtract = download.automaticallyExtract; + // Handle download completion BEFORE sending progress to renderer + // This ensures extraction starts and DB is updated before UI reacts if (progress === 1 && download) { publishDownloadCompleteNotification(game); @@ -154,6 +143,7 @@ export class DownloadManager { shouldSeed: true, queued: false, extracting: shouldExtract, + extractionProgress: shouldExtract ? 0 : download.extractionProgress, }); } else { await downloadsSublevel.put(gameId, { @@ -162,12 +152,22 @@ export class DownloadManager { shouldSeed: false, queued: false, extracting: shouldExtract, + extractionProgress: shouldExtract ? 0 : download.extractionProgress, }); this.cancelDownload(gameId); } if (shouldExtract) { + // Send initial extraction progress BEFORE download progress + // This ensures the UI shows extraction immediately + WindowManager.mainWindow?.webContents.send( + "on-extraction-progress", + game.shop, + game.objectId, + 0 + ); + const gameFilesManager = new GameFilesManager( game.shop, game.objectId @@ -209,6 +209,18 @@ export class DownloadManager { this.downloadingGameId = null; } } + + // Send progress to renderer after completion handling + if (WindowManager.mainWindow && download) { + WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress); + WindowManager.mainWindow.webContents.send( + "on-download-progress", + structuredClone({ + ...status, + game, + }) + ); + } } } diff --git a/src/main/services/game-files-manager.ts b/src/main/services/game-files-manager.ts index 120b3e8f..3e0f1b47 100644 --- a/src/main/services/game-files-manager.ts +++ b/src/main/services/game-files-manager.ts @@ -3,24 +3,58 @@ import fs from "node:fs"; import type { GameShop } from "@types"; import { downloadsSublevel, gamesSublevel, levelKeys } from "@main/level"; import { FILE_EXTENSIONS_TO_EXTRACT } from "@shared"; -import { SevenZip } from "./7zip"; +import { SevenZip, ExtractionProgress } from "./7zip"; import { WindowManager } from "./window-manager"; import { publishExtractionCompleteNotification } from "./notifications"; import { logger } from "./logger"; +const PROGRESS_THROTTLE_MS = 1000; + export class GameFilesManager { + private lastProgressUpdate = 0; + constructor( private readonly shop: GameShop, private readonly objectId: string ) {} - private async clearExtractionState() { - const gameKey = levelKeys.game(this.shop, this.objectId); - const download = await downloadsSublevel.get(gameKey); + private get gameKey() { + return levelKeys.game(this.shop, this.objectId); + } - await downloadsSublevel.put(gameKey, { - ...download!, + private async updateExtractionProgress(progress: number, force = false) { + const now = Date.now(); + + if (!force && now - this.lastProgressUpdate < PROGRESS_THROTTLE_MS) { + return; + } + + this.lastProgressUpdate = now; + + const download = await downloadsSublevel.get(this.gameKey); + if (!download) return; + + await downloadsSublevel.put(this.gameKey, { + ...download, + extractionProgress: progress, + }); + + WindowManager.mainWindow?.webContents.send( + "on-extraction-progress", + this.shop, + this.objectId, + progress + ); + } + + private async clearExtractionState() { + const download = await downloadsSublevel.get(this.gameKey); + if (!download) return; + + await downloadsSublevel.put(this.gameKey, { + ...download, extracting: false, + extractionProgress: 0, }); WindowManager.mainWindow?.webContents.send( @@ -30,6 +64,10 @@ export class GameFilesManager { ); } + private readonly handleProgress = (progress: ExtractionProgress) => { + this.updateExtractionProgress(progress.percent / 100); + }; + async extractFilesInDirectory(directoryPath: string) { if (!fs.existsSync(directoryPath)) return; const files = await fs.promises.readdir(directoryPath); @@ -42,53 +80,68 @@ export class GameFilesManager { (file) => /part1\.rar$/i.test(file) || !/part\d+\.rar$/i.test(file) ); - await Promise.all( - filesToExtract.map((file) => { - return new Promise((resolve, reject) => { - SevenZip.extractFile( - { - filePath: path.join(directoryPath, file), - cwd: directoryPath, - passwords: ["online-fix.me", "steamrip.com"], - }, - () => { - resolve(true); - }, - () => { - reject(new Error(`Failed to extract file: ${file}`)); - this.clearExtractionState(); - } - ); - }); - }) - ); + if (filesToExtract.length === 0) return; - compressedFiles.forEach((file) => { + await this.updateExtractionProgress(0, true); + + const totalFiles = filesToExtract.length; + let completedFiles = 0; + + for (const file of filesToExtract) { + try { + const result = await SevenZip.extractFile( + { + filePath: path.join(directoryPath, file), + cwd: directoryPath, + passwords: ["online-fix.me", "steamrip.com"], + }, + (progress) => { + const overallProgress = + (completedFiles + progress.percent / 100) / totalFiles; + this.updateExtractionProgress(overallProgress); + } + ); + + if (result.success) { + completedFiles++; + await this.updateExtractionProgress( + completedFiles / totalFiles, + true + ); + } + } catch (err) { + logger.error(`Failed to extract file: ${file}`, err); + await this.clearExtractionState(); + return; + } + } + + for (const file of compressedFiles) { const extractionPath = path.join(directoryPath, file); - if (fs.existsSync(extractionPath)) { - fs.unlink(extractionPath, (err) => { - if (err) { - logger.error(`Failed to delete file: ${file}`, err); - - this.clearExtractionState(); - } - }); + try { + if (fs.existsSync(extractionPath)) { + await fs.promises.unlink(extractionPath); + logger.info(`Deleted archive: ${file}`); + } + } catch (err) { + logger.error(`Failed to delete file: ${file}`, err); } - }); + } } async setExtractionComplete(publishNotification = true) { - const gameKey = levelKeys.game(this.shop, this.objectId); - const [download, game] = await Promise.all([ - downloadsSublevel.get(gameKey), - gamesSublevel.get(gameKey), + downloadsSublevel.get(this.gameKey), + gamesSublevel.get(this.gameKey), ]); - await downloadsSublevel.put(gameKey, { - ...download!, + if (!download) return; + + await downloadsSublevel.put(this.gameKey, { + ...download, extracting: false, + extractionProgress: 0, }); WindowManager.mainWindow?.webContents.send( @@ -97,17 +150,15 @@ export class GameFilesManager { this.objectId ); - if (publishNotification) { - publishExtractionCompleteNotification(game!); + if (publishNotification && game) { + publishExtractionCompleteNotification(game); } } async extractDownloadedFile() { - const gameKey = levelKeys.game(this.shop, this.objectId); - const [download, game] = await Promise.all([ - downloadsSublevel.get(gameKey), - gamesSublevel.get(gameKey), + downloadsSublevel.get(this.gameKey), + gamesSublevel.get(this.gameKey), ]); if (!download || !game) return false; @@ -119,39 +170,41 @@ export class GameFilesManager { path.parse(download.folderName!).name ); - SevenZip.extractFile( - { - filePath, - outputPath: extractionPath, - passwords: ["online-fix.me", "steamrip.com"], - }, - async () => { + await this.updateExtractionProgress(0, true); + + try { + const result = await SevenZip.extractFile( + { + filePath, + outputPath: extractionPath, + passwords: ["online-fix.me", "steamrip.com"], + }, + this.handleProgress + ); + + if (result.success) { await this.extractFilesInDirectory(extractionPath); if (fs.existsSync(extractionPath) && fs.existsSync(filePath)) { - fs.unlink(filePath, (err) => { - if (err) { - logger.error( - `Failed to delete file: ${download.folderName}`, - err - ); - - this.clearExtractionState(); - } - }); + try { + await fs.promises.unlink(filePath); + logger.info(`Deleted archive: ${download.folderName}`); + } catch (err) { + logger.error(`Failed to delete file: ${download.folderName}`, err); + } } - await downloadsSublevel.put(gameKey, { - ...download!, + await downloadsSublevel.put(this.gameKey, { + ...download, folderName: path.parse(download.folderName!).name, }); - this.setExtractionComplete(); - }, - () => { - this.clearExtractionState(); + await this.setExtractionComplete(); } - ); + } catch (err) { + logger.error(`Failed to extract downloaded file: ${filePath}`, err); + await this.clearExtractionState(); + } return true; } diff --git a/src/main/services/node-7z.d.ts b/src/main/services/node-7z.d.ts new file mode 100644 index 00000000..3877346a --- /dev/null +++ b/src/main/services/node-7z.d.ts @@ -0,0 +1,87 @@ +declare module "node-7z" { + import { ChildProcess } from "node:child_process"; + import { EventEmitter } from "node:events"; + + export interface CommandLineSwitches { + $bin?: string; + $progress?: boolean; + $spawnOptions?: { + cwd?: string; + }; + outputDir?: string; + yes?: boolean; + password?: string; + [key: string]: unknown; + } + + export interface ProgressInfo { + percent: number; + fileCount?: number; + } + + export interface FileInfo { + file?: string; + [key: string]: unknown; + } + + export interface ZipStream extends EventEmitter { + on(event: "progress", listener: (progress: ProgressInfo) => void): this; + on(event: "data", listener: (data: FileInfo) => void): this; + on(event: "end", listener: () => void): this; + on(event: "error", listener: (err: Error) => void): this; + info: Map; + _childProcess?: ChildProcess; + } + + export function extractFull( + archive: string, + output: string, + options?: CommandLineSwitches + ): ZipStream; + + export function extract( + archive: string, + output: string, + options?: CommandLineSwitches + ): ZipStream; + + export function list( + archive: string, + options?: CommandLineSwitches + ): ZipStream; + + export function add( + archive: string, + files: string | string[], + options?: CommandLineSwitches + ): ZipStream; + + export function update( + archive: string, + files: string | string[], + options?: CommandLineSwitches + ): ZipStream; + + export function deleteFiles( + archive: string, + files: string | string[], + options?: CommandLineSwitches + ): ZipStream; + + export function test( + archive: string, + options?: CommandLineSwitches + ): ZipStream; + + const Seven: { + extractFull: typeof extractFull; + extract: typeof extract; + list: typeof list; + add: typeof add; + update: typeof update; + delete: typeof deleteFiles; + test: typeof test; + }; + + export default Seven; +} diff --git a/src/preload/index.ts b/src/preload/index.ts index f7c062cb..7be92065 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -267,6 +267,18 @@ contextBridge.exposeInMainWorld("electron", { ipcRenderer.on("on-extraction-complete", listener); return () => ipcRenderer.removeListener("on-extraction-complete", listener); }, + onExtractionProgress: ( + cb: (shop: GameShop, objectId: string, progress: number) => void + ) => { + const listener = ( + _event: Electron.IpcRendererEvent, + shop: GameShop, + objectId: string, + progress: number + ) => cb(shop, objectId, progress); + ipcRenderer.on("on-extraction-progress", listener); + return () => ipcRenderer.removeListener("on-extraction-progress", listener); + }, /* Hardware */ getDiskFreeSpace: (path: string) => diff --git a/src/renderer/src/app.tsx b/src/renderer/src/app.tsx index 9badd12e..9c65d959 100644 --- a/src/renderer/src/app.tsx +++ b/src/renderer/src/app.tsx @@ -19,6 +19,8 @@ import { setUserDetails, setProfileBackground, setGameRunning, + setExtractionProgress, + clearExtraction, } from "@renderer/features"; import { useTranslation } from "react-i18next"; import { UserFriendModal } from "./pages/shared-modals/user-friend-modal"; @@ -184,12 +186,19 @@ export function App() { updateLibrary(); }), window.electron.onSignOut(() => clearUserDetails()), + window.electron.onExtractionProgress((shop, objectId, progress) => { + dispatch(setExtractionProgress({ shop, objectId, progress })); + }), + window.electron.onExtractionComplete(() => { + dispatch(clearExtraction()); + updateLibrary(); + }), ]; return () => { listeners.forEach((unsubscribe) => unsubscribe()); }; - }, [onSignIn, updateLibrary, clearUserDetails]); + }, [onSignIn, updateLibrary, clearUserDetails, dispatch]); useEffect(() => { if (contentRef.current) contentRef.current.scrollTop = 0; diff --git a/src/renderer/src/components/bottom-panel/bottom-panel.tsx b/src/renderer/src/components/bottom-panel/bottom-panel.tsx index 186fcb4f..ed7d31f3 100644 --- a/src/renderer/src/components/bottom-panel/bottom-panel.tsx +++ b/src/renderer/src/components/bottom-panel/bottom-panel.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { + useAppSelector, useDownload, useLibrary, useToast, @@ -26,6 +27,8 @@ export function BottomPanel() { const { lastPacket, progress, downloadSpeed, eta } = useDownload(); + const extraction = useAppSelector((state) => state.download.extraction); + const [version, setVersion] = useState(""); const [sessionHash, setSessionHash] = useState(""); const [commonRedistStatus, setCommonRedistStatus] = useState( @@ -68,6 +71,20 @@ export function BottomPanel() { return t("installing_common_redist", { log: commonRedistStatus }); } + if (extraction) { + const extractingGame = library.find( + (game) => game.id === extraction.visibleId + ); + + if (extractingGame) { + const extractionPercentage = Math.round(extraction.progress * 100); + return t("extracting", { + title: extractingGame.title, + percentage: `${extractionPercentage}%`, + }); + } + } + const game = lastPacket ? library.find((game) => game.id === lastPacket?.gameId) : undefined; @@ -109,6 +126,7 @@ export function BottomPanel() { eta, downloadSpeed, commonRedistStatus, + extraction, ]); return ( diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index 56205b2f..078cd65c 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -208,6 +208,9 @@ declare global { onExtractionComplete: ( cb: (shop: GameShop, objectId: string) => void ) => () => Electron.IpcRenderer; + onExtractionProgress: ( + cb: (shop: GameShop, objectId: string, progress: number) => void + ) => () => Electron.IpcRenderer; getDefaultWinePrefixSelectionPath: () => Promise; createSteamShortcut: (shop: GameShop, objectId: string) => Promise; diff --git a/src/renderer/src/features/download-slice.ts b/src/renderer/src/features/download-slice.ts index cb638cda..0330cca3 100644 --- a/src/renderer/src/features/download-slice.ts +++ b/src/renderer/src/features/download-slice.ts @@ -1,17 +1,24 @@ import { createSlice } from "@reduxjs/toolkit"; import type { PayloadAction } from "@reduxjs/toolkit"; -import type { DownloadProgress } from "@types"; +import type { DownloadProgress, GameShop } from "@types"; + +export interface ExtractionInfo { + visibleId: string; + progress: number; +} export interface DownloadState { lastPacket: DownloadProgress | null; gameId: string | null; gamesWithDeletionInProgress: string[]; + extraction: ExtractionInfo | null; } const initialState: DownloadState = { lastPacket: null, gameId: null, gamesWithDeletionInProgress: [], + extraction: null, }; export const downloadSlice = createSlice({ @@ -38,6 +45,23 @@ export const downloadSlice = createSlice({ const index = state.gamesWithDeletionInProgress.indexOf(action.payload); if (index >= 0) state.gamesWithDeletionInProgress.splice(index, 1); }, + setExtractionProgress: ( + state, + action: PayloadAction<{ + shop: GameShop; + objectId: string; + progress: number; + }> + ) => { + const { shop, objectId, progress } = action.payload; + state.extraction = { + visibleId: `${shop}:${objectId}`, + progress, + }; + }, + clearExtraction: (state) => { + state.extraction = null; + }, }, }); @@ -46,4 +70,6 @@ export const { clearDownload, setGameDeleting, removeGameFromDeleting, + setExtractionProgress, + clearExtraction, } = downloadSlice.actions; diff --git a/src/renderer/src/pages/downloads/download-group.scss b/src/renderer/src/pages/downloads/download-group.scss index e8921155..9d6cb111 100644 --- a/src/renderer/src/pages/downloads/download-group.scss +++ b/src/renderer/src/pages/downloads/download-group.scss @@ -536,5 +536,9 @@ background-color: #fff; transition: width 0.3s ease; border-radius: 4px; + + &--extraction { + background-color: #4caf50; + } } } diff --git a/src/renderer/src/pages/downloads/download-group.tsx b/src/renderer/src/pages/downloads/download-group.tsx index f956b113..52fbcdfd 100644 --- a/src/renderer/src/pages/downloads/download-group.tsx +++ b/src/renderer/src/pages/downloads/download-group.tsx @@ -128,16 +128,20 @@ function SpeedChart({ g = 255, b = 255; if (color.startsWith("#")) { - const hex = color.replace("#", ""); - r = Number.parseInt(hex.substring(0, 2), 16); - g = Number.parseInt(hex.substring(2, 4), 16); - b = Number.parseInt(hex.substring(4, 6), 16); + let hex = color.replace("#", ""); + // Handle shorthand hex colors (e.g., "#fff" -> "#ffffff") + if (hex.length === 3) { + hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]; + } + r = Number.parseInt(hex.substring(0, 2), 16) || 255; + g = Number.parseInt(hex.substring(2, 4), 16) || 255; + b = Number.parseInt(hex.substring(4, 6), 16) || 255; } else if (color.startsWith("rgb")) { const matches = color.match(/\d+/g); if (matches && matches.length >= 3) { - r = Number.parseInt(matches[0]); - g = Number.parseInt(matches[1]); - b = Number.parseInt(matches[2]); + r = Number.parseInt(matches[0]) || 255; + g = Number.parseInt(matches[1]) || 255; + b = Number.parseInt(matches[2]) || 255; } } const displaySpeeds = speeds.slice(-totalBars); @@ -203,6 +207,7 @@ function SpeedChart({ interface HeroDownloadViewProps { game: LibraryGame; isGameDownloading: boolean; + isGameExtracting?: boolean; downloadSpeed: number; finalDownloadSize: string; peakSpeed: number; @@ -221,6 +226,7 @@ interface HeroDownloadViewProps { function HeroDownloadView({ game, isGameDownloading, + isGameExtracting = false, downloadSpeed, finalDownloadSize, peakSpeed, @@ -278,11 +284,17 @@ function HeroDownloadView({
- {lastPacket?.isCheckingFiles ? ( + {isGameExtracting && ( + + {t("extracting")} + + )} + {!isGameExtracting && lastPacket?.isCheckingFiles && ( {t("checking_files")} - ) : ( + )} + {!isGameExtracting && !lastPacket?.isCheckingFiles && ( {isGameDownloading && lastPacket @@ -293,7 +305,7 @@ function HeroDownloadView({
- {!lastPacket?.isCheckingFiles && ( + {!lastPacket?.isCheckingFiles && !isGameExtracting && ( {isGameDownloading && lastPacket?.timeRemaining && @@ -311,42 +323,44 @@ function HeroDownloadView({
-
- {isGameDownloading ? ( + {!isGameExtracting && ( +
+ {isGameDownloading ? ( + + ) : ( + + )} - ) : ( - - )} - -
+
+ )}
@@ -442,6 +456,8 @@ export function DownloadGroup({ (state) => state.userPreferences.value ); + const extraction = useAppSelector((state) => state.download.extraction); + const { updateLibrary } = useLibrary(); const { @@ -819,16 +835,21 @@ export function DownloadGroup({ if (isDownloadingGroup && library.length > 0) { const game = library[0]; - const isGameDownloading = isGameDownloadingMap[game.id]; + const isGameExtracting = extraction?.visibleId === game.id; + const isGameDownloading = + isGameDownloadingMap[game.id] && !isGameExtracting; const downloadSpeed = isGameDownloading ? (lastPacket?.downloadSpeed ?? 0) : 0; const finalDownloadSize = getFinalDownloadSize(game); const peakSpeed = peakSpeeds[game.id] || 0; - const currentProgress = - isGameDownloading && lastPacket - ? lastPacket.progress - : game.download?.progress || 0; + + let currentProgress = game.download?.progress || 0; + if (isGameExtracting) { + currentProgress = extraction.progress; + } else if (isGameDownloading && lastPacket) { + currentProgress = lastPacket.progress; + } const dominantColor = dominantColors[game.id] || "#fff"; @@ -836,6 +857,7 @@ export function DownloadGroup({ {DOWNLOADER_NAME[game.download!.downloader]}
- {game.download?.extracting ? ( + {extraction?.visibleId === game.id ? ( - {t("extracting")} + {t("extracting")} ( + {Math.round(extraction.progress * 100)}%) ) : ( diff --git a/src/renderer/src/pages/downloads/downloads.tsx b/src/renderer/src/pages/downloads/downloads.tsx index c222ab65..35403ba1 100644 --- a/src/renderer/src/pages/downloads/downloads.tsx +++ b/src/renderer/src/pages/downloads/downloads.tsx @@ -1,6 +1,6 @@ import { useTranslation } from "react-i18next"; -import { useDownload, useLibrary } from "@renderer/hooks"; +import { useAppSelector, useDownload, useLibrary } from "@renderer/hooks"; import { useEffect, useMemo, useRef, useState } from "react"; import { BinaryNotFoundModal } from "../shared-modals/binary-not-found-modal"; @@ -13,6 +13,7 @@ import { ArrowDownIcon } from "@primer/octicons-react"; export default function Downloads() { const { library, updateLibrary } = useLibrary(); + const extraction = useAppSelector((state) => state.download.extraction); const { t } = useTranslation("downloads"); @@ -72,8 +73,10 @@ export default function Downloads() { /* Game has been manually added to the library */ if (!next.download) return prev; - /* Is downloading */ - if (lastPacket?.gameId === next.id || next.download.extracting) + /* Is downloading or extracting */ + const isExtracting = + next.download.extracting || extraction?.visibleId === next.id; + if (lastPacket?.gameId === next.id || isExtracting) return { ...prev, downloading: [...prev.downloading, next] }; /* Is either queued or paused */ @@ -96,7 +99,7 @@ export default function Downloads() { queued, complete, }; - }, [library, lastPacket?.gameId]); + }, [library, lastPacket?.gameId, extraction?.visibleId]); const downloadGroups = [ { diff --git a/src/renderer/src/pages/game-details/hero/hero-panel-playtime.tsx b/src/renderer/src/pages/game-details/hero/hero-panel-playtime.tsx index 270ed030..24c37b18 100644 --- a/src/renderer/src/pages/game-details/hero/hero-panel-playtime.tsx +++ b/src/renderer/src/pages/game-details/hero/hero-panel-playtime.tsx @@ -1,7 +1,12 @@ import { useContext, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { formatDownloadProgress } from "@renderer/helpers"; -import { useDate, useDownload, useFormat } from "@renderer/hooks"; +import { + useAppSelector, + useDate, + useDownload, + useFormat, +} from "@renderer/hooks"; import { Link } from "@renderer/components"; import { gameDetailsContext } from "@renderer/context"; import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants"; @@ -17,6 +22,9 @@ export function HeroPanelPlaytime() { const { numberFormatter } = useFormat(); const { progress, lastPacket } = useDownload(); const { formatDistance } = useDate(); + const extraction = useAppSelector((state) => state.download.extraction); + + const isExtracting = extraction?.visibleId === game?.id; useEffect(() => { if (game?.lastTimePlayed) { @@ -52,6 +60,16 @@ export function HeroPanelPlaytime() { const isGameDownloading = game.download?.status === "active" && lastPacket?.gameId === game.id; + const extractionInProgressInfo = ( +
+ + {t("extracting")} + + + {formatDownloadProgress(extraction?.progress ?? 0)} +
+ ); + const downloadInProgressInfo = (
@@ -72,7 +90,8 @@ export function HeroPanelPlaytime() { return ( <>

{t("not_played_yet", { title: game?.title })}

- {hasDownload && downloadInProgressInfo} + {isExtracting && extractionInProgressInfo} + {!isExtracting && hasDownload && downloadInProgressInfo} ); } @@ -81,7 +100,8 @@ export function HeroPanelPlaytime() { return ( <>

{t("playing_now")}

- {hasDownload && downloadInProgressInfo} + {isExtracting && extractionInProgressInfo} + {!isExtracting && hasDownload && downloadInProgressInfo} ); } @@ -113,9 +133,9 @@ export function HeroPanelPlaytime() { })}

- {hasDownload ? ( - downloadInProgressInfo - ) : ( + {isExtracting && extractionInProgressInfo} + {!isExtracting && hasDownload && downloadInProgressInfo} + {!isExtracting && !hasDownload && (

{t("last_time_played", { period: lastTimePlayed, diff --git a/src/renderer/src/pages/game-details/hero/hero-panel.scss b/src/renderer/src/pages/game-details/hero/hero-panel.scss index c91e685c..10265b9e 100644 --- a/src/renderer/src/pages/game-details/hero/hero-panel.scss +++ b/src/renderer/src/pages/game-details/hero/hero-panel.scss @@ -80,5 +80,11 @@ &--disabled { opacity: globals.$disabled-opacity; } + + &--extraction { + &::-webkit-progress-value { + background-color: #4caf50; + } + } } } diff --git a/src/renderer/src/pages/game-details/hero/hero-panel.tsx b/src/renderer/src/pages/game-details/hero/hero-panel.tsx index 799f2c36..48cda106 100644 --- a/src/renderer/src/pages/game-details/hero/hero-panel.tsx +++ b/src/renderer/src/pages/game-details/hero/hero-panel.tsx @@ -1,7 +1,7 @@ import { useContext } from "react"; import { useTranslation } from "react-i18next"; -import { useDate, useDownload } from "@renderer/hooks"; +import { useAppSelector, useDate, useDownload } from "@renderer/hooks"; import { HeroPanelActions } from "./hero-panel-actions"; import { HeroPanelPlaytime } from "./hero-panel-playtime"; @@ -18,9 +18,13 @@ export function HeroPanel() { const { lastPacket } = useDownload(); + const extraction = useAppSelector((state) => state.download.extraction); + const isGameDownloading = game?.download?.status === "active" && lastPacket?.gameId === game?.id; + const isExtracting = extraction?.visibleId === game?.id; + const getInfo = () => { if (!game) { const [latestRepack] = repacks; @@ -49,6 +53,8 @@ export function HeroPanel() { (game?.download?.status === "active" && game?.download?.progress < 1) || game?.download?.status === "paused"; + const showExtractionProgressBar = isExtracting; + return (

@@ -72,6 +78,14 @@ export function HeroPanel() { }`} /> )} + + {showExtractionProgressBar && ( + + )}
); diff --git a/src/types/level.types.ts b/src/types/level.types.ts index 8059e000..c7abaacb 100644 --- a/src/types/level.types.ts +++ b/src/types/level.types.ts @@ -82,6 +82,7 @@ export interface Download { timestamp: number; extracting: boolean; automaticallyExtract: boolean; + extractionProgress: number; } export interface GameAchievement { diff --git a/yarn.lock b/yarn.lock index 416f4a21..9d354966 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6438,11 +6438,26 @@ lodash.clonedeep@^4.5.0: resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef" integrity sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ== +lodash.defaultsdeep@^4.6.1: + version "4.6.1" + resolved "https://registry.yarnpkg.com/lodash.defaultsdeep/-/lodash.defaultsdeep-4.6.1.tgz#512e9bd721d272d94e3d3a63653fa17516741ca6" + integrity sha512-3j8wdDzYuWO3lM3Reg03MuQR957t287Rpcxp1njpEa8oDrikb+FwGdW3n+FELh/A6qib6yPit0j/pv9G/yeAqA== + +lodash.defaultto@^4.14.0: + version "4.14.0" + resolved "https://registry.yarnpkg.com/lodash.defaultto/-/lodash.defaultto-4.14.0.tgz#38bd3d425acee733e0e2bbbd4e4b29711cc2ee11" + integrity sha512-G6tizqH6rg4P5j32Wy4Z3ZIip7OfG8YWWlPFzUFGcYStH1Ld0l1tWs6NevEQNEDnO1M3NZYjuHuraaFSN5WqeQ== + lodash.escaperegexp@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz#64762c48618082518ac3df4ccf5d5886dae20347" integrity sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw== +lodash.flattendeep@^4.4.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz#fb030917f86a3134e5bc9bec0d69e0013ddfedb2" + integrity sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ== + lodash.includes@^4.3.0: version "4.3.0" resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f" @@ -6453,6 +6468,11 @@ lodash.isboolean@^3.0.3: resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6" integrity sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg== +lodash.isempty@^4.4.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/lodash.isempty/-/lodash.isempty-4.4.0.tgz#6f86cbedd8be4ec987be9aaf33c9684db1b31e7e" + integrity sha512-oKMuF3xEeqDltrGMfDxAPGIVMSSRv8tbRSODbrs4KGsRRLEhrW8N8Rd4DRgB2+621hY8A8XwwrTVhXWpxFvMzg== + lodash.isequal@^4.5.0: version "4.5.0" resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0" @@ -6493,6 +6513,11 @@ lodash.mergewith@^4.6.2: resolved "https://registry.yarnpkg.com/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz#617121f89ac55f59047c7aec1ccd6654c6590f55" integrity sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ== +lodash.negate@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/lodash.negate/-/lodash.negate-3.0.2.tgz#9c897b0bf610019e0b43b8ff3f0afef3d7b66f34" + integrity sha512-JGJYYVslKYC0tRMm/7igfdHulCjoXjoganRNWM8AgS+RXfOvFnPkOveDhPI65F9aAypCX9QEEQoBqWf7Q6uAeA== + lodash.once@^4.0.0: version "4.1.1" resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" @@ -6872,6 +6897,19 @@ no-case@^3.0.4: lower-case "^2.0.2" tslib "^2.0.3" +node-7z@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/node-7z/-/node-7z-3.0.0.tgz#42f71c5a43b00028749f7c88291a7abf2e2623e3" + integrity sha512-KIznWSxIkOYO/vOgKQfJEaXd7rgoFYKZbaurainCEdMhYc7V7mRHX+qdf2HgbpQFcdJL/Q6/XOPrDLoBeTfuZA== + dependencies: + debug "^4.3.2" + lodash.defaultsdeep "^4.6.1" + lodash.defaultto "^4.14.0" + lodash.flattendeep "^4.4.0" + lodash.isempty "^4.4.0" + lodash.negate "^3.0.2" + normalize-path "^3.0.0" + node-abi@^3.45.0: version "3.78.0" resolved "https://registry.yarnpkg.com/node-abi/-/node-abi-3.78.0.tgz#fd0ecbd0aa89857b98da06bd3909194abb0821ba" @@ -6927,6 +6965,11 @@ nopt@^6.0.0: dependencies: abbrev "^1.0.0" +normalize-path@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" + integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== + normalize-url@^6.0.1: version "6.1.0" resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-6.1.0.tgz#40d0885b535deffe3f3147bec877d05fe4c5668a" From 0470958629e91c740becd3031cf73955dda69564 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Thu, 11 Dec 2025 15:35:40 +0200 Subject: [PATCH 2/3] refactor(decky-plugin): simplify plugin extraction logic using async/await --- src/main/services/decky-plugin.ts | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/src/main/services/decky-plugin.ts b/src/main/services/decky-plugin.ts index 4dc1fdad..cb8999c3 100644 --- a/src/main/services/decky-plugin.ts +++ b/src/main/services/decky-plugin.ts @@ -74,21 +74,16 @@ export class DeckyPlugin { await fs.promises.mkdir(extractPath, { recursive: true }); - return new Promise((resolve, reject) => { - SevenZip.extractFile( - { - filePath: zipPath, - outputPath: extractPath, - }, - () => { - logger.log(`Plugin extracted to: ${extractPath}`); - resolve(extractPath); - }, - () => { - reject(new Error("Failed to extract plugin")); - } - ); - }); + try { + await SevenZip.extractFile({ + filePath: zipPath, + outputPath: extractPath, + }); + logger.log(`Plugin extracted to: ${extractPath}`); + return extractPath; + } catch { + throw new Error("Failed to extract plugin"); + } } private static needsSudo(): boolean { From 63f8289d0a7807374fc91be2a39b3892c67c1aa8 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Fri, 12 Dec 2025 12:44:02 +0200 Subject: [PATCH 3/3] feat: implement archive deletion prompt and translations for confirmation messages --- src/locales/en/translation.json | 6 ++- src/locales/pt-BR/translation.json | 6 ++- src/main/events/library/delete-archive.ts | 23 ++++++++++ src/main/events/library/index.ts | 1 + src/main/services/game-files-manager.ts | 28 +++++------- src/preload/index.ts | 11 +++++ src/renderer/src/app.tsx | 17 ++++++- src/renderer/src/declaration.d.ts | 4 ++ .../archive-deletion-error-modal.tsx | 44 +++++++++++++++++++ .../src/pages/downloads/download-group.scss | 2 +- .../src/pages/downloads/downloads.tsx | 6 ++- .../pages/game-details/hero/hero-panel.scss | 2 +- 12 files changed, 127 insertions(+), 23 deletions(-) create mode 100644 src/main/events/library/delete-archive.ts create mode 100644 src/renderer/src/pages/downloads/archive-deletion-error-modal.tsx diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 3709a546..9be4ff26 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -416,7 +416,11 @@ "resume_seeding": "Resume seeding", "options": "Manage", "extract": "Extract files", - "extracting": "Extracting files…" + "extracting": "Extracting files…", + "delete_archive_title": "Would you like to delete {{fileName}}?", + "delete_archive_description": "The file has been successfully extracted and it's no longer needed.", + "yes": "Yes", + "no": "No" }, "settings": { "downloads_path": "Downloads path", diff --git a/src/locales/pt-BR/translation.json b/src/locales/pt-BR/translation.json index 30a46278..ee0da176 100755 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -404,7 +404,11 @@ "resume_seeding": "Semear", "options": "Gerenciar", "extract": "Extrair arquivos", - "extracting": "Extraindo arquivos…" + "extracting": "Extraindo arquivos…", + "delete_archive_title": "Deseja deletar {{fileName}}?", + "delete_archive_description": "O arquivo foi extraído com sucesso e não é mais necessário.", + "yes": "Sim", + "no": "Não" }, "settings": { "downloads_path": "Diretório dos downloads", diff --git a/src/main/events/library/delete-archive.ts b/src/main/events/library/delete-archive.ts new file mode 100644 index 00000000..9cf64a63 --- /dev/null +++ b/src/main/events/library/delete-archive.ts @@ -0,0 +1,23 @@ +import fs from "node:fs"; + +import { registerEvent } from "../register-event"; +import { logger } from "@main/services"; + +const deleteArchive = async ( + _event: Electron.IpcMainInvokeEvent, + filePath: string +) => { + try { + if (fs.existsSync(filePath)) { + await fs.promises.unlink(filePath); + logger.info(`Deleted archive: ${filePath}`); + return true; + } + return true; + } catch (err) { + logger.error(`Failed to delete archive: ${filePath}`, err); + return false; + } +}; + +registerEvent("deleteArchive", deleteArchive); diff --git a/src/main/events/library/index.ts b/src/main/events/library/index.ts index d9d628d0..75fc5cd9 100644 --- a/src/main/events/library/index.ts +++ b/src/main/events/library/index.ts @@ -8,6 +8,7 @@ import "./close-game"; import "./copy-custom-game-asset"; import "./create-game-shortcut"; import "./create-steam-shortcut"; +import "./delete-archive"; import "./delete-game-folder"; import "./extract-game-download"; import "./get-default-wine-prefix-selection-path"; diff --git a/src/main/services/game-files-manager.ts b/src/main/services/game-files-manager.ts index 3e0f1b47..f3684a0a 100644 --- a/src/main/services/game-files-manager.ts +++ b/src/main/services/game-files-manager.ts @@ -116,17 +116,15 @@ export class GameFilesManager { } } - for (const file of compressedFiles) { - const extractionPath = path.join(directoryPath, file); + const archivePaths = compressedFiles + .map((file) => path.join(directoryPath, file)) + .filter((archivePath) => fs.existsSync(archivePath)); - try { - if (fs.existsSync(extractionPath)) { - await fs.promises.unlink(extractionPath); - logger.info(`Deleted archive: ${file}`); - } - } catch (err) { - logger.error(`Failed to delete file: ${file}`, err); - } + if (archivePaths.length > 0) { + WindowManager.mainWindow?.webContents.send( + "on-archive-deletion-prompt", + archivePaths + ); } } @@ -186,12 +184,10 @@ export class GameFilesManager { await this.extractFilesInDirectory(extractionPath); if (fs.existsSync(extractionPath) && fs.existsSync(filePath)) { - try { - await fs.promises.unlink(filePath); - logger.info(`Deleted archive: ${download.folderName}`); - } catch (err) { - logger.error(`Failed to delete file: ${download.folderName}`, err); - } + WindowManager.mainWindow?.webContents.send( + "on-archive-deletion-prompt", + [filePath] + ); } await downloadsSublevel.put(this.gameKey, { diff --git a/src/preload/index.ts b/src/preload/index.ts index 7be92065..5579b6fb 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -279,6 +279,17 @@ contextBridge.exposeInMainWorld("electron", { ipcRenderer.on("on-extraction-progress", listener); return () => ipcRenderer.removeListener("on-extraction-progress", listener); }, + onArchiveDeletionPrompt: (cb: (archivePaths: string[]) => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + archivePaths: string[] + ) => cb(archivePaths); + ipcRenderer.on("on-archive-deletion-prompt", listener); + return () => + ipcRenderer.removeListener("on-archive-deletion-prompt", listener); + }, + deleteArchive: (filePath: string) => + ipcRenderer.invoke("deleteArchive", filePath), /* Hardware */ getDiskFreeSpace: (path: string) => diff --git a/src/renderer/src/app.tsx b/src/renderer/src/app.tsx index 9c65d959..6619c890 100644 --- a/src/renderer/src/app.tsx +++ b/src/renderer/src/app.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { Sidebar, BottomPanel, Header, Toast } from "@renderer/components"; import { @@ -26,6 +26,7 @@ import { useTranslation } from "react-i18next"; import { UserFriendModal } from "./pages/shared-modals/user-friend-modal"; import { useSubscription } from "./hooks/use-subscription"; import { HydraCloudModal } from "./pages/shared-modals/hydra-cloud/hydra-cloud-modal"; +import { ArchiveDeletionModal } from "./pages/downloads/archive-deletion-error-modal"; import { injectCustomCss, @@ -80,6 +81,10 @@ export function App() { const { showSuccessToast } = useToast(); + const [showArchiveDeletionModal, setShowArchiveDeletionModal] = + useState(false); + const [archivePaths, setArchivePaths] = useState([]); + useEffect(() => { Promise.all([ levelDBService.get("userPreferences", null, "json"), @@ -193,6 +198,10 @@ export function App() { dispatch(clearExtraction()); updateLibrary(); }), + window.electron.onArchiveDeletionPrompt((paths) => { + setArchivePaths(paths); + setShowArchiveDeletionModal(true); + }), ]; return () => { @@ -290,6 +299,12 @@ export function App() { feature={hydraCloudFeature} /> + setShowArchiveDeletionModal(false)} + /> + {userDetails && ( void ) => () => Electron.IpcRenderer; + onArchiveDeletionPrompt: ( + cb: (archivePaths: string[]) => void + ) => () => Electron.IpcRenderer; + deleteArchive: (filePath: string) => Promise; getDefaultWinePrefixSelectionPath: () => Promise; createSteamShortcut: (shop: GameShop, objectId: string) => Promise; diff --git a/src/renderer/src/pages/downloads/archive-deletion-error-modal.tsx b/src/renderer/src/pages/downloads/archive-deletion-error-modal.tsx new file mode 100644 index 00000000..ff931a61 --- /dev/null +++ b/src/renderer/src/pages/downloads/archive-deletion-error-modal.tsx @@ -0,0 +1,44 @@ +import { useTranslation } from "react-i18next"; +import { ConfirmationModal } from "@renderer/components"; + +interface ArchiveDeletionModalProps { + visible: boolean; + archivePaths: string[]; + onClose: () => void; +} + +export function ArchiveDeletionModal({ + visible, + archivePaths, + onClose, +}: Readonly) { + const { t } = useTranslation("downloads"); + + const fullFileName = + archivePaths.length > 0 ? (archivePaths[0].split(/[/\\]/).pop() ?? "") : ""; + + const maxLength = 40; + const fileName = + fullFileName.length > maxLength + ? `${fullFileName.slice(0, maxLength)}…` + : fullFileName; + + const handleConfirm = async () => { + for (const archivePath of archivePaths) { + await window.electron.deleteArchive(archivePath); + } + onClose(); + }; + + return ( + + ); +} diff --git a/src/renderer/src/pages/downloads/download-group.scss b/src/renderer/src/pages/downloads/download-group.scss index 9d6cb111..bfd8fbda 100644 --- a/src/renderer/src/pages/downloads/download-group.scss +++ b/src/renderer/src/pages/downloads/download-group.scss @@ -538,7 +538,7 @@ border-radius: 4px; &--extraction { - background-color: #4caf50; + background-color: #fff; } } } diff --git a/src/renderer/src/pages/downloads/downloads.tsx b/src/renderer/src/pages/downloads/downloads.tsx index 35403ba1..10d817f1 100644 --- a/src/renderer/src/pages/downloads/downloads.tsx +++ b/src/renderer/src/pages/downloads/downloads.tsx @@ -40,11 +40,13 @@ export default function Downloads() { useEffect(() => { window.electron.onSeedingStatus((value) => setSeedingStatus(value)); - const unsubscribe = window.electron.onExtractionComplete(() => { + const unsubscribeExtraction = window.electron.onExtractionComplete(() => { updateLibrary(); }); - return () => unsubscribe(); + return () => { + unsubscribeExtraction(); + }; }, [updateLibrary]); const handleOpenGameInstaller = (shop: GameShop, objectId: string) => diff --git a/src/renderer/src/pages/game-details/hero/hero-panel.scss b/src/renderer/src/pages/game-details/hero/hero-panel.scss index 10265b9e..6aa4d311 100644 --- a/src/renderer/src/pages/game-details/hero/hero-panel.scss +++ b/src/renderer/src/pages/game-details/hero/hero-panel.scss @@ -83,7 +83,7 @@ &--extraction { &::-webkit-progress-value { - background-color: #4caf50; + background-color: #fff; } } }