diff --git a/binaries/7zr.exe b/binaries/7zr.exe new file mode 100644 index 00000000..0ce4b2c9 Binary files /dev/null and b/binaries/7zr.exe differ diff --git a/binaries/7zz b/binaries/7zz new file mode 100644 index 00000000..2cde7d8d Binary files /dev/null and b/binaries/7zz differ diff --git a/binaries/7zzs b/binaries/7zzs new file mode 100644 index 00000000..69524c55 Binary files /dev/null and b/binaries/7zzs differ diff --git a/electron-builder.yml b/electron-builder.yml index 5ce6107b..9de44591 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -20,6 +20,8 @@ asarUnpack: - resources/** win: executableName: Hydra + extraResources: + - from: binaries/7zr.exe target: - nsis - portable @@ -35,6 +37,8 @@ portable: artifactName: ${name}-${version}-portable.${ext} mac: entitlementsInherit: build/entitlements.mac.plist + extraResources: + - from: binaries/7zz extendInfo: - NSCameraUsageDescription: Application requests access to the device's camera. - NSMicrophoneUsageDescription: Application requests access to the device's microphone. @@ -44,6 +48,8 @@ mac: dmg: artifactName: ${name}-${version}.${ext} linux: + extraResources: + - from: binaries/7zzs target: - AppImage - snap diff --git a/electron.vite.config.ts b/electron.vite.config.ts index e2af60ce..08bd00f9 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -16,9 +16,6 @@ export default defineConfig(({ mode }) => { main: { build: { sourcemap: true, - rollupOptions: { - external: ["better-sqlite3"], - }, }, resolve: { alias: { diff --git a/package.json b/package.json index 71cc0ea6..3b8093c4 100644 --- a/package.json +++ b/package.json @@ -28,8 +28,7 @@ "build:win": "electron-vite build && electron-builder --win", "build:mac": "electron-vite build && electron-builder --mac", "build:linux": "electron-vite build && electron-builder --linux", - "prepare": "husky", - "knex:migrate:make": "knex --knexfile src/main/knexfile.ts migrate:make --esm" + "prepare": "husky" }, "dependencies": { "@electron-toolkit/preload": "^3.0.0", @@ -45,7 +44,6 @@ "auto-launch": "^5.0.6", "axios": "^1.7.9", "axios-cookiejar-support": "^5.0.5", - "better-sqlite3": "^11.7.0", "classic-level": "^2.0.0", "classnames": "^2.5.1", "color": "^4.2.3", @@ -62,7 +60,6 @@ "jsdom": "^24.0.0", "jsonwebtoken": "^9.0.2", "kill-port": "^2.0.1", - "knex": "^3.1.0", "lodash-es": "^4.17.21", "parse-torrent": "^11.0.17", "piscina": "^4.7.0", diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index e4341a00..2944ca00 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -230,7 +230,8 @@ "seeding": "Seeding", "stop_seeding": "Stop seeding", "resume_seeding": "Resume seeding", - "options": "Manage" + "options": "Manage", + "extracting": "Extracting files…" }, "settings": { "downloads_path": "Downloads path", @@ -344,7 +345,8 @@ "error_importing_theme": "Error importing theme", "theme_imported": "Theme imported successfully", "enable_friend_request_notifications": "When a friend request is received", - "enable_auto_install": "Download updates automatically" + "enable_auto_install": "Download updates automatically", + "automatically_extract_downloaded_files": "Automatically extract downloaded files" }, "notifications": { "download_complete": "Download complete", @@ -357,7 +359,9 @@ "notification_achievement_unlocked_title": "Achievement unlocked for {{game}}", "notification_achievement_unlocked_body": "{{achievement}} and other {{count}} were unlocked", "new_friend_request_description": "You have received a new friend request", - "new_friend_request_title": "New friend request" + "new_friend_request_title": "New friend request", + "extraction_complete": "Extraction complete", + "game_extracted": "{{title}} extracted successfully" }, "system_tray": { "open": "Open Hydra", diff --git a/src/locales/pt-BR/translation.json b/src/locales/pt-BR/translation.json index ff87ed13..a56a4f55 100644 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -182,7 +182,8 @@ "download_error_not_cached_in_real_debrid": "Este download não está disponível no Real-Debrid e a verificação do status do download não está disponível.", "download_error_not_cached_in_torbox": "Este download não está disponível no Torbox e a verificação do status do download não está disponível.", "game_removed_from_favorites": "Jogo removido dos favoritos", - "game_added_to_favorites": "Jogo adicionado aos favoritos" + "game_added_to_favorites": "Jogo adicionado aos favoritos", + "automatically_extract_downloaded_files": "Extrair automaticamente os arquivos baixados" }, "activation": { "title": "Ativação", @@ -219,7 +220,8 @@ "seeding": "Semeando", "stop_seeding": "Parar de semear", "resume_seeding": "Semear", - "options": "Gerenciar" + "options": "Gerenciar", + "extracting": "Extraindo arquivos…" }, "settings": { "downloads_path": "Diretório dos downloads", @@ -342,7 +344,9 @@ "new_update_available": "Versão {{version}} disponível", "restart_to_install_update": "Reinicie o Hydra para instalar a nova versão", "new_friend_request_title": "Novo pedido de amizade", - "new_friend_request_description": "Você recebeu um novo pedido de amizade" + "new_friend_request_description": "Você recebeu um novo pedido de amizade", + "extraction_complete": "Extração concluída", + "game_extracted": "{{title}} extraído com sucesso" }, "system_tray": { "open": "Abrir Hydra", diff --git a/src/main/events/cloud-save/upload-save-game.ts b/src/main/events/cloud-save/upload-save-game.ts index 2674e35a..891941a0 100644 --- a/src/main/events/cloud-save/upload-save-game.ts +++ b/src/main/events/cloud-save/upload-save-game.ts @@ -1,8 +1,8 @@ import { CloudSync } from "@main/services"; import { registerEvent } from "../register-event"; import type { GameShop } from "@types"; -import { t } from "i18next"; -import { format } from "date-fns"; +import i18next, { t } from "i18next"; +import { formatDate } from "date-fns"; const uploadSaveGame = async ( _event: Electron.IpcMainInvokeEvent, @@ -10,13 +10,15 @@ const uploadSaveGame = async ( shop: GameShop, downloadOptionTitle: string | null ) => { + const { language } = i18next; + return CloudSync.uploadSaveGame( objectId, shop, downloadOptionTitle, t("backup_from", { ns: "game_details", - date: format(new Date(), "dd/MM/yyyy"), + date: formatDate(new Date(), language), }) ); }; diff --git a/src/main/events/download-sources/create-download-source.ts b/src/main/events/download-sources/create-download-sources.ts similarity index 61% rename from src/main/events/download-sources/create-download-source.ts rename to src/main/events/download-sources/create-download-sources.ts index bf5d3c18..cf1f8f51 100644 --- a/src/main/events/download-sources/create-download-source.ts +++ b/src/main/events/download-sources/create-download-sources.ts @@ -1,13 +1,13 @@ import { HydraApi } from "@main/services"; import { registerEvent } from "../register-event"; -const createDownloadSource = async ( +const createDownloadSources = async ( _event: Electron.IpcMainInvokeEvent, - url: string + urls: string[] ) => { await HydraApi.post("/profile/download-sources", { - url, + urls, }); }; -registerEvent("createDownloadSource", createDownloadSource); +registerEvent("createDownloadSources", createDownloadSources); diff --git a/src/main/events/index.ts b/src/main/events/index.ts index d49ddc71..bb3399e0 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -90,7 +90,7 @@ import "./themes/get-custom-theme-by-id"; import "./themes/get-active-custom-theme"; import "./themes/close-editor-window"; import "./themes/toggle-custom-theme"; -import "./download-sources/create-download-source"; +import "./download-sources/create-download-sources"; import "./download-sources/remove-download-source"; import "./download-sources/get-download-sources"; import { isPortableVersion } from "@main/helpers"; diff --git a/src/main/events/torrenting/start-game-download.ts b/src/main/events/torrenting/start-game-download.ts index 8b5f1918..59f117d3 100644 --- a/src/main/events/torrenting/start-game-download.ts +++ b/src/main/events/torrenting/start-game-download.ts @@ -12,7 +12,15 @@ const startGameDownload = async ( _event: Electron.IpcMainInvokeEvent, payload: StartGameDownloadPayload ) => { - const { objectId, title, shop, downloadPath, downloader, uri } = payload; + const { + objectId, + title, + shop, + downloadPath, + downloader, + uri, + automaticallyExtract, + } = payload; const gameKey = levelKeys.game(shop, objectId); @@ -74,6 +82,8 @@ const startGameDownload = async ( shouldSeed: false, timestamp: Date.now(), queued: true, + extracting: false, + automaticallyExtract, }; try { diff --git a/src/main/events/user-preferences/update-user-preferences.ts b/src/main/events/user-preferences/update-user-preferences.ts index 09f39d2d..7a481837 100644 --- a/src/main/events/user-preferences/update-user-preferences.ts +++ b/src/main/events/user-preferences/update-user-preferences.ts @@ -23,10 +23,6 @@ const updateUserPreferences = async ( patchUserProfile({ language: preferences.language }).catch(() => {}); } - if (!preferences.downloadsPath) { - preferences.downloadsPath = null; - } - await db.put( levelKeys.userPreferences, { diff --git a/src/main/knex-client.ts b/src/main/knex-client.ts deleted file mode 100644 index 57982332..00000000 --- a/src/main/knex-client.ts +++ /dev/null @@ -1,11 +0,0 @@ -import knex from "knex"; -import { databasePath } from "./constants"; -import { app } from "electron"; - -export const knexClient = knex({ - debug: !app.isPackaged, - client: "better-sqlite3", - connection: { - filename: databasePath, - }, -}); diff --git a/src/main/knexfile.ts b/src/main/knexfile.ts deleted file mode 100644 index df7972a9..00000000 --- a/src/main/knexfile.ts +++ /dev/null @@ -1,10 +0,0 @@ -const config = { - development: { - migrations: { - extension: "ts", - stub: "migrations/migration.stub", - }, - }, -}; - -export default config; diff --git a/src/main/level/sublevels/keys.ts b/src/main/level/sublevels/keys.ts index 6559e460..12143917 100644 --- a/src/main/level/sublevels/keys.ts +++ b/src/main/level/sublevels/keys.ts @@ -13,6 +13,5 @@ export const levelKeys = { downloads: "downloads", userPreferences: "userPreferences", language: "language", - sqliteMigrationDone: "sqliteMigrationDone", screenState: "screenState", }; diff --git a/src/main/main.ts b/src/main/main.ts index 84bd7732..c9a8e3a8 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -1,4 +1,4 @@ -import { DownloadManager, logger, Ludusavi, startMainLoop } from "./services"; +import { DownloadManager, Ludusavi, startMainLoop } from "./services"; import { RealDebridClient } from "./services/download/real-debrid"; import { HydraApi } from "./services/hydra-api"; import { uploadGamesBatch } from "./services/library-sync"; @@ -6,26 +6,17 @@ import { Aria2 } from "./services/aria2"; import { downloadsSublevel } from "./level/sublevels/downloads"; import { sortBy } from "lodash-es"; import { Downloader } from "@shared"; -import { - gameAchievementsSublevel, - gamesSublevel, - levelKeys, - db, -} from "./level"; -import { Auth, User, type UserPreferences } from "@types"; -import { knexClient } from "./knex-client"; +import { levelKeys, db } from "./level"; +import type { UserPreferences } from "@types"; import { TorBoxClient } from "./services/download/torbox"; export const loadState = async () => { - const userPreferences = await migrateFromSqlite().then(async () => { - await db.put(levelKeys.sqliteMigrationDone, true, { + const userPreferences = await db.get( + levelKeys.userPreferences, + { valueEncoding: "json", - }); - - return db.get(levelKeys.userPreferences, { - valueEncoding: "json", - }); - }); + } + ); await import("./events"); @@ -52,6 +43,15 @@ export const loadState = async () => { return sortBy(games, "timestamp", "DESC"); }); + downloads.forEach((download) => { + if (download.extracting) { + downloadsSublevel.put(levelKeys.game(download.shop, download.objectId), { + ...download, + extracting: false, + }); + } + }); + const [nextItemOnQueue] = downloads.filter((game) => game.queued); const downloadsToSeed = downloads.filter( @@ -66,137 +66,3 @@ export const loadState = async () => { startMainLoop(); }; - -const migrateFromSqlite = async () => { - const sqliteMigrationDone = await db.get(levelKeys.sqliteMigrationDone); - - if (sqliteMigrationDone) { - return; - } - - const migrateGames = knexClient("game") - .select("*") - .then((games) => { - return gamesSublevel.batch( - games.map((game) => ({ - type: "put", - key: levelKeys.game(game.shop, game.objectID), - value: { - objectId: game.objectID, - shop: game.shop, - title: game.title, - iconUrl: game.iconUrl, - playTimeInMilliseconds: game.playTimeInMilliseconds, - lastTimePlayed: game.lastTimePlayed, - remoteId: game.remoteId, - winePrefixPath: game.winePrefixPath, - launchOptions: game.launchOptions, - executablePath: game.executablePath, - isDeleted: game.isDeleted === 1, - }, - })) - ); - }) - .then(() => { - logger.info("Games migrated successfully"); - }); - - const migrateUserPreferences = knexClient("user_preferences") - .select("*") - .then(async (userPreferences) => { - if (userPreferences.length > 0) { - const { realDebridApiToken, ...rest } = userPreferences[0]; - - await db.put( - levelKeys.userPreferences, - { - ...rest, - realDebridApiToken, - preferQuitInsteadOfHiding: rest.preferQuitInsteadOfHiding === 1, - runAtStartup: rest.runAtStartup === 1, - startMinimized: rest.startMinimized === 1, - disableNsfwAlert: rest.disableNsfwAlert === 1, - seedAfterDownloadComplete: rest.seedAfterDownloadComplete === 1, - showHiddenAchievementsDescription: - rest.showHiddenAchievementsDescription === 1, - downloadNotificationsEnabled: - rest.downloadNotificationsEnabled === 1, - repackUpdatesNotificationsEnabled: - rest.repackUpdatesNotificationsEnabled === 1, - achievementNotificationsEnabled: - rest.achievementNotificationsEnabled === 1, - }, - { valueEncoding: "json" } - ); - - if (rest.language) { - await db.put(levelKeys.language, rest.language, { - valueEncoding: "utf-8", - }); - } - } - }) - .then(() => { - logger.info("User preferences migrated successfully"); - }); - - const migrateAchievements = knexClient("game_achievement") - .select("*") - .then((achievements) => { - return gameAchievementsSublevel.batch( - achievements.map((achievement) => ({ - type: "put", - key: levelKeys.game(achievement.shop, achievement.objectId), - value: { - achievements: JSON.parse(achievement.achievements), - unlockedAchievements: JSON.parse(achievement.unlockedAchievements), - }, - })) - ); - }) - .then(() => { - logger.info("Achievements migrated successfully"); - }); - - const migrateUser = knexClient("user_auth") - .select("*") - .then(async (users) => { - if (users.length > 0) { - await db.put( - levelKeys.user, - { - id: users[0].userId, - displayName: users[0].displayName, - profileImageUrl: users[0].profileImageUrl, - backgroundImageUrl: users[0].backgroundImageUrl, - subscription: users[0].subscription, - }, - { - valueEncoding: "json", - } - ); - - await db.put( - levelKeys.auth, - { - accessToken: users[0].accessToken, - refreshToken: users[0].refreshToken, - tokenExpirationTimestamp: users[0].tokenExpirationTimestamp, - }, - { - valueEncoding: "json", - } - ); - } - }) - .then(() => { - logger.info("User data migrated successfully"); - }); - - return Promise.allSettled([ - migrateGames, - migrateUserPreferences, - migrateAchievements, - migrateUser, - ]); -}; diff --git a/src/main/services/7zip.ts b/src/main/services/7zip.ts new file mode 100644 index 00000000..f086ac25 --- /dev/null +++ b/src/main/services/7zip.ts @@ -0,0 +1,38 @@ +import { app } from "electron"; +import cp from "node:child_process"; +import path from "node:path"; + +export const binaryName = { + linux: "7zzs", + darwin: "7zz", + win32: "7zr.exe", +}; + +export class _7Zip { + private static readonly binaryPath = app.isPackaged + ? path.join(process.resourcesPath, binaryName[process.platform]) + : path.join( + __dirname, + "..", + "..", + "binaries", + binaryName[process.platform] + ); + + public static extractFile( + filePath: string, + outputPath: string, + cb: () => void + ) { + const child = cp.spawn(this.binaryPath, [ + "x", + filePath, + "-o" + outputPath, + "-y", + ]); + + child.on("exit", () => { + cb(); + }); + } +} diff --git a/src/main/services/aria2.ts b/src/main/services/aria2.ts index b7aa539c..98fd0e13 100644 --- a/src/main/services/aria2.ts +++ b/src/main/services/aria2.ts @@ -2,18 +2,15 @@ import path from "node:path"; import cp from "node:child_process"; import { app } from "electron"; -export const startAria2 = () => {}; - export class Aria2 { private static process: cp.ChildProcess | null = null; + private static readonly binaryPath = app.isPackaged + ? path.join(process.resourcesPath, "aria2", "aria2c") + : path.join(__dirname, "..", "..", "aria2", "aria2c"); public static spawn() { - const binaryPath = app.isPackaged - ? path.join(process.resourcesPath, "aria2", "aria2c") - : path.join(__dirname, "..", "..", "aria2", "aria2c"); - this.process = cp.spawn( - binaryPath, + this.binaryPath, [ "--enable-rpc", "--rpc-listen-all", diff --git a/src/main/services/download/download-manager.ts b/src/main/services/download/download-manager.ts index 860cf5cf..99176654 100644 --- a/src/main/services/download/download-manager.ts +++ b/src/main/services/download/download-manager.ts @@ -1,6 +1,9 @@ import { Downloader, DownloadError } from "@shared"; import { WindowManager } from "../window-manager"; -import { publishDownloadCompleteNotification } from "../notifications"; +import { + publishDownloadCompleteNotification, + publishExtractionCompleteNotification, +} from "../notifications"; import type { Download, DownloadProgress, UserPreferences } from "@types"; import { GofileApi, @@ -22,9 +25,11 @@ import { logger } from "../logger"; import { db, downloadsSublevel, gamesSublevel, levelKeys } from "@main/level"; import { sortBy } from "lodash-es"; import { TorBoxClient } from "./torbox"; +import { _7Zip } from "../7zip"; export class DownloadManager { private static downloadingGameId: string | null = null; + private static readonly extensionsToExtract = [".rar", ".zip", ".7z"]; public static async startRPC( download?: Download, @@ -150,13 +155,47 @@ export class DownloadManager { queued: false, }); } else { + const shouldExtract = + download.downloader !== Downloader.Torrent && + this.extensionsToExtract.some((ext) => + download.folderName?.endsWith(ext) + ) && + download.automaticallyExtract; + downloadsSublevel.put(gameId, { ...download, status: "complete", shouldSeed: false, queued: false, + extracting: shouldExtract, }); + if (shouldExtract) { + _7Zip.extractFile( + path.join(download.downloadPath, download.folderName!), + path.join( + download.downloadPath, + path.parse(download.folderName!).name + ), + async () => { + const download = await downloadsSublevel.get(gameId); + + downloadsSublevel.put(gameId, { + ...download!, + extracting: false, + }); + + WindowManager.mainWindow?.webContents.send( + "on-extraction-complete", + game.shop, + game.objectId + ); + + publishExtractionCompleteNotification(game); + } + ); + } + this.cancelDownload(gameId); } diff --git a/src/main/services/index.ts b/src/main/services/index.ts index f53dae31..50dac560 100644 --- a/src/main/services/index.ts +++ b/src/main/services/index.ts @@ -8,3 +8,4 @@ export * from "./main-loop"; export * from "./hydra-api"; export * from "./ludusavi"; export * from "./cloud-sync"; +export * from "./7zip"; diff --git a/src/main/services/notifications/index.ts b/src/main/services/notifications/index.ts index 6ddb3200..79f200fe 100644 --- a/src/main/services/notifications/index.ts +++ b/src/main/services/notifications/index.ts @@ -128,6 +128,17 @@ export const publishCombinedNewAchievementNotification = async ( } }; +export const publishExtractionCompleteNotification = async (game: Game) => { + new Notification({ + title: t("extraction_complete", { ns: "notifications" }), + body: t("game_extracted", { + ns: "notifications", + title: game.title, + }), + icon: trayIcon, + }).show(); +}; + export const publishNewAchievementNotification = async (info: { achievements: { displayName: string; iconUrl: string }[]; unlockedAchievementCount: number; diff --git a/src/main/services/process-watcher.ts b/src/main/services/process-watcher.ts index f7e30742..de0e88da 100644 --- a/src/main/services/process-watcher.ts +++ b/src/main/services/process-watcher.ts @@ -6,9 +6,9 @@ import axios from "axios"; import { exec } from "child_process"; import { ProcessPayload } from "./download/types"; import { gamesSublevel, levelKeys } from "@main/level"; -import { t } from "i18next"; +import i18next, { t } from "i18next"; import { CloudSync } from "./cloud-sync"; -import { format } from "date-fns"; +import { formatDate } from "date-fns"; const commands = { findWineDir: `lsof -c wine 2>/dev/null | grep '/drive_c/windows$' | head -n 1 | awk '{for(i=9;i<=NF;i++) printf "%s ", $i; print ""}'`, @@ -229,6 +229,8 @@ function onOpenGame(game: Game) { if (game.remoteId) { updateGamePlaytime(game, 0, new Date()).catch(() => {}); + const { language } = i18next; + if (game.automaticCloudSync) { CloudSync.uploadSaveGame( game.objectId, @@ -236,7 +238,7 @@ function onOpenGame(game: Game) { null, t("automatic_backup_from", { ns: "game_details", - date: format(new Date(), "dd/MM/yyyy"), + date: formatDate(new Date(), language), }) ); } @@ -296,6 +298,8 @@ const onCloseGame = (game: Game) => { )!; gamesPlaytime.delete(levelKeys.game(game.shop, game.objectId)); + const { language } = i18next; + if (game.remoteId) { updateGamePlaytime( game, @@ -310,7 +314,7 @@ const onCloseGame = (game: Game) => { null, t("automatic_backup_from", { ns: "game_details", - date: format(new Date(), "dd/MM/yyyy"), + date: formatDate(new Date(), language), }) ); } diff --git a/src/preload/index.ts b/src/preload/index.ts index 3ce2902c..ebe9dc04 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -100,8 +100,8 @@ contextBridge.exposeInMainWorld("electron", { /* Download sources */ putDownloadSource: (objectIds: string[]) => ipcRenderer.invoke("putDownloadSource", objectIds), - createDownloadSource: (url: string) => - ipcRenderer.invoke("createDownloadSource", url), + createDownloadSources: (urls: string[]) => + ipcRenderer.invoke("createDownloadSources", urls), removeDownloadSource: (url: string, removeAll?: boolean) => ipcRenderer.invoke("removeDownloadSource", url, removeAll), getDownloadSources: () => ipcRenderer.invoke("getDownloadSources"), @@ -200,6 +200,15 @@ contextBridge.exposeInMainWorld("electron", { return () => ipcRenderer.removeListener("on-achievement-unlocked", listener); }, + onExtractionComplete: (cb: (shop: GameShop, objectId: string) => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + shop: GameShop, + objectId: string + ) => cb(shop, objectId); + ipcRenderer.on("on-extraction-complete", listener); + return () => ipcRenderer.removeListener("on-extraction-complete", listener); + }, /* Hardware */ getDiskFreeSpace: (path: string) => diff --git a/src/renderer/src/app.tsx b/src/renderer/src/app.tsx index 67311ded..b1867279 100644 --- a/src/renderer/src/app.tsx +++ b/src/renderer/src/app.tsx @@ -143,6 +143,10 @@ export function App() { const existingDownloadSources: DownloadSource[] = await downloadSourcesTable.toArray(); + window.electron.createDownloadSources( + existingDownloadSources.map((source) => source.url) + ); + await Promise.allSettled( downloadSources.map(async (source) => { return new Promise((resolve) => { diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index 7ebebfaa..87afa16d 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -160,12 +160,15 @@ declare global { authenticateRealDebrid: (apiToken: string) => Promise; authenticateTorBox: (apiToken: string) => Promise; onAchievementUnlocked: (cb: () => void) => () => Electron.IpcRenderer; + onExtractionComplete: ( + cb: (shop: GameShop, objectId: string) => void + ) => () => Electron.IpcRenderer; /* Download sources */ putDownloadSource: ( objectIds: string[] ) => Promise<{ fingerprint: string }>; - createDownloadSource: (url: string) => Promise; + createDownloadSources: (urls: string[]) => Promise; removeDownloadSource: (url: string, removeAll?: boolean) => Promise; getDownloadSources: () => Promise< Pick[] diff --git a/src/renderer/src/hooks/use-date.ts b/src/renderer/src/hooks/use-date.ts index 9a5172d3..e0b3322a 100644 --- a/src/renderer/src/hooks/use-date.ts +++ b/src/renderer/src/hooks/use-date.ts @@ -1,19 +1,7 @@ +import { formatDate, getDateLocale } from "@shared"; import { format, formatDistance, subMilliseconds } from "date-fns"; import type { FormatDistanceOptions } from "date-fns"; -import { - ptBR, - enUS, - es, - fr, - pl, - hu, - tr, - ru, - it, - be, - zhCN, - da, -} from "date-fns/locale"; +import { enUS } from "date-fns/locale"; import { useTranslation } from "react-i18next"; export function useDate() { @@ -21,22 +9,6 @@ export function useDate() { const { language } = i18n; - const getDateLocale = () => { - if (language.startsWith("pt")) return ptBR; - if (language.startsWith("es")) return es; - if (language.startsWith("fr")) return fr; - if (language.startsWith("hu")) return hu; - if (language.startsWith("pl")) return pl; - if (language.startsWith("tr")) return tr; - if (language.startsWith("ru")) return ru; - if (language.startsWith("it")) return it; - if (language.startsWith("be")) return be; - if (language.startsWith("zh")) return zhCN; - if (language.startsWith("da")) return da; - - return enUS; - }; - return { formatDistance: ( date: string | number | Date, @@ -46,7 +18,7 @@ export function useDate() { try { return formatDistance(date, baseDate, { ...options, - locale: getDateLocale(), + locale: getDateLocale(language), }); } catch (err) { return ""; @@ -61,7 +33,7 @@ export function useDate() { try { return formatDistance(subMilliseconds(new Date(), millis), baseDate, { ...options, - locale: getDateLocale(), + locale: getDateLocale(language), }); } catch (err) { return ""; @@ -69,18 +41,13 @@ export function useDate() { }, formatDateTime: (date: number | Date | string): string => { - const locale = getDateLocale(); + const locale = getDateLocale(language); return format( date, locale == enUS ? "MM/dd/yyyy - HH:mm" : "dd/MM/yyyy HH:mm" ); }, - formatDate: (date: number | Date | string): string => { - if (isNaN(new Date(date).getDate())) return "N/A"; - - const locale = getDateLocale(); - return format(date, locale == enUS ? "MM/dd/yyyy" : "dd/MM/yyyy"); - }, + formatDate: (date: number | Date | string) => formatDate(date, language), }; } diff --git a/src/renderer/src/pages/catalogue/pagination.tsx b/src/renderer/src/pages/catalogue/pagination.tsx index 3a7356d6..dfae6164 100644 --- a/src/renderer/src/pages/catalogue/pagination.tsx +++ b/src/renderer/src/pages/catalogue/pagination.tsx @@ -18,14 +18,11 @@ export function Pagination({ if (totalPages <= 1) return null; - // Number of visible pages const visiblePages = 3; - // Calculate the start and end of the visible range - let startPage = Math.max(1, page - 1); // Shift range slightly back + let startPage = Math.max(1, page - 1); let endPage = startPage + visiblePages - 1; - // Adjust the range if we're near the start or end if (endPage > totalPages) { endPage = totalPages; startPage = Math.max(1, endPage - visiblePages + 1); @@ -33,7 +30,6 @@ export function Pagination({ return (
- {/* Previous Button */} - {/* ellipsis */}
...
)} - {/* Page Buttons */} {Array.from( { length: endPage - startPage + 1 }, (_, i) => startPage + i @@ -79,12 +72,10 @@ export function Pagination({ {page < totalPages - 1 && ( <> - {/* ellipsis */}
...
- {/* last page */}
diff --git a/src/renderer/src/pages/game-details/game-details.tsx b/src/renderer/src/pages/game-details/game-details.tsx index 81965ff0..b966e6e7 100644 --- a/src/renderer/src/pages/game-details/game-details.tsx +++ b/src/renderer/src/pages/game-details/game-details.tsx @@ -98,7 +98,8 @@ export default function GameDetails() { const handleStartDownload = async ( repack: GameRepack, downloader: Downloader, - downloadPath: string + downloadPath: string, + automaticallyExtract: boolean ) => { const response = await startDownload({ repackId: repack.id, @@ -108,6 +109,7 @@ export default function GameDetails() { shop, downloadPath, uri: selectRepackUri(repack, downloader), + automaticallyExtract: automaticallyExtract, }); if (response.ok) { diff --git a/src/renderer/src/pages/game-details/modals/download-settings-modal.tsx b/src/renderer/src/pages/game-details/modals/download-settings-modal.tsx index f41c3216..63abeb2e 100644 --- a/src/renderer/src/pages/game-details/modals/download-settings-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/download-settings-modal.tsx @@ -1,6 +1,12 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { Trans, useTranslation } from "react-i18next"; -import { Button, Link, Modal, TextField } from "@renderer/components"; +import { + Button, + CheckboxField, + Link, + Modal, + TextField, +} from "@renderer/components"; import { CheckCircleFillIcon, DownloadIcon } from "@primer/octicons-react"; import { Downloader, formatBytes, getDownloadersForUris } from "@shared"; import type { GameRepack } from "@types"; @@ -14,7 +20,8 @@ export interface DownloadSettingsModalProps { startDownload: ( repack: GameRepack, downloader: Downloader, - downloadPath: string + downloadPath: string, + automaticallyExtract: boolean ) => Promise<{ ok: boolean; error?: string }>; repack: GameRepack | null; } @@ -32,6 +39,8 @@ export function DownloadSettingsModal({ const [diskFreeSpace, setDiskFreeSpace] = useState(null); const [selectedPath, setSelectedPath] = useState(""); const [downloadStarting, setDownloadStarting] = useState(false); + const [automaticExtractionEnabled, setAutomaticExtractionEnabled] = + useState(true); const [selectedDownloader, setSelectedDownloader] = useState(null); const [hasWritePermission, setHasWritePermission] = useState( @@ -72,6 +81,21 @@ export function DownloadSettingsModal({ return getDownloadersForUris(repack?.uris ?? []); }, [repack?.uris]); + const getDefaultDownloader = useCallback( + (availableDownloaders: Downloader[]) => { + if (availableDownloaders.includes(Downloader.TorBox)) { + return Downloader.TorBox; + } + + if (availableDownloaders.includes(Downloader.RealDebrid)) { + return Downloader.RealDebrid; + } + + return availableDownloaders[0]; + }, + [] + ); + useEffect(() => { if (userPreferences?.downloadsPath) { setSelectedPath(userPreferences.downloadsPath); @@ -89,13 +113,9 @@ export function DownloadSettingsModal({ return true; }); - /* Gives preference to TorBox */ - const selectedDownloader = filteredDownloaders.includes(Downloader.TorBox) - ? Downloader.TorBox - : filteredDownloaders[0]; - - setSelectedDownloader(selectedDownloader ?? null); + setSelectedDownloader(getDefaultDownloader(filteredDownloaders)); }, [ + getDefaultDownloader, userPreferences?.downloadsPath, downloaders, userPreferences?.realDebridApiToken, @@ -122,7 +142,8 @@ export function DownloadSettingsModal({ const response = await startDownload( repack, selectedDownloader!, - selectedPath + selectedPath, + automaticExtractionEnabled ); if (response.ok) { @@ -217,6 +238,16 @@ export function DownloadSettingsModal({

+ {selectedDownloader !== Downloader.Torrent && ( + + setAutomaticExtractionEnabled(!automaticExtractionEnabled) + } + /> + )} +